mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-11 19:09:20 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user