web: let the photo be zoomed and moved under the brush, and keep its × out of the hand

With HEAL armed the wheel was the brush's size and nothing else. A repair is
aimed at a detail — a scratch, a speck on a face — and the detail is usually
smaller than the photo, so the one gesture the stage uses to bring it closer was
the one gesture the brush had taken: a user could size the spot they were about
to lay and could not zoom the photo they were laying it on. The same layer took
the pointer the stage pans with, so a zoomed-in photo could not be moved out of
the way either, and it swallowed the double-click the img answers with. A tool
that covers the surface has to hand back the gestures it does not use.

The wheel over the brush is the stage's own now, exactly as it is everywhere
else in the app: one notch at the pointer, the point under the cursor held
still, the same arithmetic the wrap's listener has always used (lifted out of
that listener as zoomAt, so the two callers cannot drift). The brush's size —
the one knob this tool has — is that same wheel with a modifier held: Alt, or
Ctrl/Meta, which is also what a trackpad sends for a pinch, so pinching still
sizes the spot without reaching for a key. The photo can be moved out from under
the brush as well: the middle button, or the space bar held, drags the photo
while a tool is up, at a zoom, which is the only place a pan means anything —
the listener is on the layer, so the key is watched on the window and read from
a ref. A pan drag paints nothing, and the modifier wheel does not touch the
zoom: measured, 24.744px -> 29.6239px of brush across while the photo stayed at
1568px.

The × a chosen spot wears is now drawn for the screen rather than the layer: it
keeps 18px and a 5px gap at any zoom, scaled back out of the layer's own
transform. It was scaling with the photo — 18px of badge is 27.9px at ×1.52 —
and at that size its corner sat over the circle it belongs to, so a press meant
to take hold of a repair landed on the × and deleted it instead. 5px of daylight
at the fit and at ×1.52, measured both.

The histogram goes off the photo while a brush is up. It is a panel over the
photo's top-left corner with the pointer on it, and the corner is where a user
paints first: a drag under it painted nothing, and the wheel over it belonged to
the panel rather than to the photo. The crop frame already had it hidden for the
same reason.

ponytail: the pan is bound to the middle button and the space bar rather than to
a second pointer, so a trackpad-only user has the zoom (two fingers) and the
brush's own size (pinch) but no one-finger pan while a tool is up; a modifier
plus drag, or a small hand tool on the toolbar, is the upgrade path. The brush
size now lives behind a modifier that nothing on screen advertises — the chip's
percentage is the only hint — so a stepper in the brush chip is the next thing
to add if that turns out to be a wall. Double-click to zoom is deliberately not
wired up: the layer swallows the press, so the first half of the double-click
lays a spot, and a zoom that leaves a stray repair behind is worse than no
zoom. The middle-button pan only engages past the fit, where the drag is free
rather than a scroll, matching what the stage already did.

Verified: heal-zoom-drag-probe.cjs 28 PASS / 0 FAIL on :5199 and on :8090 after
deploy — the brush arms as a layer, the histogram is gone from the corner and
the circle follows the pointer there, the wheel zooms (1031px -> 1185px) and
leaves the brush its size (24.744px -> 24.744px), Alt+wheel sizes the brush
(24.744 -> 29.62) without moving the zoom, the modified wheel over the brush
sizes it and leaves the window's own frame alone (1440 CSS px, dpr 1, either side of the
notch, so the modified notch is not handed on to the browser's page zoom), a
plain drag paints 0 -> 7 spots, the
middle button moves the photo 215,34 -> 269,66 without painting, space+drag
moves it 269,66 -> 179,6 without painting, and taking hold of a repair moves it
from the centre, from 0.8 inside its edge and from its other side at both the
fit and ×1.52; the × has 5.0px of daylight and is 18px across at both zooms; 0
console errors. brush-edit-probe.cjs 33/0, heal-blotch-lab 12, heal-edge-lab 9,
heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49,
heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all
green, and heal-probe.cjs and mosaic-probe.cjs had their wheelTo helper moved to
Alt+wheel because the plain wheel now zooms the photo, which is the contract
they were testing. Regression on :8090: landing-test 172/0, pro-gate-test 27/0,
award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0.
Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean.
This commit is contained in:
2026-09-24 07:45:47 +07:00
parent abfc6595e7
commit 53554ce42a
+112 -33
View File
@@ -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<HTMLDivElement>) => {
// 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement | null>(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 ? <div className="busy">{t('drop.peeking')}</div> : 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 ? <Histogram url={previewUrl} onClose={() => 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 ? <Histogram url={previewUrl} onClose={() => setShowHist(false)} /> : null}
</div>
)}