diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 64ff48a..8f76df3 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -169,17 +169,22 @@ const maskFromDrag = ( // One handle's move, as the shape it leaves. The two ends of a linear ramp are // themselves; a radial mask resizes along the ellipse's OWN axes — the pointer // is projected onto the axis the handle rides, so a hand that wanders across it -// does not shrink it — and its rotation handle sits on the +ry axis, so the -// angle is simply the direction from the centre to the pointer, one quarter turn -// back. A line has no angle to store, so turning one is moving its two ends -// about its own middle by the same turn, which keeps the ramp's length and its -// place — the gradient falls in the new direction and nowhere else changes. -// 'move' takes the whole of it, the pin's own drag. +// does not shrink it. A line has no angle to store, so turning one is moving its +// two ends about its own middle by the same turn, which keeps the ramp's length +// and its place — the gradient falls in the new direction and nowhere else +// changes. 'move' takes the whole of it, the pin's own drag. +// +// Turning is RELATIVE to where the hand took hold: the turn is how far it has +// travelled about the pin since then, not the direction it now points. A mask's +// edge is a grip you can land on anywhere, so an absolute angle would snap the +// shape to face the finger the instant it was brushed. `from` is the previous +// pointer position, the press itself on the first move. const maskDragged = ( m: GradientMask, part: MaskPart, p: { fx: number; fy: number }, - box: { width: number; height: number } + box: { width: number; height: number }, + from: { fx: number; fy: number } ): GradientMask => { const dx = (p.fx - m.x) * box.width; const dy = (p.fy - m.y) * box.height; @@ -199,14 +204,15 @@ const maskDragged = ( 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 }; - const cx = (m.x + m.ex) / 2; - const cy = (m.y + m.ey) / 2; + const pin = maskPin(m); // The turn is asked for in the photo's own pixels, so the angle the hand // asks for and the angle the ramp lies at are measured the same way. - const turn = - Math.atan2((p.fy - cy) * box.height, (p.fx - cx) * box.width) - - Math.atan2((m.ey - cy) * box.height, (m.ex - cx) * box.width); + const bearing = (q: { fx: number; fy: number }) => + Math.atan2((q.fy - pin.y) * box.height, (q.fx - pin.x) * box.width); + const turn = bearing(p) - bearing(from); + if (m.kind === 'radial') return { ...m, angle: m.angle + turn }; + const cx = (m.x + m.ex) / 2; + const cy = (m.y + m.ey) / 2; const ca = Math.cos(turn); const sa = Math.sin(turn); const spin = (x: number, y: number) => { @@ -952,7 +958,7 @@ export function ImageStage({ // the shape can be seen before it is let go; the flags below are the gesture's // own undo boundary, the same one the brush's grab keeps. const [draft, setDraft] = useState<{ a: BrushPoint; b: BrushPoint } | null>(null); - const maskGrab = useRef<{ i: number; part: MaskPart } | null>(null); + const maskGrab = useRef<{ i: number; part: MaskPart; from: { fx: number; fy: number } | null } | null>(null); const maskUndo = useRef(false); const maskDown = (e: React.PointerEvent) => { @@ -988,7 +994,7 @@ export function ImageStage({ if (!onMaskEdit) return; e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); - maskGrab.current = { i, part }; + maskGrab.current = { i, part, from: brushPoint(e.clientX, e.clientY) }; maskUndo.current = false; onMaskSel?.(i); }; @@ -1000,7 +1006,8 @@ export function ImageStage({ const p = brushPoint(e.clientX, e.clientY); if (!p) return; const next = masks.slice(); - next[g.i] = maskDragged(was, g.part, p, box); + next[g.i] = maskDragged(was, g.part, p, box, g.from ?? p); + g.from = p; onMaskEdit(next, !maskUndo.current); maskUndo.current = true; };