@hyperframes/studio 0.8.87 → 0.8.89
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-CtNVImMi.js → hyperframes-player-CVoS7kv8.js} +1 -1
- package/dist/assets/{index-DZKcDAtf.js → index-BM8PwYby.js} +187 -187
- package/dist/assets/{index-DJ-Ol7YS.js → index-D35-L_yn.js} +1 -1
- package/dist/assets/{index-B_MF86RE.js → index-DBub9qQY.js} +1 -1
- package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
- package/dist/chunk-KU4F7QZ3.js.map +1 -0
- package/dist/index.d.ts +42 -5
- package/dist/index.html +1 -1
- package/dist/index.js +269 -94
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineHistoryButtons.dom.test.tsx +14 -3
- package/src/components/TimelineToolPicker.tsx +90 -0
- package/src/components/TimelineToolbar.tsx +2 -27
- package/src/components/editor/DomEditOverlay.tsx +7 -4
- package/src/components/editor/domEditOverlayGestures.ts +4 -4
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/hooks/appHotkeysDispatch.ts +8 -0
- package/src/hooks/useAppHotkeys.test.ts +58 -1
- package/src/hooks/useDomGeometryCommit.test.tsx +226 -0
- package/src/hooks/useDomGeometryCommit.ts +147 -0
- package/src/hooks/useGsapAwareEditing.ts +9 -4
- package/src/index.ts +3 -1
- package/src/player/components/Timeline.showKeyframes.test.tsx +91 -0
- package/src/player/components/TimelineTypes.ts +1 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/useTimelineKeyframeData.ts +14 -0
- package/src/player/components/useTimelineProviderState.tsx +5 -3
- package/src/player/store/playerStore.ts +16 -0
- package/dist/chunk-7PLTC6SV.js.map +0 -1
package/dist/player.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.89",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/parsers": "0.8.89",
|
|
64
|
+
"@hyperframes/sdk": "0.8.89",
|
|
65
|
+
"@hyperframes/shader-transitions": "0.8.89",
|
|
66
|
+
"@hyperframes/core": "0.8.89",
|
|
67
|
+
"@hyperframes/player": "0.8.89",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.89"
|
|
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.89"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -96,14 +96,25 @@ it("leaves enabled buttons without the disabled attribute", () => {
|
|
|
96
96
|
expect(el.className).not.toContain("cursor-not-allowed");
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
it("orders the toolbar Undo, Redo,
|
|
99
|
+
it("orders the toolbar Undo, Redo, then the tool picker showing the active tool", () => {
|
|
100
100
|
const host = mount(<TimelineToolbar />);
|
|
101
101
|
const labels = Array.from(host.querySelectorAll("button"))
|
|
102
102
|
.map((b) => b.getAttribute("aria-label"))
|
|
103
103
|
.filter((l) => l !== null)
|
|
104
|
-
.slice(0,
|
|
104
|
+
.slice(0, 3);
|
|
105
105
|
|
|
106
|
-
expect(labels).toEqual(["Undo", "Redo", "
|
|
106
|
+
expect(labels).toEqual(["Undo", "Redo", "Timeline tool: Select"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("shows the Split tool on the picker while the razor is active", async () => {
|
|
110
|
+
const { usePlayerStore } = await import("../player");
|
|
111
|
+
usePlayerStore.setState({ activeTool: "razor" });
|
|
112
|
+
try {
|
|
113
|
+
const host = mount(<TimelineToolbar />);
|
|
114
|
+
expect(host.querySelector('button[aria-label="Timeline tool: Split"]')).not.toBeNull();
|
|
115
|
+
} finally {
|
|
116
|
+
usePlayerStore.setState({ activeTool: "select" });
|
|
117
|
+
}
|
|
107
118
|
});
|
|
108
119
|
|
|
109
120
|
it("classifies Undo and Redo for the hotkey filters at their new location (KTD13)", () => {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { ArrowLineLeft, ArrowLineRight, CaretDown } from "@phosphor-icons/react";
|
|
3
|
+
import { usePlayerStore } from "../player";
|
|
4
|
+
import { Scissors } from "../icons/SystemIcons";
|
|
5
|
+
import { Menu, MenuItem } from "./ui";
|
|
6
|
+
import { cn } from "./ui/cn";
|
|
7
|
+
import { flatIdle } from "./timelineToolbarStyles";
|
|
8
|
+
|
|
9
|
+
function SelectIcon() {
|
|
10
|
+
return (
|
|
11
|
+
<svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
|
|
12
|
+
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
|
|
13
|
+
</svg>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ToolRow {
|
|
18
|
+
label: string;
|
|
19
|
+
shortcut: string;
|
|
20
|
+
icon: ReactNode;
|
|
21
|
+
active: boolean;
|
|
22
|
+
run: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One button for the active timeline tool; its menu switches tools or selects clips around the playhead. */
|
|
26
|
+
export function TimelineToolPicker() {
|
|
27
|
+
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
28
|
+
const store = () => usePlayerStore.getState();
|
|
29
|
+
const rows: ToolRow[] = [
|
|
30
|
+
{
|
|
31
|
+
label: "Select",
|
|
32
|
+
shortcut: "V",
|
|
33
|
+
icon: <SelectIcon />,
|
|
34
|
+
active: activeTool === "select",
|
|
35
|
+
run: () => store().setActiveTool("select"),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
label: "Split",
|
|
39
|
+
shortcut: "B",
|
|
40
|
+
icon: <Scissors size={16} />,
|
|
41
|
+
active: activeTool === "razor",
|
|
42
|
+
run: () => store().setActiveTool("razor"),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: "Select leftward",
|
|
46
|
+
shortcut: "[",
|
|
47
|
+
icon: <ArrowLineLeft size={16} aria-hidden="true" />,
|
|
48
|
+
active: false,
|
|
49
|
+
run: () => store().selectLeftward(),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
label: "Select rightward",
|
|
53
|
+
shortcut: "]",
|
|
54
|
+
icon: <ArrowLineRight size={16} aria-hidden="true" />,
|
|
55
|
+
active: false,
|
|
56
|
+
run: () => store().selectRightward(),
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
const current = rows.find((row) => row.active) ?? rows[0];
|
|
60
|
+
return (
|
|
61
|
+
<Menu
|
|
62
|
+
aria-label="Timeline tools"
|
|
63
|
+
trigger={
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
aria-label={`Timeline tool: ${current.label}`}
|
|
67
|
+
className={cn(flatIdle, "gap-0.5")}
|
|
68
|
+
>
|
|
69
|
+
{current.icon}
|
|
70
|
+
<CaretDown size={10} weight="bold" aria-hidden="true" />
|
|
71
|
+
</button>
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
{rows.map((row) => (
|
|
75
|
+
<MenuItem
|
|
76
|
+
key={row.label}
|
|
77
|
+
shortcut={row.shortcut}
|
|
78
|
+
onClick={row.run}
|
|
79
|
+
data-active={row.active || undefined}
|
|
80
|
+
className={row.active ? "bg-neutral-800 text-white" : undefined}
|
|
81
|
+
>
|
|
82
|
+
<span className="flex items-center gap-2">
|
|
83
|
+
{row.icon}
|
|
84
|
+
{row.label}
|
|
85
|
+
</span>
|
|
86
|
+
</MenuItem>
|
|
87
|
+
))}
|
|
88
|
+
</Menu>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -26,7 +26,7 @@ import { AudioMetersIcon } from "./icons/AudioMetersIcon";
|
|
|
26
26
|
import { RippleEditIcon } from "./icons/RippleEditIcon";
|
|
27
27
|
import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
|
|
28
28
|
import { TimelineHistoryButtons, type TimelineHistoryButtonsProps } from "./TimelineHistoryButtons";
|
|
29
|
-
import {
|
|
29
|
+
import { TimelineToolPicker } from "./TimelineToolPicker";
|
|
30
30
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
31
31
|
import type { DomEditSelection } from "./editor/domEditingTypes";
|
|
32
32
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
@@ -144,8 +144,6 @@ export function TimelineToolbar({
|
|
|
144
144
|
showAddBeat = true,
|
|
145
145
|
showKeyframes = true,
|
|
146
146
|
}: TimelineToolbarProps) {
|
|
147
|
-
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
148
|
-
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
|
|
149
147
|
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
|
|
150
148
|
const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
|
|
151
149
|
const rippleEditEnabled = usePlayerStore((s) => s.rippleEditEnabled);
|
|
@@ -216,30 +214,7 @@ export function TimelineToolbar({
|
|
|
216
214
|
<div className="flex items-center justify-between px-2 py-0.5">
|
|
217
215
|
<div className="flex items-center gap-0.5">
|
|
218
216
|
<TimelineHistoryButtons {...history} />
|
|
219
|
-
<
|
|
220
|
-
<button
|
|
221
|
-
type="button"
|
|
222
|
-
onClick={() => setActiveTool("select")}
|
|
223
|
-
aria-label="Selection tool"
|
|
224
|
-
aria-pressed={activeTool === "select"}
|
|
225
|
-
className={activeTool === "select" ? flatActive : flatIdle}
|
|
226
|
-
>
|
|
227
|
-
<svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor">
|
|
228
|
-
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
|
|
229
|
-
</svg>
|
|
230
|
-
</button>
|
|
231
|
-
</Tooltip>
|
|
232
|
-
<Tooltip label="Razor tool (B) — Shift+click splits all tracks">
|
|
233
|
-
<button
|
|
234
|
-
type="button"
|
|
235
|
-
onClick={() => setActiveTool("razor")}
|
|
236
|
-
aria-label="Razor tool"
|
|
237
|
-
aria-pressed={activeTool === "razor"}
|
|
238
|
-
className={activeTool === "razor" ? flatActive : flatIdle}
|
|
239
|
-
>
|
|
240
|
-
<Scissors size={16} />
|
|
241
|
-
</button>
|
|
242
|
-
</Tooltip>
|
|
217
|
+
<TimelineToolPicker />
|
|
243
218
|
{/* Divider: tool-mode | editing-actions */}
|
|
244
219
|
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
|
|
245
220
|
<Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
|
|
@@ -81,15 +81,18 @@ export interface DomEditOverlayProps {
|
|
|
81
81
|
selection: DomEditSelection,
|
|
82
82
|
next: { x: number; y: number },
|
|
83
83
|
modifiers?: { altKey?: boolean },
|
|
84
|
-
) => Promise<
|
|
85
|
-
onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<
|
|
84
|
+
) => Promise<unknown> | void;
|
|
85
|
+
onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown> | void;
|
|
86
86
|
onBoxSizeCommit: (
|
|
87
87
|
selection: DomEditSelection,
|
|
88
88
|
next: { width: number; height: number },
|
|
89
89
|
offset?: { x: number; y: number },
|
|
90
90
|
restore?: () => void,
|
|
91
|
-
) => Promise<
|
|
92
|
-
onRotationCommit: (
|
|
91
|
+
) => Promise<unknown> | void;
|
|
92
|
+
onRotationCommit: (
|
|
93
|
+
selection: DomEditSelection,
|
|
94
|
+
next: { angle: number },
|
|
95
|
+
) => Promise<unknown> | void;
|
|
93
96
|
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
94
97
|
recordingState?: GestureRecordingState;
|
|
95
98
|
onToggleRecording?: () => void;
|
|
@@ -247,10 +247,10 @@ export type UseDomEditOverlayGesturesOptions = {
|
|
|
247
247
|
s: DomEditSelection,
|
|
248
248
|
n: { x: number; y: number },
|
|
249
249
|
m?: { altKey?: boolean },
|
|
250
|
-
) => Promise<
|
|
250
|
+
) => Promise<unknown> | void
|
|
251
251
|
>;
|
|
252
252
|
onGroupPathOffsetCommitRef: RefObject<
|
|
253
|
-
(updates: DomEditGroupPathOffsetCommit[]) => Promise<
|
|
253
|
+
(updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown> | void
|
|
254
254
|
>;
|
|
255
255
|
onBoxSizeCommitRef: RefObject<
|
|
256
256
|
(
|
|
@@ -258,10 +258,10 @@ export type UseDomEditOverlayGesturesOptions = {
|
|
|
258
258
|
n: { width: number; height: number },
|
|
259
259
|
offset?: { x: number; y: number },
|
|
260
260
|
restore?: () => void,
|
|
261
|
-
) => Promise<
|
|
261
|
+
) => Promise<unknown> | void
|
|
262
262
|
>;
|
|
263
263
|
onRotationCommitRef: RefObject<
|
|
264
|
-
(s: DomEditSelection, n: { angle: number }) => Promise<
|
|
264
|
+
(s: DomEditSelection, n: { angle: number }) => Promise<unknown> | void
|
|
265
265
|
>;
|
|
266
266
|
onCanvasPointerMoveRef: RefObject<
|
|
267
267
|
(
|
|
@@ -60,10 +60,10 @@ export interface UseDomEditNudgeParams {
|
|
|
60
60
|
s: DomEditSelection,
|
|
61
61
|
n: { x: number; y: number },
|
|
62
62
|
m?: { altKey?: boolean },
|
|
63
|
-
) => Promise<
|
|
63
|
+
) => Promise<unknown> | void
|
|
64
64
|
>;
|
|
65
65
|
onGroupPathOffsetCommitRef: RefObject<
|
|
66
|
-
(updates: DomEditGroupPathOffsetCommit[]) => Promise<
|
|
66
|
+
(updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown> | void
|
|
67
67
|
>;
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -207,6 +207,14 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
|
|
207
207
|
return;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
if ((key === "[" || key === "]") && !event.shiftKey && !event.altKey) {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
const store = usePlayerStore.getState();
|
|
213
|
+
if (key === "[") store.selectLeftward();
|
|
214
|
+
else store.selectRightward();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
210
218
|
// CapCut returns to the select tool with "A". Scoped to the razor being
|
|
211
219
|
// armed; usePlaybackKeyboard.ts's own "A" (seek to in-point) checks the
|
|
212
220
|
// same activeTool to stay out of the way.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { dispatchModifierKey, dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
|
|
4
|
-
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
+
import { liveTime, usePlayerStore } from "../player/store/playerStore";
|
|
5
5
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
6
6
|
import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
|
|
7
7
|
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
@@ -60,6 +60,63 @@ afterEach(() => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe("dispatchPlainKey — select leftward / rightward", () => {
|
|
64
|
+
const clips = [
|
|
65
|
+
{ ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
|
|
66
|
+
{ ...bgmElement, id: "at", key: "at", start: 4, track: 1 },
|
|
67
|
+
{ ...bgmElement, id: "late", key: "late", start: 7, track: 2 },
|
|
68
|
+
];
|
|
69
|
+
beforeEach(() => usePlayerStore.setState({ elements: clips, currentTime: 4 }));
|
|
70
|
+
|
|
71
|
+
it("[ selects every clip starting before the playhead, on every track", () => {
|
|
72
|
+
const event = press("[");
|
|
73
|
+
dispatchPlainKey(event, "[", callbacks());
|
|
74
|
+
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["early"]);
|
|
75
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("early");
|
|
76
|
+
expect(event.defaultPrevented).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("uses the live playhead while playing, not the time stored at play start", () => {
|
|
80
|
+
usePlayerStore.setState({ isPlaying: true, currentTime: 0 });
|
|
81
|
+
liveTime.notify(8);
|
|
82
|
+
try {
|
|
83
|
+
dispatchPlainKey(press("["), "[", callbacks());
|
|
84
|
+
expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual([
|
|
85
|
+
"at",
|
|
86
|
+
"early",
|
|
87
|
+
"late",
|
|
88
|
+
]);
|
|
89
|
+
} finally {
|
|
90
|
+
usePlayerStore.setState({ isPlaying: false });
|
|
91
|
+
liveTime.notify(0);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("clears a clicked keyframe like any other selection change", () => {
|
|
96
|
+
usePlayerStore.setState({ activeKeyframePct: 50 });
|
|
97
|
+
dispatchPlainKey(press("]"), "]", callbacks());
|
|
98
|
+
expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("] selects every clip starting at or after the playhead, on every track", () => {
|
|
102
|
+
dispatchPlainKey(press("]"), "]", callbacks());
|
|
103
|
+
const { selectedElementIds, selectedElementId } = usePlayerStore.getState();
|
|
104
|
+
expect([...selectedElementIds].sort()).toEqual(["at", "late"]);
|
|
105
|
+
expect(selectedElementId).toBe("at");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("selects nothing when no clip is on that side", () => {
|
|
109
|
+
usePlayerStore.setState({
|
|
110
|
+
currentTime: 0,
|
|
111
|
+
selectedElementId: "late",
|
|
112
|
+
selectedElementIds: new Set(["late"]),
|
|
113
|
+
});
|
|
114
|
+
dispatchPlainKey(press("["), "[", callbacks());
|
|
115
|
+
expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
|
|
116
|
+
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
63
120
|
describe("dispatchPlainKey — Delete arbitration", () => {
|
|
64
121
|
const selectBgm = () =>
|
|
65
122
|
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement, useRef } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
useDomGeometryCommit,
|
|
7
|
+
usePlayerStore,
|
|
8
|
+
type DomEditOverlayProps,
|
|
9
|
+
type UseDomGeometryCommitOptions,
|
|
10
|
+
} from "../index";
|
|
11
|
+
import { makeSelection } from "./domSelectionTestHarness";
|
|
12
|
+
|
|
13
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
14
|
+
|
|
15
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
16
|
+
|
|
17
|
+
const SOURCE = '<div id="card">Card</div><div id="other">Other</div>';
|
|
18
|
+
|
|
19
|
+
/** A warm parse where only another element animates, and a GSAP writer answering `status`. */
|
|
20
|
+
function stubServer(status = 200, parseStatus = 200) {
|
|
21
|
+
const mutations: unknown[] = [];
|
|
22
|
+
vi.stubGlobal(
|
|
23
|
+
"fetch",
|
|
24
|
+
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
25
|
+
const url = String(input instanceof Request ? input.url : input);
|
|
26
|
+
const json = (body: unknown, code = 200) =>
|
|
27
|
+
new Response(JSON.stringify(body), {
|
|
28
|
+
status: code,
|
|
29
|
+
headers: { "content-type": "application/json" },
|
|
30
|
+
});
|
|
31
|
+
if (url.includes("/api/projects/p1/gsap-animations/")) {
|
|
32
|
+
if (parseStatus !== 200) return json({ error: "down" }, parseStatus);
|
|
33
|
+
return json({
|
|
34
|
+
animations: [
|
|
35
|
+
{
|
|
36
|
+
id: "other-fade",
|
|
37
|
+
targetSelector: "#other",
|
|
38
|
+
method: "to",
|
|
39
|
+
position: 0,
|
|
40
|
+
duration: 1,
|
|
41
|
+
properties: { opacity: 1 },
|
|
42
|
+
propertyGroup: "opacity",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
|
48
|
+
mutations.push(JSON.parse(String(init?.body)));
|
|
49
|
+
if (status !== 200) return json({ error: "refused" }, status);
|
|
50
|
+
return json({ ok: true, changed: true, before: "BEFORE", after: "AFTER" });
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
return mutations;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderHost(options: Partial<UseDomGeometryCommitOptions> = {}) {
|
|
59
|
+
const iframe = document.createElement("iframe");
|
|
60
|
+
document.body.append(iframe);
|
|
61
|
+
const doc = iframe.contentDocument;
|
|
62
|
+
if (!doc) throw new Error("Expected iframe document");
|
|
63
|
+
doc.body.innerHTML = SOURCE;
|
|
64
|
+
const element = doc.getElementById("card") as HTMLElement;
|
|
65
|
+
const recordEdit = vi.fn(async () => {});
|
|
66
|
+
const writeProjectFile = vi.fn(async () => {});
|
|
67
|
+
const reloadPreview = vi.fn();
|
|
68
|
+
const api: { current: ReturnType<typeof useDomGeometryCommit> | null } = { current: null };
|
|
69
|
+
function Host() {
|
|
70
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(iframe);
|
|
71
|
+
api.current = useDomGeometryCommit({
|
|
72
|
+
projectId: "p1",
|
|
73
|
+
iframeRef,
|
|
74
|
+
writeProjectFile,
|
|
75
|
+
recordEdit,
|
|
76
|
+
reloadPreview,
|
|
77
|
+
...options,
|
|
78
|
+
});
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const root = createRoot(document.createElement("div"));
|
|
82
|
+
act(() => root.render(createElement(Host)));
|
|
83
|
+
const hook = () => {
|
|
84
|
+
if (!api.current) throw new Error("Expected the hook to render");
|
|
85
|
+
return api.current;
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
element,
|
|
89
|
+
recordEdit,
|
|
90
|
+
hook,
|
|
91
|
+
rerender: () => act(() => root.render(createElement(Host))),
|
|
92
|
+
unmount: () => act(() => root.unmount()),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
usePlayerStore.setState({ previewBooted: true, timelineProjectId: "p1" });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
vi.unstubAllGlobals();
|
|
102
|
+
document.body.innerHTML = "";
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("useDomGeometryCommit, from the package entry", () => {
|
|
106
|
+
it("plugs into DomEditOverlay and saves a move as one GSAP write and one undo step", async () => {
|
|
107
|
+
const mutations = stubServer();
|
|
108
|
+
const { element, recordEdit, hook, unmount } = renderHost();
|
|
109
|
+
const overlayCommits: Pick<
|
|
110
|
+
DomEditOverlayProps,
|
|
111
|
+
"onPathOffsetCommit" | "onGroupPathOffsetCommit" | "onBoxSizeCommit" | "onRotationCommit"
|
|
112
|
+
> = {
|
|
113
|
+
onPathOffsetCommit: hook().commitPathOffset,
|
|
114
|
+
onGroupPathOffsetCommit: hook().commitGroupPathOffset,
|
|
115
|
+
onBoxSizeCommit: hook().commitBoxSize,
|
|
116
|
+
onRotationCommit: hook().commitRotation,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const outcome = await overlayCommits.onPathOffsetCommit(makeSelection("card", element), {
|
|
120
|
+
x: 40,
|
|
121
|
+
y: 20,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(outcome).toEqual({ ok: true });
|
|
125
|
+
expect(mutations).toEqual([
|
|
126
|
+
expect.objectContaining({ type: "add", targetSelector: "#card", method: "set" }),
|
|
127
|
+
]);
|
|
128
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(recordEdit).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({ files: { "index.html": { before: "BEFORE", after: "AFTER" } } }),
|
|
131
|
+
);
|
|
132
|
+
unmount();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("keeps the same commits across renders, so the overlay's handlers stay put", () => {
|
|
136
|
+
stubServer();
|
|
137
|
+
const { hook, rerender, unmount } = renderHost();
|
|
138
|
+
const first = hook();
|
|
139
|
+
rerender();
|
|
140
|
+
expect(hook().commitPathOffset).toBe(first.commitPathOffset);
|
|
141
|
+
expect(hook().commitRotation).toBe(first.commitRotation);
|
|
142
|
+
unmount();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("rejects a move the server refuses, so the overlay undoes it", async () => {
|
|
146
|
+
stubServer(500);
|
|
147
|
+
const showToast = vi.fn();
|
|
148
|
+
const { element, recordEdit, hook, unmount } = renderHost({ showToast });
|
|
149
|
+
|
|
150
|
+
await expect(
|
|
151
|
+
hook().commitPathOffset(makeSelection("card", element), { x: 40, y: 20 }),
|
|
152
|
+
).rejects.toThrow();
|
|
153
|
+
expect(recordEdit).not.toHaveBeenCalled();
|
|
154
|
+
expect(showToast).toHaveBeenCalledWith(expect.any(String), "error");
|
|
155
|
+
unmount();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("undoes a resize whose animations cannot be read, and saves nothing", async () => {
|
|
159
|
+
const mutations = stubServer(200, 500);
|
|
160
|
+
const showToast = vi.fn();
|
|
161
|
+
const restore = vi.fn();
|
|
162
|
+
const { element, recordEdit, hook, unmount } = renderHost({ showToast });
|
|
163
|
+
|
|
164
|
+
await expect(
|
|
165
|
+
hook().commitBoxSize(
|
|
166
|
+
makeSelection("card", element),
|
|
167
|
+
{ width: 300, height: 90 },
|
|
168
|
+
undefined,
|
|
169
|
+
restore,
|
|
170
|
+
),
|
|
171
|
+
).rejects.toThrow();
|
|
172
|
+
expect(restore).toHaveBeenCalledTimes(1);
|
|
173
|
+
expect(showToast).toHaveBeenCalledWith(expect.any(String), "error");
|
|
174
|
+
expect(mutations).toHaveLength(0);
|
|
175
|
+
expect(recordEdit).not.toHaveBeenCalled();
|
|
176
|
+
unmount();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it.each([
|
|
180
|
+
["the player shows another project", { timelineProjectId: "another-project" }],
|
|
181
|
+
["the preview has not booted", { previewBooted: false }],
|
|
182
|
+
])("refuses at once when %s", async (_state, store) => {
|
|
183
|
+
const mutations = stubServer();
|
|
184
|
+
usePlayerStore.setState(store);
|
|
185
|
+
const restore = vi.fn();
|
|
186
|
+
const showToast = vi.fn();
|
|
187
|
+
const { element, hook, unmount } = renderHost({ showToast });
|
|
188
|
+
|
|
189
|
+
await expect(
|
|
190
|
+
hook().commitBoxSize(
|
|
191
|
+
makeSelection("card", element),
|
|
192
|
+
{ width: 300, height: 90 },
|
|
193
|
+
undefined,
|
|
194
|
+
restore,
|
|
195
|
+
),
|
|
196
|
+
).rejects.toThrow("has not loaded yet");
|
|
197
|
+
expect(restore).toHaveBeenCalledTimes(1);
|
|
198
|
+
expect(showToast).toHaveBeenCalledWith(expect.stringContaining("not loaded"), "error");
|
|
199
|
+
expect(mutations).toHaveLength(0);
|
|
200
|
+
unmount();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("waits for a move still being saved", async () => {
|
|
204
|
+
const mutations = stubServer();
|
|
205
|
+
const { element, recordEdit, hook, unmount } = renderHost();
|
|
206
|
+
|
|
207
|
+
const move = hook().commitPathOffset(makeSelection("card", element), { x: 40, y: 20 });
|
|
208
|
+
await hook().waitForPendingSaves();
|
|
209
|
+
|
|
210
|
+
expect(mutations).toHaveLength(1);
|
|
211
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
212
|
+
await move;
|
|
213
|
+
unmount();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("rejects without a project and writes nothing", async () => {
|
|
217
|
+
const mutations = stubServer();
|
|
218
|
+
const { element, hook, unmount } = renderHost({ projectId: null });
|
|
219
|
+
|
|
220
|
+
await expect(
|
|
221
|
+
hook().commitRotation(makeSelection("card", element), { angle: 15 }),
|
|
222
|
+
).rejects.toThrow("No project is open");
|
|
223
|
+
expect(mutations).toHaveLength(0);
|
|
224
|
+
unmount();
|
|
225
|
+
});
|
|
226
|
+
});
|