diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index f09de44..ea692c3 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -962,6 +962,22 @@ export function Workspace() { [brushTool, addMosaicSpots, addHealSpots] ); + // Moving or deleting a repair already on the photo is a knob being turned + // rather than a stroke being laid: the stage hands back the list the photo now + // holds, and says whether this change is the one the gesture started with — + // that first one is remembered, so a drag is a single undo step however many + // moves it took, and a press that only chose a spot records nothing at all. An + // empty list drops the field, the same way CLEAR does. + const editBrushSpots = useCallback( + (spots: (HealSpot | MosaicSpot)[], undo: boolean) => { + if (undo) remember(); + const list = spots.length ? spots : undefined; + if (brushTool === 'mosaic') setAdjustment({ mosaic: list as MosaicSpot[] | undefined }); + else setAdjustment({ heal: list as HealSpot[] | undefined }); + }, + [brushTool, remember, setAdjustment] + ); + // FRAME's STRAIGHTEN rides the same one-edit-per-gesture rule as a knob, so // dragging the ruler is one undo step instead of one per degree. const setStraightenOnce = useCallback( @@ -2848,6 +2864,7 @@ export function Workspace() { brushR={brushR} onBrushR={setBrushR} onBrushSpots={addBrushSpots} + onBrushEdit={editBrushSpots} pickPanel={pickPanel} pickPanelAt={pickedAt} // FRAME's custom mark owns a box on the photo while its panel is diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 4f3c234..0a2b5bc 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -466,6 +466,51 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } } .brush-spot { border: 1px solid rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); } .brush-source { border: 1px dashed rgba(255, 255, 255, 0.7); } +/* The spots a stroke left are drawn as the one mark it was painted as: a band + of the brush's own width through them, in the same translucent white with the + same dark hairline the circles wear, so the band reads on a white photo too. + The dashed one is the patch HEAL borrowed, which is a band of its own and is + meant to look borrowed. A stroke is one path, so where it crosses itself its + translucency is laid once rather than twice — that doubling-up is exactly + what a row of overlapping circles looks like. */ +.brush-band { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.45)); +} +.brush-band path { + fill: none; + stroke: rgba(255, 255, 255, 0.18); + stroke-linecap: round; + stroke-linejoin: round; +} +.brush-band.band-src path { stroke: rgba(255, 255, 255, 0.12); stroke-dasharray: 5 5; } +/* A spot the band stands for: its box on the photo is still the spot's, its own + edge is the band's now. */ +.brush-spot[data-band], +.brush-source[data-band] { border-color: transparent; box-shadow: none; } +/* 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. */ +.brush-del { + position: absolute; + transform: translate(-50%, -50%); + width: 18px; + height: 18px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.9); + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font: 12px/1 sans-serif; + cursor: pointer; + pointer-events: auto; +} +.brush-del:hover { background: var(--accent); } /* The mixer's readout: the sampled swatch, its RGB and HSL, and the band the ruler is pointed at. */ .hsl-readout { diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 382a561..0877b9e 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -74,6 +74,49 @@ const wmBoxOf = (mark: WmMark, fontPx: number) => { }; }; +// The spots a stroke laid down, read back as the one mark it was painted as. +// A drag lays a spot every 0.6 of a radius (BRUSH_SPACING), so its spots always +// overlap: consecutive spots of the same brush that do are one run, drawn as a +// single path of the brush's own width — one stroke of paint, not a row of +// circles each showing its own edge, and a single path rather than one capsule +// per pair so the band's own translucency never doubles up on itself where two +// of them would meet. A spot that overlaps no such neighbour keeps the circle +// it is. All of it is read off the recipe, which is where the run comes from +// after a reload, an undo, or a copy of the recipe to another photo: a stroke +// is not stored anywhere. +const strokeRuns = (spots: { x: number; y: number; r: number }[], box: { width: number; height: number }) => { + const paths: { d: string; w: number }[] = []; + const joined = spots.map(() => false); + let run: number[] = []; + const flush = () => { + if (run.length > 1) { + const w = 2 * spots[run[0]].r * box.width; + const at = (i: number) => `${spots[i].x * box.width} ${spots[i].y * box.height}`; + paths.push({ d: `M ${run.map(at).join(' L ')}`, w }); + for (const i of run) joined[i] = true; + } + run = []; + }; + for (let i = 0; i + 1 < spots.length; i++) { + const a = spots[i]; + const b = spots[i + 1]; + // The circles are round in the render's own pixels, so the gap is measured + // there too — the band then reads the same on a portrait photo as on a + // landscape one. Different brushes are different marks and are not joined. + const gap = Math.hypot((a.x - b.x) * box.width, (a.y - b.y) * box.height); + // A spot with no radius is one this run is not about — the patch band's + // patchless spots — and two of those are not a band either. + if (a.r !== b.r || a.r <= 0 || gap > (a.r + b.r) * box.width) { + flush(); + continue; + } + if (!run.length) run = [i]; + run.push(i + 1); + } + flush(); + return { paths, joined }; +}; + // The centre column: drop target, preview canvas, and the PEEK comparison. // Dragging a photo anywhere on the page works, not just on the frame — the // whole stage is the drop zone. @@ -116,6 +159,7 @@ export function ImageStage({ brushR = 0.02, onBrushR, onBrushSpots, + onBrushEdit, dims = null, }: { previewUrl: string | null; @@ -178,6 +222,11 @@ export function ImageStage({ brushR?: number; onBrushR?: (r: number) => void; onBrushSpots?: (spots: (HealSpot | MosaicSpot)[]) => void; + // Moving or deleting a spot already on the photo, in the same shape: the list + // the photo now holds, and whether this is the change the gesture starts with + // — which is what makes a drag one undo step and a press that only selects + // one cost no step at all. + onBrushEdit?: (spots: (HealSpot | MosaicSpot)[], undo: boolean) => void; // The photo's own pixel size, as the stage stands: the file's shape turned by // the quarter turn and cut by a committed crop, so the number in the row under // the photo is the resolution an export at the photo's own size renders. @@ -365,9 +414,23 @@ export function ImageStage({ // spots land with the gesture rather than under it, and that is what keeps one // stroke to one undo step however long it was — while the trail being painted // is drawn from the pointer track as it moves. + // + // A spot already on the photo is the other thing the pointer can take: HEAL's + // spots are aimed repairs, so pressing one and dragging moves it — the hole, + // or the patch it borrowed — and a press that never travels only chooses it, + // which is what puts its × on the photo. MOSAIC's spots are not held: they are + // cells of a covered region rather than a repair to place, and a mosaic that + // could be grabbed would also be one the user cannot paint over again. type BrushPoint = { fx: number; fy: number; px: number; py: number }; const strokeRef = useRef<{ pts: BrushPoint[]; w: number } | null>(null); const [brushTrail, setBrushTrail] = useState([]); + // The circle the pointer has hold of, then the spot a press chose, which + // wears the × that deletes it. undoPushedRef is the gesture's own undo + // boundary: the step is taken on the first change, so a drag is one step and + // a press that changed nothing costs none. + const grabRef = useRef<{ i: number; part: 'dst' | 'src' } | null>(null); + const [selSpot, setSelSpot] = useState(null); + const undoPushedRef = useRef(false); // The same decode the eyedropper keeps, under the same rule: one bitmap per // URL, and HEAL's search reads it a few hundred times rather than once. @@ -396,6 +459,76 @@ export function ImageStage({ return { fx, fy, px: clientX - rect.left, py: clientY - rect.top }; }, []); + // Which spot the pointer is on, if any: HEAL's own circles, holes and the + // patches they borrowed alike. The nearest one wins — the patch a repair + // borrowed sits a few pixels from the hole it fills on a small brush, so + // taking the first circle found would hand the patch's own centre to the + // hole — and the hole wins a tie with its own patch. The reach is grown past + // the circle so a brush turned down to a few pixels is still a repair the + // user can take hold of. + const spotUnder = useCallback( + (p: BrushPoint) => { + if (!box || brushTool !== 'heal' || !onBrushEdit) return null; + let best: { i: number; part: 'dst' | 'src' } | null = null; + let bestGap = Infinity; + for (let i = brushSpots.length - 1; i >= 0; i--) { + const s = brushSpots[i]; + // The circle itself, with a few pixels of slop: a brush turned down to + // a couple of pixels is still a repair the user can take hold of, and a + // press a little way off one is still a press that means to paint. + const reach = Math.max(4, s.r * box.width); + const at = [ + { part: 'dst' as const, x: s.x, y: s.y }, + ...('sx' in s ? [{ part: 'src' as const, x: s.sx, y: s.sy }] : []), + ]; + for (const c of at) { + const gap = Math.hypot((p.fx - c.x) * box.width, (p.fy - c.y) * box.height); + if (gap > reach || gap >= bestGap) continue; + best = { i, part: c.part }; + bestGap = gap; + } + } + return best; + }, + [box, brushSpots, brushTool, onBrushEdit] + ); + + // The × on the spot that is being held: that one spot goes and the rest keep + // their places — one step, like every other edit the brush makes. + const removeSpot = (i: number) => { + if (!onBrushEdit || !(i < brushSpots.length)) return; + onBrushEdit( + brushSpots.filter((_, j) => j !== i), + true + ); + setSelSpot(null); + }; + + // The band each stroke left, read off the spots the recipe holds — the holes + // one way and, for HEAL, the patches they borrowed the other. Nothing about a + // stroke is stored, so this is where "that long drag was one mark" comes from, + // and it is the same band after a reload. + const bands = useMemo(() => { + if (!box) return null; + // Only HEAL's spots borrowed anything, so a spot with no patch has no + // circle in the patch band: it is mapped to its own centre with no radius, + // which joins nothing and draws nothing. + const src = brushSpots.map((s) => ('sx' in s ? { x: s.sx, y: s.sy, r: s.r } : { x: s.x, y: s.y, r: 0 })); + return { dst: strokeRuns(brushSpots, box), src: strokeRuns(src, box) }; + }, [brushSpots, box]); + + // 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. + 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)` }; + })() + : null; + const brushDown = (e: React.PointerEvent) => { if (!onBrushSpots) return; const p = brushPoint(e.clientX, e.clientY); @@ -403,6 +536,17 @@ export function ImageStage({ // Capture, so a stroke that runs off the photo still ends where the // pointer does instead of leaving a spot hanging at the edge. e.currentTarget.setPointerCapture(e.pointerId); + // A repair already on the photo is what the pointer takes hold of, so the + // press moves it rather than painting over it; anywhere else paints the + // stroke it always did. + const hit = spotUnder(p); + if (hit) { + grabRef.current = hit; + undoPushedRef.current = false; + setSelSpot(hit.i); + return; + } + setSelSpot(null); strokeRef.current = { pts: [p], w: imgRef.current?.getBoundingClientRect().width ?? 0 }; setBrushTrail([p]); }; @@ -410,6 +554,19 @@ export function ImageStage({ const brushMove = (e: React.PointerEvent) => { const p = brushPoint(e.clientX, e.clientY); setBrushPos(p ? { fx: p.fx, fy: p.fy } : null); + const grab = grabRef.current; + if (grab && onBrushEdit) { + // Off the photo the spot stays where it was: a repair is a place on the + // picture, and a pointer past the frame has left it. + if (!p) return; + const was = brushSpots[grab.i]; + const next = brushSpots.slice(); + next[grab.i] = + grab.part === 'src' && 'sx' in was ? { ...was, sx: p.fx, sy: p.fy } : { ...was, x: p.fx, y: p.fy }; + onBrushEdit(next, !undoPushedRef.current); + undoPushedRef.current = true; + return; + } const stroke = strokeRef.current; if (!stroke || !p) return; const last = stroke.pts[stroke.pts.length - 1]; @@ -421,6 +578,12 @@ export function ImageStage({ }; const brushUp = useCallback(async () => { + 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. + grabRef.current = null; + return; + } const stroke = strokeRef.current; strokeRef.current = null; setBrushTrail([]); @@ -467,6 +630,10 @@ export function ImageStage({ return () => el.removeEventListener('wheel', onWheel); }, [onBrushR, brushR, brushTool]); + // The × belongs to the tool that drew it: arming the other brush puts the + // spot down. + useEffect(() => setSelSpot(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 // into it. @@ -970,6 +1137,40 @@ export function ImageStage({ /> ) : null} + {/* The band each stroke left, under the circles: the spots the + run is made of, joined into the one mark the drag painted — + the holes here, and HEAL's borrowed patches in the dashed band + beside them. What is drawn is the recipe's own spots, so the + band is what survives a reload, an undo and a copy of the + recipe to another photo, where a stroke of its own would not. + Laid in the same pass, the two bands cannot double up on + themselves and print a darker patch at every joint. */} + {bands?.dst.paths.length ? ( + + ) : null} + {bands?.src.paths.length ? ( + + ) : null} {brushSpots.map((s, i) => { // r is a fraction of the photo's WIDTH, so the circle is round // whatever the picture's shape is — the same measurement the @@ -981,15 +1182,45 @@ export function ImageStage({ width: d, height: d, }); + // A spot the band stands for keeps its place and its box on the + // 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. return ( {'sx' in s ? ( - + ) : null} - + ); })} + {/* The spot the pointer has chosen, and the × that deletes it. It + sits on the spot's own corner at the brush's size, so which of + the repairs the × is for is never in question. */} + {selBox && selSpot !== null ? ( + + ) : null} {brushPos ? (