@hyperframes/studio 0.7.52 → 0.7.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/assets/{index-DKcziwpY.js → index-CMHYjEZ5.js} +1 -1
  2. package/dist/assets/index-pRhCpGPz.js +423 -0
  3. package/dist/assets/{index-B27HFK8R.js → index-uBY329wb.js} +1 -1
  4. package/dist/{chunk-BA66NM4L.js → chunk-SOTCF4DF.js} +4 -2
  5. package/dist/chunk-SOTCF4DF.js.map +1 -0
  6. package/dist/{domEditingLayers-H7LDFIJ7.js → domEditingLayers-2ECJK24D.js} +2 -2
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +669 -338
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/DomEditCropHandles.test.tsx +86 -0
  12. package/src/components/editor/DomEditCropHandles.tsx +96 -58
  13. package/src/components/editor/DomEditOverlay.test.ts +41 -0
  14. package/src/components/editor/DomEditOverlay.tsx +17 -1
  15. package/src/components/editor/domEditOverlayCrop.test.ts +137 -0
  16. package/src/components/editor/domEditOverlayCrop.ts +103 -7
  17. package/src/components/editor/domEditOverlayGestures.ts +35 -6
  18. package/src/components/editor/domEditOverlayStartGesture.ts +35 -2
  19. package/src/components/editor/domEditingDom.ts +1 -2
  20. package/src/components/editor/useDomEditOverlayGestures.ts +48 -7
  21. package/src/hooks/gsapResizeIntercept.test.ts +129 -0
  22. package/src/hooks/gsapResizeIntercept.ts +385 -0
  23. package/src/hooks/gsapRuntimeBridge.ts +3 -226
  24. package/src/hooks/gsapRuntimePatch.test.ts +63 -0
  25. package/src/hooks/gsapRuntimePatch.ts +35 -1
  26. package/src/hooks/gsapRuntimeReaders.test.ts +111 -0
  27. package/src/hooks/gsapRuntimeReaders.ts +39 -14
  28. package/src/hooks/useAnimatedPropertyCommit.test.tsx +66 -5
  29. package/src/hooks/useAnimatedPropertyCommit.ts +85 -17
  30. package/src/hooks/useGsapAwareEditing.ts +2 -5
  31. package/src/hooks/useGsapScriptCommits.test.tsx +86 -66
  32. package/src/hooks/useGsapScriptCommits.ts +27 -4
  33. package/src/utils/authoredOpacity.ts +35 -0
  34. package/src/utils/elementGsap.ts +31 -0
  35. package/src/utils/gsapSoftReload.test.ts +86 -2
  36. package/src/utils/gsapSoftReload.ts +69 -7
  37. package/dist/assets/index-v3DXednk.js +0 -423
  38. package/dist/chunk-BA66NM4L.js.map +0 -1
  39. /package/dist/{domEditingLayers-H7LDFIJ7.js.map → domEditingLayers-2ECJK24D.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.52",
3
+ "version": "0.7.54",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.52",
50
- "@hyperframes/parsers": "0.7.52",
51
- "@hyperframes/sdk": "0.7.52",
52
- "@hyperframes/player": "0.7.52",
53
- "@hyperframes/studio-server": "0.7.52"
49
+ "@hyperframes/player": "0.7.54",
50
+ "@hyperframes/sdk": "0.7.54",
51
+ "@hyperframes/parsers": "0.7.54",
52
+ "@hyperframes/studio-server": "0.7.54",
53
+ "@hyperframes/core": "0.7.54"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.52"
68
+ "@hyperframes/producer": "0.7.54"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -0,0 +1,86 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import type { DomEditSelection } from "./domEditing";
6
+ import type { OverlayRect } from "./domEditOverlayGeometry";
7
+ import { DomEditCropHandles } from "./DomEditCropHandles";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ const overlayRect: OverlayRect = {
16
+ left: 0,
17
+ top: 0,
18
+ width: 200,
19
+ height: 100,
20
+ editScaleX: 1,
21
+ editScaleY: 1,
22
+ };
23
+
24
+ function selectionFor(el: HTMLElement): DomEditSelection {
25
+ return { element: el, id: el.id, selector: `#${el.id}` } as unknown as DomEditSelection;
26
+ }
27
+
28
+ function makeEl(id: string, clip: string): HTMLElement {
29
+ const el = document.createElement("div");
30
+ el.id = id;
31
+ if (clip) el.style.setProperty("clip-path", clip);
32
+ document.body.append(el);
33
+ return el;
34
+ }
35
+
36
+ function render(el: HTMLElement): { root: Root; rerender: (next: HTMLElement) => void } {
37
+ const host = document.createElement("div");
38
+ document.body.append(host);
39
+ const root = createRoot(host);
40
+ const draw = (target: HTMLElement) =>
41
+ act(() => {
42
+ root.render(
43
+ <DomEditCropHandles
44
+ selection={selectionFor(target)}
45
+ overlayRect={overlayRect}
46
+ onStyleCommit={() => undefined}
47
+ />,
48
+ );
49
+ });
50
+ draw(el);
51
+ return { root, rerender: draw };
52
+ }
53
+
54
+ // Regression: the deselect restore used a ref recomputed from RENDER state — on
55
+ // a direct A→B selection switch, state re-syncs to B before A's effect cleanup
56
+ // runs, so A used to get B's crop string (or lose its crop entirely). The
57
+ // restore value must be owned by A's own lift effect / crop gesture.
58
+ describe("DomEditCropHandles clip lift/restore", () => {
59
+ it("lifts on select and restores the inline clip verbatim on unmount", () => {
60
+ const a = makeEl("a", "inset(16px round 12px)");
61
+ const { root } = render(a);
62
+ expect(a.style.getPropertyValue("clip-path")).toBe("none");
63
+ act(() => root.unmount());
64
+ expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px round 12px)");
65
+ });
66
+
67
+ it("restores A's own clip when switching directly to B", () => {
68
+ const a = makeEl("a", "inset(16px)");
69
+ const b = makeEl("b", "inset(40px 8px 4px 2px)");
70
+ const { root, rerender } = render(a);
71
+ rerender(b);
72
+ // A got ITS clip back, not B's (and not removed); B is now lifted.
73
+ expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px)");
74
+ expect(b.style.getPropertyValue("clip-path")).toBe("none");
75
+ act(() => root.unmount());
76
+ expect(b.style.getPropertyValue("clip-path")).toBe("inset(40px 8px 4px 2px)");
77
+ });
78
+
79
+ it("never lifts an uneditable clip and leaves it untouched across select/deselect", () => {
80
+ const a = makeEl("a", "circle(50% at 50% 50%)");
81
+ const { root } = render(a);
82
+ expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
83
+ act(() => root.unmount());
84
+ expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
85
+ });
86
+ });
@@ -4,9 +4,11 @@ import type { OverlayRect } from "./domEditOverlayGeometry";
4
4
  import {
5
5
  type CropEdge,
6
6
  cropRectFromInsets,
7
+ readElementCropFrame,
7
8
  readElementCropInsets,
8
9
  resolveCropInsetFromEdgeDrag,
9
10
  resolveCropInsetFromMoveDrag,
11
+ rotateDeltaIntoFrame,
10
12
  } from "./domEditOverlayCrop";
