web: let a photo's repairs read as the marks they are, not as a field of rings
HEAL draws every repair it holds as the circle the shader fills — the brush's own size at the moment it was laid — so a photo with two repairs has two rings and a photo with twenty has twenty. Each one is loud enough to be the loudest thing on the picture, and none of them is the one the user is looking for: the speck that was mended, and the patch borrowed to mend it, are both inside the ring that covers them. What the ring is for is the spot that is about to be taken hold of, and there is only ever one of those. A repair now wears its circle only while it is the spot in hand — the one the pointer is over, the one just laid, which is the one the user is watching, or the one a press chose, which is the one wearing the ×. The moment the pointer walks elsewhere the ring goes, and what is left is the pair the renderer works with: the patch it borrowed, still dashed, and a soft print of the place it mended. The ring comes back under the pointer, which is where the spot is taken hold of again; the grab was always the geometry — the circle plus a few pixels of slop — and never depended on the ring being drawn, so an idle spot is as easy to move as a ringed one (measured: a 60,40px drag on an idle spot moved it 970.2,390.7 -> 1030.3,430.7). The run a stroke left is the mark of that stroke, so the spots the band stands for keep their boxes and give up their own edges as before, and no print is laid under the band: a row of blurred discs under one translucent band is a second, blurrier band. The one spot of the run that is in hand wears its circle again over the band, because that is the spot a press would take hold of. MOSAIC keeps its rings. Its spots are not repairs to be placed and moved — the circle is the only thing that says where the cover is, and the cover is the edit. ponytail: the print is a blurred translucent disc (14% grey, a soft dark shadow, 1px of blur) rather than a tint read off the pixels under it, so it reads on a photo of any tone without a second pass over the render — the upgrade path, if a print that sits on the repaired pixels themselves is wanted, is the shader the repair already runs through. The ring under the pointer is reported from the pointer's own move events rather than from a hit test per frame, so the one case it does not cover is the pointer that has not moved since the repair landed; that case is covered by the spot just laid being in hand, and a twitch of the mouse covers it everywhere else. The idle/hover split is HEAL's only: MOSAIC's spots keep the border they always had, which is the asymmetry this tool set already had about moving them. Verified: heal-idle-probe.cjs (new, 23 checks) 23 PASS / 0 FAIL on :5199 and on :8090 after deploy — a press on the speck lays one repair and that repair is in hand, so it wears its circle (rgba(255,255,255,0.85)); a repair the pointer has left is idle with the ring given up (rgba(0,0,0,0)), the print of the place it mended left (rgba(127,127,127,0.14)) and the patch it borrowed still dashed and in the same place (640.3,297.9 vs 640.4,297.9); the ring comes back under the pointer; a press chooses it, puts its × on the photo and the × stays while the pointer walks off; laying the next repair takes the choice and the × off the first and gives its ring up; hovering the second leaves the first idle; a drag still takes hold of an idle spot; a run of 13 spots shows the band, no print under it and no ring while the pointer is away, and the spot of the run under the pointer wears its circle again; a MOSAIC spot keeps its ring; 0 page errors. brush-edit-probe.cjs 33/0 — its "every spot of the run gave up its own edge" now reads "but the one in hand", which is the rule this commit adds, and its "a spot with no neighbour keeps its own circle" passes off the repair just laid being in hand. heal-zoom-drag-probe 28/0 (the wheel, the pan, the × and taking hold of a repair, at the fit and at x1.52, unchanged), heal-blotch-lab 12, heal-edge-lab 9, heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49, heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all green on :8090. Regression: landing-test 172/0, pro-gate-test 27/0, award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0. Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean.
This commit is contained in:
@@ -493,6 +493,24 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
edge is the band's now. */
|
||||
.brush-spot[data-band],
|
||||
.brush-source[data-band] { border-color: transparent; box-shadow: none; }
|
||||
/* 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,
|
||||
so an idle spot is the patch it borrowed and a faint print of the place it
|
||||
mended — soft, because it is a record of an edit rather than something to
|
||||
aim at, and it is the ring under the pointer that says the spot can be taken
|
||||
hold of again. */
|
||||
.brush-spot[data-idle] {
|
||||
border-color: transparent;
|
||||
background: rgba(127, 127, 127, 0.14);
|
||||
box-shadow: 0 0 7px 2px rgba(0, 0, 0, 0.25);
|
||||
filter: blur(1px);
|
||||
}
|
||||
/* A run the band already stands for needs no print of its own under it. */
|
||||
.brush-spot[data-idle][data-band] { background: none; box-shadow: none; filter: none; }
|
||||
/* 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. */
|
||||
|
||||
@@ -448,6 +448,12 @@ export function ImageStage({
|
||||
// a press that changed nothing costs none.
|
||||
const grabRef = useRef<{ i: number; part: 'dst' | 'src' } | 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 HEAL spot wears its ring only
|
||||
// while it is in hand or chosen; the rest of them fall back to the patch they
|
||||
// borrowed and a faint print of what they mended, 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);
|
||||
|
||||
// The same decode the eyedropper keeps, under the same rule: one bitmap per
|
||||
@@ -519,7 +525,10 @@ export function ImageStage({
|
||||
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 band each stroke left, read off the spots the recipe holds — the holes
|
||||
@@ -613,6 +622,10 @@ export function ImageStage({
|
||||
return;
|
||||
}
|
||||
const stroke = strokeRef.current;
|
||||
// Which repair is the one in hand, for the circle it wears: the spot under
|
||||
// the pointer. Only while nothing is being painted or dragged, so a stroke
|
||||
// does not flicker the rings it runs over.
|
||||
if (!stroke) setHandSpot(p ? (spotUnder(p)?.i ?? null) : null);
|
||||
if (!stroke || !p) return;
|
||||
const last = stroke.pts[stroke.pts.length - 1];
|
||||
// The spacing is a fraction of the brush, measured in the photo's own
|
||||
@@ -659,7 +672,12 @@ export function ImageStage({
|
||||
return src ? [{ x: p.fx, y: p.fy, r: brushR, sx: src.sx, sy: src.sy }] : [];
|
||||
});
|
||||
onBrushSpots(spots);
|
||||
}, [brushR, brushTool, previewReader, onBrushSpots]);
|
||||
// The repair just laid is the one in hand: the pointer has not moved since,
|
||||
// so no move has reported a hover, and without this the ring of the spot the
|
||||
// user is looking at is the one thing that would not be there. It gives the
|
||||
// ring up again the moment the pointer walks anywhere.
|
||||
if (spots.length) setHandSpot(brushSpots.length + spots.length - 1);
|
||||
}, [brushR, brushSpots.length, brushTool, previewReader, onBrushSpots]);
|
||||
|
||||
// The wheel over the brush is the stage's own, as it is everywhere else in
|
||||
// the app: a repair is aimed at a zoom, and the wheel is the gesture a user
|
||||
@@ -673,7 +691,10 @@ export function ImageStage({
|
||||
|
||||
// The × belongs to the tool that drew it: arming the other brush puts the
|
||||
// spot down.
|
||||
useEffect(() => setSelSpot(null), [brushTool]);
|
||||
useEffect(() => {
|
||||
setSelSpot(null);
|
||||
setHandSpot(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
|
||||
@@ -1164,10 +1185,12 @@ export function ImageStage({
|
||||
{/* FX's brush — HEAL or MOSAIC. The layer swallows the pointer the way
|
||||
the eyedropper's does, so a click paints instead of panning the
|
||||
photo and a drag paints a run; the circle rides the cursor at the
|
||||
size a spot will be, the wheel changes that size, and every spot
|
||||
already laid down keeps its own circle on the picture. A HEAL spot
|
||||
size a spot will be, and the wheel changes that size. A HEAL spot
|
||||
shows the pair the renderer works with — the circle it fills and
|
||||
the patch it borrowed; a MOSAIC spot has nothing to show beside
|
||||
the patch it borrowed — and wears that circle only while it is the
|
||||
spot in hand: the repairs already on the photo read as the marks
|
||||
they left rather than as a field of rings, and the ring comes back
|
||||
under the pointer. A MOSAIC spot has nothing to show beside
|
||||
itself, since what is under it is gone. */}
|
||||
{brushTool && !cropEditing && box ? (
|
||||
<div
|
||||
@@ -1188,12 +1211,16 @@ export function ImageStage({
|
||||
brushDown(e);
|
||||
}}
|
||||
onPointerMove={brushMove}
|
||||
onPointerLeave={() => setBrushPos(null)}
|
||||
onPointerLeave={() => {
|
||||
setBrushPos(null);
|
||||
setHandSpot(null);
|
||||
}}
|
||||
onPointerUp={brushUp}
|
||||
onPointerCancel={() => {
|
||||
strokeRef.current = null;
|
||||
panRef.current = null;
|
||||
setBrushTrail([]);
|
||||
setHandSpot(null);
|
||||
}}
|
||||
>
|
||||
{/* The stroke being painted: the run the pointer has drawn so
|
||||
@@ -1263,6 +1290,13 @@ export function ImageStage({
|
||||
// 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.
|
||||
//
|
||||
// A HEAL spot that is neither in hand nor chosen is idle: it
|
||||
// keeps the patch it borrowed and a faint print of the place it
|
||||
// mended, and gives up the ring, which comes back under the
|
||||
// pointer — that is where the spot is taken hold of again, so
|
||||
// nothing about it is lost.
|
||||
const idle = brushTool === 'heal' && i !== selSpot && i !== handSpot;
|
||||
return (
|
||||
<span className="brush-set" data-key="brush-set" data-i={i} key={i}>
|
||||
{'sx' in s ? (
|
||||
@@ -1277,6 +1311,7 @@ export function ImageStage({
|
||||
className="brush-spot"
|
||||
data-key="brush-spot"
|
||||
data-band={bands?.dst.joined[i] ? '1' : undefined}
|
||||
data-idle={idle ? '1' : undefined}
|
||||
style={at(s.x, s.y)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user