@hyperframes/studio 0.8.96 → 0.8.97
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-ClfxdXeC.js → hyperframes-player-Biu5_W0l.js} +1 -1
- package/dist/assets/{index-D8ztHPd3.js → index-B5Z-1RW9.js} +1 -1
- package/dist/assets/{index-CSHEHeOo.js → index-BdV5T-QI.js} +200 -194
- package/dist/assets/{index-BNQLP5_v.js → index-CWuozokW.js} +1 -1
- package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
- package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
- package/dist/chunk-6GAO75IU.js.map +1 -0
- package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
- package/dist/chunk-MTAVNAGW.js.map +1 -0
- package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
- package/dist/chunk-U3EE4ACX.js.map +1 -0
- package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.html +2 -2
- package/dist/index.js +1981 -1779
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolPicker.tsx +19 -15
- package/src/components/TimelineToolbar.test.tsx +62 -0
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/domEditing.test.ts +9 -1
- package/src/components/editor/domEditingDom.ts +2 -1
- package/src/components/editor/fontAssets.test.ts +69 -0
- package/src/components/editor/fontAssets.ts +11 -2
- package/src/components/ui/Menu.test.tsx +21 -0
- package/src/components/ui/Popover.tsx +21 -1
- package/src/hooks/domSelectionTimelineMirror.ts +5 -4
- package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
- package/src/hooks/useAppHotkeys.test.ts +7 -5
- package/src/hooks/useClipboard.ts +25 -7
- package/src/hooks/useDomSelection.test.ts +56 -32
- package/src/hooks/useDomSelection.ts +4 -3
- package/src/player/components/BeatStrip.tsx +6 -3
- package/src/player/components/Timeline.readOnly.test.tsx +670 -0
- package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
- package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
- package/src/player/components/TimelineClipDiamonds.tsx +7 -2
- package/src/player/components/TimelineFxButton.tsx +3 -0
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineProvider.tsx +23 -7
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/studioShortcuts.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.ts +3 -1
- package/src/player/components/timelineDragDrop.ts +11 -1
- package/src/player/components/timelineEditCapabilities.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +24 -0
- package/src/player/components/timelineReadOnly.ts +64 -0
- package/src/player/components/useAutomationLaneGestures.ts +13 -2
- package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
- package/src/player/components/useTimelineProviderState.tsx +42 -14
- package/src/player/hooks/useShadowPreviewReload.ts +2 -2
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
- package/src/player/hooks/useTimelinePlayer.ts +6 -0
- package/src/player/lib/timelineDOM.test.ts +25 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
- package/src/player/store/playerStore.ts +4 -4
- package/src/utils/clipboardLook.test.ts +130 -0
- package/src/utils/clipboardLook.ts +82 -0
- package/src/utils/clipboardPayload.ts +8 -2
- package/src/utils/studioFontHelpers.ts +6 -3
- package/dist/chunk-PQFEGZWM.js.map +0 -1
- package/dist/chunk-QPSOTK6M.js.map +0 -1
- package/dist/chunk-QZ6HNNFG.js.map +0 -1
- /package/dist/{domEditingLayers-3BAP6NNO.js.map → domEditingLayers-NU6Z5QWA.js.map} +0 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Timeline } from "./Timeline";
|
|
7
|
+
import { installTimelineMountEnv, KEYFRAMED_CARD } from "./timelineMountTestEnv";
|
|
8
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
9
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
10
|
+
import type { TimelineProps } from "./TimelineTypes";
|
|
11
|
+
import { TimelineReadOnlyContext } from "./timelineReadOnly";
|
|
12
|
+
import { BeatStrip } from "./BeatStrip";
|
|
13
|
+
import { TimelineFxButton } from "./TimelineFxButton";
|
|
14
|
+
import { TimelineDiamondLane } from "./TimelineClipDiamonds";
|
|
15
|
+
|
|
16
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
17
|
+
|
|
18
|
+
installTimelineMountEnv();
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
22
|
+
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
let root: Root | null = null;
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (root) act(() => root!.unmount());
|
|
28
|
+
root = null;
|
|
29
|
+
document.body.innerHTML = "";
|
|
30
|
+
usePlayerStore.getState().reset();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const CLIPS: TimelineElement[] = [
|
|
34
|
+
{ id: "a", key: "a", domId: "a", tag: "video", start: 0, duration: 2, track: 0 },
|
|
35
|
+
{ id: "b", key: "b", domId: "b", tag: "video", start: 5, duration: 3, track: 0 },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function mount(props: TimelineProps, elements: TimelineElement[] = CLIPS, wired = true) {
|
|
39
|
+
usePlayerStore.setState({ duration: 10, currentTime: 0, timelineReady: true, elements });
|
|
40
|
+
const edits = {
|
|
41
|
+
onMoveElement: vi.fn(),
|
|
42
|
+
onMoveElements: vi.fn(),
|
|
43
|
+
onResizeElement: vi.fn(),
|
|
44
|
+
onBlockedEditAttempt: vi.fn(),
|
|
45
|
+
onToggleTrackHidden: vi.fn(),
|
|
46
|
+
onSetElementAttributeLive: vi.fn(),
|
|
47
|
+
onSetElementAttributeQuiet: vi.fn(),
|
|
48
|
+
onRazorSplit: vi.fn(),
|
|
49
|
+
onRazorSplitAll: vi.fn(),
|
|
50
|
+
onTogglePropertyGroupKeyframe: vi.fn(),
|
|
51
|
+
onMoveKeyframe: vi.fn().mockResolvedValue(true),
|
|
52
|
+
onDeleteElement: vi.fn(),
|
|
53
|
+
};
|
|
54
|
+
const onSeek = vi.fn();
|
|
55
|
+
const onFileDrop = vi.fn();
|
|
56
|
+
const host = document.createElement("div");
|
|
57
|
+
document.body.append(host);
|
|
58
|
+
root = createRoot(host);
|
|
59
|
+
const render = (next: TimelineProps) =>
|
|
60
|
+
act(() =>
|
|
61
|
+
root!.render(
|
|
62
|
+
!wired ? (
|
|
63
|
+
<Timeline {...next} onSeek={onSeek} />
|
|
64
|
+
) : (
|
|
65
|
+
<TimelineEditProvider value={edits}>
|
|
66
|
+
{/* The host's edits arrive both ways: through the edit context and as Timeline props. */}
|
|
67
|
+
<Timeline
|
|
68
|
+
{...next}
|
|
69
|
+
onSeek={onSeek}
|
|
70
|
+
onFileDrop={onFileDrop}
|
|
71
|
+
onMoveElement={edits.onMoveElement}
|
|
72
|
+
onMoveElements={edits.onMoveElements}
|
|
73
|
+
onResizeElement={edits.onResizeElement}
|
|
74
|
+
onBlockedEditAttempt={edits.onBlockedEditAttempt}
|
|
75
|
+
onDeleteElement={edits.onDeleteElement}
|
|
76
|
+
/>
|
|
77
|
+
</TimelineEditProvider>
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
render(props);
|
|
82
|
+
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
83
|
+
if (elements.length > 0) {
|
|
84
|
+
viewport.getBoundingClientRect = () =>
|
|
85
|
+
({ left: 0, top: 0, right: 900, bottom: 400, width: 900, height: 400 }) as DOMRect;
|
|
86
|
+
}
|
|
87
|
+
const clip = (id: string) => host.querySelector<HTMLElement>(`[data-clip][data-el-id="${id}"]`);
|
|
88
|
+
const pointer = (clientX: number, clientY: number) => ({
|
|
89
|
+
bubbles: true,
|
|
90
|
+
button: 0,
|
|
91
|
+
pointerId: 1,
|
|
92
|
+
clientX,
|
|
93
|
+
clientY,
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
host,
|
|
97
|
+
edits,
|
|
98
|
+
onSeek,
|
|
99
|
+
onFileDrop,
|
|
100
|
+
clip,
|
|
101
|
+
rerender: render,
|
|
102
|
+
/** Press clip `a`, travel past the drag threshold, and report whether a drag had started. */
|
|
103
|
+
dragClip() {
|
|
104
|
+
const target = clip("a")!;
|
|
105
|
+
const rect = target.getBoundingClientRect();
|
|
106
|
+
const x = rect.left + rect.width / 2;
|
|
107
|
+
const y = rect.top + rect.height / 2;
|
|
108
|
+
act(() => {
|
|
109
|
+
target.dispatchEvent(new PointerEvent("pointerdown", pointer(x, y)));
|
|
110
|
+
});
|
|
111
|
+
act(() => {
|
|
112
|
+
window.dispatchEvent(new PointerEvent("pointermove", pointer(x + 30, y)));
|
|
113
|
+
window.dispatchEvent(new PointerEvent("pointermove", pointer(x + 60, y)));
|
|
114
|
+
});
|
|
115
|
+
const dragging = clip("a") === null;
|
|
116
|
+
act(() => {
|
|
117
|
+
window.dispatchEvent(new PointerEvent("pointerup", pointer(x + 60, y)));
|
|
118
|
+
});
|
|
119
|
+
return dragging;
|
|
120
|
+
},
|
|
121
|
+
pressRuler() {
|
|
122
|
+
const at = pointer(400, 4);
|
|
123
|
+
act(() => viewport.dispatchEvent(new PointerEvent("pointerdown", at)));
|
|
124
|
+
act(() => viewport.dispatchEvent(new PointerEvent("pointerup", at)));
|
|
125
|
+
},
|
|
126
|
+
contextMenu(target: HTMLElement, clientX: number) {
|
|
127
|
+
act(() => {
|
|
128
|
+
target.dispatchEvent(
|
|
129
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX, clientY: 40 }),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
/** Right-click the first lane's empty space, past clip `a`. */
|
|
134
|
+
laneMenu() {
|
|
135
|
+
const cell = host.querySelector<HTMLElement>('[role="gridcell"][aria-colindex="2"]')!;
|
|
136
|
+
cell.getBoundingClientRect = () =>
|
|
137
|
+
({ left: 0, top: 0, right: 900, bottom: 40, width: 900, height: 40 }) as DOMRect;
|
|
138
|
+
this.contextMenu(cell, 350);
|
|
139
|
+
},
|
|
140
|
+
dragFileOver() {
|
|
141
|
+
const dataTransfer = {
|
|
142
|
+
types: ["Files"],
|
|
143
|
+
dropEffect: "copy",
|
|
144
|
+
files: [new File(["x"], "clip.mp4", { type: "video/mp4" })],
|
|
145
|
+
items: [],
|
|
146
|
+
getData: () => "",
|
|
147
|
+
};
|
|
148
|
+
const fire = (type: string) => {
|
|
149
|
+
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
150
|
+
Object.assign(event, { dataTransfer, clientX: 400, clientY: 40 });
|
|
151
|
+
act(() => {
|
|
152
|
+
viewport.dispatchEvent(event);
|
|
153
|
+
});
|
|
154
|
+
return event;
|
|
155
|
+
};
|
|
156
|
+
fire("dragover");
|
|
157
|
+
const second = fire("dragover");
|
|
158
|
+
// The shell rings itself while a drop would land (the drag-over preview).
|
|
159
|
+
const preview = host
|
|
160
|
+
.querySelector('[aria-label="Timeline track view"]')!
|
|
161
|
+
.className.includes("ring-studio-accent");
|
|
162
|
+
fire("drop");
|
|
163
|
+
return { dropEffect: dataTransfer.dropEffect, accepted: second.defaultPrevented, preview };
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
describe("Timeline readOnly", () => {
|
|
169
|
+
it("still scrubs from the ruler", () => {
|
|
170
|
+
const onReadOnlyPress = vi.fn();
|
|
171
|
+
const t = mount({ readOnly: true, onReadOnlyPress });
|
|
172
|
+
t.pressRuler();
|
|
173
|
+
expect(t.onSeek).toHaveBeenCalled();
|
|
174
|
+
expect(onReadOnlyPress).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("refuses a clip drag before it starts and reports it once", () => {
|
|
178
|
+
const onReadOnlyPress = vi.fn();
|
|
179
|
+
const t = mount({ readOnly: true, onReadOnlyPress });
|
|
180
|
+
expect(t.dragClip()).toBe(false);
|
|
181
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(1);
|
|
182
|
+
expect(t.edits.onMoveElement).not.toHaveBeenCalled();
|
|
183
|
+
expect(t.edits.onMoveElements).not.toHaveBeenCalled();
|
|
184
|
+
expect(t.edits.onBlockedEditAttempt).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("reports a clip drag even when the host wires no edit callbacks", () => {
|
|
188
|
+
const onReadOnlyPress = vi.fn();
|
|
189
|
+
const t = mount({ readOnly: true, onReadOnlyPress }, CLIPS, false);
|
|
190
|
+
expect(t.dragClip()).toBe(false);
|
|
191
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("offers no trim handle and still selects on a plain click", () => {
|
|
195
|
+
const onReadOnlyPress = vi.fn();
|
|
196
|
+
const t = mount({ readOnly: true, onReadOnlyPress });
|
|
197
|
+
act(() => t.clip("a")!.click());
|
|
198
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("a");
|
|
199
|
+
expect(t.clip("a")!.querySelector('[style*="col-resize"]')).toBeNull();
|
|
200
|
+
expect(onReadOnlyPress).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("opens no clip menu on right-click and reports it once", () => {
|
|
204
|
+
const onReadOnlyPress = vi.fn();
|
|
205
|
+
const t = mount({ readOnly: true, onReadOnlyPress });
|
|
206
|
+
t.contextMenu(t.clip("a")!, 20);
|
|
207
|
+
expect(document.querySelector('[role="menu"][aria-label="Clip actions"]')).toBeNull();
|
|
208
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("opens no gap menu on an empty lane and reports it once", () => {
|
|
212
|
+
const onReadOnlyPress = vi.fn();
|
|
213
|
+
const t = mount({ readOnly: true, onReadOnlyPress });
|
|
214
|
+
t.laneMenu();
|
|
215
|
+
expect(document.body.textContent).not.toContain("Close gap");
|
|
216
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("refuses a file drag with no preview or commit and reports it once", () => {
|
|
220
|
+
const onReadOnlyPress = vi.fn();
|
|
221
|
+
const t = mount({ readOnly: true, onReadOnlyPress });
|
|
222
|
+
const drag = t.dragFileOver();
|
|
223
|
+
expect(drag).toEqual({ dropEffect: "none", accepted: false, preview: false });
|
|
224
|
+
expect(t.onFileDrop).not.toHaveBeenCalled();
|
|
225
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("reports to the host's latest callback after it re-renders with a new one", () => {
|
|
229
|
+
const first = vi.fn();
|
|
230
|
+
const latest = vi.fn();
|
|
231
|
+
const t = mount({ readOnly: true, onReadOnlyPress: first });
|
|
232
|
+
t.rerender({ readOnly: true, onReadOnlyPress: latest });
|
|
233
|
+
act(() => t.host.querySelector<HTMLElement>('button[aria-label^="Hide track"]')!.click());
|
|
234
|
+
expect([first.mock.calls.length, latest.mock.calls.length]).toEqual([0, 1]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("reports the track eye instead of toggling it", () => {
|
|
238
|
+
const onReadOnlyPress = vi.fn();
|
|
239
|
+
const t = mount({ readOnly: true, onReadOnlyPress });
|
|
240
|
+
act(() => t.host.querySelector<HTMLElement>('button[aria-label^="Hide track"]')!.click());
|
|
241
|
+
expect(t.edits.onToggleTrackHidden).not.toHaveBeenCalled();
|
|
242
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(1);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const FADED_AUDIO: TimelineElement[] = [
|
|
247
|
+
{
|
|
248
|
+
id: "vo",
|
|
249
|
+
key: "vo",
|
|
250
|
+
domId: "vo",
|
|
251
|
+
tag: "audio",
|
|
252
|
+
src: "vo.mp3",
|
|
253
|
+
start: 0,
|
|
254
|
+
duration: 4,
|
|
255
|
+
track: 0,
|
|
256
|
+
fadeIn: 1,
|
|
257
|
+
},
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const CARD: TimelineElement[] = [
|
|
261
|
+
{
|
|
262
|
+
id: "card",
|
|
263
|
+
key: "card",
|
|
264
|
+
domId: "card",
|
|
265
|
+
label: "Hero card",
|
|
266
|
+
tag: "div",
|
|
267
|
+
start: 0,
|
|
268
|
+
duration: 4,
|
|
269
|
+
track: 0,
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
const CARD_KEYFRAMES = new Map([
|
|
273
|
+
[
|
|
274
|
+
"card",
|
|
275
|
+
{
|
|
276
|
+
format: "percentage" as const,
|
|
277
|
+
keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
/** Every one-shot gesture routed through the refusal, run against a mount with or without readOnly. */
|
|
283
|
+
function oneShotGestures(readOnly: boolean) {
|
|
284
|
+
const onReadOnlyPress = vi.fn();
|
|
285
|
+
const props = readOnly ? { readOnly, onReadOnlyPress } : {};
|
|
286
|
+
const remount = (elements: TimelineElement[]) => {
|
|
287
|
+
if (root) act(() => root!.unmount());
|
|
288
|
+
root = null;
|
|
289
|
+
document.body.innerHTML = "";
|
|
290
|
+
return mount(props, elements);
|
|
291
|
+
};
|
|
292
|
+
const reported = () => onReadOnlyPress.mock.calls.length;
|
|
293
|
+
|
|
294
|
+
usePlayerStore.setState({ activeTool: "razor" });
|
|
295
|
+
let t = remount(CLIPS);
|
|
296
|
+
act(() => t.clip("a")!.click());
|
|
297
|
+
const razor = { reported: reported(), split: t.edits.onRazorSplit.mock.calls.length };
|
|
298
|
+
act(() => t.clip("b")!.dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true })));
|
|
299
|
+
const razorAll = {
|
|
300
|
+
reported: reported() - razor.reported,
|
|
301
|
+
split: t.edits.onRazorSplitAll.mock.calls.length,
|
|
302
|
+
};
|
|
303
|
+
const beforeShift = reported();
|
|
304
|
+
const viewport = t.host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
305
|
+
act(() => {
|
|
306
|
+
viewport.dispatchEvent(
|
|
307
|
+
new PointerEvent("pointerdown", {
|
|
308
|
+
bubbles: true,
|
|
309
|
+
button: 0,
|
|
310
|
+
pointerId: 2,
|
|
311
|
+
clientX: 700,
|
|
312
|
+
clientY: 60,
|
|
313
|
+
shiftKey: true,
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
const shiftSplitAll = {
|
|
318
|
+
reported: reported() - beforeShift,
|
|
319
|
+
split: t.edits.onRazorSplitAll.mock.calls.length - razorAll.split,
|
|
320
|
+
};
|
|
321
|
+
usePlayerStore.setState({ activeTool: "select" });
|
|
322
|
+
|
|
323
|
+
usePlayerStore.setState({ gsapAnimations: new Map(), keyframeCache: CARD_KEYFRAMES });
|
|
324
|
+
t = remount(CARD);
|
|
325
|
+
const beforeMenu = reported();
|
|
326
|
+
t.contextMenu(t.host.querySelector<HTMLElement>('button[title="50%"]')!, 60);
|
|
327
|
+
const keyframeMenu = {
|
|
328
|
+
reported: reported() - beforeMenu,
|
|
329
|
+
open: document.querySelector('[role="menu"][aria-label="Keyframe actions"]') !== null,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
usePlayerStore.setState({ selectedElementId: "card", gsapAnimations: KEYFRAMED_CARD });
|
|
333
|
+
t = remount(CARD);
|
|
334
|
+
const beforeToggle = reported();
|
|
335
|
+
act(() =>
|
|
336
|
+
t.host.querySelector<HTMLElement>('button[aria-pressed][aria-label$=" keyframe"]')!.click(),
|
|
337
|
+
);
|
|
338
|
+
const propertyToggle = {
|
|
339
|
+
reported: reported() - beforeToggle,
|
|
340
|
+
toggled: t.edits.onTogglePropertyGroupKeyframe.mock.calls.length,
|
|
341
|
+
};
|
|
342
|
+
return { razor, razorAll, shiftSplitAll, keyframeMenu, propertyToggle };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
describe("Timeline readOnly one-shot edits", () => {
|
|
346
|
+
it("refuses razor splits, the keyframe menu and the property keyframe toggle, reporting each once", () => {
|
|
347
|
+
expect(oneShotGestures(true)).toEqual({
|
|
348
|
+
razor: { reported: 1, split: 0 },
|
|
349
|
+
razorAll: { reported: 1, split: 0 },
|
|
350
|
+
shiftSplitAll: { reported: 1, split: 0 },
|
|
351
|
+
keyframeMenu: { reported: 1, open: false },
|
|
352
|
+
propertyToggle: { reported: 1, toggled: 0 },
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("performs every one of them without readOnly", () => {
|
|
357
|
+
expect(oneShotGestures(false)).toEqual({
|
|
358
|
+
razor: { reported: 0, split: 1 },
|
|
359
|
+
razorAll: { reported: 0, split: 1 },
|
|
360
|
+
shiftSplitAll: { reported: 0, split: 1 },
|
|
361
|
+
keyframeMenu: { reported: 0, open: true },
|
|
362
|
+
propertyToggle: { reported: 0, toggled: 1 },
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const LOCKED: TimelineProps = { readOnly: true, onReadOnlyPress: vi.fn() };
|
|
368
|
+
const press = (x: number, y = 10) => ({
|
|
369
|
+
bubbles: true,
|
|
370
|
+
button: 0,
|
|
371
|
+
pointerId: 1,
|
|
372
|
+
clientX: x,
|
|
373
|
+
clientY: y,
|
|
374
|
+
});
|
|
375
|
+
const onWindow = (type: string, x: number) =>
|
|
376
|
+
act(() => {
|
|
377
|
+
window.dispatchEvent(new PointerEvent(type, press(x)));
|
|
378
|
+
});
|
|
379
|
+
const pressOn = (el: Element, x: number) =>
|
|
380
|
+
act(() => {
|
|
381
|
+
el.dispatchEvent(new PointerEvent("pointerdown", press(x)));
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe("Timeline switched to readOnly mid-edit", () => {
|
|
385
|
+
it("drops a clip drag without committing it", () => {
|
|
386
|
+
const t = mount({});
|
|
387
|
+
pressOn(t.clip("a")!, 0);
|
|
388
|
+
onWindow("pointermove", 30);
|
|
389
|
+
onWindow("pointermove", 60);
|
|
390
|
+
expect(t.clip("a")).toBeNull();
|
|
391
|
+
t.rerender(LOCKED);
|
|
392
|
+
onWindow("pointerup", 60);
|
|
393
|
+
expect(t.edits.onMoveElement).not.toHaveBeenCalled();
|
|
394
|
+
expect(t.edits.onMoveElements).not.toHaveBeenCalled();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("drops a clip resize without committing it", () => {
|
|
398
|
+
const t = mount({});
|
|
399
|
+
act(() => t.clip("a")!.click());
|
|
400
|
+
pressOn(t.clip("a")!.querySelector('[style*="col-resize"]')!, 0);
|
|
401
|
+
onWindow("pointermove", 20);
|
|
402
|
+
onWindow("pointermove", 40);
|
|
403
|
+
t.rerender(LOCKED);
|
|
404
|
+
onWindow("pointerup", 40);
|
|
405
|
+
expect(t.edits.onResizeElement).not.toHaveBeenCalled();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("drops a beat drag without committing it", () => {
|
|
409
|
+
const commitBeatEdits = vi.fn();
|
|
410
|
+
const t = mount({}, [
|
|
411
|
+
{
|
|
412
|
+
id: "music",
|
|
413
|
+
tag: "audio",
|
|
414
|
+
src: "m.mp3",
|
|
415
|
+
start: 0,
|
|
416
|
+
duration: 10,
|
|
417
|
+
track: 0,
|
|
418
|
+
timelineRole: "music",
|
|
419
|
+
},
|
|
420
|
+
]);
|
|
421
|
+
// The music analysis resets the beats on mount, so they arrive afterwards.
|
|
422
|
+
act(() =>
|
|
423
|
+
usePlayerStore.setState({
|
|
424
|
+
beatAnalysis: {
|
|
425
|
+
beatTimes: [1, 3],
|
|
426
|
+
beatStrengths: [0.5, 0.8],
|
|
427
|
+
bpm: 120,
|
|
428
|
+
bpmConfidence: "high",
|
|
429
|
+
channelData: null,
|
|
430
|
+
sampleRate: 48_000,
|
|
431
|
+
peak: 1,
|
|
432
|
+
},
|
|
433
|
+
commitBeatEdits,
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
pressOn(t.host.querySelector('[title="Drag to move · ⌥-click to delete"]')!, 100);
|
|
437
|
+
expect(usePlayerStore.getState().beatDragging).toBe(true);
|
|
438
|
+
onWindow("pointermove", 160);
|
|
439
|
+
t.rerender(LOCKED);
|
|
440
|
+
expect(usePlayerStore.getState().beatDragging).toBe(false);
|
|
441
|
+
onWindow("pointerup", 160);
|
|
442
|
+
expect(commitBeatEdits).not.toHaveBeenCalled();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("drops a keyframe retime without committing it", () => {
|
|
446
|
+
usePlayerStore.setState({ selectedElementId: "card", gsapAnimations: KEYFRAMED_CARD });
|
|
447
|
+
const t = mount({}, CARD);
|
|
448
|
+
pressOn(t.host.querySelector('button[title="25%"]')!, 80);
|
|
449
|
+
t.rerender(LOCKED);
|
|
450
|
+
onWindow("pointerup", 120);
|
|
451
|
+
expect(t.edits.onMoveKeyframe).not.toHaveBeenCalled();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("closes an open clip menu, gap menu and keyframe menu", () => {
|
|
455
|
+
const t = mount({});
|
|
456
|
+
t.contextMenu(t.clip("a")!, 20);
|
|
457
|
+
t.rerender(LOCKED);
|
|
458
|
+
const clipMenu = document.querySelector('[role="menu"][aria-label="Clip actions"]');
|
|
459
|
+
t.rerender({});
|
|
460
|
+
t.laneMenu();
|
|
461
|
+
t.rerender(LOCKED);
|
|
462
|
+
const gapMenu = document.body.textContent?.includes("Close gap");
|
|
463
|
+
act(() => root!.unmount());
|
|
464
|
+
root = null;
|
|
465
|
+
usePlayerStore.setState({ gsapAnimations: new Map(), keyframeCache: CARD_KEYFRAMES });
|
|
466
|
+
const k = mount({}, CARD);
|
|
467
|
+
k.contextMenu(k.host.querySelector<HTMLElement>('button[title="50%"]')!, 60);
|
|
468
|
+
k.rerender(LOCKED);
|
|
469
|
+
const keyframeMenu = document.querySelector('[role="menu"][aria-label="Keyframe actions"]');
|
|
470
|
+
expect({ clipMenu, gapMenu, keyframeMenu }).toEqual({
|
|
471
|
+
clipMenu: null,
|
|
472
|
+
gapMenu: false,
|
|
473
|
+
keyframeMenu: null,
|
|
474
|
+
});
|
|
475
|
+
expect(t.edits.onDeleteElement).not.toHaveBeenCalled();
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe("Timeline readOnly with a host edit context", () => {
|
|
480
|
+
it("draws no fade handle, so the context's fade writers are never reached", () => {
|
|
481
|
+
const t = mount({ readOnly: true, onReadOnlyPress: vi.fn() }, FADED_AUDIO);
|
|
482
|
+
act(() => t.clip("vo")!.click());
|
|
483
|
+
expect(t.host.querySelector('[role="slider"]')).toBeNull();
|
|
484
|
+
expect(t.edits.onSetElementAttributeLive).not.toHaveBeenCalled();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("says nothing about dropping media on an empty timeline", () => {
|
|
488
|
+
const t = mount({ readOnly: true, onReadOnlyPress: vi.fn() }, []);
|
|
489
|
+
expect(t.host.textContent).not.toContain("Drop media");
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
describe("Timeline without readOnly (unchanged)", () => {
|
|
494
|
+
it("scrubs, drags, opens both menus, previews and commits a drop, toggles the eye", () => {
|
|
495
|
+
const t = mount({});
|
|
496
|
+
t.pressRuler();
|
|
497
|
+
expect(t.onSeek).toHaveBeenCalled();
|
|
498
|
+
|
|
499
|
+
expect(t.dragClip()).toBe(true);
|
|
500
|
+
expect(t.edits.onMoveElement.mock.calls.length + t.edits.onMoveElements.mock.calls.length).toBe(
|
|
501
|
+
1,
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
t.contextMenu(t.clip("a")!, 20);
|
|
505
|
+
expect(document.querySelector('[role="menu"][aria-label="Clip actions"]')).not.toBeNull();
|
|
506
|
+
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
|
507
|
+
|
|
508
|
+
t.laneMenu();
|
|
509
|
+
expect(document.body.textContent).toContain("Close gap");
|
|
510
|
+
|
|
511
|
+
const drag = t.dragFileOver();
|
|
512
|
+
expect(drag.dropEffect).toBe("copy");
|
|
513
|
+
expect(drag.accepted).toBe(true);
|
|
514
|
+
expect(drag.preview).toBe(true);
|
|
515
|
+
expect(t.onFileDrop).toHaveBeenCalledTimes(1);
|
|
516
|
+
|
|
517
|
+
act(() => t.host.querySelector<HTMLElement>('button[aria-label^="Hide track"]')!.click());
|
|
518
|
+
expect(t.edits.onToggleTrackHidden).toHaveBeenCalledTimes(1);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("draws the fade handles from the edit context and offers media drops", () => {
|
|
522
|
+
const t = mount({}, FADED_AUDIO);
|
|
523
|
+
act(() => t.clip("vo")!.click());
|
|
524
|
+
expect(t.host.querySelector('[role="slider"]')).not.toBeNull();
|
|
525
|
+
act(() => root!.unmount());
|
|
526
|
+
root = null;
|
|
527
|
+
expect(mount({}, []).host.textContent).toContain("Drop media");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("draws the trim handles on a selected clip", () => {
|
|
531
|
+
const t = mount({});
|
|
532
|
+
act(() => t.clip("a")!.click());
|
|
533
|
+
expect(t.clip("a")!.querySelector('[style*="col-resize"]')).not.toBeNull();
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
function mountReadOnly(node: React.ReactElement) {
|
|
538
|
+
const onReadOnlyPress = vi.fn();
|
|
539
|
+
const host = document.createElement("div");
|
|
540
|
+
host.setAttribute("data-timeline-scroll-viewport", "");
|
|
541
|
+
document.body.append(host);
|
|
542
|
+
root = createRoot(host);
|
|
543
|
+
act(() =>
|
|
544
|
+
root!.render(
|
|
545
|
+
<TimelineReadOnlyContext.Provider value={onReadOnlyPress}>
|
|
546
|
+
{node}
|
|
547
|
+
</TimelineReadOnlyContext.Provider>,
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
return { host, onReadOnlyPress };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
describe("read-only children", () => {
|
|
554
|
+
it("a beat dot starts no drag, deletes nothing on alt-click, and reports once per press", () => {
|
|
555
|
+
const commitBeatEdits = vi.fn();
|
|
556
|
+
usePlayerStore.setState({
|
|
557
|
+
timelineSessionEpoch: 1,
|
|
558
|
+
timelineProjectId: "project-a",
|
|
559
|
+
elements: [
|
|
560
|
+
{
|
|
561
|
+
id: "music",
|
|
562
|
+
tag: "audio",
|
|
563
|
+
src: "m.mp3",
|
|
564
|
+
start: 0,
|
|
565
|
+
duration: 10,
|
|
566
|
+
track: 0,
|
|
567
|
+
timelineRole: "music",
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
beatAnalysis: {
|
|
571
|
+
beatTimes: [1, 3],
|
|
572
|
+
beatStrengths: [0.5, 0.8],
|
|
573
|
+
bpm: 120,
|
|
574
|
+
bpmConfidence: "high",
|
|
575
|
+
channelData: null,
|
|
576
|
+
sampleRate: 48_000,
|
|
577
|
+
peak: 1,
|
|
578
|
+
},
|
|
579
|
+
commitBeatEdits,
|
|
580
|
+
});
|
|
581
|
+
const { host, onReadOnlyPress } = mountReadOnly(
|
|
582
|
+
<BeatStrip beatTimes={[1, 3]} beatStrengths={[0.5, 0.8]} pps={100} />,
|
|
583
|
+
);
|
|
584
|
+
host.getBoundingClientRect = () => new DOMRect(0, 0, 1_000, 500);
|
|
585
|
+
const beat = host.querySelector<HTMLElement>('[title="Drag to move · ⌥-click to delete"]')!;
|
|
586
|
+
const at = { bubbles: true, button: 0, pointerId: 1, clientX: 100, clientY: 5 };
|
|
587
|
+
act(() => beat.dispatchEvent(new PointerEvent("pointerdown", at)));
|
|
588
|
+
expect(usePlayerStore.getState().beatDragging).toBe(false);
|
|
589
|
+
act(() => {
|
|
590
|
+
beat.dispatchEvent(new PointerEvent("pointerdown", { ...at, altKey: true }));
|
|
591
|
+
beat.dispatchEvent(new MouseEvent("click", { bubbles: true, altKey: true }));
|
|
592
|
+
});
|
|
593
|
+
expect(commitBeatEdits).not.toHaveBeenCalled();
|
|
594
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(2);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("the FX button opens no popover and reports the click", () => {
|
|
598
|
+
const { host, onReadOnlyPress } = mountReadOnly(
|
|
599
|
+
<TimelineFxButton
|
|
600
|
+
variant="chain"
|
|
601
|
+
fxChainRaw={undefined}
|
|
602
|
+
onChainChange={vi.fn()}
|
|
603
|
+
onOpenRack={vi.fn()}
|
|
604
|
+
/>,
|
|
605
|
+
);
|
|
606
|
+
act(() => host.querySelector<HTMLButtonElement>("button")!.click());
|
|
607
|
+
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
|
608
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(1);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("a keyframe diamond is not retimed by a drag, reports the drag once, and still selects", () => {
|
|
612
|
+
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
|
613
|
+
const onClickKeyframe = vi.fn();
|
|
614
|
+
const { host, onReadOnlyPress } = mountReadOnly(
|
|
615
|
+
<TimelineDiamondLane
|
|
616
|
+
keyframesData={{
|
|
617
|
+
format: "percentage",
|
|
618
|
+
keyframes: [
|
|
619
|
+
{
|
|
620
|
+
percentage: 20,
|
|
621
|
+
tweenPercentage: 0,
|
|
622
|
+
propertyGroup: "position",
|
|
623
|
+
animationId: "a",
|
|
624
|
+
properties: { x: 0 },
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
percentage: 40,
|
|
628
|
+
tweenPercentage: 50,
|
|
629
|
+
propertyGroup: "position",
|
|
630
|
+
animationId: "a",
|
|
631
|
+
properties: { x: 1 },
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
percentage: 60,
|
|
635
|
+
tweenPercentage: 100,
|
|
636
|
+
propertyGroup: "position",
|
|
637
|
+
animationId: "a",
|
|
638
|
+
properties: { x: 2 },
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
}}
|
|
642
|
+
clipWidthPx={200}
|
|
643
|
+
clipHeightPx={48}
|
|
644
|
+
clipDuration={10}
|
|
645
|
+
accentColor="#4ba3d2"
|
|
646
|
+
isSelected
|
|
647
|
+
currentPercentage={0}
|
|
648
|
+
elementId="clip-1"
|
|
649
|
+
selectedKeyframes={new Set()}
|
|
650
|
+
onClickKeyframe={onClickKeyframe}
|
|
651
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
652
|
+
groupAware
|
|
653
|
+
/>,
|
|
654
|
+
);
|
|
655
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]')!;
|
|
656
|
+
act(() => {
|
|
657
|
+
diamond.dispatchEvent(
|
|
658
|
+
new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
|
|
659
|
+
);
|
|
660
|
+
window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 90 }));
|
|
661
|
+
window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 100 }));
|
|
662
|
+
diamond.dispatchEvent(
|
|
663
|
+
new PointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }),
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
|
667
|
+
expect(onClickKeyframe).toHaveBeenCalledTimes(1);
|
|
668
|
+
expect(onReadOnlyPress).toHaveBeenCalledTimes(1);
|
|
669
|
+
});
|
|
670
|
+
});
|
|
@@ -4,7 +4,7 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { Timeline } from "./Timeline";
|
|
7
|
-
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
7
|
+
import { installTimelineMountEnv, KEYFRAMED_CARD as KEYFRAMED } from "./timelineMountTestEnv";
|
|
8
8
|
import { usePlayerStore } from "../store/playerStore";
|
|
9
9
|
|
|
10
10
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -20,30 +20,6 @@ afterEach(() => {
|
|
|
20
20
|
document.body.innerHTML = "";
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
const KEYFRAMED = new Map([
|
|
24
|
-
[
|
|
25
|
-
"card",
|
|
26
|
-
[
|
|
27
|
-
{
|
|
28
|
-
id: "card-position",
|
|
29
|
-
targetSelector: "#card",
|
|
30
|
-
method: "to" as const,
|
|
31
|
-
position: 0,
|
|
32
|
-
duration: 2,
|
|
33
|
-
properties: {},
|
|
34
|
-
propertyGroup: "position" as const,
|
|
35
|
-
keyframes: {
|
|
36
|
-
format: "percentage" as const,
|
|
37
|
-
keyframes: [
|
|
38
|
-
{ percentage: 0, properties: { x: 0 } },
|
|
39
|
-
{ percentage: 50, properties: { x: 100 } },
|
|
40
|
-
],
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
],
|
|
44
|
-
],
|
|
45
|
-
]);
|
|
46
|
-
|
|
47
23
|
/** Presses the ruler exactly on the first drawn tick after 0 and returns its time and the seek. */
|
|
48
24
|
async function seekOnFirstTick(
|
|
49
25
|
props: { showKeyframes?: boolean },
|