From 81ed53cf78b234e3f556648a242af820df3d77bd Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 17:12:31 +0700 Subject: [PATCH] web: anchor the watermark box to the photo's layout box, not its rect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two zoom-only defects in the FRAME watermark boxes, both of them units mistakes between what the stage measures and what the engine draws. measure() read the box off img.getBoundingClientRect(). Zoom is a TRANSFORM on the photo, and every layer above it (wm, crop, pick, compare, straighten) carries that same transform itself, so a rect read off the transformed element comes back already scaled and is then scaled again by the layer. It only shows once measure() runs while the stage is zoomed, which is exactly what the zoom itself causes: onStageZoom re-cuts the preview copy, the new src fires onLoad, and measure() re-runs on the transformed photo. Measured on the built app (a 2560x1706 upload, a 955px stage, four wheel notches -> 1.749x): the layer came out at -651,-796 2921x1947 against the photo's own 43,-241 1670x1113 — the zoom printed twice — with the box at 809,951 while the mark sits at 878,757 and the baked text at 882,765. The user's report exactly: the textbox not anchored where the mark was placed, and its frame not drawn where the text is. The box is now the photo's LAYOUT box (offsetLeft/offsetTop/offsetWidth/ offsetHeight, whose offsetParent is the relative-positioned .canvas-wrap), which no transform can touch. Same run after: layer 43,-242 1670x1114 against the photo 43,-241 1670x1113, box x 878 against the mark's own 0.5 x 1670 = 835 + the handle, and the box at 878,757 against the text at 882,765. The corner handle mixed the other way round. It read the pointer's travel as (e.clientX - d.left) / d.width — a SCREEN distance over an UNSCALED width, and with d.left the mark's own anchor rather than where the handle was grabbed. At scale 1 the handle sits exactly one box width from that anchor, so f came out 1 and the drag read true; zoomed, the same pull arrives s times larger and the box it grows was itself s times too wide. Measured: an 80px pull at 1.749x reported size 1.503 and left the box 163 -> 420px where 163 -> 241px was asked for. The handlers now take the photo's unscaled width and height plus the scale it is drawn at (rect.width / offsetWidth) and divide the pointer's screen delta by both, so a pull reads the same at any zoom, and the face size and the y compensation it feeds are computed from the photo's own width — that is the width the engine draws the fraction against. Same pull after: 163 -> 241px, the top-left corner left where it was. Verified: wm-zoom-probe.cjs (new, scratchpad) — 2560x1706 upload, four notches of wheel held on the handle's own corner: layer vs photo box, box vs the baked white text, the box's growth against the zoom, then an 80px handle pull. 9/9 on http://localhost:8090 (built asset index-BOwzIcQD.js). On the previous build the same probe is 5/9 — the layer, anchoring and size checks above. wm-test.cjs 23, wm-font-test.cjs 38, wm-font-registry-test.cjs 26, wm-gps-test.cjs 27, wm-gps-date-test.cjs 18, crop-frame-test.cjs, compare-test.cjs 25, compare-crop-test.cjs, zoom-test.cjs 25 — 0 fail at scale 1, where the old arithmetic happened to be right. web tsc --noEmit clean. ponytail: offsetWidth/offsetHeight round to whole CSS pixels, so the box can sit half a pixel off the photo's own box — under a box whose tolerance is the dashed hairline, not worth un-applying the transform by hand; the day something reads those pixels, compute the fit instead, as baseRect already does. --- docker/frontend/src/ui/ImageStage.tsx | 60 +++++++++++++++++---------- 1 file changed, 39 insertions(+), 21 deletions(-) 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), }); };