diff --git a/packages/studio/src/components/editor/DomEditCropHandles.tsx b/packages/studio/src/components/editor/DomEditCropHandles.tsx index 7ccdaad3e..7ac3b485b 100644 --- a/packages/studio/src/components/editor/DomEditCropHandles.tsx +++ b/packages/studio/src/components/editor/DomEditCropHandles.tsx @@ -4,9 +4,11 @@ import type { OverlayRect } from "./domEditOverlayGeometry"; import { type CropEdge, cropRectFromInsets, + readElementCropFrame, readElementCropInsets, resolveCropInsetFromEdgeDrag, resolveCropInsetFromMoveDrag, + rotateDeltaIntoFrame, } from "./domEditOverlayCrop"; import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers"; @@ -17,6 +19,10 @@ interface CropGestureState { startY: number; startInsets: ClipPathInsetSides; didMove: boolean; + /** Element frame captured at gesture start: pointer deltas rotate into it. */ + angleDeg: number; + scaleX: number; + scaleY: number; } interface DomEditCropHandlesProps { @@ -133,11 +139,20 @@ export function DomEditCropHandles({ }; }, [selection.element]); - const scaleX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1; - const scaleY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1; - const width = overlayRect.width / scaleX; - const height = overlayRect.height / scaleY; - const cropRect = cropRectFromInsets(overlayRect, state.insets, scaleX, scaleY); + // The crop applies in the element's LOCAL frame (clip-path precedes the + // transform), so all crop UI is drawn inside a container rotated with the + // element — on a rotated element an axis-aligned dim visually "straightens" + // it by masking the rotated corners. + const frame = readElementCropFrame(selection.element, overlayRect); + const width = frame.width / frame.scaleX; // element CSS px + const height = frame.height / frame.scaleY; + // Crop rect in FRAME-LOCAL coordinates (origin = frame top-left). + const cropRect = cropRectFromInsets( + { left: 0, top: 0, width: frame.width, height: frame.height }, + state.insets, + frame.scaleX, + frame.scaleY, + ); const startCropGesture = (edge: CropEdge | "move", event: ReactPointerEvent) => { if (!onStyleCommit) return; @@ -151,6 +166,9 @@ export function DomEditCropHandles({ startY: event.clientY, startInsets: state.insets, didMove: false, + angleDeg: frame.angleDeg, + scaleX: frame.scaleX, + scaleY: frame.scaleY, }; // Clip is already lifted by the selection effect; just flag the drag so the // rule-of-thirds grid shows. @@ -162,12 +180,17 @@ export function DomEditCropHandles({ if (!gesture || gesture.pointerId !== event.pointerId) return; event.preventDefault(); event.stopPropagation(); + const local = rotateDeltaIntoFrame( + event.clientX - gesture.startX, + event.clientY - gesture.startY, + gesture.angleDeg, + ); const drag = { startInsets: gesture.startInsets, - deltaX: event.clientX - gesture.startX, - deltaY: event.clientY - gesture.startY, - scaleX, - scaleY, + deltaX: local.deltaX, + deltaY: local.deltaY, + scaleX: gesture.scaleX, + scaleY: gesture.scaleY, }; const nextInsets = gesture.edge === "move" @@ -225,24 +248,27 @@ export function DomEditCropHandles({ if (!state.croppable) return null; return ( - <> +
{/* Dim the cropped-away area whenever the element is cropped and selected, - so the hidden content is visible (ghosted) without dragging. */} + so the hidden content is visible (ghosted) without dragging. Clipped to + the element's own (rotated) box. */} {hasCrop && ( -
+
); })} - +
); } diff --git a/packages/studio/src/components/editor/domEditOverlayCrop.test.ts b/packages/studio/src/components/editor/domEditOverlayCrop.test.ts index a23abc0e7..9831f06ed 100644 --- a/packages/studio/src/components/editor/domEditOverlayCrop.test.ts +++ b/packages/studio/src/components/editor/domEditOverlayCrop.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import { cropRectFromInsets, hugRectForElement, + readElementCropFrame, readElementCropInsets, resolveCropInsetFromEdgeDrag, resolveCropInsetFromMoveDrag, + rotateDeltaIntoFrame, } from "./domEditOverlayCrop"; describe("resolveCropInsetFromEdgeDrag", () => { @@ -151,3 +153,91 @@ describe("readElementCropInsets tri-state", () => { expect(hugRectForElement(rect, fakeEl("circle(50%)"))).toEqual(rect); }); }); + +// Regression: crop UI drawn on the axis-aligned bounding box visually +// "straightens" a rotated element — the dim masks the rotated corners. The +// frame gives the element's own box + rotation so the UI rotates with it. +describe("readElementCropFrame", () => { + const overlayRect = { left: 100, top: 50, width: 220, height: 130, editScaleX: 1, editScaleY: 1 }; + + const fakeEl = (transform: string, offsetWidth = 200, offsetHeight = 100) => + ({ + offsetWidth, + offsetHeight, + ownerDocument: { defaultView: { getComputedStyle: () => ({ transform }) } }, + }) as unknown as HTMLElement; + + it("identity transform → the axis-aligned overlay rect", () => { + expect(readElementCropFrame(fakeEl("none"), overlayRect)).toEqual({ + angleDeg: 0, + left: 100, + top: 50, + width: 220, + height: 130, + scaleX: 1, + scaleY: 1, + }); + }); + + it("rotated element → its own box, centered on the AABB, with the angle", () => { + // rotate(30deg): matrix(cos, sin, -sin, cos, tx, ty) + const cos = Math.cos(Math.PI / 6); + const sin = Math.sin(Math.PI / 6); + const frame = readElementCropFrame( + fakeEl(`matrix(${cos}, ${sin}, ${-sin}, ${cos}, 10, 20)`), + overlayRect, + ); + expect(frame.angleDeg).toBeCloseTo(30, 3); + expect(frame.width).toBeCloseTo(200, 3); + expect(frame.height).toBeCloseTo(100, 3); + // centered on the AABB center (210, 115) + expect(frame.left + frame.width / 2).toBeCloseTo(210, 3); + expect(frame.top + frame.height / 2).toBeCloseTo(115, 3); + expect(frame.scaleX).toBeCloseTo(1, 3); + }); + + it("scaled element → scale factored into px-per-element-px", () => { + const frame = readElementCropFrame(fakeEl("matrix(1.5, 0, 0, 2, 0, 0)"), overlayRect); + expect(frame.angleDeg).toBe(0); + expect(frame.scaleX).toBeCloseTo(1.5, 3); + expect(frame.scaleY).toBeCloseTo(2, 3); + expect(frame.width).toBeCloseTo(300, 3); + expect(frame.height).toBeCloseTo(200, 3); + }); + + it("3D transform falls back to the axis-aligned frame", () => { + const frame = readElementCropFrame( + fakeEl("matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)"), + overlayRect, + ); + expect(frame).toEqual({ + angleDeg: 0, + left: 100, + top: 50, + width: 220, + height: 130, + scaleX: 1, + scaleY: 1, + }); + }); +}); + +describe("rotateDeltaIntoFrame", () => { + it("passes deltas through at 0deg", () => { + expect(rotateDeltaIntoFrame(10, 5, 0)).toEqual({ deltaX: 10, deltaY: 5 }); + }); + + it("rotates a screen delta into a 90deg-rotated frame", () => { + // Element rotated +90°: dragging DOWN on screen moves along the element's +x. + const { deltaX, deltaY } = rotateDeltaIntoFrame(0, 10, 90); + expect(deltaX).toBeCloseTo(10, 6); + expect(deltaY).toBeCloseTo(0, 6); + }); + + it("round-trips a 30deg rotation", () => { + const local = rotateDeltaIntoFrame(7, -3, 30); + const back = rotateDeltaIntoFrame(local.deltaX, local.deltaY, -30); + expect(back.deltaX).toBeCloseTo(7, 6); + expect(back.deltaY).toBeCloseTo(-3, 6); + }); +}); diff --git a/packages/studio/src/components/editor/domEditOverlayCrop.ts b/packages/studio/src/components/editor/domEditOverlayCrop.ts index 4f0e7dd7f..cfff9daf4 100644 --- a/packages/studio/src/components/editor/domEditOverlayCrop.ts +++ b/packages/studio/src/components/editor/domEditOverlayCrop.ts @@ -122,3 +122,91 @@ export function hugRectForElement( return rect; return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY); } + +/** + * The element's own (unrotated) box in overlay space, plus the rotation to + * apply when drawing crop UI over it. `clip-path` applies in the element's + * LOCAL frame — before its transform — so the crop dim/outline/handles must be + * drawn rotated with the element, not on its axis-aligned bounding box: an + * AABB-drawn dim visually "straightens" a rotated element by masking its + * corners (the crop window looks axis-aligned while the pixels are not). + * + * scaleX/scaleY are overlay px per element CSS px (element's own scale × the + * editor zoom), so element-space insets map straight onto the frame. Assumes + * the default 50%/50% transform-origin (the GSAP/studio convention). 3D or + * unparseable transforms fall back to the axis-aligned frame (angle 0, AABB + * box) — the pre-existing presentation. + */ +export interface CropFrame { + angleDeg: number; + left: number; + top: number; + width: number; + height: number; + scaleX: number; + scaleY: number; +} + +export function readElementCropFrame( + element: HTMLElement, + overlayRect: CropScreenRect & { editScaleX: number; editScaleY: number }, +): CropFrame { + const editX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1; + const editY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1; + const aabb: CropFrame = { + angleDeg: 0, + left: overlayRect.left, + top: overlayRect.top, + width: overlayRect.width, + height: overlayRect.height, + scaleX: editX, + scaleY: editY, + }; + let transform = ""; + try { + transform = element.ownerDocument.defaultView?.getComputedStyle(element).transform ?? ""; + } catch { + return aabb; + } + if (!transform || transform === "none") return aabb; + const m = /^matrix\(([^)]+)\)$/.exec(transform); + if (!m) return aabb; // matrix3d or unparseable → axis-aligned fallback + const [a, b, c, d] = m[1]!.split(",").map((v) => Number.parseFloat(v)); + if (![a, b, c, d].every(Number.isFinite)) return aabb; + const elScaleX = Math.hypot(a!, b!); + const det = a! * d! - b! * c!; + const elScaleY = elScaleX !== 0 ? det / elScaleX : 1; + if (elScaleX <= 0 || elScaleY <= 0) return aabb; + const angleDeg = (Math.atan2(b!, a!) * 180) / Math.PI; + const scaleX = elScaleX * editX; + const scaleY = elScaleY * editY; + const width = element.offsetWidth * scaleX; + const height = element.offsetHeight * scaleY; + if (!(width > 0) || !(height > 0)) return aabb; + // Rotation about the default center keeps the center invariant, so the + // local box is centered on the AABB center. + const cx = overlayRect.left + overlayRect.width / 2; + const cy = overlayRect.top + overlayRect.height / 2; + return { + angleDeg, + left: cx - width / 2, + top: cy - height / 2, + width, + height, + scaleX, + scaleY, + }; +} + +/** Rotate a screen-space pointer delta into the element's local frame. */ +export function rotateDeltaIntoFrame( + deltaX: number, + deltaY: number, + angleDeg: number, +): { deltaX: number; deltaY: number } { + if (angleDeg === 0) return { deltaX, deltaY }; + const rad = (-angleDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return { deltaX: deltaX * cos - deltaY * sin, deltaY: deltaX * sin + deltaY * cos }; +}