From 4eb74c377917ff8f0bd8787b1d56ca164550a485 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 13 Oct 2026 19:56:46 +0700 Subject: [PATCH] fix(studio): a drag on the photo holds the render until the finger lifts --- docker/frontend/scripts/studio-drag-check.mjs | 21 +++ docker/frontend/src/App.tsx | 29 ++++- docker/frontend/src/ui/ImageStage.tsx | 123 +++++++++++++++--- 3 files changed, 150 insertions(+), 23 deletions(-) diff --git a/docker/frontend/scripts/studio-drag-check.mjs b/docker/frontend/scripts/studio-drag-check.mjs index 4deefb6..72bb14c 100644 --- a/docker/frontend/scripts/studio-drag-check.mjs +++ b/docker/frontend/scripts/studio-drag-check.mjs @@ -279,6 +279,27 @@ const quarter = await rulerValue(); step('a finger dragged to the right lifts the knob with it', Math.abs(quarter - 50) <= 1, `CONTRAST ${quarter} for a quarter of the width`); step('and the photo stayed where it was — the drag is the value, not the view', (await scale()) === 1, `scale ${await scale()}`); +// --- the look waits for the hand to come off --------------------------------- +// Rendering the look is the one thing in a drag that costs more than a frame, so +// it is held while the finger is down: the number moves on the row as the hand +// does, the photo's own src does not — not even over a pause a debounce would +// have rendered on — and it lands once, when the finger lifts. +const srcBefore = await page.$eval('.canvas-wrap img', (e) => e.src); +const from = await rulerValue(); +await touch('touchStart', [[box.cx, box.cy]]); +await touch('touchMove', [[box.cx + box.w * 0.1, box.cy]]); +await page.waitForTimeout(600); +const srcMid = await page.$eval('.canvas-wrap img', (e) => e.src); +const live = await rulerValue(); +step('the number is on the row while the hand is still on the photo', Math.abs(live - (from + 20)) <= 1, `CONTRAST ${from} -> ${live}`); +step('and the look itself is not rendered under it', srcMid === srcBefore, srcMid === srcBefore ? 'the photo held' : 'the photo re-rendered mid-drag'); +await touch('touchMove', [[box.cx + box.w * 0.2, box.cy]]); +await touch('touchEnd', []); +await page.waitForFunction((before) => document.querySelector('.canvas-wrap img')?.src !== before, srcBefore, { timeout: 30_000 }).catch(() => {}); +const srcAfter = await page.$eval('.canvas-wrap img', (e) => e.src); +step('the look lands once, when the finger lifts', srcAfter !== srcBefore, srcAfter === srcBefore ? 'nothing rendered' : 'one render for the gesture'); +step('and the knob stopped where the hand left it', Math.abs((await rulerValue()) - (from + 40)) <= 1, `CONTRAST ${await rulerValue()}`); + // Past the far edge the number stops at its own end rather than running on. await drag([box.cx, box.cy], [box.w * 0.6, 0]); step('dragged past the edge, the knob stops at its own end', (await rulerValue()) === 100, `CONTRAST ${await rulerValue()}`); diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index deb71d2..55d61b8 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -505,6 +505,12 @@ export function Workspace() { const [flipH, setFlipH] = useState(() => session.flipH ?? false); const [flipV, setFlipV] = useState(() => session.flipV ?? false); const [openParam, setOpenParam] = useState(null); + // A finger on the photo, moving the open ruler. The look it is moving is not + // rendered while the hand is down: a develop and a JPEG is the one thing here + // that costs more than a frame, and a render landing between two moves of a + // drag is the drag the reader feels. The number the finger stops on is applied + // when it lifts, and the render follows it then — one for the whole gesture. + const [rulerDragging, setRulerDragging] = useState(false); // The HSL mixer: which hue band the ruler edits, whether the eyedropper is // armed, and the last colour it read off the photo. const [hslBand, setHslBand] = useState('red'); @@ -1048,6 +1054,11 @@ export function Workspace() { // recipe, so a preview behind it would only compete for the same renderer — // and the user is watching the live one. It comes back with the camera. if (!engine || !previewBytes || shooting) return; + // A hand on the photo is a drag of the open ruler, and a drag is not a + // moment to render: the value it lands on comes with the finger's own lift, + // and this effect runs again then (rulerDragging is a dependency) — so one + // render for the whole gesture instead of one in the middle of it. + if (rulerDragging) return; const token = ++renderToken.current; const timer = setTimeout(async () => { setBusy(t('act.exporting')); @@ -1107,7 +1118,7 @@ export function Workspace() { }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, previewBytes, recipe, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting]); + }, [engine, previewBytes, recipe, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting, rulerDragging]); // The source copy behind previewBytes, re-made only when previewMax moves: // applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back. @@ -1444,6 +1455,9 @@ export function Workspace() { const sliderEditRef = useRef(false); useEffect(() => { sliderEditRef.current = false; + // A row that is no longer the open one is not being held: whatever gesture + // was moving it ended with it. + setRulerDragging(false); }, [openParam]); // The watermark's drag box rides the same one-edit-per-gesture rule as a knob: // a whole drag is one undo step, not one per pointer move. The ref clears @@ -4727,7 +4741,18 @@ export function Workspace() { // open no row (see `slider`), and neither hands anything over. rulerDrag={ slider - ? { value: slider.value, min: slider.min, max: slider.max, step: slider.step, onChange: slider.onChange } + ? { + value: slider.value, + min: slider.min, + max: slider.max, + step: slider.step, + onChange: slider.onChange, + // The gesture's two edges: while the hand is on the photo the + // look is not rendered, and the number it stops on is the one + // that lands (see `rulerDragging`). + onStart: () => setRulerDragging(true), + onEnd: () => setRulerDragging(false), + } : null } picking={picking} diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index e09e2bf..f513d0d 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -72,6 +72,27 @@ export type RulerDrag = { max: number; step: number; onChange: (value: number) => void; + // The gesture's own two edges, and what they are for: rendering the look is + // the one thing here that costs more than a frame — a full develop and a JPEG + // — so a hand on the photo holds it back (see PREVIEW_DEBOUNCE_MS in App) and + // the number the finger stops on is the one that lands, instead of a render + // standing between two moves of a drag that is not finished. + onStart: () => void; + onEnd: () => void; +}; + +// The ruler's own drag while it is in hand: which finger started it, where it +// began, the width the stage was when it did, what has already been applied, +// and the value still waiting for the frame it will be applied on. +type RulerHold = { + id: number; + x: number; + value: number; + width: number; + last: number; + started: boolean; + pending: number | null; + raf: number; }; // One mark's box, from the numbers the engine draws it with. The width of the @@ -1034,11 +1055,10 @@ export function ImageStage({ // and changes on every move of the very drag it is measuring. const rulerDragRef = useRef(rulerDrag); rulerDragRef.current = rulerDrag; - // The ruler's own drag, in hand: which finger started it, where it began and - // what the value was then. Read against its own beginning rather than against - // the last move, so a step the pointer snaps to is never counted twice and the - // number under the finger never drifts away from it. - const rulerRef = useRef<{ id: number; x: number; value: number } | null>(null); + // The ruler's own drag, in hand. Read against its own beginning rather than + // against the last move, so a step the pointer snaps to is never counted twice + // and the number under the finger never drifts away from it. + const rulerRef = useRef(null); useEffect(() => { const wrap = wrapRef.current; if (!wrap) return; @@ -1055,21 +1075,66 @@ export function ImageStage({ d: Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y), }; }; + // One value, applied through the ruler's own setter, and the app told the + // first time one lands that the photo is in hand and its render can wait. + const applyRuler = (r: RulerHold, v: number) => { + const d = rulerDragRef.current; + if (!d || v === r.last) return; + r.last = v; + if (!r.started) { + r.started = true; + d.onStart(); + } + d.onChange(v); + }; + // Letting the ruler go: the finger lifted, the touch was cancelled, or a + // second finger turned the gesture into the zoom. Whatever the last frame + // had not applied goes in here (flush), the app is told it may render again, + // and the hand is emptied. + const dropRuler = (flush: boolean) => { + const r = rulerRef.current; + if (!r) return; + if (r.raf) { + cancelAnimationFrame(r.raf); + r.raf = 0; + } + if (flush && r.pending !== null) applyRuler(r, r.pending); + if (r.started) rulerDragRef.current?.onEnd(); + rulerRef.current = null; + }; const down = (e: PointerEvent) => { if (e.pointerType !== 'touch') return; fingersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); // The first finger is the open ruler's, when there is one: where it went - // down and what the knob read at that moment. Nothing is changed yet — a - // tap on the photo is not a move of the number. + // down, what the knob read at that moment, and how wide the stage was — + // measured once, because the box does not move under a drag and measuring + // it again on every move would lay the document out between two frames of + // the gesture being read. Nothing is changed yet: a tap on the photo is + // not a move of the number. const r = rulerDragRef.current; if (fingersRef.current.size === 1) { - if (r) rulerRef.current = { id: e.pointerId, x: e.clientX, value: r.value }; + if (r) { + const rect = wrap.getBoundingClientRect(); + if (rect.width > 0 && r.max > r.min) { + rulerRef.current = { + id: e.pointerId, + x: e.clientX, + value: r.value, + width: rect.width, + last: r.value, + started: false, + pending: null, + raf: 0, + }; + } + } return; } if (fingersRef.current.size !== 2) return; // The second finger is the zoom, and a pinch is not a value: whatever the - // first finger had hold of is let go the moment this one lands. - rulerRef.current = null; + // first finger had hold of is let go the moment this one lands, and the + // look renders again — the zoom is what is being looked at now. + dropRuler(false); const m = pinch(); if (!m || !(m.d > 0)) return; // The photo's own point under the fingers, and the size the stage is at: @@ -1091,17 +1156,25 @@ export function ImageStage({ // slider has — and the value is read from where this drag began, so a // hand that comes back the way it went lands on the number it left. const r = rulerRef.current; - const d = rulerDragRef.current; - if (!r || !d || r.id !== e.pointerId) return; - const width = wrap.getBoundingClientRect().width; - if (!(width > 0) || !(d.max > d.min)) return; + if (!r || r.id !== e.pointerId || !rulerDragRef.current) return; e.preventDefault(); + const d = rulerDragRef.current; const step = d.step > 0 ? d.step : 1; - const raw = r.value + ((e.clientX - r.x) / width) * (d.max - d.min); - const next = Math.min(d.max, Math.max(d.min, Number((Math.round(raw / step) * step).toFixed(4)))); - // A move that lands on the same step as the last one is not an edit: - // firing it would lay a second undo step over a drag that never left. - if (next !== d.value) d.onChange(next); + const raw = r.value + ((e.clientX - r.x) / r.width) * (d.max - d.min); + r.pending = Math.min(d.max, Math.max(d.min, Number((Math.round(raw / step) * step).toFixed(4)))); + // One value a frame, not one a move: a finger reports more moves than a + // screen has frames, and what is applied is whatever the last of them + // said — the row is re-rendered once for it instead of once each. + if (!r.raf) { + r.raf = requestAnimationFrame(() => { + const held = rulerRef.current; + if (!held) return; + held.raf = 0; + const v = held.pending; + held.pending = null; + if (v !== null) applyRuler(held, v); + }); + } return; } // What the first finger had begun — a stroke, a repair in hand, a pan — is @@ -1119,7 +1192,10 @@ export function ImageStage({ setView({ s, ...clampPan(s, m.x - s * p.px, m.y - s * p.py) }); }; const up = (e: PointerEvent) => { - if (rulerRef.current?.id === e.pointerId) rulerRef.current = null; + // The finger that was holding the ruler lets it go here, with whatever the + // last frame had not applied: the number the hand stopped on lands, and + // the render it was holding back follows it in the same step. + if (rulerRef.current?.id === e.pointerId) dropRuler(true); fingersRef.current.delete(e.pointerId); // One finger left is not a pinch: the stage stays where the gesture put it // and the finger that is left paints nothing until it is lifted and put @@ -1137,7 +1213,12 @@ export function ImageStage({ wrap.removeEventListener('pointercancel', up); fingersRef.current.clear(); pinchRef.current = null; - rulerRef.current = null; + // The listener is re-bound whenever a preview lands, and a face on the + // photo is not the end of a gesture: the ruler's hold is kept unless the + // box itself has gone (a frame replaced, a photo cleared) — where the app + // would otherwise be left holding its render back for a hand that is no + // longer on anything. + if (!wrap.isConnected) dropRuler(false); }; // `previewUrl` because the wrap is only on the stage once a frame is: the // listener has to follow the node it is bound to.