fix(studio): a touch anywhere on the ruler takes the number, on every engine

This commit is contained in:
2026-10-13 23:06:27 +07:00
parent b6ae819002
commit ad85f0bd0a
3 changed files with 120 additions and 3 deletions
+62 -1
View File
@@ -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=<path to playwright-core/index.mjs> CHROME=<path to a chromium> \
@@ -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
+5 -1
View File
@@ -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;
+53 -1
View File
@@ -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<HTMLDivElement>(null);
const trackRef = useRef<HTMLInputElement>(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 }) {
<div className="head">
<span>{slider.label}</span>
<input
ref={trackRef}
type="range"
min={slider.min}
max={slider.max}