fix(studio): anchor corner-resize to the element's real corner, not its AABB

Resizing a rotated element from an NW/NE/SW handle made it jump/lag and left
the selection box detached from the element (empty gap when shrinking); the SE
handle was fine. Root cause: the anchor translation that keeps the opposite
corner fixed was computed from the axis-aligned bounding-box delta
(originWidth − measuredWidth) and pushed through GSAP x/y. GSAP x/y is a
screen-space translate, but a rotated element grows about its transform-origin
in its LOCAL frame, so its real corners move along rotated axes — the AABB
delta then slid the fixed corner every frame (drift ∝ drag distance, up to
~10px at −4° on a 640×360 image; zero only when unrotated, which is why SE and
axis-aligned elements looked correct).

Fix: pin the actual (rotation-aware) fixed corner. New
elementCornerOverlayPoints() transforms the element's border-box corners
through its live transform matrix into overlay coords; startGesture captures
the fixed corner (opposite the grabbed handle) at gesture start; the
pointermove anchor is now (fixedStart − fixedAfterSize), the exact screen shift
needed to return that corner home. Falls back to the old AABB delta only when
the live DOM can't be measured. The box left/top is re-read from the element's
post-anchor AABB so it hugs the true bounds every frame.

Verified live in Studio against a rotated (−4°) image via the shipped
functions: fixed-corner drift 0.00px on every sampled frame for NW/NE/SW
(was up to 10.58px), and 0.00px across 0°/−4°/30°.
This commit is contained in:
ukimsanov
2026-07-10 10:24:00 -07:00
parent ee8555feee
commit 0a49aec461
4 changed files with 172 additions and 25 deletions
@@ -144,6 +144,92 @@ export function toOverlayRect(
};
}
/** Which physical corner of the (possibly rotated) element a resize handle keeps
* fixed: NW grabs the top-left, so the bottom-right (se) is the anchor, etc. */
export type FixedCorner = "nw" | "ne" | "sw" | "se";
/**
* The element's border-box corners in OVERLAY coordinates, honoring its live
* transform (rotation/skew/scale) — NOT the axis-aligned getBoundingClientRect.
* A rotated element's four visual corners are the transformed local box corners;
* anchoring a corner-resize off the AABB (BCR) width/height only holds the
* opposite corner still when the element is unrotated. Uses the same
* iframe→overlay root scale as toOverlayRect so the returned points share that
* function's coordinate space. Returns null when the geometry is unmeasurable.
*/
export function elementCornerOverlayPoints(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
element: HTMLElement,
): Record<FixedCorner, { x: number; y: number }> | null {
const win = iframe.contentWindow;
const doc = iframe.contentDocument;
if (!win || !doc) return null;
const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix;
const DOMPointCtor = (win as Window & typeof globalThis).DOMPoint;
if (!DOMMatrixCtor || !DOMPointCtor) return null;
const iframeRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const root =
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
const rootRect = root?.getBoundingClientRect();
const declaredWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null);
const declaredHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null);
const rootWidth = declaredWidth ?? rootRect?.width;
const rootHeight = declaredHeight ?? rootRect?.height;
if (!rootWidth || !rootHeight) return null;
const rootScaleX = iframeRect.width / rootWidth;
const rootScaleY = iframeRect.height / rootHeight;
// The element's local border box maps to viewport coords by the SAME transform
// matrix the browser used for its BCR. We recover the transform's screen-space
// action from the BCR: transformPoint(localCorner - origin) gives a corner
// RELATIVE to the transformed origin. We anchor those relative corners to the
// BCR by matching the AABB of the transformed corners to the real BCR — the
// constant offset cancels in the before/after difference the caller takes, but
// we resolve it fully here so callers can also read absolute overlay positions.
const cs = win.getComputedStyle(element);
const w = element.offsetWidth;
const h = element.offsetHeight;
const originParts = cs.transformOrigin.split(" ").map((p) => Number.parseFloat(p));
const ox = Number.isFinite(originParts[0]!) ? originParts[0]! : w / 2;
const oy = Number.isFinite(originParts[1]!) ? originParts[1]! : h / 2;
let matrix: DOMMatrix;
try {
matrix = new DOMMatrixCtor(cs.transform === "none" ? "" : cs.transform);
} catch {
return null;
}
const rel = (lx: number, ly: number): { x: number; y: number } => {
const p = matrix.transformPoint(new DOMPointCtor(lx - ox, ly - oy));
return { x: p.x, y: p.y };
};
const relCorners = {
nw: rel(0, 0),
ne: rel(w, 0),
se: rel(w, h),
sw: rel(0, h),
};
// Recover the absolute viewport position by matching to the element's BCR:
// the relative corners' AABB min corresponds to the BCR's top-left.
const xs = [relCorners.nw.x, relCorners.ne.x, relCorners.se.x, relCorners.sw.x];
const ys = [relCorners.nw.y, relCorners.ne.y, relCorners.se.y, relCorners.sw.y];
const bcr = element.getBoundingClientRect();
const dx = bcr.left - Math.min(...xs);
const dy = bcr.top - Math.min(...ys);
const toOverlay = (pt: { x: number; y: number }): { x: number; y: number } => ({
x: iframeRect.left - overlayRect.left + (pt.x + dx) * rootScaleX,
y: iframeRect.top - overlayRect.top + (pt.y + dy) * rootScaleY,
});
return {
nw: toOverlay(relCorners.nw),
ne: toOverlay(relCorners.ne),
se: toOverlay(relCorners.se),
sw: toOverlay(relCorners.sw),
};
}
const OVERLAY_RECT_EPSILON_PX = 0.5;
export function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
@@ -50,6 +50,29 @@ export interface GestureState {
resizeHandle?: ResizeHandle;
/** Last anchoring translation applied during a corner resize (overlay px). */
lastResizeAnchor?: { dx: number; dy: number };
/**
* The FIXED corner's overlay position at gesture start (the corner opposite the
* grabbed handle, in the element's real — possibly rotated — geometry). A corner
* resize keeps this point pinned; the per-frame anchor translation is computed as
* the shift of this exact corner, not an AABB width/height delta (which only holds
* the corner still when the element is unrotated). Undefined for SE (no anchor)
* and when the corner geometry can't be measured.
*/
resizeFixedCornerStart?: { x: number; y: number };
}
/** The element corner a handle keeps FIXED: opposite the grabbed corner. */
export function anchorCornerForHandle(handle: ResizeHandle): "nw" | "ne" | "sw" | "se" {
switch (handle) {
case "nw":
return "se";
case "ne":
return "sw";
case "sw":
return "ne";
case "se":
return "nw";
}
}
export interface GroupGestureState {
@@ -19,6 +19,7 @@ import {
} from "./manualEdits";
import {
type OverlayRect,
elementCornerOverlayPoints,
filterNestedDomEditGroupItems,
selectionCacheKey,
} from "./domEditOverlayGeometry";
@@ -27,6 +28,7 @@ import {
type GestureState,
type ResizeHandle,
type UseDomEditOverlayGesturesOptions,
anchorCornerForHandle,
} from "./domEditOverlayGestures";
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
@@ -178,6 +180,23 @@ export function startGesture(
const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
const iframe = opts.iframeRef.current;
// For an anchored corner resize, capture the FIXED corner's real (rotation-aware)
// overlay position now, so per-frame anchoring can pin that exact point instead of
// an axis-aligned width/height delta (which slides the fixed corner on a rotated
// element — the resize jump/gap bug).
const resizeHandleForAnchor = kind === "resize" ? (options?.resizeHandle ?? "se") : undefined;
let resizeFixedCornerStart: { x: number; y: number } | undefined;
if (
resizeHandleForAnchor !== undefined &&
resizeHandleForAnchor !== "se" &&
pathOffsetMember &&
overlayEl &&
iframe
) {
const corners = elementCornerOverlayPoints(overlayEl, iframe, sel.element);
if (corners) resizeFixedCornerStart = corners[anchorCornerForHandle(resizeHandleForAnchor)];
}
const snapContext =
(kind === "drag" || kind === "resize") && overlayEl && iframe
? collectSnapContext({
@@ -214,6 +233,7 @@ export function startGesture(
manualEditDragToken,
snapContext,
resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
resizeFixedCornerStart,
};
return true;
}
@@ -29,6 +29,7 @@ import {
import {
type GroupOverlayItem,
type OverlayRect,
elementCornerOverlayPoints,
resolveDomEditGroupOverlayRect,
toOverlayRect,
} from "./domEditOverlayGeometry";
@@ -39,6 +40,7 @@ import {
type GroupGestureState,
type ResizeHandle,
type UseDomEditOverlayGesturesOptions,
anchorCornerForHandle,
hasDomEditRotationChanged,
resolveDomEditResizeGesture,
resolveDomEditRotationGesture,
@@ -302,42 +304,58 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
// clamps width/height (and GSAP scale + a centered transform-origin can
// make the visual size diverge further from the CSS size), so the math
// `nextSize.overlay{Width,Height}` is NOT what the element actually renders.
// Anchoring off the math value (the old code) trailed the true edge by the
// rounding/scale residual every frame — the opposite edge wobbled while the
// box hugged the real rect, leaving the gap (fix B) and the per-frame
// anchor drift on NW/NE/SW (fix C). Anchor off the measured size instead so
// the fixed edge stays glued and the offset write is frame-synchronous with
// the size write (same synchronous pass as SE's size-only path).
const overlayEl = opts.overlayRef.current;
const iframe = opts.iframeRef.current;
const sizedRect = overlayEl && iframe ? toOverlayRect(overlayEl, iframe, sel.element) : null;
const measuredWidth = sizedRect ? sizedRect.width : nextSize.overlayWidth;
const measuredHeight = sizedRect ? sizedRect.height : nextSize.overlayHeight;
// West/north handles keep the opposite corner visually fixed by
// translating the element through the same manual-offset channel a
// drag uses (member created at gesture start).
// West/north handles keep the OPPOSITE corner visually fixed by translating
// the element through the same manual-offset channel a drag uses (member
// created at gesture start).
let overlayLeft: number;
let overlayTop: number;
if (g.pathOffsetMember) {
const anchor = resolveResizeAnchorOffset({
originWidth: g.originWidth,
originHeight: g.originHeight,
overlayWidth: measuredWidth,
overlayHeight: measuredHeight,
anchorX: deltas.anchorX,
anchorY: deltas.anchorY,
});
// Corner-based anchor (rotation-safe). The old code translated by the
// AABB width/height delta (originWidth − measuredWidth). That only keeps
// the opposite corner still when the element is UNROTATED: a rotated
// element grows about its transform-origin in its LOCAL frame, so its
// real (rotated) corners move along the rotated axes, not the screen
// axes — the AABB delta then slid the fixed corner every frame (the
// "jump/lag" and the box gap the user saw). GSAP x/y is a screen-space
// translation (it writes matrix e/f directly), so pin the fixed corner by
// measuring where it landed after the size write and translating it back
// to its gesture-start position. This holds for any rotation/skew and any
// transform-origin, because it corrects the actual corner rather than a
// bounding-box proxy.
const cornersAfterSize =
overlayEl && iframe ? elementCornerOverlayPoints(overlayEl, iframe, sel.element) : null;
const fixedStart = g.resizeFixedCornerStart;
let anchor: { dx: number; dy: number };
if (cornersAfterSize && fixedStart) {
const fixedNow = cornersAfterSize[anchorCornerForHandle(handle)];
anchor = { dx: fixedStart.x - fixedNow.x, dy: fixedStart.y - fixedNow.y };
} else {
// Geometry unmeasurable (no live DOM) — fall back to the AABB delta.
anchor = resolveResizeAnchorOffset({
originWidth: g.originWidth,
originHeight: g.originHeight,
overlayWidth: measuredWidth,
overlayHeight: measuredHeight,
anchorX: deltas.anchorX,
anchorY: deltas.anchorY,
});
}
g.lastResizeAnchor = anchor;
applyManualOffsetDragDraft(g.pathOffsetMember, anchor.dx, anchor.dy);
// Box geometry is exact by construction from the measured size + the anchor
// we just applied: the opposite corner is fixed at origin, so the moving
// top-left is origin + anchor. This tracks the element's REAL bounds every
// frame WITHOUT a second BCR read, which would reflect the transform one
// frame late and make the anchored corners lag/jump (fix C) while leaving
// the box detached from the element (fix B).
overlayLeft = g.originLeft + anchor.dx;
overlayTop = g.originTop + anchor.dy;
// Re-measure the element's AABB AFTER the anchor translate so the box
// hugs the element's true rendered bounds every frame (the corner-anchor
// moved the element, so the pre-anchor sizedRect is stale). One extra BCR
// read inside the same synchronous pointermove pass — no async lag.
const anchoredRect =
overlayEl && iframe ? toOverlayRect(overlayEl, iframe, sel.element) : null;
overlayLeft = anchoredRect ? anchoredRect.left : g.originLeft + anchor.dx;
overlayTop = anchoredRect ? anchoredRect.top : g.originTop + anchor.dy;
} else {
// SE (anchorless): no translate, so the element's own top-left is the
// source of truth. A GSAP scale + centered transform-origin drifts it as