diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index c0adaef18c..8d89a3637d 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -73,6 +73,7 @@ interface DomEditOverlayProps { onBoxSizeCommit: ( selection: DomEditSelection, next: { width: number; height: number }, + offset?: { x: number; y: number }, ) => Promise | void; onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise | void; onStyleCommit?: (property: string, value: string) => Promise | void; diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts index 31af58f644..11bcf811a0 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts @@ -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 { @@ -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("[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("[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({ @@ -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): { + 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 | 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; @@ -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 ); } diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index 2abc793b65..8f6949a549 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -228,3 +228,10 @@ export type UseDomEditOverlayGesturesOptions = { onCanvasMouseDown: (e: React.MouseEvent, o?: PreviewMouseDownOptions) => void; snapGuidesRef: RefObject; }; + +/** + * 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"; diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts index 636265e206..826c0a26dd 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts @@ -26,6 +26,7 @@ import { import { type GestureKind, type GestureState, + type ResizeHandle, type UseDomEditOverlayGesturesOptions, } from "./domEditOverlayGestures"; import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection"; @@ -100,7 +101,11 @@ export function startGesture( kind: GestureKind, e: React.PointerEvent, 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; diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 6151a09790..a808c792fb 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -441,22 +441,26 @@ export function applyManualOffsetDragDraft( return offset; } -export function applyManualOffsetDragCommit( - member: ManualOffsetDragMember, - dx: number, - dy: number, -): { x: number; y: number } { - // Re-stamp the STABLE gesture-start base/offset before the source commit reads - // them. A mid-drag re-render can wipe these attrs; the commit converts the drop - // offset → gsap x/y via computeDraggedGsapPosition, which without the base falls - // back to the live (already-dragged) transform and re-adds the delta — so the - // element flies off-screen the instant you drop it. The member holds the true - // gesture-start values in JS, immune to the re-render. +/** + * Re-stamp the STABLE gesture-start base/offset before the source commit reads + * them. A mid-gesture re-render can wipe these attrs; the commit converts the + * drop offset → gsap x/y via computeDraggedGsapPosition, which without the base + * falls back to the live (already-dragged) transform and re-adds the delta — so + * the element flies off-screen the instant you drop it. The member holds the + * true gesture-start values in JS, immune to the re-render. + */ +function restampManualOffsetDragGestureBase(member: ManualOffsetDragMember): void { member.element.setAttribute("data-hf-drag-gsap-base-x", String(member.baseGsap.x)); member.element.setAttribute("data-hf-drag-gsap-base-y", String(member.baseGsap.y)); member.element.setAttribute("data-hf-drag-initial-offset-x", String(member.initialOffset.x)); member.element.setAttribute("data-hf-drag-initial-offset-y", String(member.initialOffset.y)); - const offset = resolveManualOffsetDragMemberOffset(member, dx, dy); +} + +function applyManualOffsetCommitValue( + member: ManualOffsetDragMember, + offset: { x: number; y: number }, +): { x: number; y: number } { + restampManualOffsetDragGestureBase(member); // Optimistic visual through the GSAP channel (same as the live draft and the // committed `tl.set`), so the element holds its dropped position until the // source mutation soft-reloads — no transient CSS `--hf-studio-offset` write. @@ -467,6 +471,45 @@ export function applyManualOffsetDragCommit( return offset; } +export function applyManualOffsetDragCommit( + member: ManualOffsetDragMember, + dx: number, + dy: number, +): { x: number; y: number } { + return applyManualOffsetCommitValue(member, resolveManualOffsetDragMemberOffset(member, dx, dy)); +} + +/** + * Arrow-key nudge, in OFFSET units (composition px), not screen px — "nudge + * 1px" means one composition pixel regardless of canvas zoom, so the delta + * adds to the gesture-start offset directly instead of going through the + * screen→offset matrix. Draft/commit land in the same GSAP channel (with the + * same CSS fallback) as the drag equivalents above. + */ +export function applyManualOffsetNudgeDraft( + member: ManualOffsetDragMember, + delta: { x: number; y: number }, +): { x: number; y: number } { + const offset = { + x: member.initialOffset.x + delta.x, + y: member.initialOffset.y + delta.y, + }; + if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) { + applyStudioPathOffsetDraft(member.element, offset); + } + return offset; +} + +export function applyManualOffsetNudgeCommit( + member: ManualOffsetDragMember, + delta: { x: number; y: number }, +): { x: number; y: number } { + return applyManualOffsetCommitValue(member, { + x: member.initialOffset.x + delta.x, + y: member.initialOffset.y + delta.y, + }); +} + function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void { restoreStudioPathOffset(member.element, member.initialPathOffset); endStudioManualEditGesture(member.element, member.gestureToken); diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index 83da0418e6..3bd0909ab5 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -42,6 +42,7 @@ import { hasDomEditRotationChanged, resolveDomEditResizeGesture, resolveDomEditRotationGesture, + type ResizeHandle, } from "./domEditOverlayGestures"; import { startGesture as _startGesture, @@ -88,7 +89,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu const startGesture = ( kind: GestureKind, e: React.PointerEvent, - options?: { selection?: DomEditSelection; rect?: OverlayRect | null }, + options?: { + selection?: DomEditSelection; + rect?: OverlayRect | null; + resizeHandle?: ResizeHandle; + }, ) => _startGesture(kind, e, opts, options); // fallow-ignore-next-line complexity diff --git a/packages/studio/src/components/sidebar/AssetContextMenu.tsx b/packages/studio/src/components/sidebar/AssetContextMenu.tsx index 07b5417a80..b23a74fbd0 100644 --- a/packages/studio/src/components/sidebar/AssetContextMenu.tsx +++ b/packages/studio/src/components/sidebar/AssetContextMenu.tsx @@ -6,6 +6,7 @@ export function ContextMenu({ onCopy, onDelete, onRename, + onAddAtPlayhead, }: { x: number; y: number; @@ -14,6 +15,7 @@ export function ContextMenu({ onCopy: (path: string) => void; onDelete?: (path: string) => void; onRename?: (oldPath: string, newPath: string) => void; + onAddAtPlayhead?: (path: string) => void; }) { return (
+ {onAddAtPlayhead && ( + + )}