@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.
- package/dist/assets/{index-DKcziwpY.js → index-CMHYjEZ5.js} +1 -1
- package/dist/assets/index-pRhCpGPz.js +423 -0
- package/dist/assets/{index-B27HFK8R.js → index-uBY329wb.js} +1 -1
- package/dist/{chunk-BA66NM4L.js → chunk-SOTCF4DF.js} +4 -2
- package/dist/chunk-SOTCF4DF.js.map +1 -0
- package/dist/{domEditingLayers-H7LDFIJ7.js → domEditingLayers-2ECJK24D.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/index.js +669 -338
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/DomEditCropHandles.test.tsx +86 -0
- package/src/components/editor/DomEditCropHandles.tsx +96 -58
- package/src/components/editor/DomEditOverlay.test.ts +41 -0
- package/src/components/editor/DomEditOverlay.tsx +17 -1
- package/src/components/editor/domEditOverlayCrop.test.ts +137 -0
- package/src/components/editor/domEditOverlayCrop.ts +103 -7
- package/src/components/editor/domEditOverlayGestures.ts +35 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +35 -2
- package/src/components/editor/domEditingDom.ts +1 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +48 -7
- package/src/hooks/gsapResizeIntercept.test.ts +129 -0
- package/src/hooks/gsapResizeIntercept.ts +385 -0
- package/src/hooks/gsapRuntimeBridge.ts +3 -226
- package/src/hooks/gsapRuntimePatch.test.ts +63 -0
- package/src/hooks/gsapRuntimePatch.ts +35 -1
- package/src/hooks/gsapRuntimeReaders.test.ts +111 -0
- package/src/hooks/gsapRuntimeReaders.ts +39 -14
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +66 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +85 -17
- package/src/hooks/useGsapAwareEditing.ts +2 -5
- package/src/hooks/useGsapScriptCommits.test.tsx +86 -66
- package/src/hooks/useGsapScriptCommits.ts +27 -4
- package/src/utils/authoredOpacity.ts +35 -0
- package/src/utils/elementGsap.ts +31 -0
- package/src/utils/gsapSoftReload.test.ts +86 -2
- package/src/utils/gsapSoftReload.ts +69 -7
- package/dist/assets/index-v3DXednk.js +0 -423
- package/dist/chunk-BA66NM4L.js.map +0 -1
- /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.
|
|
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/
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
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.
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
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:
|
|
170
|
-
deltaY:
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
233
|
-
top: cropRect.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
|
+
});
|