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:
@@ -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={[
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user