feat(heal): group brush stroke circles into unified patches with contour outline, bulk drag, and bulk delete
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
ca55c46c0374f76a007f5309759bf25e07fcee625483a87fe96cce5c34a93645 recipescam-images.tar.gz
|
||||
aec775faf6086b3a1e304e714b937178ddff1a5148fa050e4fa415f8b576799a recipescam-images.tar.gz
|
||||
|
||||
Reference in New Issue
Block a user