web: anchor the watermark box to the photo's layout box, not its rect
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.
This commit is contained in:
@@ -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<HTMLElement>) => {
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user