11
13
  import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
12
14
 
@@ -17,6 +19,10 @@ interface CropGestureState {
17
19
  startY: number;
18
20
  startInsets: ClipPathInsetSides;
19
21
  didMove: boolean;
22
+ /** Element frame captured at gesture start: pointer deltas rotate into it. */
23
+ angleDeg: number;
24
+ scaleX: number;
25
+ scaleY: number;
20
26
  }
21
27
 
22
28
  interface DomEditCropHandlesProps {
@@ -77,36 +83,23 @@ export function DomEditCropHandles({
77
83
  }: DomEditCropHandlesProps) {
78
84
  const gestureRef = useRef<CropGestureState | null>(null);
79
85
  const [dragging, setDragging] = useState(false);
80
- const [state, setState] = useState(() => {
81
- const parsed = readElementCropInsets(selection.element);
82
- return {
83
- element: selection.element,
84
- insets: {
85
- top: parsed.top,
86
- right: parsed.right,
87
- bottom: parsed.bottom,
88
- left: parsed.left,
89
- } as ClipPathInsetSides,
90
- radius: parsed.radius,
91
- };
92
- });
86
+ // readElementCropInsets returns null for a clip this tool can't represent
87
+ // (circle/polygon/non-px inset): the crop UI must fully stand down for that
88
+ // element — no lift, no handles — or select+deselect replaces the authored
89
+ // clip with an inset (or deletes it).
90
+ const cropStateFor = (element: HTMLElement) => {
91
+ const parsed = readElementCropInsets(element);
92
+ const { radius, ...insets } = parsed ?? { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
93
+ return { element, croppable: parsed !== null, insets, radius };
94
+ };
95
+ const [state, setState] = useState(() => cropStateFor(selection.element));
93
96
 
94
97
  // Re-sync when the selection targets a different element (reselect, or an
95
98
  // undo/redo that re-keys the node): read its committed crop before the lift
96
99
  // effect runs. Read inside the guard so a drag's per-frame setState doesn't
97
100
  // re-run getComputedStyle every frame.
98
101
  if (state.element !== selection.element) {
99
- const liveInsets = readElementCropInsets(selection.element);
100
- setState({
101
- element: selection.element,
102
- insets: {
103
- top: liveInsets.top,
104
- right: liveInsets.right,
105
- bottom: liveInsets.bottom,
106
- left: liveInsets.left,
107
- },
108
- radius: liveInsets.radius,
109
- });
102
+ setState(cropStateFor(selection.element));
110
103
  }
111
104
 
112
105
  const hasCrop =
@@ -115,31 +108,51 @@ export function DomEditCropHandles({
115
108
  state.insets.bottom > 0 ||
116
109
  state.insets.left > 0;
117
110
 
118
- // Latest committed crop — re-applied to the element when the selection drops.
119
- const committedRef = useRef<string | null>(null);
120
- committedRef.current = hasCrop ? buildInsetClipPathSides(state.insets, state.radius) : null;
121
-
122
111
  // Lift the clip while the element is selected so the full content shows and the
123
- // cropped-away area can be dimmed; restore the committed crop on deselect. Keyed
124
- // on the element so switching selections restores the previous one. Runs after
125
- // render, so the state re-sync above still reads the element's real committed clip.
112
+ // cropped-away area can be dimmed; restore on deselect. Keyed on the element so
113
+ // switching selections restores the previous one. Runs after render, so the
114
+ // state re-sync above still reads the element's real committed clip. Restore
115
+ // prefers the pre-lift inline value VERBATIM — the rebuilt inset only replaces
116
+ // it after a crop gesture actually commits, so a mere select+deselect can
117
+ // never reformat (or drop) what the author wrote. Both refs are written only
118
+ // by THIS element's lift effect and crop gestures — never derived from render
119
+ // state, which by cleanup time already describes the NEXT selection (a direct
120
+ // A→B switch re-syncs state to B before A's cleanup runs).
126
121
  const liftedRef = useRef(false);
122
+ const preLiftInlineClipRef = useRef("");
123
+ // null = no crop gesture committed this selection; "" = committed a crop
124
+ // removal; anything else = the exact committed clip-path value.
125
+ const committedClipRef = useRef<string | null>(null);
127
126
  useEffect(() => {
128
127
  const el = selection.element;
128
+ if (readElementCropInsets(el) === null) return;
129
+ preLiftInlineClipRef.current = el.style.getPropertyValue("clip-path");
130
+ committedClipRef.current = null;
129
131
  el.style.setProperty("clip-path", "none");
130
132
  liftedRef.current = true;
131
133
  return () => {
132
134
  liftedRef.current = false;
133
- if (committedRef.current) el.style.setProperty("clip-path", committedRef.current);
135
+ const committed = committedClipRef.current;
136
+ const restore = committed !== null ? committed || null : preLiftInlineClipRef.current || null;
137
+ if (restore) el.style.setProperty("clip-path", restore);
134
138
  else el.style.removeProperty("clip-path");
135
139
  };
136
140
  }, [selection.element]);
137
141
 
138
- const scaleX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
139
- const scaleY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
140
- const width = overlayRect.width / scaleX;
141
- const height = overlayRect.height / scaleY;
142
- const cropRect = cropRectFromInsets(overlayRect, state.insets, scaleX, scaleY);
142
+ // The crop applies in the element's LOCAL frame (clip-path precedes the
143
+ // transform), so all crop UI is drawn inside a container rotated with the
144
+ // element on a rotated element an axis-aligned dim visually "straightens"
145
+ // it by masking the rotated corners.
146
+ const frame = readElementCropFrame(selection.element, overlayRect);
147
+ const width = frame.width / frame.scaleX; // element CSS px
148
+ const height = frame.height / frame.scaleY;
149
+ // Crop rect in FRAME-LOCAL coordinates (origin = frame top-left).
150
+ const cropRect = cropRectFromInsets(
151
+ { left: 0, top: 0, width: frame.width, height: frame.height },
152
+ state.insets,
153
+ frame.scaleX,
154
+ frame.scaleY,
155
+ );
143
156
 
144
157
  const startCropGesture = (edge: CropEdge | "move", event: ReactPointerEvent<HTMLElement>) => {
145
158
  if (!onStyleCommit) return;
@@ -153,6 +166,9 @@ export function DomEditCropHandles({
153
166
  startY: event.clientY,
154
167
  startInsets: state.insets,
155
168
  didMove: false,
169
+ angleDeg: frame.angleDeg,
170
+ scaleX: frame.scaleX,
171
+ scaleY: frame.scaleY,
156
172
  };
157
173
  // Clip is already lifted by the selection effect; just flag the drag so the
158
174
  // rule-of-thirds grid shows.
@@ -164,12 +180,17 @@ export function DomEditCropHandles({
164
180
  if (!gesture || gesture.pointerId !== event.pointerId) return;
165
181
  event.preventDefault();
166
182
  event.stopPropagation();
183
+ const local = rotateDeltaIntoFrame(
184
+ event.clientX - gesture.startX,
185
+ event.clientY - gesture.startY,
186
+ gesture.angleDeg,
187
+ );
167
188
  const drag = {
168
189
  startInsets: gesture.startInsets,
169
- deltaX: event.clientX - gesture.startX,
170
- deltaY: event.clientY - gesture.startY,
171
- scaleX,
172
- scaleY,
190
+ deltaX: local.deltaX,
191
+ deltaY: local.deltaY,
192
+ scaleX: gesture.scaleX,
193
+ scaleY: gesture.scaleY,
173
194
  };
174
195
  const nextInsets =
175
196
  gesture.edge === "move"
@@ -196,9 +217,19 @@ export function DomEditCropHandles({
196
217
  const reLift = () => {
197
218
  if (liftedRef.current) el.style.setProperty("clip-path", "none");
198
219
  };
199
- void Promise.resolve(
200
- onStyleCommit?.("clip-path", buildInsetClipPathSides(state.insets, state.radius)),
201
- ).then(reLift, reLift);
220
+ const committedValue = buildInsetClipPathSides(state.insets, state.radius);
221
+ const cropped =
222
+ state.insets.top > 0 ||
223
+ state.insets.right > 0 ||
224
+ state.insets.bottom > 0 ||
225
+ state.insets.left > 0;
226
+ void Promise.resolve(onStyleCommit?.("clip-path", committedValue)).then(() => {
227
+ // Only a landed commit makes the rebuilt inset the restore value; a
228
+ // failed one keeps restoring the pre-lift clip. Store the value itself —
229
+ // by deselect time, render state describes the next selection.
230
+ committedClipRef.current = cropped ? committedValue : "";
231
+ reLift();
232
+ }, reLift);
202
233
  };
203
234
 
204
235
  const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
@@ -212,25 +243,32 @@ export function DomEditCropHandles({
212
243
  setState((prev) => ({ ...prev, insets: gesture.startInsets }));
213
244
  };
214
245
 
246
+ // Uneditable clip (circle/polygon/non-px inset): the element renders exactly
247
+ // as authored and the crop tool shows nothing. All hooks above stay mounted.
248
+ if (!state.croppable) return null;
249
+
215
250
  return (
216
- <>
251
+ <div
252
+ data-dom-edit-crop-frame="true"
253
+ className="pointer-events-none absolute"
254
+ style={{
255
+ left: frame.left,
256
+ top: frame.top,
257
+ width: frame.width,
258
+ height: frame.height,
259
+ transform: frame.angleDeg !== 0 ? `rotate(${frame.angleDeg}deg)` : undefined,
260
+ }}
261
+ >
217
262
  {/* Dim the cropped-away area whenever the element is cropped and selected,
218
- so the hidden content is visible (ghosted) without dragging. */}
263
+ so the hidden content is visible (ghosted) without dragging. Clipped to
264
+ the element's own (rotated) box. */}
219
265
  {hasCrop && (
220
- <div
221
- className="pointer-events-none absolute overflow-hidden"
222
- style={{
223
- left: overlayRect.left,
224
- top: overlayRect.top,
225
- width: overlayRect.width,
226
- height: overlayRect.height,
227
- }}
228
- >
266
+ <div className="pointer-events-none absolute inset-0 overflow-hidden">
229
267
  <div
230
268
  className="absolute"
231
269
  style={{
232
- left: cropRect.left - overlayRect.left,
233
- top: cropRect.top - overlayRect.top,
270
+ left: cropRect.left,
271
+ top: cropRect.top,
234
272
  width: cropRect.width,
235
273
  height: cropRect.height,
236
274
  boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
@@ -312,6 +350,6 @@ export function DomEditCropHandles({
312
350
  />
313
351
  );
314
352
  })}
315
- </>
353
+ </div>
316
354
  );
317
355
  }
@@ -535,6 +535,47 @@ describe("resolveDomEditResizeGesture", () => {
535
535
  });
536
536
  });
537
537
 
538
+ it("divides the cursor delta by the element's content scale (rescaled element)", () => {
539
+ // Element renders at 2x via a GSAP scale: a 30px cursor delta must grow the
540
+ // CSS box by only 15px so the RENDERED box tracks the pointer 1:1.
541
+ const next = resolveDomEditResizeGesture({
542
+ originWidth: 480, // 240 css x 2 content scale (overlay px at editScale 1)
543
+ originHeight: 240,
544
+ actualWidth: 240,
545
+ actualHeight: 120,
546
+ scaleX: 1,
547
+ scaleY: 1,
548
+ contentScaleX: 2,
549
+ contentScaleY: 2,
550
+ dx: 30,
551
+ dy: 12,
552
+ uniform: false,
553
+ });
554
+ expect(next.width).toBe(255);
555
+ expect(next.height).toBe(126);
556
+ // The overlay box keeps tracking the raw cursor.
557
+ expect(next.overlayWidth).toBe(510);
558
+ expect(next.overlayHeight).toBe(252);
559
+ });
560
+
561
+ it("treats a missing/invalid content scale as 1 (unscaled element)", () => {
562
+ const next = resolveDomEditResizeGesture({
563
+ originWidth: 240,
564
+ originHeight: 120,
565
+ actualWidth: 240,
566
+ actualHeight: 120,
567
+ scaleX: 1,
568
+ scaleY: 1,
569
+ contentScaleX: 0,
570
+ contentScaleY: Number.NaN,
571
+ dx: 30,
572
+ dy: 12,
573
+ uniform: false,
574
+ });
575
+ expect(next.width).toBe(270);
576
+ expect(next.height).toBe(132);
577
+ });
578
+
538
579
  it("snaps width and height to the same value when Shift is held", () => {
539
580
  expect(
540
581
  resolveDomEditResizeGesture({
@@ -1,4 +1,5 @@
1
1
  import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
+ import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
2
3
  import { type DomEditSelection } from "./domEditing";
3
4
  import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
4
5
  import { useMarqueeGestures } from "./marqueeCommit";
@@ -305,8 +306,23 @@ export const DomEditOverlay = memo(function DomEditOverlay({
305
306
  const target = event.target as HTMLElement | null;
306
307
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
307
308
 
308
- // Start marquee if clicking on empty canvas (no element under pointer)
309
+ // Start marquee if clicking on empty canvas (no element under pointer).
310
+ // The hover selection is an ASYNC cache: on a fast click (or when the
311
+ // pointer was already resting over an element) it can still be empty while
312
+ // an element IS under the pointer — starting a marquee here would swallow
313
+ // the selection mousedown and the click would silently select nothing.
314
+ // Confirm emptiness with a fresh SYNCHRONOUS hit-test before committing.
309
315
  if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
316
+ const iframe = iframeRef.current;
317
+ const freshTarget = iframe
318
+ ? getPreviewTargetFromPointer(
319
+ iframe,
320
+ event.clientX,
321
+ event.clientY,
322
+ activeCompositionPathRef.current,
323
+ )
324
+ : null;
325
+ if (freshTarget) return;
310
326
  const overlayEl = overlayRef.current;
311
327
  if (overlayEl) {
312
328
  const oRect = overlayEl.getBoundingClientRect();
@@ -1,8 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  cropRectFromInsets,
4
+ hugRectForElement,
5
+ readElementCropFrame,
6
+ readElementCropInsets,
4
7
  resolveCropInsetFromEdgeDrag,
5
8
  resolveCropInsetFromMoveDrag,
9
+ rotateDeltaIntoFrame,
6
10
  } from "./domEditOverlayCrop";
7
11
 
8
12
  describe("resolveCropInsetFromEdgeDrag", () => {
@@ -104,3 +108,136 @@ describe("cropRectFromInsets", () => {
104
108
  expect(r.height).toBe(0);
105
109
  });
106
110
  });
111
+
112
+ describe("readElementCropInsets tri-state", () => {
113
+ // Regression: a clip-path the crop tool can't represent (circle/polygon/
114
+ // non-px inset) used to parse to ZEROS — indistinguishable from "no crop" —
115
+ // so selecting lifted the clip and deselecting removed/replaced it: the
116
+ // authored circle clip was silently destroyed by a mere select+deselect.
117
+ const fakeEl = (inlineClip: string) =>
118
+ ({
119
+ style: { getPropertyValue: (p: string) => (p === "clip-path" ? inlineClip : "") },
120
+ ownerDocument: { defaultView: { getComputedStyle: () => ({ clipPath: "none" }) } },
121
+ }) as unknown as HTMLElement;
122
+
123
+ it("zeros for no clip", () => {
124
+ expect(readElementCropInsets(fakeEl(""))).toEqual({
125
+ top: 0,
126
+ right: 0,
127
+ bottom: 0,
128
+ left: 0,
129
+ radius: 0,
130
+ });
131
+ });
132
+
133
+ it("parses a px inset", () => {
134
+ expect(readElementCropInsets(fakeEl("inset(16px round 12px)"))).toEqual({
135
+ top: 16,
136
+ right: 16,
137
+ bottom: 16,
138
+ left: 16,
139
+ radius: 12,
140
+ });
141
+ });
142
+
143
+ it("null for a circle clip (uneditable, must not be lifted)", () => {
144
+ expect(readElementCropInsets(fakeEl("circle(50% at 50% 50%)"))).toBeNull();
145
+ });
146
+
147
+ it("null for a non-px inset (uneditable, must not be lifted)", () => {
148
+ expect(readElementCropInsets(fakeEl("inset(10%)"))).toBeNull();
149
+ });
150
+
151
+ it("hugRectForElement passes the rect through for uneditable clips", () => {
152
+ const rect = { left: 1, top: 2, width: 30, height: 40, editScaleX: 1, editScaleY: 1 };
153
+ expect(hugRectForElement(rect, fakeEl("circle(50%)"))).toEqual(rect);
154
+ });
155
+ });
156
+
157
+ // Regression: crop UI drawn on the axis-aligned bounding box visually
158
+ // "straightens" a rotated element — the dim masks the rotated corners. The
159
+ // frame gives the element's own box + rotation so the UI rotates with it.
160
+ describe("readElementCropFrame", () => {
161
+ const overlayRect = { left: 100, top: 50, width: 220, height: 130, editScaleX: 1, editScaleY: 1 };
162
+
163
+ const fakeEl = (transform: string, offsetWidth = 200, offsetHeight = 100) =>
164
+ ({
165
+ offsetWidth,
166
+ offsetHeight,
167
+ ownerDocument: { defaultView: { getComputedStyle: () => ({ transform }) } },
168
+ }) as unknown as HTMLElement;
169
+
170
+ it("identity transform → the axis-aligned overlay rect", () => {
171
+ expect(readElementCropFrame(fakeEl("none"), overlayRect)).toEqual({
172
+ angleDeg: 0,
173
+ left: 100,
174
+ top: 50,
175
+ width: 220,
176
+ height: 130,
177
+ scaleX: 1,
178
+ scaleY: 1,
179
+ });
180
+ });
181
+
182
+ it("rotated element → its own box, centered on the AABB, with the angle", () => {
183
+ // rotate(30deg): matrix(cos, sin, -sin, cos, tx, ty)
184
+ const cos = Math.cos(Math.PI / 6);
185
+ const sin = Math.sin(Math.PI / 6);
186
+ const frame = readElementCropFrame(
187
+ fakeEl(`matrix(${cos}, ${sin}, ${-sin}, ${cos}, 10, 20)`),
188
+ overlayRect,
189
+ );
190
+ expect(frame.angleDeg).toBeCloseTo(30, 3);
191
+ expect(frame.width).toBeCloseTo(200, 3);
192
+ expect(frame.height).toBeCloseTo(100, 3);
193
+ // centered on the AABB center (210, 115)
194
+ expect(frame.left + frame.width / 2).toBeCloseTo(210, 3);
195
+ expect(frame.top + frame.height / 2).toBeCloseTo(115, 3);
196
+ expect(frame.scaleX).toBeCloseTo(1, 3);
197
+ });
198
+
199
+ it("scaled element → scale factored into px-per-element-px", () => {
200
+ const frame = readElementCropFrame(fakeEl("matrix(1.5, 0, 0, 2, 0, 0)"), overlayRect);
201
+ expect(frame.angleDeg).toBe(0);
202
+ expect(frame.scaleX).toBeCloseTo(1.5, 3);
203
+ expect(frame.scaleY).toBeCloseTo(2, 3);
204
+ expect(frame.width).toBeCloseTo(300, 3);
205
+ expect(frame.height).toBeCloseTo(200, 3);
206
+ });
207
+
208
+ it("3D transform falls back to the axis-aligned frame", () => {
209
+ const frame = readElementCropFrame(
210
+ fakeEl("matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)"),
211
+ overlayRect,
212
+ );
213
+ expect(frame).toEqual({
214
+ angleDeg: 0,
215
+ left: 100,
216
+ top: 50,
217
+ width: 220,
218
+ height: 130,
219
+ scaleX: 1,
220
+ scaleY: 1,
221
+ });
222
+ });
223
+ });
224
+
225
+ describe("rotateDeltaIntoFrame", () => {
226
+ it("passes deltas through at 0deg", () => {
227
+ expect(rotateDeltaIntoFrame(10, 5, 0)).toEqual({ deltaX: 10, deltaY: 5 });
228
+ });
229
+
230
+ it("rotates a screen delta into a 90deg-rotated frame", () => {
231
+ // Element rotated +90°: dragging DOWN on screen moves along the element's +x.
232
+ const { deltaX, deltaY } = rotateDeltaIntoFrame(0, 10, 90);
233
+ expect(deltaX).toBeCloseTo(10, 6);
234
+ expect(deltaY).toBeCloseTo(0, 6);
235
+ });
236
+
237
+ it("round-trips a 30deg rotation", () => {
238
+ const local = rotateDeltaIntoFrame(7, -3, 30);
239
+ const back = rotateDeltaIntoFrame(local.deltaX, local.deltaY, -30);
240
+ expect(back.deltaX).toBeCloseTo(7, 6);
241
+ expect(back.deltaY).toBeCloseTo(-3, 6);
242
+ });
243
+ });