mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(studio): draw the crop UI in the element's rotated frame
Selecting a rotated (cropped) element appeared to straighten it: the crop dim and dashed window were drawn on the axis-aligned bounding box, so the bright window was a straight rectangle and the element's rotated corners were masked to near-black — while the DOM transform was untouched. clip-path applies in the element's LOCAL frame, before its transform, so the crop visualization now renders inside a container rotated with the element: readElementCropFrame decomposes the computed 2D matrix into angle + per-axis scale (element scale finally factored into the px mapping too) and 3D/unparseable transforms keep the axis-aligned presentation. Pointer deltas rotate into the element frame before the inset resolvers, so edge/pan drags track the rotated handles correctly.
This commit is contained in:
@@ -4,9 +4,11 @@ import type { OverlayRect } from "./domEditOverlayGeometry";
|
|||||||
import {
|
import {
|
||||||
type CropEdge,
|
type CropEdge,
|
||||||
cropRectFromInsets,
|
cropRectFromInsets,
|
||||||
|
readElementCropFrame,
|
||||||
readElementCropInsets,
|
readElementCropInsets,
|
||||||
resolveCropInsetFromEdgeDrag,
|
resolveCropInsetFromEdgeDrag,
|
||||||
resolveCropInsetFromMoveDrag,
|
resolveCropInsetFromMoveDrag,
|
||||||
|
rotateDeltaIntoFrame,
|
||||||
} from "./domEditOverlayCrop";
|
} from "./domEditOverlayCrop";
|
||||||
import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
|
import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
|
||||||
|
|
||||||
@@ -17,6 +19,10 @@ interface CropGestureState {
|
|||||||
startY: number;
|
startY: number;
|
||||||
startInsets: ClipPathInsetSides;
|
startInsets: ClipPathInsetSides;
|
||||||
didMove: boolean;
|
didMove: boolean;
|
||||||
|
/** Element frame captured at gesture start: pointer deltas rotate into it. */
|
||||||
|
angleDeg: number;
|
||||||
|
scaleX: number;
|
||||||
|
scaleY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DomEditCropHandlesProps {
|
interface DomEditCropHandlesProps {
|
||||||
@@ -133,11 +139,20 @@ export function DomEditCropHandles({
|
|||||||
};
|
};
|
||||||
}, [selection.element]);
|
}, [selection.element]);
|
||||||
|
|
||||||
const scaleX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
|
// The crop applies in the element's LOCAL frame (clip-path precedes the
|
||||||
const scaleY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
|
// transform), so all crop UI is drawn inside a container rotated with the
|
||||||
const width = overlayRect.width / scaleX;
|
// element — on a rotated element an axis-aligned dim visually "straightens"
|
||||||
const height = overlayRect.height / scaleY;
|
// it by masking the rotated corners.
|
||||||
const cropRect = cropRectFromInsets(overlayRect, state.insets, scaleX, scaleY);
|
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<HTMLElement>) => {
|
const startCropGesture = (edge: CropEdge | "move", event: ReactPointerEvent<HTMLElement>) => {
|
||||||
if (!onStyleCommit) return;
|
if (!onStyleCommit) return;
|
||||||
@@ -151,6 +166,9 @@ export function DomEditCropHandles({
|
|||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
startInsets: state.insets,
|
startInsets: state.insets,
|
||||||
didMove: false,
|
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
|
// Clip is already lifted by the selection effect; just flag the drag so the
|
||||||
// rule-of-thirds grid shows.
|
// rule-of-thirds grid shows.
|
||||||
@@ -162,12 +180,17 @@ export function DomEditCropHandles({
|
|||||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
const local = rotateDeltaIntoFrame(
|
||||||
|
event.clientX - gesture.startX,
|
||||||
|
event.clientY - gesture.startY,
|
||||||
|
gesture.angleDeg,
|
||||||
|
);
|
||||||
const drag = {
|
const drag = {
|
||||||
startInsets: gesture.startInsets,
|
startInsets: gesture.startInsets,
|
||||||
deltaX: event.clientX - gesture.startX,
|
deltaX: local.deltaX,
|
||||||
deltaY: event.clientY - gesture.startY,
|
deltaY: local.deltaY,
|
||||||
scaleX,
|
scaleX: gesture.scaleX,
|
||||||
scaleY,
|
scaleY: gesture.scaleY,
|
||||||
};
|
};
|
||||||
const nextInsets =
|
const nextInsets =
|
||||||
gesture.edge === "move"
|
gesture.edge === "move"
|
||||||
@@ -225,24 +248,27 @@ export function DomEditCropHandles({
|
|||||||
if (!state.croppable) return null;
|
if (!state.croppable) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
|
data-dom-edit-crop-frame="true"
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{
|
||||||
|
left: frame.left,
|
||||||
|
top: frame.top,
|
||||||
|
width: frame.width,
|
||||||
|
height: frame.height,
|
||||||
|
transform: frame.angleDeg !== 0 ? `rotate(${frame.angleDeg}deg)` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Dim the cropped-away area whenever the element is cropped and selected,
|
{/* 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 && (
|
{hasCrop && (
|
||||||
<div
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
className="pointer-events-none absolute overflow-hidden"
|
|
||||||
style={{
|
|
||||||
left: overlayRect.left,
|
|
||||||
top: overlayRect.top,
|
|
||||||
width: overlayRect.width,
|
|
||||||
height: overlayRect.height,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="absolute"
|
className="absolute"
|
||||||
style={{
|
style={{
|
||||||
left: cropRect.left - overlayRect.left,
|
left: cropRect.left,
|
||||||
top: cropRect.top - overlayRect.top,
|
top: cropRect.top,
|
||||||
width: cropRect.width,
|
width: cropRect.width,
|
||||||
height: cropRect.height,
|
height: cropRect.height,
|
||||||
boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
|
boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
|
||||||
@@ -324,6 +350,6 @@ export function DomEditCropHandles({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
cropRectFromInsets,
|
cropRectFromInsets,
|
||||||
hugRectForElement,
|
hugRectForElement,
|
||||||
|
readElementCropFrame,
|
||||||
readElementCropInsets,
|
readElementCropInsets,
|
||||||
resolveCropInsetFromEdgeDrag,
|
resolveCropInsetFromEdgeDrag,
|
||||||
resolveCropInsetFromMoveDrag,
|
resolveCropInsetFromMoveDrag,
|
||||||
|
rotateDeltaIntoFrame,
|
||||||
} from "./domEditOverlayCrop";
|
} from "./domEditOverlayCrop";
|
||||||
|
|
||||||
describe("resolveCropInsetFromEdgeDrag", () => {
|
describe("resolveCropInsetFromEdgeDrag", () => {
|
||||||
@@ -151,3 +153,91 @@ describe("readElementCropInsets tri-state", () => {
|
|||||||
expect(hugRectForElement(rect, fakeEl("circle(50%)"))).toEqual(rect);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -122,3 +122,91 @@ export function hugRectForElement(
|
|||||||
return rect;
|
return rect;
|
||||||
return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY);
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user