web: take hold of a repair, and draw a stroke as the mark it was

A repair laid on the photo was finished the moment it landed. The brush could
only put more spots down, so a repair aimed one brush-width off the speck was
deleted and laid again, and the patch a spot borrowed — the other half of what
the renderer works with — could not be moved at all. And a drag, which is one
mark of the brush and is drawn as one while it is being painted, came back as
the beads it is stored as: a run of circles a fraction of a radius apart, each
showing its own edge, so a long stroke over a scratch read as twenty repairs.

HEAL's spots can now be taken hold of. A press inside a spot's circle moves that
circle — the hole, or the patch it borrowed, one at a time, since the pair is
the user's to arrange — and the repair is re-rendered under the pointer as it
travels, off the same snapshot the preview and the export read from. A press
that does not travel only chooses the spot, and a chosen spot wears a small ×
just off its circle: click it and that one spot goes, the rest keep their
places, and UNDO takes it back. One gesture is still one step — the undo
boundary is the gesture's first actual change, so a drag is a single step
however far it went and a press that only chose a spot records nothing. The
recipe is written exactly as before, one list of fractions and radii, so a moved
or deleted repair survives a reload, rides UNDO and REDO, and reaches the
exported file through the numbers it always did.

The band a stroke leaves is now read back off the recipe's own spots: the ones
that overlap — which is what a drag lays, one spot every 0.6 of a radius — are
joined into one run and drawn as a single path of the brush's own width, and
only a spot with no such neighbour keeps the circle it is. One path per run
rather than one capsule per pair, because the band is translucent and a pair of
capsules would print a darker patch wherever they meet — which is what a row of
overlapping circles looks like in the first place. Two spots whose circles do
not overlap are two marks and stay two: a band drawn through the gap between
them would be paint that is not there. Nothing about a stroke is stored, so the
band is a reading of the geometry the shader works from, and a saved photo opens
onto the same band it was left with.

Three things came out of the probe rather than out of the design, and all three
are in here because the numbers said so:

  - The × first sat on the spot's corner at the brush's own radius. The default
    brush is 6px wide and the badge is 18px across, so the badge covered the
    circle: the next press on the repair — a user putting the spot down again —
    deleted it. Measured: after undo/redo, a press at the spot's centre left one
    spot instead of two. The badge now sits on the top-right diagonal at the
    circle's edge plus a badge's radius, so it can never take a press meant for
    the spot.
  - The hit test first took the hole before the patch. On the default brush the
    patch the search borrows sits about 8px from the hole it fills — inside any
    reach a pointer can use — so dragging the patch's own centre grabbed the hole
    and the patch never moved (measured: dragging the patch from (0.331, 0.300)
    to the neighbouring speck left it at (0.331, 0.300) and painted a stroke
    instead). The nearest circle now wins, and the hole wins a tie with its own
    patch.
  - The reach was first the circle plus 8px, for a brush turned down to a few
    pixels. heal-probe.cjs went to 48 PASS / 1 FAIL: eight clicks on a grid
    12.8px apart were meant to lay eight repairs and four of them landed, because
    four were within 8px of a patch circle and grabbed the spot instead. The
    reach is now the circle plus 4px: the same probe is 49/0 and the patch's
    centre is still 0px from the pointer that grabs it.

MOSAIC's spots are deliberately not held, and that is the one asymmetry here: a
repair is aimed, a mosaic cell is part of a region that gets painted over, and a
grab that could take a cell would also be one the user could not paint through.
Its cells are drawn as one band like HEAL's, since a mosaic stroke is the same
kind of mark.

Verified, on the rebuilt app at http://localhost:8090 (docker compose up -d
--build frontend):

  brush-edit-probe.cjs (new, 33 checks, 0 FAIL): the band is one path of the
    brush's width through all 15 spots of a drag, its length inside 2px of the
    polyline the spots stand for, every joined spot's border transparent and a
    lone spot's not; dragging the hole moves it to (0.550, 0.550) at the size it
    was laid and the speck comes back at (0.300, 0.300), UNDO/REDO move it back
    and forth in one step each; dragging the patch onto the neighbouring speck
    puts the speck back into the repair (level 5 on a field of 151) and UNDO
    returns it; a press chooses a spot and shows the ×, that press records no
    step (the next UNDO still takes the last repair back), the × deletes that
    spot and no other, and UNDO restores it; a mosaic drag's overlapping cells
    are one band and every cell in the run joins it, painting across mosaic
    already laid down paints more cells, and no mosaic spot is ever offered an ×.
  Unchanged and still green: heal-blotch-lab.cjs 12, heal-edge-lab.cjs 9,
    heal-seam-lab.cjs 10, heal-skia-lab.cjs 28, heal-search-lab.cjs 15,
    heal-probe.cjs 49, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8,
    mosaic-skia-lab.cjs 27, mosaic-probe.cjs 51 — all 0 FAIL.
  Regressions against the rebuilt app, rc=0, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
    tsc --noEmit clean.

ponytail: a stroke is still not stored — the band is derived from the spots that
overlap, so a stroke whose pointer jumped (a coalesced event, a fast flick) lays
spots further apart than the brush is wide and comes back as separate circles,
and a run breaks where the wheel changed the brush size mid-stroke. A stroke id
in the recipe, written once per gesture, is the rung for that, when a photo shows
a run the geometry cannot join. The hit test is the nearest circle within a few
pixels, so a press meant to paint a new repair within that reach of an existing
one moves the existing one instead — a shared modifier to paint regardless is
the rung there. Choosing a spot is an index into HEAL's list, so an UNDO that
changes the list under a chosen spot can leave the × on the spot that took its
place; the × is guarded against an index past the end but not against that. No
keyboard delete: the × is the whole affordance. And the band is drawn only while
the brush is armed — the spots are the recipe's, so nothing outside FX sees
them, which is the same as it was.
This commit is contained in:
2026-09-24 07:21:01 +07:00
parent 88d6d0e648
commit abfc6595e7
3 changed files with 295 additions and 2 deletions
+17
View File
@@ -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
+45
View File
@@ -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 {
+233 -2
View File
@@ -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<BrushPoint[]>([]);
// 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<number | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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({
/>
</svg>
) : 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 ? (
<svg
className="brush-band"
data-key="brush-band"
data-bands={bands.dst.paths.length}
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 }} />
))}
</svg>
) : null}
{bands?.src.paths.length ? (
<svg
className="brush-band band-src"
data-key="brush-band-src"
data-bands={bands.src.paths.length}
viewBox={`0 0 ${box.width} ${box.height}`}
aria-hidden="true"
>
{bands.src.paths.map((b, i) => (
<path key={i} d={b.d} style={{ strokeWidth: b.w }} />
))}
</svg>
) : 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 (
<span className="brush-set" data-key="brush-set" data-i={i} key={i}>
{'sx' in s ? (
<span className="brush-source" data-key="brush-source" style={at(s.sx, s.sy)} />
<span
className="brush-source"
data-key="brush-source"
data-band={bands?.src.joined[i] ? '1' : undefined}
style={at(s.sx, s.sy)}
/>
) : null}
<span className="brush-spot" data-key="brush-spot" style={at(s.x, s.y)} />
<span
className="brush-spot"
data-key="brush-spot"
data-band={bands?.dst.joined[i] ? '1' : undefined}
style={at(s.x, s.y)}
/>
</span>
);
})}
{/* 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 ? (
<button
type="button"
className="brush-del"
data-key="brush-del"
aria-label="Xóa điểm heal"
style={selBox}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => removeSpot(selSpot)}
>
×
</button>
) : null}
{brushPos ? (
<span
className="brush-cursor"