fix(studio): a touch anywhere on the ruler takes the number, on every engine
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user