diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 0877b9e..9e9dab6 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -254,6 +254,24 @@ export function ImageStage({ const viewRef = useRef(view); viewRef.current = view; const panRef = useRef<{ px: number; py: number } | null>(null); + // The space bar is the hand the stage is moved with; the brush's own layer + // swallows the pointer that would otherwise start that pan, so the key is + // watched here for the layer to read. + const spaceRef = useRef(false); + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.code === 'Space') spaceRef.current = true; + }; + const up = (e: KeyboardEvent) => { + if (e.code === 'Space') spaceRef.current = false; + }; + window.addEventListener('keydown', down); + window.addEventListener('keyup', up); + return () => { + window.removeEventListener('keydown', down); + window.removeEventListener('keyup', up); + }; + }, []); // Compare: the original on the left of the split, the render on the right. // 0.5 is the middle of the photo, which is where the bar starts. const [split, setSplit] = useState(0.5); @@ -520,16 +538,36 @@ export function ImageStage({ // Where the × of the spot the pointer chose goes: on its top-right diagonal, // just clear of the circle. A badge sitting over the spot would eat the next // press on it — which is a user putting the spot down again, not deleting it. + // It keeps a screen size and a screen gap of its own, and is scaled back out + // of the layer's zoom: the badge is the one thing on this layer the pointer + // is meant to press, and drawn in the layer's own units it grows over the + // circle it belongs to as the photo is zoomed, until a press that means to + // take hold of the spot lands on the × and deletes it instead. const selBox = box && selSpot !== null && selSpot < brushSpots.length ? (() => { const s = brushSpots[selSpot]; - const off = (s.r * box.width + 12) * Math.SQRT1_2; - return { left: `calc(${s.x * 100}% + ${off}px)`, top: `calc(${s.y * 100}% - ${off}px)` }; + const off = (s.r * box.width + 14 / view.s) * Math.SQRT1_2; + return { + left: `calc(${s.x * 100}% + ${off}px)`, + top: `calc(${s.y * 100}% - ${off}px)`, + transform: `translate(-50%, -50%) scale(${1 / view.s})`, + }; })() : null; const brushDown = (e: React.PointerEvent) => { + // The stage's own hand, under the brush: the middle button and the space + // bar are how the photo is moved out of the way when the brush is down, and + // a repair is aimed at a zoom, where what has to be reached is rarely the + // middle of the picture. The wheel that zooms stays the wheel; this is the + // drag that gets the place into view. + if ((e.button === 1 || spaceRef.current) && viewRef.current.s > 1) { + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + panRef.current = { px: e.clientX, py: e.clientY }; + return; + } if (!onBrushSpots) return; const p = brushPoint(e.clientX, e.clientY); if (!p) return; @@ -552,6 +590,13 @@ export function ImageStage({ }; const brushMove = (e: React.PointerEvent) => { + const pan = panRef.current; + if (pan) { + const v = viewRef.current; + setView({ s: v.s, ...clampPan(v.s, v.x + (e.clientX - pan.px), v.y + (e.clientY - pan.py)) }); + panRef.current = { px: e.clientX, py: e.clientY }; + return; + } const p = brushPoint(e.clientX, e.clientY); setBrushPos(p ? { fx: p.fx, fy: p.fy } : null); const grab = grabRef.current; @@ -577,7 +622,12 @@ export function ImageStage({ setBrushTrail(stroke.pts.slice()); }; - const brushUp = useCallback(async () => { + const brushUp = useCallback(async (e?: React.PointerEvent) => { + if (panRef.current) { + panRef.current = null; + if (e?.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); + return; + } if (grabRef.current) { // A press on a spot that never travelled is a choice, not an edit: nothing // was written, so the caller is not asked for a step. @@ -611,24 +661,15 @@ export function ImageStage({ onBrushSpots(spots); }, [brushR, brushTool, previewReader, onBrushSpots]); - // The wheel is the brush's size. It is bound natively rather than through - // React's own onWheel because that one is passive: the wheel would scroll the - // page under the brush instead of growing it. The layer is a child of the - // stage that also listens for the wheel (its own zoom), so the event is - // stopped here as well: while the brush is up the wheel sizes the brush and - // nothing else. + // The wheel over the brush is the stage's own, as it is everywhere else in + // the app: a repair is aimed at a zoom, and the wheel is the gesture a user + // already has under their hand for one. The brush's own size — the one knob + // this tool has — is that same wheel with a modifier held, which is also what + // a trackpad sends for a pinch. Bound natively rather than through React's + // own onWheel because that one is passive: the wheel would scroll the page + // under the brush instead of zooming it. The layer is a child of the stage + // that also listens for the wheel, so the event is stopped here as well. const brushLayerRef = useRef(null); - useEffect(() => { - const el = brushLayerRef.current; - if (!el || !onBrushR) return; - const onWheel = (e: WheelEvent) => { - e.preventDefault(); - e.stopPropagation(); - onBrushR(wheelBrushR(brushR, e.deltaY)); - }; - el.addEventListener('wheel', onWheel, { passive: false }); - return () => el.removeEventListener('wheel', onWheel); - }, [onBrushR, brushR, brushTool]); // The × belongs to the tool that drew it: arming the other brush puts the // spot down. @@ -691,6 +732,29 @@ export function ImageStage({ [baseRect, cropRect, cropWH, onCropRectChange] ); + // One wheel notch closer or further, at a point on the photo. Named rather + // than inlined in the listener below because the brush's own layer takes the + // wheel for its size and hands the modified notches back here: a repair is + // aimed at a zoom, and the wheel that sizes the brush is the one gesture the + // user has in their hand at the time. + const zoomAt = useCallback( + (clientX: number, clientY: number, up: boolean) => { + const wrap = wrapRef.current; + if (!wrap) return; + const v = viewRef.current; + const s = Math.min(ZOOM_MAX, Math.max(1, v.s * (up ? ZOOM_STEP : 1 / ZOOM_STEP))); + if (s === v.s) return; + const r = wrap.getBoundingClientRect(); + const cx = clientX - r.left - r.width / 2; + const cy = clientY - r.top - r.height / 2; + // the point under the cursor stays put: t' = c - (s'/s)(c - t) + const x = cx - (s / v.s) * (cx - v.x); + const y = cy - (s / v.s) * (cy - v.y); + setView({ s, ...clampPan(s, x, y) }); + }, + [clampPan] + ); + // Native, because React's wheel listener is passive and the page must not // scroll while the photo zooms under the cursor. useEffect(() => { @@ -703,20 +767,32 @@ export function ImageStage({ wheelCrop(e.deltaY < 0); return; } - const v = viewRef.current; - const s = Math.min(ZOOM_MAX, Math.max(1, v.s * (e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP))); - if (s === v.s) return; - const r = wrap.getBoundingClientRect(); - const cx = e.clientX - r.left - r.width / 2; - const cy = e.clientY - r.top - r.height / 2; - // the point under the cursor stays put: t' = c - (s'/s)(c - t) - const x = cx - (s / v.s) * (cx - v.x); - const y = cy - (s / v.s) * (cy - v.y); - setView({ s, ...clampPan(s, x, y) }); + zoomAt(e.clientX, e.clientY, e.deltaY < 0); }; wrap.addEventListener('wheel', onWheel, { passive: false }); return () => wrap.removeEventListener('wheel', onWheel); - }, [cropEditing, previewUrl, wheelCrop, clampPan]); + }, [cropEditing, previewUrl, wheelCrop, zoomAt]); + + // The same wheel, where the brush's layer has taken it: sized when a modifier + // is held, and the stage's zoom otherwise. `box` is a dependency because the + // layer is only on the stage once the photo has been measured — the listener + // has to follow the node it is bound to, or the brush is armed with no wheel + // of its own at all and the stage's zoom takes every notch. + useEffect(() => { + const el = brushLayerRef.current; + if (!el || !onBrushR) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.ctrlKey || e.metaKey || e.altKey) { + onBrushR(wheelBrushR(brushR, e.deltaY)); + return; + } + zoomAt(e.clientX, e.clientY, e.deltaY < 0); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, [onBrushR, brushR, brushTool, box, zoomAt]); // A new photo, or the frame coming up or going down (APPLY, CANCEL, UNDO), // puts the view back on the fit. @@ -1116,6 +1192,7 @@ export function ImageStage({ onPointerUp={brushUp} onPointerCancel={() => { strokeRef.current = null; + panRef.current = null; setBrushTrail([]); }} > @@ -1414,8 +1491,10 @@ export function ImageStage({ {peek ?
{t('drop.peeking')}
: null} {/* The histogram reads the render, so it goes over the photo itself. Not while the crop frame is up: the frame's shade and handles own - that surface. */} - {showHist && !cropEditing ? setShowHist(false)} /> : null} + that surface. Nor while a brush is up: it is a panel over the + photo's corner and it takes the pointer, so the repairs laid under + it could neither be painted nor taken hold of. */} + {showHist && !cropEditing && !brushTool ? setShowHist(false)} /> : null} )}