diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 0a2b5bc..99101e7 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -493,6 +493,24 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } edge is the band's now. */ .brush-spot[data-band], .brush-source[data-band] { border-color: transparent; box-shadow: none; } +/* A repair the pointer is not on and no press has chosen: the ring would be one + more circle on a photo that already carries as many repairs as the user laid, + so an idle spot is the patch it borrowed and a faint print of the place it + mended — soft, because it is a record of an edit rather than something to + aim at, and it is the ring under the pointer that says the spot can be taken + hold of again. */ +.brush-spot[data-idle] { + border-color: transparent; + background: rgba(127, 127, 127, 0.14); + box-shadow: 0 0 7px 2px rgba(0, 0, 0, 0.25); + filter: blur(1px); +} +/* A run the band already stands for needs no print of its own under it. */ +.brush-spot[data-idle][data-band] { background: none; box-shadow: none; filter: none; } +/* In hand, one of the run's spots is the circle it would be taken hold of by, so + it wears that circle again over the band — the ring is what says the spot is + under the pointer. */ +.brush-spot[data-band]:not([data-idle]) { border-color: rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); } /* The × on the spot the pointer chose. It sits on the spot's corner and is the one thing on this layer the pointer is meant to click, so it takes the pointer back off the paint. */ diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 9e9dab6..5b1b388 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -448,6 +448,12 @@ export function ImageStage({ // a press that changed nothing costs none. const grabRef = useRef<{ i: number; part: 'dst' | 'src' } | null>(null); const [selSpot, setSelSpot] = useState(null); + // The spot in hand: the one the pointer is over, or the repair just laid, + // which is the one the user is looking at. A HEAL spot wears its ring only + // while it is in hand or chosen; the rest of them fall back to the patch they + // borrowed and a faint print of what they mended, so a photo with a dozen + // repairs is not a photo with a dozen rings. + const [handSpot, setHandSpot] = useState(null); const undoPushedRef = useRef(false); // The same decode the eyedropper keeps, under the same rule: one bitmap per @@ -519,7 +525,10 @@ export function ImageStage({ brushSpots.filter((_, j) => j !== i), true ); + // The indices move up behind the one that went, so neither the choice nor + // the spot under the pointer is a spot any more. setSelSpot(null); + setHandSpot(null); }; // The band each stroke left, read off the spots the recipe holds — the holes @@ -613,6 +622,10 @@ export function ImageStage({ return; } const stroke = strokeRef.current; + // Which repair is the one in hand, for the circle it wears: the spot under + // the pointer. Only while nothing is being painted or dragged, so a stroke + // does not flicker the rings it runs over. + if (!stroke) setHandSpot(p ? (spotUnder(p)?.i ?? null) : null); if (!stroke || !p) return; const last = stroke.pts[stroke.pts.length - 1]; // The spacing is a fraction of the brush, measured in the photo's own @@ -659,7 +672,12 @@ export function ImageStage({ return src ? [{ x: p.fx, y: p.fy, r: brushR, sx: src.sx, sy: src.sy }] : []; }); onBrushSpots(spots); - }, [brushR, brushTool, previewReader, onBrushSpots]); + // The repair just laid is the one in hand: the pointer has not moved since, + // so no move has reported a hover, and without this the ring of the spot the + // user is looking at is the one thing that would not be there. It gives the + // ring up again the moment the pointer walks anywhere. + if (spots.length) setHandSpot(brushSpots.length + spots.length - 1); + }, [brushR, brushSpots.length, brushTool, previewReader, onBrushSpots]); // 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 @@ -673,7 +691,10 @@ export function ImageStage({ // The × belongs to the tool that drew it: arming the other brush puts the // spot down. - useEffect(() => setSelSpot(null), [brushTool]); + useEffect(() => { + setSelSpot(null); + setHandSpot(null); + }, [brushTool]); // The img's box at scale 1 — the natural size fitted into the wrap, never // enlarged. Computed rather than measured so the transform cannot feed back @@ -1164,10 +1185,12 @@ export function ImageStage({ {/* FX's brush — HEAL or MOSAIC. The layer swallows the pointer the way the eyedropper's does, so a click paints instead of panning the photo and a drag paints a run; the circle rides the cursor at the - size a spot will be, the wheel changes that size, and every spot - already laid down keeps its own circle on the picture. A HEAL spot + size a spot will be, and the wheel changes that size. A HEAL spot shows the pair the renderer works with — the circle it fills and - the patch it borrowed; a MOSAIC spot has nothing to show beside + the patch it borrowed — and wears that circle only while it is the + spot in hand: the repairs already on the photo read as the marks + they left rather than as a field of rings, and the ring comes back + under the pointer. A MOSAIC spot has nothing to show beside itself, since what is under it is gone. */} {brushTool && !cropEditing && box ? (
setBrushPos(null)} + onPointerLeave={() => { + setBrushPos(null); + setHandSpot(null); + }} onPointerUp={brushUp} onPointerCancel={() => { strokeRef.current = null; panRef.current = null; setBrushTrail([]); + setHandSpot(null); }} > {/* The stroke being painted: the run the pointer has drawn so @@ -1263,6 +1290,13 @@ export function ImageStage({ // photo — it is still the spot the shader works from — and gives // up only its own edge, which is the whole of what a row of // circles was showing. + // + // A HEAL spot that is neither in hand nor chosen is idle: it + // keeps the patch it borrowed and a faint print of the place it + // mended, and gives up the ring, which comes back under the + // pointer — that is where the spot is taken hold of again, so + // nothing about it is lost. + const idle = brushTool === 'heal' && i !== selSpot && i !== handSpot; return ( {'sx' in s ? ( @@ -1277,6 +1311,7 @@ export function ImageStage({ className="brush-spot" data-key="brush-spot" data-band={bands?.dst.joined[i] ? '1' : undefined} + data-idle={idle ? '1' : undefined} style={at(s.x, s.y)} />