192 lines
7.6 KiB
JavaScript
192 lines
7.6 KiB
JavaScript
/**
|
|
* Slider-UX wiring shared across the editor:
|
|
*
|
|
* 1. `is-using` class while a slider is being dragged (eraser-rows
|
|
* expand to a wider track when in use). Cleared 0.5s after
|
|
* pointerup so a quick click doesn't snap back instantly.
|
|
* 2. Floating value bubble above the thumb during drag.
|
|
* Desktop: only the layer-opacity slider gets a bubble (the
|
|
* eraser-row sliders already show a value chip on the right).
|
|
* Mobile: every slider in the editor gets a bubble.
|
|
* 3. Click the value chip to type a number directly — replaces
|
|
* the span with an inline input until blur/Enter.
|
|
*
|
|
* Wired ONCE on editor open; the listeners stay alive for the whole
|
|
* session via state.container delegation.
|
|
*
|
|
* @param {{
|
|
* registerDocClickAway: (handler: (e: Event) => void) => void,
|
|
* }} deps
|
|
*/
|
|
import { state } from './state.js';
|
|
|
|
export function wireSliderUx({ registerDocClickAway }) {
|
|
const container = state.container;
|
|
if (!container) return;
|
|
|
|
// ── Floating bubble ──
|
|
const sliderBubble = document.createElement('div');
|
|
sliderBubble.className = 'ge-slider-bubble';
|
|
sliderBubble.hidden = true;
|
|
let sliderBubbleSlider = null;
|
|
|
|
// Find the container row for any slider — works for ge-eraser-row
|
|
// sliders AND the layer-opacity slider on each layer item.
|
|
function bubbleRowFor(slider) {
|
|
return slider.closest('.ge-eraser-row, .ge-layer-item, .ge-control-row, .ge-adj-row');
|
|
}
|
|
function bubbleText(slider) {
|
|
const row = bubbleRowFor(slider);
|
|
// Pulled-out value chip (after the slider) wins; fall back to
|
|
// the various `<label> <span>` styles used across the editor.
|
|
const chip = row?.querySelector('.ge-slider-value')
|
|
|| row?.querySelector('label > span[id$="-label"]')
|
|
|| row?.querySelector('label > .ge-size-label')
|
|
|| row?.querySelector('.ge-adj-value');
|
|
if (chip) return chip.textContent;
|
|
if (slider.classList.contains('ge-layer-opacity')) {
|
|
return Math.round(parseFloat(slider.value)) + '%';
|
|
}
|
|
return slider.value;
|
|
}
|
|
function bubblePos(slider, cursorX) {
|
|
// Bubble is fixed-positioned on document.body so it escapes any
|
|
// overflow:hidden / overflow:auto on the row's ancestors. The
|
|
// bubble's X is CLAMPED to the slider's track so it can't follow
|
|
// a finger that drags way past either end.
|
|
const sliderRect = slider.getBoundingClientRect();
|
|
const minX = sliderRect.left + 8;
|
|
const maxX = sliderRect.right - 8;
|
|
const x = Math.max(minX, Math.min(maxX, cursorX));
|
|
sliderBubble.style.left = x + 'px';
|
|
sliderBubble.style.top = (sliderRect.top - 8) + 'px';
|
|
}
|
|
function showSliderBubble(slider, e) {
|
|
if (sliderBubble.parentElement !== document.body) document.body.appendChild(sliderBubble);
|
|
sliderBubble.textContent = bubbleText(slider);
|
|
bubblePos(slider, e ? e.clientX : slider.getBoundingClientRect().left + slider.offsetWidth / 2);
|
|
sliderBubble.hidden = false;
|
|
sliderBubble.classList.add('visible');
|
|
sliderBubbleSlider = slider;
|
|
}
|
|
function hideSliderBubble() {
|
|
sliderBubble.classList.remove('visible');
|
|
sliderBubble.hidden = true;
|
|
sliderBubbleSlider = null;
|
|
}
|
|
|
|
const slidingTimers = new WeakMap();
|
|
// Desktop: only the layer-opacity slider gets the bubble (eraser-
|
|
// rows have their own chip). Mobile: every slider gets one.
|
|
const isMobileSliders = window.matchMedia('(max-width: 820px)').matches;
|
|
const SLIDER_SEL = isMobileSliders
|
|
? '.ge-layer-opacity, .ge-eraser-row input[type="range"], .ge-control-row input[type="range"], .ge-adj-row input[type="range"]'
|
|
: '.ge-layer-opacity';
|
|
|
|
container.addEventListener('pointerdown', (e) => {
|
|
const slider = e.target.closest(SLIDER_SEL);
|
|
if (!slider) return;
|
|
const t = slidingTimers.get(slider);
|
|
if (t) { clearTimeout(t); slidingTimers.delete(slider); }
|
|
slider.classList.add('is-using');
|
|
showSliderBubble(slider, e);
|
|
// Compensate for the leftward-expanding eraser sliders so the
|
|
// thumb lands at the cursor's X on the new (wider) track. Layer-
|
|
// opacity doesn't shift left when it grows, so it uses the
|
|
// browser default.
|
|
if (slider.matches('.ge-eraser-row input[type="range"]')) {
|
|
const rect = slider.getBoundingClientRect();
|
|
const valFrac = Math.max(0, Math.min(1, 1 - (rect.right - e.clientX) / 140));
|
|
const min = parseFloat(slider.min) || 0;
|
|
const max = parseFloat(slider.max) || 100;
|
|
const step = parseFloat(slider.step) || 1;
|
|
const raw = min + valFrac * (max - min);
|
|
const stepped = Math.round(raw / step) * step;
|
|
requestAnimationFrame(() => {
|
|
slider.value = String(stepped);
|
|
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
|
sliderBubble.textContent = bubbleText(slider);
|
|
});
|
|
} else {
|
|
requestAnimationFrame(() => {
|
|
sliderBubble.textContent = bubbleText(slider);
|
|
});
|
|
}
|
|
}, true);
|
|
document.addEventListener('pointermove', (e) => {
|
|
if (!sliderBubbleSlider) return;
|
|
bubblePos(sliderBubbleSlider, e.clientX);
|
|
sliderBubble.textContent = bubbleText(sliderBubbleSlider);
|
|
});
|
|
const scheduleSliderRelease = (slider) => {
|
|
if (!slider) return;
|
|
const old = slidingTimers.get(slider);
|
|
if (old) clearTimeout(old);
|
|
const t = setTimeout(() => {
|
|
slider.classList.remove('is-using');
|
|
slidingTimers.delete(slider);
|
|
}, 500);
|
|
slidingTimers.set(slider, t);
|
|
};
|
|
document.addEventListener('pointerup', () => {
|
|
container.querySelectorAll('input[type="range"].is-using').forEach(scheduleSliderRelease);
|
|
hideSliderBubble();
|
|
});
|
|
|
|
// ── Click value chip to type a number ──
|
|
// Replaces the chip with a tiny inline input until blur/Enter,
|
|
// then writes back to the slider and dispatches `input` so
|
|
// previews react. Matches the legacy chip AND the pulled-out
|
|
// `.ge-slider-value` chip so every slider row in the editor is
|
|
// click-to-type editable.
|
|
registerDocClickAway((e) => {
|
|
const chip = e.target.closest(
|
|
'.ge-eraser-row .ge-slider-value, ' +
|
|
'.ge-eraser-row label > span[id$="-label"], ' +
|
|
'.ge-eraser-row > span[id$="-label"], ' +
|
|
'.ge-adj-row .ge-adj-value'
|
|
);
|
|
if (!chip) return;
|
|
const row = chip.closest('.ge-eraser-row, .ge-adj-row');
|
|
const slider = row?.querySelector('input[type="range"]');
|
|
if (!slider) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const numeric = (slider.value ?? '').toString();
|
|
const inp = document.createElement('input');
|
|
inp.type = 'text';
|
|
inp.value = numeric;
|
|
inp.className = 'ge-slider-edit';
|
|
chip.style.visibility = 'hidden';
|
|
row.appendChild(inp);
|
|
// Position the input over where the chip sits.
|
|
const crect = chip.getBoundingClientRect();
|
|
const rrect = row.getBoundingClientRect();
|
|
inp.style.left = (crect.left - rrect.left) + 'px';
|
|
inp.style.top = (crect.top - rrect.top - 1) + 'px';
|
|
inp.style.width = Math.max(40, crect.width + 8) + 'px';
|
|
inp.focus();
|
|
inp.select();
|
|
const commit = () => {
|
|
const v = parseFloat(inp.value);
|
|
if (!Number.isNaN(v)) {
|
|
const min = parseFloat(slider.min) || 0;
|
|
const max = parseFloat(slider.max) || 100;
|
|
const clamped = Math.max(min, Math.min(max, v));
|
|
slider.value = String(clamped);
|
|
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
cleanup();
|
|
};
|
|
const cleanup = () => {
|
|
inp.remove();
|
|
chip.style.visibility = '';
|
|
};
|
|
inp.addEventListener('blur', commit);
|
|
inp.addEventListener('keydown', (ev) => {
|
|
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
|
|
if (ev.key === 'Escape') { ev.preventDefault(); cleanup(); }
|
|
});
|
|
});
|
|
}
|