diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 5a48bd5..08e903d 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -229,10 +229,14 @@ export function ImageStage({ const img = imgRef.current; const wrap = wrapRef.current; if (!img || !wrap) return setBox(null); - const i = img.getBoundingClientRect(); - const w = wrap.getBoundingClientRect(); - if (!(i.width > 0 && i.height > 0)) return setBox(null); - setBox({ left: i.left - w.left, top: i.top - w.top, width: i.width, height: i.height }); + // The photo's LAYOUT box, relative to the wrap (its offsetParent), never + // getBoundingClientRect: zoom is a transform on the photo, and every layer + // above it carries that same transform — a rect read off the transformed + // element would be scaled a second time, and the box would drift off the + // text the moment a zoom settled and re-cut the preview. + const { offsetWidth: width, offsetHeight: height } = img; + if (!(width > 0 && height > 0)) return setBox(null); + setBox({ left: img.offsetLeft, top: img.offsetTop, width, height }); }, []); useEffect(() => { @@ -559,8 +563,10 @@ export function ImageStage({ }); }, [marks, box]); - // Dragging a box writes x/y, its corner handle writes size. Both measure the - // img's own box (not the layer's), so a zoom on the stage cannot skew them. + // Dragging a box writes x/y, its corner handle writes size. Both are fed the + // photo's own size in its unscaled pixels and the zoom the stage is drawing + // it at, so the pointer's screen delta is divided straight back out and a + // drag reads the same however far the stage has been zoomed. const wmDragRef = useRef<{ mode: 'move' | 'size'; mark: WmMark; @@ -569,8 +575,10 @@ export function ImageStage({ size: number; px: number; py: number; - left: number; + scale: number; width: number; + photoW: number; + photoH: number; fontPx: number; // The face's ascent in em, so a resize keeps the box's top where it is // whichever family the mark draws in. @@ -581,10 +589,13 @@ export function ImageStage({ if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); - const b = imgRef.current?.getBoundingClientRect(); - if (!b || !(b.width > 0 && b.height > 0)) return; + const img = imgRef.current; + if (!img) return; + const photoW = img.offsetWidth; + const photoH = img.offsetHeight; + if (!(photoW > 0 && photoH > 0)) return; e.currentTarget.setPointerCapture(e.pointerId); - const fontPx = wmFontPx(b.width, mark.size); + const fontPx = wmFontPx(photoW, mark.size); const { width, ascentEm } = wmBoxOf(mark, fontPx); wmDragRef.current = { mode, @@ -594,8 +605,10 @@ export function ImageStage({ size: mark.size, px: e.clientX, py: e.clientY, - left: b.left + mark.x * b.width, + scale: img.getBoundingClientRect().width / photoW || 1, width, + photoW, + photoH, fontPx, ascentEm, }; @@ -603,28 +616,33 @@ export function ImageStage({ const moveWmDrag = (e: React.PointerEvent) => { const d = wmDragRef.current; - const img = imgRef.current; - if (!d || !img) return; - const b = img.getBoundingClientRect(); - if (!(b.width > 0 && b.height > 0)) return; + if (!d) return; e.preventDefault(); const cl = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); + // Screen pixels back into the photo's own pixels, so the numbers the box is + // written with are the engine's, not the stage's zoom. + const dx = (e.clientX - d.px) / d.scale; + const dy = (e.clientY - d.py) / d.scale; if (d.mode === 'move') { d.mark.onMove({ - x: cl(d.x + (e.clientX - d.px) / b.width, 0, 1), - y: cl(d.y + (e.clientY - d.py) / b.height, 0, 1), + x: cl(d.x + dx / d.photoW, 0, 1), + y: cl(d.y + dy / d.photoH, 0, 1), }); return; } + // The handle measures the pointer's own travel from where it was grabbed: + // read from the box's left edge instead and the mark's own x would be + // counted as a resize, throwing the face to the far end of the slider on + // the click alone. + const f = d.width > 0 ? 1 + dx / d.width : 1; + const size = cl(d.size * f, WM_SIZE_MIN, WM_SIZE_MAX); + const fontPx = wmFontPx(d.photoW, size); // The box's top-left corner stays put while the face grows: the top sits // (ascent - the first baseline)em above y, so y gives back exactly that // much of the height the larger face adds over the corner. - const f = d.width > 0 ? (e.clientX - d.left) / d.width : 1; - const size = cl(d.size * f, WM_SIZE_MIN, WM_SIZE_MAX); - const fontPx = wmFontPx(b.width, size); d.mark.onMove({ size, - y: cl(d.y + ((d.ascentEm - d.mark.baselineEm) * (fontPx - d.fontPx)) / b.height, 0, 1), + y: cl(d.y + ((d.ascentEm - d.mark.baselineEm) * (fontPx - d.fontPx)) / d.photoH, 0, 1), }); };