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 ? (