fix(studio): a drag on the photo holds the render until the finger lifts
This commit is contained in:
@@ -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()}`);
|
||||
|
||||
@@ -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<string | null>(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<HslBandId>('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}
|
||||
|
||||
@@ -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<RulerHold | null>(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.
|
||||
|
||||
Reference in New Issue
Block a user