Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ interface DomEditOverlayProps {
onBoxSizeCommit: (
selection: DomEditSelection,
next: { width: number; height: number },
offset?: { x: number; y: number },
) => Promise<void> | void;
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
Expand Down
247 changes: 228 additions & 19 deletions packages/studio/src/components/editor/domEditOverlayGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export interface OverlayRect {
height: number;
editScaleX: number;
editScaleY: number;
/**
* The element's live transform rotation in DEGREES (screen/CSS convention, CW
* positive), decomposed from its computed transform matrix. Present so the
* selection chrome can render as an oriented bounding box (OBB) that co-rotates
* with the element. Omitted (treated as 0) for group/union rects and when the
* transform is unmeasurable — those render axis-aligned exactly as before.
*/
angle?: number;
}

export interface GroupOverlayItem {
Expand Down Expand Up @@ -98,31 +106,95 @@ export function toVisibleOverlayRect(
return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
}

/**
* The element's live transform rotation, in DEGREES (screen/CSS convention, CW
* positive), decomposed from its computed transform matrix (rotation = atan2(b, a)).
* GSAP folds rotation and scale into the same matrix; this reads rotation only.
* Returns 0 when the transform is "none" or unmeasurable — callers treat 0 as
* axis-aligned. Skew is ignored (does not affect atan2(b, a)).
*/
function readElementRotationDegrees(iframe: HTMLIFrameElement, element: HTMLElement): number {
const win = iframe.contentWindow;
if (!win) return 0;
const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix;
if (!DOMMatrixCtor) return 0;
const transform = win.getComputedStyle(element).transform;
if (!transform || transform === "none") return 0;
let matrix: DOMMatrix;
try {
matrix = new DOMMatrixCtor(transform);
} catch {
return 0;
}
const a = Number.isFinite(matrix.a) ? matrix.a : 1;
const b = Number.isFinite(matrix.b) ? matrix.b : 0;
const deg = (Math.atan2(b, a) * 180) / Math.PI;
return Number.isFinite(deg) ? deg : 0;
}

/** iframe→overlay mapping basis shared by every overlay-geometry function. */
interface OverlayRootScale {
iframeRect: DOMRect;
overlayRect: DOMRect;
rootScaleX: number;
rootScaleY: number;
}

/** The composition root element inside the preview doc (or null when absent). */
function findOverlayRootElement(doc: Document | null): HTMLElement | null {
return doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
}

/**
* The root's effective width/height for scaling: prefer the composition's
* declared dimensions (data-width/data-height), which stay fixed while GSAP
* transforms mutate the measured rect; fall back to the measured rect. Null when
* unmeasurable.
*/
function resolveRootDimensions(root: HTMLElement | null): { width: number; height: number } | null {
if (!root) return null;
const rootRect = root.getBoundingClientRect();
const width = readPositiveDimension(root.getAttribute("data-width")) ?? rootRect.width;
const height = readPositiveDimension(root.getAttribute("data-height")) ?? rootRect.height;
if (!width || !height) return null;
return { width, height };
}

/**
* The iframe/overlay client rects and the iframe→root scale factors. Uses the
* composition's declared dimensions (data-width/data-height) for the scale
* instead of rootRect.width/height: when GSAP applies transforms (scale,
* translate) to the root, rootRect dimensions change but the composition's
* canonical size stays fixed, and using rootRect misaligns the overlay during
* animated playback. Returns null when the geometry is unmeasurable.
*/
function computeOverlayRootScale(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
doc: Document | null,
): OverlayRootScale | null {
const iframeRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const dims = resolveRootDimensions(findOverlayRootElement(doc));
if (!dims) return null;
return {
iframeRect,
overlayRect,
rootScaleX: iframeRect.width / dims.width,
rootScaleY: iframeRect.height / dims.height,
};
}

export function toOverlayRect(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
element: HTMLElement,
): OverlayRect | null {
const iframeRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const doc = iframe.contentDocument;
const root =
doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
const rootRect = root?.getBoundingClientRect();
// Use the composition's declared dimensions (data-width/data-height) for scale
// calculation instead of rootRect.width/height. When GSAP applies transforms
// (scale, translate) to the root element, rootRect dimensions change but the
// composition's canonical size stays the same. Using rootRect causes overlay
// misalignment during animated playback.
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 || !rootRect) return null;
const scale = computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
if (!scale) return null;
const { iframeRect, overlayRect, rootScaleX, rootScaleY } = scale;

const elementRect = element.getBoundingClientRect();
const rootScaleX = iframeRect.width / rootWidth;
const rootScaleY = iframeRect.height / rootHeight;
const sourceBoundary = findSourceBoundary(element);
const sourceBoundaryRect = sourceBoundary?.getBoundingClientRect();
const editScale = resolveDomEditCoordinateScale({
Expand All @@ -144,7 +216,143 @@ 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 centroid (rendered center) of the four transformed corners from
* `elementCornerOverlayPoints`, in overlay px. This is the element's true rotation
* center — the point a center-anchored resize keeps planted.
*/
export function overlayCornersCentroid(corners: Record<FixedCorner, { x: number; y: number }>): {
x: number;
y: number;
} {
return {
x: (corners.nw.x + corners.ne.x + corners.se.x + corners.sw.x) / 4,
y: (corners.nw.y + corners.ne.y + corners.se.y + corners.sw.y) / 4,
};
}

/**
* 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.
* 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 scale = computeOverlayRootScale(overlayEl, iframe, doc);
if (!scale) return null;
const { iframeRect, overlayRect, rootScaleX, rootScaleY } = scale;

// 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),
};
}

/**
* The selection chrome's ORIENTED bounding box: the element's UNROTATED border box
* expressed in overlay coordinates (center-anchored left/top/width/height) plus the
* live rotation angle. Rendering that rect with `transform: rotate(angle)` about its
* center reproduces the element's real transformed corners exactly, so the border,
* corner dots, rotate handle, and crop pills all co-rotate with the object.
*
* Built from `elementCornerOverlayPoints` (the real transformed corners): the OBB
* center is the corner centroid, the unrotated width/height are the edge lengths, and
* left/top place the unrotated box so that rotating it about its center lands the
* corners back on the measured points. At angle 0 this equals `toOverlayRect` (the
* AABB and OBB coincide), so unrotated chrome is pixel-identical to today.
*
* Returns the plain AABB rect (angle 0) when the corner geometry can't be measured.
*/
export function orientedOverlayRect(
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
element: HTMLElement,
): OverlayRect | null {
const base = toOverlayRect(overlayEl, iframe, element);
if (!base) return null;
const corners = elementCornerOverlayPoints(overlayEl, iframe, element);
if (!corners) return base;
const angle = readElementRotationDegrees(iframe, element);
// Unrotated edge lengths (in overlay px): nw→ne is the width, nw→sw the height.
const width = Math.hypot(corners.ne.x - corners.nw.x, corners.ne.y - corners.nw.y);
const height = Math.hypot(corners.sw.x - corners.nw.x, corners.sw.y - corners.nw.y);
const centerX = (corners.nw.x + corners.se.x) / 2;
const centerY = (corners.nw.y + corners.se.y) / 2;
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return base;
}
return {
left: centerX - width / 2,
top: centerY - height / 2,
width,
height,
editScaleX: base.editScaleX,
editScaleY: base.editScaleY,
angle,
};
}

const OVERLAY_RECT_EPSILON_PX = 0.5;
const OVERLAY_RECT_ANGLE_EPSILON_DEG = 0.1;

export function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
if (a === b) return true;
Expand All @@ -155,7 +363,8 @@ export function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolea
Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX &&
Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX &&
Math.abs(a.editScaleX - b.editScaleX) < 0.001 &&
Math.abs(a.editScaleY - b.editScaleY) < 0.001
Math.abs(a.editScaleY - b.editScaleY) < 0.001 &&
Math.abs((a.angle ?? 0) - (b.angle ?? 0)) < OVERLAY_RECT_ANGLE_EPSILON_DEG
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,10 @@ export type UseDomEditOverlayGesturesOptions = {
onCanvasMouseDown: (e: React.MouseEvent<HTMLDivElement>, o?: PreviewMouseDownOptions) => void;
snapGuidesRef: RefObject<SnapGuidesState | null>;
};

/**
* Corner identity for a chrome resize handle. Consumed by the selection
* chrome + resize-local math; the gesture pipeline threads it through
* startGesture options.
*/
export type ResizeHandle = "nw" | "ne" | "sw" | "se";
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import {
type GestureKind,
type GestureState,
type ResizeHandle,
type UseDomEditOverlayGesturesOptions,
} from "./domEditOverlayGestures";
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
Expand Down Expand Up @@ -100,7 +101,11 @@ export function startGesture(
kind: GestureKind,
e: React.PointerEvent<HTMLElement>,
opts: UseDomEditOverlayGesturesOptions,
options?: { selection?: DomEditSelection; rect?: OverlayRect | null },
options?: {
selection?: DomEditSelection;
rect?: OverlayRect | null;
resizeHandle?: ResizeHandle;
},
): boolean {
const sel = options?.selection ?? opts.selectionRef.current;
const rect = options?.rect ?? opts.overlayRectRef.current;
Expand Down
Loading
Loading