diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts index 31af58f644..a372c95e1c 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts @@ -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 | 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("[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 { diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index f32ea65b30..625ebd1dd4 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -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 { diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts index b9b4e4fe8b..502b3f27ff 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts @@ -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; } diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index e2e4a1bb8a..0525ef85c8 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -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