diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 5f2d6ca..944422f 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -1020,6 +1020,21 @@ export function Workspace() { setAdjustment({ masks: list.length ? list : undefined }); setMaskSel(null); }, [masks, maskSel, remember, setAdjustment]); + // DELETE takes the chosen shape off the photo, the key the chip beside the + // photo already carries: a shape is chosen by pressing it on the picture, and + // the hand that chose it is on the picture, not on the column. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Delete' && e.key !== 'Backspace') return; + if (e.ctrlKey || e.metaKey || e.altKey || maskSel === null) return; + const el = e.target as HTMLElement | null; + if (el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)) return; + e.preventDefault(); + deleteMask(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [deleteMask, maskSel]); const clearMasks = useCallback(() => { remember(); setAdjustment({ masks: undefined }); @@ -1764,6 +1779,11 @@ export function Workspace() { const close = openParam === key; setOpenParam(close ? null : key); setShowRecipes(false); + // Another chip means another thing being worked on, so the mask tool goes + // down with it: the mask's chips and its own column belong to that tool, + // and a photo that is still armed to draw masks is not what a hand + // reaching for a knob is asking for. + setMaskTool(null); // Opening a ruler clears a strip from another branch; closing one leaves // its own strip standing, so the column the ruler came from is still there. if (!close) setOpenGroup(PARAM_GROUP[key] ?? null); @@ -1774,6 +1794,7 @@ export function Workspace() { const toggleGroup = useCallback( (g: GroupKey) => { const next = openGroup === g ? null : g; + setMaskTool(null); if (next) { // A ruler only survives if it belongs to the strip being opened. if (PARAM_GROUP[openParam ?? ''] !== next) setOpenParam(null); @@ -2830,11 +2851,13 @@ export function Workspace() { - {/* column 2 — FX's GRADIENT MASK, while one is chosen: the shape's own + {/* column 2 — FX's GRADIENT MASK, while a mask is chosen: the shape's own name, the way to take it off the photo, and its knobs as the app's own rulers — one parameter to a row. A radial mask adds the feather - it fades over, since that is the number its rim is made of. */} - {selMask ? ( + it fades over, since that is the number its rim is made of. The + column is the mask tool's, so putting that tool down — which is what + another chip does — takes the column with it. */} + {maskTool && selMask ? (
; + // The centre a mask is pinned and moved by: an ellipse's own centre, and the // middle of a linear ramp — where Lightroom puts its pin too, since the shape is // dragged as one thing from there. @@ -185,11 +189,14 @@ const maskDragged = ( case 'p1': return { ...m, ex: p.fx, ey: p.fy }; case 'move': { - // The whole shape by the same delta: the ends of a ramp travel with its - // middle, and an ellipse travels with its centre. - const ddx = p.fx - m.x; - const ddy = p.fy - m.y; - return { ...m, x: p.fx, y: p.fy, ex: m.ex + ddx, ey: m.ey + ddy }; + // The whole shape by the same delta, taken from the PIN the press landed + // on rather than from the shape's own first point: a ramp's `x` is one of + // its ends, so measuring the hand against it would put that end under the + // pointer and throw the rest of the ramp sideways by half its length. + const pin = maskPin(m); + const ddx = p.fx - pin.x; + const ddy = p.fy - pin.y; + return { ...m, x: m.x + ddx, y: m.y + ddy, ex: m.ex + ddx, ey: m.ey + ddy }; } case 'rot': { if (m.kind === 'radial') return { ...m, angle: Math.atan2(dy, dx) - Math.PI / 2 }; @@ -977,7 +984,7 @@ export function ImageStage({ // hold of that shape rather than drawing a new one. The handler stops the event // where it is, because the layer underneath would read the same press as the // start of a drag. - const grabMask = (i: number, part: MaskPart, e: React.PointerEvent) => { + const grabMask = (i: number, part: MaskPart, e: MaskPointer) => { if (!onMaskEdit) return; e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); @@ -986,7 +993,7 @@ export function ImageStage({ onMaskSel?.(i); }; - const moveMask = (e: React.PointerEvent) => { + const moveMask = (e: MaskPointer) => { const g = maskGrab.current; const was = g ? masks[g.i] : null; if (!g || !was || !onMaskEdit || !box) return; @@ -998,7 +1005,7 @@ export function ImageStage({ maskUndo.current = true; }; - const endMask = (e: React.PointerEvent) => { + const endMask = (e: MaskPointer) => { // A press that never travelled is a choice, not an edit: nothing was // written, so the caller is not asked for a step. maskGrab.current = null; @@ -1051,7 +1058,12 @@ export function ImageStage({ // and an ellipse is its rim plus the inner ellipse the alpha fades to. The // ellipse's semi-axes are fractions of the WIDTH, like HEAL's radius, so both // of them are that times the box's width in the layer's own pixels. - const maskShape = (m: GradientMask | null) => + // The shape itself, as the render will read it. `edge` is the grip the chosen + // shape's own outline takes: a press anywhere on the line it falls along, or + // on an ellipse's rim, turns it — and the nodes that resize it sit on that + // same outline and are drawn over it, so a press on one of those is a resize + // and every other press on the edge is a turn. + const maskShape = (m: GradientMask | null, edge?: (e: MaskPointer) => void) => m && box ? ( ({ + x1: t.x - n.x * L, + y1: t.y - n.y * L, + x2: t.x + n.x * L, + y2: t.y + n.y * L, + }); return ( {ticks.map((t, i) => ( - + ))} + {edge + ? ticks.map((t, i) => ( + + )) + : null} ); })() @@ -1101,6 +1125,19 @@ export function ImageStage({ rx={rx * (1 - m.feather)} ry={ry * (1 - m.feather)} /> + {edge ? ( + + ) : null} ); })()} @@ -1645,10 +1682,10 @@ export function ImageStage({ so what is being drawn is visible while it is drawn. Every mask on the photo keeps a pin — the handle it is moved by, and how one of them is chosen at all — and the chosen one adds the outline of what - the render will do (the two ends of a linear ramp and the ramp - between them; an ellipse and the inner ellipse its feather fades - to) plus the handles that resize it and the one that turns it: the - angle of an ellipse, or the direction a ramp falls in. */} + the render will do (the two ends of a linear ramp and the lines + across it; an ellipse and the inner ellipse its feather fades to), + which is itself the grip that turns the shape, plus the nodes that + resize it: the ramp's own ends, or the ellipse's two axes. */} {maskTool && !cropEditing && box ? (
( - {i === maskSel ? maskShape(m) : null} + {i === maskSel ? maskShape(m, (e) => grabMask(i, 'rot', e)) : null} {(() => { const pin = maskPin(m); return (