diff --git a/docker/frontend/scripts/studio-drag-check.mjs b/docker/frontend/scripts/studio-drag-check.mjs index 72bb14c..048074e 100644 --- a/docker/frontend/scripts/studio-drag-check.mjs +++ b/docker/frontend/scripts/studio-drag-check.mjs @@ -10,7 +10,9 @@ // number stops at its own ends. It holds down the two things that could be lost // to it as well: two fingers are still the phone's own zoom (a pinch is not a // value, so the finger's hold is given up the moment the second one lands), and -// with the row put away the same drag changes nothing at all. +// with the row put away the same drag changes nothing at all. The row's own +// track takes a touch anywhere on it too, whatever the engine would have done +// with the thumb's 3px box. // // npm run build && node scripts/studio-drag-check.mjs // PLAYWRIGHT_CORE= CHROME= \ @@ -321,6 +323,65 @@ await drag([box.cx, box.cy], [-box.w * 0.25, 0]); const down = await rulerValue(); step('dragged to the left the knob comes down with it', Math.abs(down - 50) <= 1, `CONTRAST ${down}`); +// --- the hand on the ruler itself -------------------------------------------- +// The other half of the same trade, and the one an engine can take away: the +// number can be had by the hand that lands on the ruler, not only by the one +// walking across the photo. WebKit begins a slider drag only when the touch +// falls inside the thumb's own box — a 3px line here (see `SliderThumbElement:: +// handleTouchStart`) — so on a phone the ticks across the track answered a finger +// while the middle of the ruler answered nothing. The row reads the value from +// the touch's own place on the track, so what is made here is the gesture a +// browser's slider would take: an untrusted `PointerEvent`, which Blink's own +// slider ignores and the row's own listener does not. The whole gesture is put +// down on one pixel of the box it started on, the way a hand is. +const trackTouch = (rail, fraction, type = 'pointerdown', pointerId = 7, pointerType = 'touch') => + page.$eval( + '[data-col="slider"] input[type="range"]', + (e, p) => { + e.dispatchEvent( + new PointerEvent(p.type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: p.pointerId, + pointerType: p.pointerType, + isPrimary: p.pointerId === 7, + buttons: 1, + clientX: p.rail.left + p.rail.width * p.fraction, + clientY: p.rail.mid, + }) + ); + }, + { rail, fraction, type, pointerId, pointerType } + ); +// The ruler's own box, taken once: a hand on it is a fixed pixel, so the whole +// gesture is measured against the box it started on. +const trackRect = () => + page.$eval('[data-col="slider"] input[type="range"]', (e) => { + const r = e.getBoundingClientRect(); + return { left: r.left, width: r.width, mid: r.top + r.height / 2, value: Number(e.value) }; + }); + +const rail = await trackRect(); +await trackTouch(rail, 0.85); +const onTrack = await rulerValue(); +step('a touch on the ruler’s own track takes the number from where it landed', Math.abs(onTrack - 70) <= 1, `CONTRAST ${onTrack} at 85% of the track`); +const steady = (await trackRect()).width; +await trackTouch(rail, 0.5, 'pointermove'); +step('and the number walks with the hand along the ruler', Math.abs(await rulerValue()) <= 1, `CONTRAST ${await rulerValue()} at the middle`); +step('and the track stays under the hand while the number changes length', (await trackRect()).width === steady, `${steady} -> ${(await trackRect()).width}`); +const sole = await rulerValue(); +await trackTouch(rail, 0.25, 'pointermove', 9); +step('a second finger is not a second value', (await rulerValue()) === sole, `CONTRAST ${sole} -> ${await rulerValue()}`); +await trackTouch(rail, 0.2, 'pointermove'); +step('and the number comes down as the hand goes back', Math.abs((await rulerValue()) + 60) <= 1, `CONTRAST ${await rulerValue()} at a fifth`); +await trackTouch(rail, 0.5, 'pointerup'); +const lifted = await rulerValue(); +await trackTouch(rail, 0.95, 'pointermove'); +step('the number stands where the finger was lifted', (await rulerValue()) === lifted, `CONTRAST ${lifted} -> ${await rulerValue()}`); +await trackTouch(rail, 0.1, 'pointerdown', 11, 'mouse'); +step('and the mouse keeps the drag the engines already give it', (await rulerValue()) === lifted, `CONTRAST ${lifted} -> ${await rulerValue()}`); + // --- with the row put away --------------------------------------------------- // The drag belongs to the ruler, not to the photo, and a phone puts the row away // through the path under the strip — the strip it came from is off the screen diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index da52053..e3ad016 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -365,7 +365,11 @@ .slider-row .head { display: flex; align-items: center; gap: 8px; font-size: 11px; letter-spacing: 0.1em; color: var(--text-dim); } .slider-row .head span { flex: 0 0 auto; } .slider-row .head input[type="range"] { flex: 1 1 40px; min-width: 0; } -.slider-row .head b { flex: 0 0 auto; color: var(--text); font-family: var(--mono); letter-spacing: 0; } +/* The readout holds a box wide enough for the longest number it ever shows + ("-100", "+1.5", "5500K"): a hand on the track is a fixed pixel, and a row + whose number changed the track's width under it would slide the ruler out + from under the finger that is reading it. */ +.slider-row .head b { flex: 0 0 auto; min-width: 5ch; color: var(--text); font-family: var(--mono); letter-spacing: 0; } .slider-row .head b.amber { color: var(--accent); } .slider-row .swatch { height: 26px; diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index 341f908..a85ff75 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -189,10 +189,12 @@ export function SliderRow({ slider }: { slider: SliderDef }) { // parameter's own range. The listener is native because React's is passive — // the panel must not scroll away under the cursor while the value moves. const rowRef = useRef(null); + const trackRef = useRef(null); const live = useRef(slider); live.current = slider; useEffect(() => { const el = rowRef.current; + const track = trackRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); @@ -201,8 +203,57 @@ export function SliderRow({ slider }: { slider: SliderDef }) { const next = Math.min(s.max, Math.max(s.min, s.value + (e.deltaY < 0 ? step : -step))); if (next !== s.value) s.onChange(next); }; + // The hand belongs to the ruler, wherever on the ruler it landed — and that + // is an engine's business, not the reader's. WebKit begins a slider drag + // only when the touch falls inside the thumb's own box (measured at 12.5px + // of slack a side, `SliderThumbElement::handleTouchStart`), and this thumb + // is the 3px line the ruler is drawn with: on a phone the whole track but + // that line answered nothing, while the ticks across it invited exactly + // that hand. Chrome and Firefox take the touch anywhere, so the row reads + // the value from the touch's own place on the track here, on every engine — + // the same mapping its photo-drag uses. + const valueAt = (clientX: number, rect: DOMRect) => { + const s = live.current; + const step = s.step > 0 ? s.step : 1; + const raw = s.min + ((clientX - rect.left) / rect.width) * (s.max - s.min); + return Math.min(s.max, Math.max(s.min, Number((Math.round(raw / step) * step).toFixed(4)))); + }; + const onTrackDown = (e: PointerEvent) => { + // The mouse keeps the drag the engines already give it; this is the + // hand's path, and a pen writes like one. + if (e.pointerType === 'mouse' || !track) return; + const rect = track.getBoundingClientRect(); + if (!(rect.width > 0) || live.current.max <= live.current.min) return; + // Measured once: the box does not move under a drag, and measuring it on + // every move would lay the document out between two frames of the gesture + // being read. The pointer keeps its own capture (the platform's rule for + // touch), so the moves and the lift come back here unasked. + let last: number | null = null; + const apply = (clientX: number) => { + const v = valueAt(clientX, rect); + if (v === last) return; + last = v; + live.current.onChange(v); + }; + apply(e.clientX); + const onMove = (m: PointerEvent) => { + if (m.pointerId === e.pointerId) apply(m.clientX); + }; + const onUp = () => { + track.removeEventListener('pointermove', onMove); + track.removeEventListener('pointerup', onUp); + track.removeEventListener('pointercancel', onUp); + }; + track.addEventListener('pointermove', onMove); + track.addEventListener('pointerup', onUp); + track.addEventListener('pointercancel', onUp); + }; + track?.addEventListener('pointerdown', onTrackDown); el.addEventListener('wheel', onWheel, { passive: false }); - return () => el.removeEventListener('wheel', onWheel); + return () => { + track?.removeEventListener('pointerdown', onTrackDown); + el.removeEventListener('wheel', onWheel); + }; }, []); return ( // No data-key on the row itself: the parameter's own key is already on the @@ -219,6 +270,7 @@ export function SliderRow({ slider }: { slider: SliderDef }) {
{slider.label}