feat(heal): group brush stroke circles into unified patches with contour outline, bulk drag, and bulk delete

This commit is contained in:
2026-10-14 18:07:18 +07:00
parent 4b5e55bc50
commit c384d421d6
3 changed files with 244 additions and 81 deletions
+50 -16
View File
@@ -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
+193 -64
View File
@@ -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<BrushPoint[]>([]);
// 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<number | null>(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<number | null>(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<number | null>(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) => (
<path
key={i}
d={b.d}
style={{ strokeWidth: b.w }}
data-idle={bandOn(b) ? undefined : '1'}
/>
))}
{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 (
<path
key={i}
d={b.contourPath}
className="brush-stroke-contour"
data-selected={isSel ? '1' : undefined}
data-hover={isHover ? '1' : undefined}
data-idle={on ? undefined : '1'}
/>
);
})}
</svg>
) : null}
{brushSpots.map((s, i) => {
+1 -1
View File
@@ -1 +1 @@
ca55c46c0374f76a007f5309759bf25e07fcee625483a87fe96cce5c34a93645 recipescam-images.tar.gz
aec775faf6086b3a1e304e714b937178ddff1a5148fa050e4fa415f8b576799a recipescam-images.tar.gz