@hyperframes/studio 0.8.97 → 0.8.99
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/{hyperframes-player-Biu5_W0l.js → hyperframes-player-Rml34FRW.js} +1 -1
- package/dist/assets/{index-BdV5T-QI.js → index-CVDArg3m.js} +188 -188
- package/dist/assets/{index-B5Z-1RW9.js → index-C_fi-jTe.js} +1 -1
- package/dist/assets/{index-CWuozokW.js → index-D9YzDuBx.js} +1 -1
- package/dist/index.d.ts +16 -4
- package/dist/index.html +1 -1
- package/dist/index.js +758 -607
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
- package/src/components/EditorShell.tsx +7 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
- package/src/components/editor/DomEditCropHandles.tsx +30 -62
- package/src/components/editor/DomEditOverlay.test.ts +2 -2
- package/src/components/editor/DomEditOverlay.tsx +0 -27
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
- package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
- package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
- package/src/components/editor/cropResize.test.ts +129 -0
- package/src/components/editor/cropResize.ts +143 -0
- package/src/components/editor/domEditOverlayCrop.ts +65 -15
- package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
- package/src/components/editor/domEditOverlayGestures.ts +2 -5
- package/src/components/editor/elementPositionOffset.ts +5 -4
- package/src/components/editor/manualEdits.test.ts +22 -8
- package/src/components/editor/manualEditsDom.ts +27 -11
- package/src/components/editor/manualEditsParsing.ts +0 -4
- package/src/components/editor/manualEditsSnapshot.ts +3 -0
- package/src/components/editor/manualOffsetDrag.test.ts +14 -0
- package/src/components/editor/propertyPanelFont.test.tsx +48 -5
- package/src/components/editor/propertyPanelFont.tsx +2 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
- package/src/components/nle/TimelinePane.tsx +4 -0
- package/src/hooks/domSelectionTestHarness.ts +8 -0
- package/src/hooks/draggedGsapPosition.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +6 -6
- package/src/hooks/gsapEditOutcome.ts +6 -0
- package/src/hooks/gsapResizeIntercept.ts +5 -5
- package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
- package/src/hooks/gsapRuntimeBridge.ts +2 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
- package/src/hooks/timelineTimingSync.ts +40 -4
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
- package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
- package/src/hooks/useCropOverlay.ts +8 -2
- package/src/hooks/useDomEditCommits.test.tsx +6 -1
- package/src/hooks/useDomEditCommits.ts +1 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
- package/src/hooks/useDomGeometryCommits.ts +5 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
- package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
- package/src/hooks/useGsapAwareEditing.ts +37 -23
- package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
- package/src/hooks/useGsapTweenCache.ts +0 -24
- package/src/hooks/useTimelineEditing.test.tsx +190 -135
- package/src/hooks/useTimelineEditing.ts +13 -13
- package/src/hooks/useTimelineGroupEditing.ts +13 -9
- package/src/index.ts +4 -1
- package/src/player/components/ClipContextMenu.test.tsx +182 -0
- package/src/player/components/ClipContextMenu.tsx +52 -10
- package/src/player/components/TimelineOverlays.test.ts +30 -7
- package/src/player/components/TimelineOverlays.tsx +7 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTypes.ts +10 -0
- package/src/player/components/menuKeyboardNav.ts +12 -5
- package/src/player/components/useTimelineProviderState.tsx +2 -0
- package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
- package/src/utils/rounding.ts +5 -0
- package/src/utils/sdkEditTransaction.ts +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.99",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -54,18 +54,18 @@
|
|
|
54
54
|
"@codemirror/view": "6.40.0",
|
|
55
55
|
"@mcp-b/global": "^5.0.1",
|
|
56
56
|
"@phosphor-icons/react": "^2.1.10",
|
|
57
|
-
"@tanstack/react-virtual": "^3.14.
|
|
57
|
+
"@tanstack/react-virtual": "^3.14.13",
|
|
58
58
|
"bpm-detective": "^2.0.5",
|
|
59
59
|
"cn": "0.2.4",
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/parsers": "0.8.
|
|
65
|
-
"@hyperframes/player": "0.8.
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.99",
|
|
64
|
+
"@hyperframes/parsers": "0.8.99",
|
|
65
|
+
"@hyperframes/player": "0.8.99",
|
|
66
|
+
"@hyperframes/sdk": "0.8.99",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.99",
|
|
68
|
+
"@hyperframes/shader-transitions": "0.8.99"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.99"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
"build": "vite build && tsup",
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
|
95
95
|
"test": "vitest run",
|
|
96
|
+
"test:edit-accuracy": "bun tests/e2e/edit-accuracy/run.mjs",
|
|
96
97
|
"test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
|
|
97
98
|
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
|
|
98
99
|
"test:watch": "vitest",
|
|
@@ -18,6 +18,7 @@ import type { GestureRecordingState } from "./editor/GestureRecordControl";
|
|
|
18
18
|
import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
|
|
19
19
|
import { StudioAgentTools } from "../webmcp/StudioAgentTools";
|
|
20
20
|
import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
|
|
21
|
+
import type { TimelineProps } from "../player/components/TimelineTypes";
|
|
21
22
|
import { Dock } from "./dock/Dock";
|
|
22
23
|
|
|
23
24
|
type RenderClipContent = (
|
|
@@ -57,6 +58,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
|
|
|
57
58
|
onPasteClip: () => Promise<void>;
|
|
58
59
|
onDuplicateClip: () => Promise<boolean>;
|
|
59
60
|
canPasteClip: () => boolean;
|
|
61
|
+
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
60
62
|
setCompIdToSrc: (map: Map<string, string>) => void;
|
|
61
63
|
setCompositionLoading: (loading: boolean) => void;
|
|
62
64
|
shouldShowMotionPath: boolean;
|
|
@@ -105,6 +107,7 @@ export function EditorShell({
|
|
|
105
107
|
onPasteClip,
|
|
106
108
|
onDuplicateClip,
|
|
107
109
|
canPasteClip,
|
|
110
|
+
clipMenuItems,
|
|
108
111
|
setCompIdToSrc,
|
|
109
112
|
setCompositionLoading,
|
|
110
113
|
shouldShowMotionPath,
|
|
@@ -201,6 +204,7 @@ export function EditorShell({
|
|
|
201
204
|
onPasteClip={onPasteClip}
|
|
202
205
|
onDuplicateClip={onDuplicateClip}
|
|
203
206
|
canPasteClip={canPasteClip}
|
|
207
|
+
clipMenuItems={clipMenuItems}
|
|
204
208
|
previewOverlay={
|
|
205
209
|
<PreviewOverlays
|
|
206
210
|
shouldShowMotionPath={shouldShowMotionPath}
|
|
@@ -244,6 +248,7 @@ interface EditorShellBodyProps {
|
|
|
244
248
|
onPasteClip: () => Promise<void>;
|
|
245
249
|
onDuplicateClip: () => Promise<boolean>;
|
|
246
250
|
canPasteClip: () => boolean;
|
|
251
|
+
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
function EditorShellBody({
|
|
@@ -264,6 +269,7 @@ function EditorShellBody({
|
|
|
264
269
|
onPasteClip,
|
|
265
270
|
onDuplicateClip,
|
|
266
271
|
canPasteClip,
|
|
272
|
+
clipMenuItems,
|
|
267
273
|
}: EditorShellBodyProps) {
|
|
268
274
|
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
|
|
269
275
|
|
|
@@ -316,6 +322,7 @@ function EditorShellBody({
|
|
|
316
322
|
onPasteClip={onPasteClip}
|
|
317
323
|
onDuplicateClip={onDuplicateClip}
|
|
318
324
|
canPasteClip={canPasteClip}
|
|
325
|
+
clipMenuItems={clipMenuItems}
|
|
319
326
|
onSelectTimelineElement={onSelectTimelineElement}
|
|
320
327
|
timelineFooter={
|
|
321
328
|
captionEditMode ? (
|
|
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import type { DomEditSelection } from "./domEditing";
|
|
6
6
|
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
7
7
|
import { DomEditCropHandles } from "./DomEditCropHandles";
|
|
8
|
+
import { isElementCropLifted } from "./domEditOverlayCrop";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
@@ -55,27 +56,27 @@ function render(
|
|
|
55
56
|
return { root, rerender: draw };
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
//
|
|
59
|
-
// a direct A→B
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
describe("DomEditCropHandles clip lift/restore", () => {
|
|
63
|
-
it("lifts on select and restores the inline clip verbatim on unmount", () => {
|
|
59
|
+
// The lift never touches the element's own clip-path: select+deselect must leave what the author
|
|
60
|
+
// wrote verbatim, and a direct A→B switch must drop A's lift, not B's.
|
|
61
|
+
describe("DomEditCropHandles clip lift", () => {
|
|
62
|
+
it("lifts on select without rewriting the inline clip, and drops the lift on unmount", () => {
|
|
64
63
|
const a = makeEl("a", "inset(16px round 12px)");
|
|
65
64
|
const { root } = render(a);
|
|
66
|
-
expect(a
|
|
65
|
+
expect(isElementCropLifted(a)).toBe(true);
|
|
66
|
+
expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px round 12px)");
|
|
67
67
|
act(() => root.unmount());
|
|
68
|
+
expect(isElementCropLifted(a)).toBe(false);
|
|
68
69
|
expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px round 12px)");
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
it("
|
|
72
|
+
it("drops A's lift when switching directly to B", () => {
|
|
72
73
|
const a = makeEl("a", "inset(16px)");
|
|
73
74
|
const b = makeEl("b", "inset(40px 8px 4px 2px)");
|
|
74
75
|
const { root, rerender } = render(a);
|
|
75
76
|
rerender(b);
|
|
76
|
-
|
|
77
|
+
expect(isElementCropLifted(a)).toBe(false);
|
|
78
|
+
expect(isElementCropLifted(b)).toBe(true);
|
|
77
79
|
expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px)");
|
|
78
|
-
expect(b.style.getPropertyValue("clip-path")).toBe("none");
|
|
79
80
|
act(() => root.unmount());
|
|
80
81
|
expect(b.style.getPropertyValue("clip-path")).toBe("inset(40px 8px 4px 2px)");
|
|
81
82
|
});
|
|
@@ -83,49 +84,11 @@ describe("DomEditCropHandles clip lift/restore", () => {
|
|
|
83
84
|
it("never lifts an uneditable clip and leaves it untouched across select/deselect", () => {
|
|
84
85
|
const a = makeEl("a", "circle(50% at 50% 50%)");
|
|
85
86
|
const { root } = render(a);
|
|
86
|
-
expect(a
|
|
87
|
+
expect(isElementCropLifted(a)).toBe(false);
|
|
87
88
|
act(() => root.unmount());
|
|
88
89
|
expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
|
|
89
90
|
});
|
|
90
91
|
|
|
91
|
-
it("re-lifts synchronously after the commit path re-applies the cropped value", async () => {
|
|
92
|
-
const a = makeEl("a", "inset(10px)");
|
|
93
|
-
let resolveCommit: (() => void) | undefined;
|
|
94
|
-
const pendingCommit = new Promise<void>((resolve) => {
|
|
95
|
-
resolveCommit = resolve;
|
|
96
|
-
});
|
|
97
|
-
const onStyleCommit = vi.fn((property: string, value: string) => {
|
|
98
|
-
a.style.setProperty(property, value);
|
|
99
|
-
return pendingCommit;
|
|
100
|
-
});
|
|
101
|
-
const { root } = render(a, onStyleCommit);
|
|
102
|
-
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
|
|
103
|
-
expect(handle).toBeTruthy();
|
|
104
|
-
|
|
105
|
-
act(() =>
|
|
106
|
-
handle!.dispatchEvent(
|
|
107
|
-
new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 100 }),
|
|
108
|
-
),
|
|
109
|
-
);
|
|
110
|
-
act(() =>
|
|
111
|
-
handle!.dispatchEvent(
|
|
112
|
-
new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 80 }),
|
|
113
|
-
),
|
|
114
|
-
);
|
|
115
|
-
act(() =>
|
|
116
|
-
handle!.dispatchEvent(
|
|
117
|
-
new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 80 }),
|
|
118
|
-
),
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
|
|
122
|
-
expect(a.style.getPropertyValue("clip-path")).toBe("none");
|
|
123
|
-
resolveCommit?.();
|
|
124
|
-
await act(async () => pendingCommit);
|
|
125
|
-
act(() => root.unmount());
|
|
126
|
-
expect(a.style.getPropertyValue("clip-path")).toBe("inset(10px 30px 10px 10px)");
|
|
127
|
-
});
|
|
128
|
-
|
|
129
92
|
it.each([
|
|
130
93
|
{ name: "a crop edge", clip: "inset(10px)", handle: "Crop right", dx: -20, dy: 0 },
|
|
131
94
|
{
|
|
@@ -189,33 +152,38 @@ describe("DomEditCropHandles clip lift/restore", () => {
|
|
|
189
152
|
expect(a.style.getPropertyValue("clip-path")).toBe("inset(0px 20px 0px 0px)");
|
|
190
153
|
});
|
|
191
154
|
|
|
192
|
-
it("
|
|
155
|
+
it("commits the dragged crop and stays lifted when the save fails", async () => {
|
|
193
156
|
const a = makeEl("a", "inset(10px)");
|
|
194
157
|
const onStyleCommit = vi.fn((property: string, value: string) => {
|
|
195
158
|
a.style.setProperty(property, value);
|
|
196
159
|
return Promise.reject(new Error("persist failed"));
|
|
197
160
|
});
|
|
198
161
|
const { root } = render(a, onStyleCommit);
|
|
199
|
-
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 100 }),
|
|
204
|
-
),
|
|
205
|
-
);
|
|
206
|
-
act(() =>
|
|
207
|
-
handle!.dispatchEvent(
|
|
208
|
-
new PointerEvent("pointermove", { bubbles: true, pointerId: 2, clientX: 80 }),
|
|
209
|
-
),
|
|
210
|
-
);
|
|
211
|
-
await act(async () => {
|
|
212
|
-
handle!.dispatchEvent(
|
|
213
|
-
new PointerEvent("pointerup", { bubbles: true, pointerId: 2, clientX: 80 }),
|
|
162
|
+
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
|
|
163
|
+
const press = (type: string, clientX: number) =>
|
|
164
|
+
act(() =>
|
|
165
|
+
handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientX })),
|
|
214
166
|
);
|
|
167
|
+
press("pointerdown", 100);
|
|
168
|
+
press("pointermove", 80);
|
|
169
|
+
await act(async () => {
|
|
170
|
+
press("pointerup", 80);
|
|
215
171
|
await Promise.resolve();
|
|
216
172
|
});
|
|
217
173
|
|
|
218
|
-
expect(
|
|
174
|
+
expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
|
|
175
|
+
expect(isElementCropLifted(a)).toBe(true);
|
|
176
|
+
act(() => root.unmount());
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("draws the crop the element has now, after an undo rewrites it", () => {
|
|
180
|
+
const a = makeEl("a", "inset(0px 20px 0px 0px)");
|
|
181
|
+
const { root, rerender } = render(a);
|
|
182
|
+
const outline = () => document.querySelector<HTMLElement>(".border-dashed")!.style.width;
|
|
183
|
+
expect(outline()).toBe("180px");
|
|
184
|
+
a.style.setProperty("clip-path", "inset(0px 50px 0px 0px)");
|
|
185
|
+
rerender(a);
|
|
186
|
+
expect(outline()).toBe("150px");
|
|
219
187
|
act(() => root.unmount());
|
|
220
188
|
});
|
|
221
189
|
});
|
|
@@ -4,6 +4,9 @@ import { type OverlayRect, RESIZE_HANDLE_HIT_PX } from "./domEditOverlayGeometry
|
|
|
4
4
|
import {
|
|
5
5
|
type CropEdge,
|
|
6
6
|
cropRectFromInsets,
|
|
7
|
+
dropElementCropLift,
|
|
8
|
+
hasCropInsets,
|
|
9
|
+
liftElementCrop,
|
|
7
10
|
readElementCropFrame,
|
|
8
11
|
readElementCropInsets,
|
|
9
12
|
resolveCropInsetFromEdgeDrag,
|
|
@@ -11,6 +14,7 @@ import {
|
|
|
11
14
|
rotateDeltaIntoFrame,
|
|
12
15
|
} from "./domEditOverlayCrop";
|
|
13
16
|
import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
|
|
17
|
+
import { readCropFollowingResize } from "./cropResize";
|
|
14
18
|
|
|
15
19
|
interface CropGestureState {
|
|
16
20
|
edge: CropEdge | "move";
|
|
@@ -19,6 +23,7 @@ interface CropGestureState {
|
|
|
19
23
|
startY: number;
|
|
20
24
|
startInsets: ClipPathInsetSides;
|
|
21
25
|
insets: ClipPathInsetSides;
|
|
26
|
+
radius: number;
|
|
22
27
|
/** Element frame captured at gesture start: pointer deltas rotate into it. */
|
|
23
28
|
angleDeg: number;
|
|
24
29
|
scaleX: number;
|
|
@@ -83,6 +88,7 @@ function repositionHandleSize(rect: Rect): number {
|
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
|
|
91
|
+
const NO_CROP = { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
|
|
86
92
|
|
|
87
93
|
/**
|
|
88
94
|
* Always-on crop, integrated with the selection (no crop "mode"): while a
|
|
@@ -92,7 +98,7 @@ const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
|
|
|
92
98
|
* grid guides framing); release commits `clip-path: inset(...)` through the
|
|
93
99
|
* normal style-commit path (one undo step per drag). When cropped, a center
|
|
94
100
|
* handle pans the crop window. Corners stay free for the selection's own resize
|
|
95
|
-
* handle. Leaving the selection
|
|
101
|
+
* handle. Leaving the selection drops the lift. The element's clip-path
|
|
96
102
|
* is the source of truth — nothing here mutates layout.
|
|
97
103
|
*/
|
|
98
104
|
export function DomEditCropHandles({
|
|
@@ -109,54 +115,29 @@ export function DomEditCropHandles({
|
|
|
109
115
|
// clip with an inset (or deletes it).
|
|
110
116
|
const cropStateFor = (element: HTMLElement) => {
|
|
111
117
|
const parsed = readElementCropInsets(element);
|
|
112
|
-
const {
|
|
113
|
-
return { element, croppable: parsed !== null, insets,
|
|
118
|
+
const { top, right, bottom, left } = parsed ?? NO_CROP;
|
|
119
|
+
return { element, croppable: parsed !== null, insets: { top, right, bottom, left } };
|
|
114
120
|
};
|
|
115
121
|
const [state, setState] = useState(() => cropStateFor(selection.element));
|
|
116
122
|
|
|
117
123
|
// Re-sync when the selection targets a different element (reselect, or an
|
|
118
|
-
// undo/redo that re-keys the node)
|
|
119
|
-
// effect runs. Read inside the guard so a drag's per-frame setState doesn't
|
|
120
|
-
// re-run getComputedStyle every frame.
|
|
124
|
+
// undo/redo that re-keys the node).
|
|
121
125
|
if (state.element !== selection.element) {
|
|
122
126
|
setState(cropStateFor(selection.element));
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
state.insets.left > 0;
|
|
129
|
+
// The element's clip-path is the crop; state only holds a crop drag's draft.
|
|
130
|
+
const committed = readCropFollowingResize(selection.element) ?? NO_CROP;
|
|
131
|
+
const insets = dragging ? state.insets : committed;
|
|
132
|
+
const hasCrop = hasCropInsets(insets);
|
|
130
133
|
|
|
131
134
|
// Lift the clip while the element is selected so the full content shows and the
|
|
132
|
-
// cropped-away area can be dimmed
|
|
133
|
-
// switching selections restores the previous one. Runs after render, so the
|
|
134
|
-
// state re-sync above still reads the element's real committed clip. Restore
|
|
135
|
-
// prefers the pre-lift inline value VERBATIM — the rebuilt inset only replaces
|
|
136
|
-
// it after a crop gesture actually commits, so a mere select+deselect can
|
|
137
|
-
// never reformat (or drop) what the author wrote. Both refs are written only
|
|
138
|
-
// by THIS element's lift effect and crop gestures — never derived from render
|
|
139
|
-
// state, which by cleanup time already describes the NEXT selection (a direct
|
|
140
|
-
// A→B switch re-syncs state to B before A's cleanup runs).
|
|
141
|
-
const liftedRef = useRef(false);
|
|
142
|
-
const preLiftInlineClipRef = useRef("");
|
|
143
|
-
// null = no crop gesture committed this selection; "" = committed a crop
|
|
144
|
-
// removal; anything else = the exact committed clip-path value.
|
|
145
|
-
const committedClipRef = useRef<string | null>(null);
|
|
135
|
+
// cropped-away area can be dimmed. Keyed on the element so a direct A→B switch drops A's lift.
|
|
146
136
|
useEffect(() => {
|
|
147
137
|
const el = selection.element;
|
|
148
138
|
if (readElementCropInsets(el) === null) return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
el.style.setProperty("clip-path", "none");
|
|
152
|
-
liftedRef.current = true;
|
|
153
|
-
return () => {
|
|
154
|
-
liftedRef.current = false;
|
|
155
|
-
const committed = committedClipRef.current;
|
|
156
|
-
const restore = committed !== null ? committed || null : preLiftInlineClipRef.current || null;
|
|
157
|
-
if (restore) el.style.setProperty("clip-path", restore);
|
|
158
|
-
else el.style.removeProperty("clip-path");
|
|
159
|
-
};
|
|
139
|
+
liftElementCrop(el);
|
|
140
|
+
return () => dropElementCropLift(el);
|
|
160
141
|
}, [selection.element]);
|
|
161
142
|
|
|
162
143
|
// The crop applies in the element's LOCAL frame (clip-path precedes the
|
|
@@ -169,7 +150,7 @@ export function DomEditCropHandles({
|
|
|
169
150
|
// Crop rect in FRAME-LOCAL coordinates (origin = frame top-left).
|
|
170
151
|
const cropRect = cropRectFromInsets(
|
|
171
152
|
{ left: 0, top: 0, width: frame.width, height: frame.height },
|
|
172
|
-
|
|
153
|
+
insets,
|
|
173
154
|
frame.scaleX,
|
|
174
155
|
frame.scaleY,
|
|
175
156
|
);
|
|
@@ -180,19 +161,23 @@ export function DomEditCropHandles({
|
|
|
180
161
|
event.preventDefault();
|
|
181
162
|
event.stopPropagation();
|
|
182
163
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
164
|
+
// Read at press: a resize may have rescaled the crop since the last render.
|
|
165
|
+
const pressed = readCropFollowingResize(selection.element) ?? NO_CROP;
|
|
183
166
|
gestureRef.current = {
|
|
184
167
|
edge,
|
|
185
168
|
pointerId: event.pointerId,
|
|
186
169
|
startX: event.clientX,
|
|
187
170
|
startY: event.clientY,
|
|
188
|
-
startInsets:
|
|
189
|
-
insets:
|
|
171
|
+
startInsets: pressed,
|
|
172
|
+
insets: pressed,
|
|
173
|
+
radius: pressed.radius,
|
|
190
174
|
angleDeg: frame.angleDeg,
|
|
191
175
|
scaleX: frame.scaleX,
|
|
192
176
|
scaleY: frame.scaleY,
|
|
193
177
|
};
|
|
194
178
|
// Clip is already lifted by the selection effect; just flag the drag so the
|
|
195
179
|
// rule-of-thirds grid shows.
|
|
180
|
+
setState((prev) => ({ ...prev, insets: pressed }));
|
|
196
181
|
setDragging(true);
|
|
197
182
|
};
|
|
198
183
|
|
|
@@ -234,29 +219,12 @@ export function DomEditCropHandles({
|
|
|
234
219
|
const finishCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
235
220
|
const gesture = endCropGesture(event);
|
|
236
221
|
if (!gesture) return;
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
if (liftedRef.current) el.style.setProperty("clip-path", "none");
|
|
244
|
-
};
|
|
245
|
-
const { insets } = gesture;
|
|
246
|
-
const committedValue = buildInsetClipPathSides(insets, state.radius);
|
|
247
|
-
if (committedValue === buildInsetClipPathSides(gesture.startInsets, state.radius)) return;
|
|
248
|
-
const cropped = insets.top > 0 || insets.right > 0 || insets.bottom > 0 || insets.left > 0;
|
|
249
|
-
const commit = onStyleCommit?.("clip-path", committedValue);
|
|
250
|
-
// handleDomStyleCommit applies the persisted value to the live element
|
|
251
|
-
// synchronously before its first await. Restore the crop-mode lift in this
|
|
252
|
-
// same turn so the browser never paints that intermediate cropped state.
|
|
253
|
-
reLift();
|
|
254
|
-
void Promise.resolve(commit).then(() => {
|
|
255
|
-
// Only a landed commit makes the rebuilt inset the restore value; a
|
|
256
|
-
// failed one keeps restoring the pre-lift clip. Store the value itself —
|
|
257
|
-
// by deselect time, render state describes the next selection.
|
|
258
|
-
committedClipRef.current = cropped ? committedValue : "";
|
|
259
|
-
}, reLift);
|
|
222
|
+
// The commit writes the element's clip-path (and puts it back if the save fails);
|
|
223
|
+
// the lift keeps it hidden while selected. A drag that ends where it started saves nothing.
|
|
224
|
+
const value = buildInsetClipPathSides(gesture.insets, gesture.radius);
|
|
225
|
+
if (value === buildInsetClipPathSides(gesture.startInsets, gesture.radius)) return;
|
|
226
|
+
const commit = onStyleCommit?.("clip-path", value);
|
|
227
|
+
void Promise.resolve(commit).catch(() => undefined);
|
|
260
228
|
};
|
|
261
229
|
|
|
262
230
|
const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
@@ -697,7 +697,7 @@ describe("resolveDomEditRotationGesture", () => {
|
|
|
697
697
|
).toEqual({ angle: 15 });
|
|
698
698
|
});
|
|
699
699
|
|
|
700
|
-
it("allows small pointer movements
|
|
700
|
+
it("allows small pointer movements and keeps the exact angle", () => {
|
|
701
701
|
const nextRotation = resolveDomEditRotationGesture({
|
|
702
702
|
centerX: 0,
|
|
703
703
|
centerY: 0,
|
|
@@ -709,7 +709,7 @@ describe("resolveDomEditRotationGesture", () => {
|
|
|
709
709
|
snap: false,
|
|
710
710
|
});
|
|
711
711
|
|
|
712
|
-
expect(nextRotation.angle).toBe(1.
|
|
712
|
+
expect(nextRotation.angle).toBe(1.432);
|
|
713
713
|
expect(hasDomEditRotationChanged(0, nextRotation.angle)).toBe(true);
|
|
714
714
|
expect(hasDomEditRotationChanged(0, 0)).toBe(false);
|
|
715
715
|
});
|
|
@@ -161,8 +161,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
161
161
|
const groupGestureRef = useRef<GroupGestureState | null>(null);
|
|
162
162
|
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
|
|
163
163
|
const suppressNextBoxClickRef = useRef(false);
|
|
164
|
-
const suppressNextBoxMouseDownRef = useRef(false);
|
|
165
|
-
const suppressNextOverlayMouseDownRef = useRef(false);
|
|
166
164
|
const snapGuidesRef = useRef<SnapGuidesState | null>(null);
|
|
167
165
|
const rafPausedRef = useRef(false);
|
|
168
166
|
|
|
@@ -329,15 +327,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
329
327
|
|
|
330
328
|
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
331
329
|
if (!allowCanvasMovement) return;
|
|
332
|
-
if (suppressNextOverlayMouseDownRef.current) {
|
|
333
|
-
logSelect("mousedown-suppressed", { shift: event.shiftKey });
|
|
334
|
-
suppressNextOverlayMouseDownRef.current = false;
|
|
335
|
-
suppressNextBoxMouseDownRef.current = false;
|
|
336
|
-
suppressNextBoxClickRef.current = false;
|
|
337
|
-
event.preventDefault();
|
|
338
|
-
event.stopPropagation();
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
330
|
const target = event.target as HTMLElement | null;
|
|
342
331
|
const onBox = Boolean(target?.closest('[data-dom-edit-selection-box="true"]'));
|
|
343
332
|
logSelect("mousedown", { shift: event.shiftKey, onBox });
|
|
@@ -346,10 +335,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
346
335
|
// extend beyond the composition rect into the gray zone, and users need
|
|
347
336
|
// to select/deselect them by clicking there.
|
|
348
337
|
onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
|
|
349
|
-
if (event.shiftKey) {
|
|
350
|
-
suppressNextBoxMouseDownRef.current = true;
|
|
351
|
-
suppressNextBoxClickRef.current = true;
|
|
352
|
-
}
|
|
353
338
|
};
|
|
354
339
|
|
|
355
340
|
// fallow-ignore-next-line complexity
|
|
@@ -373,8 +358,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
373
358
|
if (!candidate) return;
|
|
374
359
|
event.preventDefault();
|
|
375
360
|
event.stopPropagation();
|
|
376
|
-
suppressNextOverlayMouseDownRef.current = true;
|
|
377
|
-
suppressNextBoxMouseDownRef.current = true;
|
|
378
361
|
suppressNextBoxClickRef.current = true;
|
|
379
362
|
onSelectionChangeRef.current(candidate, { additive: true });
|
|
380
363
|
return;
|
|
@@ -419,7 +402,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
419
402
|
// so those elements were always selectable once the band could begin.
|
|
420
403
|
event.preventDefault();
|
|
421
404
|
event.stopPropagation();
|
|
422
|
-
suppressNextOverlayMouseDownRef.current = true;
|
|
423
405
|
marquee.begin(event);
|
|
424
406
|
return;
|
|
425
407
|
}
|
|
@@ -437,13 +419,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
437
419
|
onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
|
|
438
420
|
};
|
|
439
421
|
|
|
440
|
-
const suppressBoxMouseDown = (e: React.MouseEvent) => {
|
|
441
|
-
if (!suppressNextBoxMouseDownRef.current) return;
|
|
442
|
-
suppressNextBoxMouseDownRef.current = false;
|
|
443
|
-
e.preventDefault();
|
|
444
|
-
e.stopPropagation();
|
|
445
|
-
};
|
|
446
|
-
|
|
447
422
|
// Right-click state + handler: select the element under the pointer (if
|
|
448
423
|
// needed), then open the menu; closes when the selection moves off-target.
|
|
449
424
|
const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({
|
|
@@ -509,7 +484,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
509
484
|
allowBodyDrag={bodyDrag}
|
|
510
485
|
groupCanMove={groupCanMove}
|
|
511
486
|
gestures={gestures}
|
|
512
|
-
onBoxMouseDown={suppressBoxMouseDown}
|
|
513
487
|
onBoxClick={handleBoxClick}
|
|
514
488
|
/>
|
|
515
489
|
)}
|
|
@@ -528,7 +502,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
528
502
|
groupSelectionCount={groupSelections.length}
|
|
529
503
|
gestures={gestures}
|
|
530
504
|
onStyleCommit={onStyleCommitRef.current}
|
|
531
|
-
onBoxMouseDown={suppressBoxMouseDown}
|
|
532
505
|
onBoxClick={handleBoxClick}
|
|
533
506
|
/>
|
|
534
507
|
)}
|
|
@@ -56,7 +56,6 @@ describe("DomEditSelectionChrome crop composition", () => {
|
|
|
56
56
|
groupSelectionCount={0}
|
|
57
57
|
gestures={{ startGesture: vi.fn() } as never}
|
|
58
58
|
onStyleCommit={vi.fn()}
|
|
59
|
-
onBoxMouseDown={vi.fn()}
|
|
60
59
|
onBoxClick={vi.fn()}
|
|
61
60
|
/>,
|
|
62
61
|
);
|
|
@@ -112,7 +111,6 @@ describe("DomEditSelectionChrome crop composition", () => {
|
|
|
112
111
|
groupSelectionCount={0}
|
|
113
112
|
gestures={{ startGesture: vi.fn() } as never}
|
|
114
113
|
onStyleCommit={vi.fn()}
|
|
115
|
-
onBoxMouseDown={vi.fn()}
|
|
116
114
|
onBoxClick={vi.fn()}
|
|
117
115
|
/>,
|
|
118
116
|
);
|
|
@@ -173,7 +171,6 @@ describe("DomEditSelectionChrome while editing text", () => {
|
|
|
173
171
|
groupSelectionCount={0}
|
|
174
172
|
gestures={{ startGesture: vi.fn() } as never}
|
|
175
173
|
onStyleCommit={vi.fn()}
|
|
176
|
-
onBoxMouseDown={vi.fn()}
|
|
177
174
|
onBoxClick={vi.fn()}
|
|
178
175
|
inlineText={{ editing, startFromPress: vi.fn() }}
|
|
179
176
|
/>,
|
|
@@ -258,7 +255,6 @@ describe("DomEditSelectionChrome with body drag off", () => {
|
|
|
258
255
|
selectionKey="box"
|
|
259
256
|
groupSelectionCount={0}
|
|
260
257
|
gestures={gestures as never}
|
|
261
|
-
onBoxMouseDown={vi.fn()}
|
|
262
258
|
onBoxClick={vi.fn()}
|
|
263
259
|
/>,
|
|
264
260
|
);
|
|
@@ -313,7 +309,6 @@ describe("DomEditSelectionChrome with body drag off", () => {
|
|
|
313
309
|
allowBodyDrag={false}
|
|
314
310
|
groupCanMove
|
|
315
311
|
gestures={gestures as never}
|
|
316
|
-
onBoxMouseDown={vi.fn()}
|
|
317
312
|
onBoxClick={vi.fn()}
|
|
318
313
|
/>,
|
|
319
314
|
);
|
|
@@ -62,7 +62,6 @@ interface DomEditGroupChromeProps {
|
|
|
62
62
|
allowBodyDrag: boolean;
|
|
63
63
|
groupCanMove: boolean;
|
|
64
64
|
gestures: GestureHandlers;
|
|
65
|
-
onBoxMouseDown: (e: React.MouseEvent) => void;
|
|
66
65
|
onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
67
66
|
}
|
|
68
67
|
|
|
@@ -75,7 +74,6 @@ export function DomEditGroupChrome({
|
|
|
75
74
|
allowBodyDrag,
|
|
76
75
|
groupCanMove,
|
|
77
76
|
gestures,
|
|
78
|
-
onBoxMouseDown,
|
|
79
77
|
onBoxClick,
|
|
80
78
|
}: DomEditGroupChromeProps) {
|
|
81
79
|
const canManipulate = allowCanvasMovement && !usePreviewReadOnly();
|
|
@@ -108,7 +106,6 @@ export function DomEditGroupChrome({
|
|
|
108
106
|
if (!canManipulate || !allowBodyDrag || (e.shiftKey && !groupCanMove)) return;
|
|
109
107
|
gestures.startGroupDrag(e);
|
|
110
108
|
}}
|
|
111
|
-
onMouseDown={onBoxMouseDown}
|
|
112
109
|
onClick={onBoxClick}
|
|
113
110
|
/>
|
|
114
111
|
</>
|
|
@@ -128,7 +125,6 @@ interface DomEditSelectionChromeProps {
|
|
|
128
125
|
groupSelectionCount: number;
|
|
129
126
|
gestures: GestureHandlers;
|
|
130
127
|
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
131
|
-
onBoxMouseDown: (e: React.MouseEvent) => void;
|
|
132
128
|
onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
133
129
|
/** The canvas' text-editing session: what opens one, and whether one is open. */
|
|
134
130
|
inlineText?: {
|
|
@@ -157,7 +153,6 @@ export function DomEditSelectionChrome({
|
|
|
157
153
|
groupSelectionCount,
|
|
158
154
|
gestures,
|
|
159
155
|
onStyleCommit,
|
|
160
|
-
onBoxMouseDown,
|
|
161
156
|
onBoxClick,
|
|
162
157
|
inlineText,
|
|
163
158
|
}: DomEditSelectionChromeProps) {
|
|
@@ -225,7 +220,6 @@ export function DomEditSelectionChrome({
|
|
|
225
220
|
}
|
|
226
221
|
if (!e.shiftKey) gestures.startBlockedMove(e, selection);
|
|
227
222
|
}}
|
|
228
|
-
onMouseDown={onBoxMouseDown}
|
|
229
223
|
onClick={onBoxClick}
|
|
230
224
|
>
|
|
231
225
|
{cropOutlineInsetPx && (
|