web: let a mask's own edge turn it, and another chip put it down

A mask could only be taken hold of by its pin or by one of the handles, and the
pin of a linear mask sits on the middle of its own line, so the press a hand aims
at "that line" was the press that moved the shape. What it moved by was worse: a
ramp is stored as the two points it falls between, and 'move' measured the hand
against the shape's first point, so the first move of a drag put that end under
the pointer and threw the rest of the ramp sideways by half its length — the
shape landed somewhere off to the side of the hand instead of under it. The move
now takes its delta from the pin the press landed on, which is what the press
wanted, and which for an ellipse was already the centre it moved by.

The drawn outline is the shape's own handle. A press on the line a ramp falls
across — any of the three, anywhere along it — or on an ellipse's rim, turns the
shape; the nodes that resize it sit on that same outline and are drawn over it,
so a press on one of those is still a resize. That is the Lightroom gesture: the
edge is what you drag to aim a gradient, and the ends and the axes are what you
drag to lay it out again. Until now a press on the edge was a press on the layer,
which read it as the start of the next shape and laid a second mask down while
the first was being turned. The band is sixteen screen pixels wide and does not
scale with the zoom, so a finger finds it at any size of photo.

A mask is chosen by pressing it on the picture, so the keys the hand is on are
DELETE, which is what the chip beside the photo already says. And the column the
chosen mask puts up — its name, DELETE, and its knobs as rulers — belongs to the
mask tool: another chip puts that tool down and takes the column with it, because
a photo still armed to draw shapes is not what a hand reaching for a knob is
asking for. The shape stays chosen and stays live in the render; the mask chip
brings its column straight back.

ponytail: Backspace is read as DELETE as well, since that is the key the label
sits under on a Mac keyboard, and is the one shortcut this adds. A mask can still
only be chosen while a mask tool is armed — the shapes answer the pointer through
the layer that only exists then — so the column coming back with the mask chip is
also the way back to a shape drawn a moment ago.

Verified: tsc clean; mask-probe 50/0 on the dev server and again on 8090 — a ramp
lands with its pin on its middle, dragging its own edge turns it (ends at 0.200
and 0.800, one pin, no second shape laid), the same drag on an ellipse's rim turns
that (90deg -> -39deg, one pin), the pin takes the shape with the hand to where
the hand went (0.560,0.560, no sideways throw), another chip leaves 0 mask
columns and the mask chip brings 1 back, and DELETE takes the chosen shape off the
photo with its grade (64 -> 255, then back to 61). brush-edit 33/0, heal-idle
23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/tone-curve/
hsl-panel/grain-controls/chip-edge/chips-desk/slider-reset/temp-swatch all ALL
PASS, backend 180/0. panel-test (4), histogram-wb (1) and studio-save-hl fail
exactly as they do on the build before this one, on their own tabs.
This commit is contained in:
2026-09-24 11:18:59 +07:00
parent b7ff298789
commit 1f6c97be62
3 changed files with 98 additions and 27 deletions
+26 -3
View File
@@ -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() {
</div>
</div>
{/* 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 ? (
<div className="col col-sub strip" data-col="mask">
<ChipRow
chips={[
+13 -2
View File
@@ -540,8 +540,11 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
cursor: crosshair;
touch-action: none;
}
/* The shape's own outline, over the photo and never in front of the pointer: the
pin and the handles are the only things on this layer to press. */
/* The shape's own outline, over the photo: the pin and the handles are the
things on this layer to press, and the chosen shape's edge is one more — the
line a ramp falls along, or an ellipse's rim. Invisible and a finger wide, so
a press on the edge turns the shape without having to find a handle for it;
non-scaling, so the band is the same width however the photo is zoomed. */
.mask-shape {
position: absolute;
inset: 0;
@@ -552,6 +555,14 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.45));
}
.mask-line { stroke: rgba(255, 255, 255, 0.85); stroke-width: 1; }
.mask-edge {
fill: none;
stroke: rgba(255, 255, 255, 0.01);
stroke-width: 16;
pointer-events: stroke;
vector-effect: non-scaling-stroke;
cursor: grab;
}
/* An ellipse's rim, and inside it the ellipse its feather fades to — so the
hand the shape has on the photo is visible before the knobs are moved. */
.mask-ring { fill: none; stroke: rgba(255, 255, 255, 0.85); stroke-width: 1; }
+59 -22
View File
@@ -122,6 +122,10 @@ const strokeRuns = (spots: { x: number; y: number; r: number }[], box: { width:
// took hold of, and where that handle's move leaves the shape.
type MaskPart = 'move' | 'p0' | 'p1' | 'rx' | 'ry' | 'rot';
// A press that takes hold of a shape. The pin and the handles are HTML, the
// shape's own outline is SVG, and to the hand they are the same grip.
type MaskPointer = React.PointerEvent<HTMLElement | SVGElement>;
// 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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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 ? (
<svg
className="mask-shape"
@@ -1071,18 +1083,30 @@ export function ImageStage({
const n = { x: -(p1.y - p0.y) / len, y: (p1.x - p0.x) / len };
const L = Math.hypot(box.width, box.height) / 2;
const ticks = [p0, { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 }, p1];
const across = (t: { x: number; y: number }) => ({
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 (
<g>
{ticks.map((t, i) => (
<line
key={i}
className="mask-line"
x1={t.x - n.x * L}
y1={t.y - n.y * L}
x2={t.x + n.x * L}
y2={t.y + n.y * L}
/>
<line key={i} className="mask-line" {...across(t)} />
))}
{edge
? ticks.map((t, i) => (
<line
key={`edge${i}`}
className="mask-edge"
{...across(t)}
onPointerDown={edge}
onPointerMove={moveMask}
onPointerUp={endMask}
onPointerCancel={endMask}
/>
))
: null}
</g>
);
})()
@@ -1101,6 +1125,19 @@ export function ImageStage({
rx={rx * (1 - m.feather)}
ry={ry * (1 - m.feather)}
/>
{edge ? (
<ellipse
className="mask-edge"
cx={cx}
cy={cy}
rx={rx}
ry={ry}
onPointerDown={edge}
onPointerMove={moveMask}
onPointerUp={endMask}
onPointerCancel={endMask}
/>
) : null}
</g>
);
})()}
@@ -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 ? (
<div
className="mask-layer"
@@ -1678,7 +1715,7 @@ export function ImageStage({
})())}
{masks.map((m, i) => (
<span key={i}>
{i === maskSel ? maskShape(m) : null}
{i === maskSel ? maskShape(m, (e) => grabMask(i, 'rot', e)) : null}
{(() => {
const pin = maskPin(m);
return (