From c384d421d6c0cc823c1659206883031fa526cb06 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 14 Oct 2026 18:07:18 +0700 Subject: [PATCH] feat(heal): group brush stroke circles into unified patches with contour outline, bulk drag, and bulk delete --- docker/frontend/src/styles/app.css | 66 +++++-- docker/frontend/src/ui/ImageStage.tsx | 257 +++++++++++++++++++------ docker/recipescam-images.tar.gz.sha256 | 2 +- 3 files changed, 244 insertions(+), 81 deletions(-) diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 142c13b..379f429 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -788,9 +788,13 @@ button.hint:disabled { cursor: default; } /* No run in hand here: the band is the tool's record, not the photo's own colour, so it steps out of the way of what it was drawn over. */ .brush-band path[data-idle] { opacity: 0; } -/* 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] { border-color: transparent; box-shadow: none; } +/* A spot the band stands for: the unified contour path represents the whole stroke, + so individual member circles remain hidden */ +.brush-spot[data-band] { + border-color: transparent !important; + box-shadow: none !important; + opacity: 0 !important; +} /* 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, and a print of the mark it made is a wash of the tool's own over the photo — @@ -799,28 +803,58 @@ button.hint:disabled { cursor: default; } says the spot can be taken hold of again, and the photo carries only the repair itself until then. */ .brush-spot[data-idle] { opacity: 0; } -/* 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. */ + +/* Contour border outlining the entire unified stroke / patch */ +.brush-stroke-contour { + fill: rgba(255, 255, 255, 0.08); + stroke: rgba(255, 255, 255, 0.85); + stroke-width: 1.5px; + stroke-dasharray: 4 3; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)); + pointer-events: none; + transition: opacity 0.15s ease, stroke 0.15s ease, fill 0.15s ease; +} +.brush-stroke-contour[data-selected="1"] { + fill: rgba(230, 126, 34, 0.22); + stroke: var(--accent, #e67e22); + stroke-width: 2px; + stroke-dasharray: none; + filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.9)); +} +.brush-stroke-contour[data-hover="1"]:not([data-selected="1"]) { + fill: rgba(255, 255, 255, 0.15); + stroke: #ffffff; + stroke-width: 1.5px; +} +.brush-stroke-contour[data-idle] { + opacity: 0; +} + +/* The × on the spot the pointer chose. It sits on the stroke's corner and is the + one thing on this layer the pointer is meant to click, deleting the whole stroke/patch. */ .brush-del { position: absolute; transform: translate(-50%, -50%); - width: 18px; - height: 18px; + width: 22px; + height: 22px; padding: 0; - border: 1px solid rgba(255, 255, 255, 0.9); + border: 1.5px solid rgba(255, 255, 255, 0.95); border-radius: 50%; - background: rgba(0, 0, 0, 0.6); + background: rgba(220, 38, 38, 0.9); color: #fff; - font: 12px/1 sans-serif; + font: bold 14px/20px sans-serif; cursor: pointer; pointer-events: auto; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.12s ease, background 0.12s ease; +} +.brush-del:hover { + background: #dc2626; + transform: translate(-50%, -50%) scale(1.15); } -.brush-del:hover { background: var(--accent); } /* FX's gradient masks — LINEAR and RADIAL GRADIENT, one layer. It takes the pointer while a mask tool is armed, so a drag draws the next shape instead of panning the photo; the shapes already on the photo answer only through their diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index f644376..cbcf32f 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -89,38 +89,138 @@ const wmBoxOf = (mark: WmMark, fontPx: number) => { // 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; spots: 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}`; - // The run's own spots travel with the band, so the band can be shown for a - // run the pointer is on and left off for the runs it is not. - paths.push({ d: `M ${run.map(at).join(' L ')}`, w, spots: run.slice() }); - for (const i of run) joined[i] = true; +// Traces a smooth, closed SVG contour around a stroke (capsule hull around spots at distance r) +const strokeContourPath = ( + spots: { x: number; y: number; r: number }[], + box: { width: number; height: number } +): string => { + if (!spots.length) return ''; + const bw = box.width; + const bh = box.height; + if (spots.length === 1) { + const s = spots[0]; + const cx = s.x * bw; + const cy = s.y * bh; + const r = s.r * bw; + return `M ${(cx - r).toFixed(1)} ${cy.toFixed(1)} A ${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(cx + r).toFixed(1)} ${cy.toFixed(1)} A ${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(cx - r).toFixed(1)} ${cy.toFixed(1)} Z`; + } + + const pts = spots.map((s) => ({ x: s.x * bw, y: s.y * bh, r: s.r * bw })); + const r = pts[0].r; + const n = pts.length; + + const leftPts: { x: number; y: number }[] = []; + const rightPts: { x: number; y: number }[] = []; + + for (let i = 0; i < n; i++) { + let dx = 0; + let dy = 0; + if (i === 0) { + dx = pts[1].x - pts[0].x; + dy = pts[1].y - pts[0].y; + } else if (i === n - 1) { + dx = pts[n - 1].x - pts[n - 2].x; + dy = pts[n - 1].y - pts[n - 2].y; + } else { + dx = pts[i + 1].x - pts[i - 1].x; + dy = pts[i + 1].y - pts[i - 1].y; } - run = []; - }; + const len = Math.hypot(dx, dy) || 1; + const nx = (-dy / len) * r; + const ny = (dx / len) * r; + leftPts.push({ x: pts[i].x + nx, y: pts[i].y + ny }); + rightPts.push({ x: pts[i].x - nx, y: pts[i].y - ny }); + } + + let d = `M ${leftPts[0].x.toFixed(1)} ${leftPts[0].y.toFixed(1)}`; + for (let i = 1; i < n; i++) { + d += ` L ${leftPts[i].x.toFixed(1)} ${leftPts[i].y.toFixed(1)}`; + } + d += ` A ${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${rightPts[n - 1].x.toFixed(1)} ${rightPts[n - 1].y.toFixed(1)}`; + for (let i = n - 2; i >= 0; i--) { + d += ` L ${rightPts[i].x.toFixed(1)} ${rightPts[i].y.toFixed(1)}`; + } + d += ` A ${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${leftPts[0].x.toFixed(1)} ${leftPts[0].y.toFixed(1)} Z`; + + return d; +}; + +// Finds all spot indices belonging to the same stroke as targetIndex +const getStrokeIndices = ( + spotIndex: number, + spots: { x: number; y: number; r: number }[], + box: { width: number; height: number } | null +): number[] => { + if (spotIndex < 0 || spotIndex >= spots.length || !box) return [spotIndex]; + let start = spotIndex; + while (start > 0) { + const prev = spots[start - 1]; + const curr = spots[start]; + const gap = Math.hypot((curr.x - prev.x) * box.width, (curr.y - prev.y) * box.height); + if (prev.r === curr.r && prev.r > 0 && gap <= (prev.r + curr.r) * box.width) { + start--; + } else { + break; + } + } + let end = spotIndex; + while (end + 1 < spots.length) { + const curr = spots[end]; + const next = spots[end + 1]; + const gap = Math.hypot((next.x - curr.x) * box.width, (next.y - curr.y) * box.height); + if (next.r === curr.r && next.r > 0 && gap <= (curr.r + next.r) * box.width) { + end++; + } else { + break; + } + } + const result: number[] = []; + for (let k = start; k <= end; k++) result.push(k); + return result; +}; + +interface StrokeRun { + spots: number[]; + contourPath: string; + w: number; +} + +const strokeRuns = ( + spots: { x: number; y: number; r: number }[], + box: { width: number; height: number } +): { paths: StrokeRun[]; joined: boolean[] } => { + const paths: StrokeRun[] = []; + const joined = spots.map(() => false); + if (!spots.length) return { paths, joined }; + + let currentRun: number[] = [0]; 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 no run is about, 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 (a.r === b.r && a.r > 0 && gap <= (a.r + b.r) * box.width) { + currentRun.push(i + 1); + } else { + const runSpots = currentRun.map((idx) => spots[idx]); + const contourPath = strokeContourPath(runSpots, box); + const w = 2 * (runSpots[0]?.r ?? 0.012) * box.width; + paths.push({ spots: currentRun.slice(), contourPath, w }); + if (currentRun.length > 1) { + for (const idx of currentRun) joined[idx] = true; + } + currentRun = [i + 1]; } - if (!run.length) run = [i]; - run.push(i + 1); } - flush(); + if (currentRun.length) { + const runSpots = currentRun.map((idx) => spots[idx]); + const contourPath = strokeContourPath(runSpots, box); + const w = 2 * (runSpots[0]?.r ?? 0.012) * box.width; + paths.push({ spots: currentRun.slice(), contourPath, w }); + if (currentRun.length > 1) { + for (const idx of currentRun) joined[idx] = true; + } + } + return { paths, joined }; }; @@ -611,13 +711,12 @@ export function ImageStage({ const [brushTrail, setBrushTrail] = useState([]); // 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(null); + // The stroke in hand being dragged as a unified entity + const grabRef = useRef<{ + anchor: { fx: number; fy: number }; + spots: { idx: number; x: number; y: number }[]; + } | 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 spot wears its ring only while it - // is in hand or chosen; the rest keep a faint print of the mark they made, so - // a photo with a dozen repairs is not a photo with a dozen rings. const [handSpot, setHandSpot] = useState(null); const undoPushedRef = useRef(false); @@ -662,19 +761,37 @@ export function ImageStage({ [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 - ); - // 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 × on the stroke that is being held: deletes the ENTIRE stroke/patch at once + const removeSpot = useCallback( + (i: number) => { + if (!onBrushEdit || !(i < brushSpots.length) || !box) return; + const strokeIndices = getStrokeIndices(i, brushSpots, box); + const toRemove = new Set(strokeIndices); + onBrushEdit( + brushSpots.filter((_, j) => !toRemove.has(j)), + true + ); + setSelSpot(null); + setHandSpot(null); + }, + [box, brushSpots, onBrushEdit] + ); + + // Keyboard delete shortcut for selected stroke + useEffect(() => { + const down = (e: KeyboardEvent) => { + if ((e.key === 'Delete' || e.key === 'Backspace') && selSpot !== null) { + const target = e.target as HTMLElement | null; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) { + return; + } + e.preventDefault(); + removeSpot(selSpot); + } + }; + window.addEventListener('keydown', down); + return () => window.removeEventListener('keydown', down); + }, [selSpot, removeSpot]); // The band each stroke left, read off the spots the recipe holds. Nothing // about a stroke is stored, so this is where "that long drag was one mark" @@ -705,13 +822,9 @@ export function ImageStage({ })() : null; - // Whether a run's own band is the one in hand: the pointer is on one of the - // spots the run is made of, or a press has chosen one of them. A band is the - // tool's mark over the photo and not the photo, so every other run's band — - // and every other repair's print — stays off it, and what a repair looks like - // is what the renderer made of it. - const bandOn = (b: { spots: number[] }) => { + const bandOn = (b: StrokeRun) => { if (selSpot !== null && b.spots.includes(selSpot)) return true; + if (handSpot !== null && b.spots.includes(handSpot)) return true; if (!brushPos || !box) return false; return b.spots.some((i) => { const s = brushSpots[i]; @@ -744,7 +857,11 @@ export function ImageStage({ // stroke it always did. const hit = spotUnder(p); if (hit !== null) { - grabRef.current = hit; + const strokeIndices = getStrokeIndices(hit, brushSpots, box); + grabRef.current = { + anchor: { fx: p.fx, fy: p.fy }, + spots: strokeIndices.map((idx) => ({ idx, x: brushSpots[idx].x, y: brushSpots[idx].y })), + }; undoPushedRef.current = false; setSelSpot(hit); return; @@ -765,12 +882,17 @@ export function ImageStage({ const p = brushPoint(e.clientX, e.clientY); setBrushPos(p ? { fx: p.fx, fy: p.fy } : null); const grab = grabRef.current; - if (grab !== null && 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; + if (grab !== null && onBrushEdit && p) { + const dx = p.fx - grab.anchor.fx; + const dy = p.fy - grab.anchor.fy; const next = brushSpots.slice(); - next[grab] = { ...next[grab], x: p.fx, y: p.fy }; + for (const item of grab.spots) { + next[item.idx] = { + ...next[item.idx], + x: Math.max(0, Math.min(1, item.x + dx)), + y: Math.max(0, Math.min(1, item.y + dy)), + }; + } onBrushEdit(next, !undoPushedRef.current); undoPushedRef.current = true; return; @@ -1764,14 +1886,21 @@ export function ImageStage({ viewBox={`0 0 ${box.width} ${box.height}`} aria-hidden="true" > - {bands.dst.paths.map((b, i) => ( - - ))} + {bands.dst.paths.map((b, i) => { + const isSel = selSpot !== null && b.spots.includes(selSpot); + const isHover = handSpot !== null && b.spots.includes(handSpot); + const on = isSel || isHover || bandOn(b); + return ( + + ); + })} ) : null} {brushSpots.map((s, i) => { diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 23353ae..bc08126 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -ca55c46c0374f76a007f5309759bf25e07fcee625483a87fe96cce5c34a93645 recipescam-images.tar.gz +aec775faf6086b3a1e304e714b937178ddff1a5148fa050e4fa415f8b576799a recipescam-images.tar.gz