@hyperframes/studio 0.7.46 → 0.7.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
- package/dist/assets/index-DX02Q1Yl.js +423 -0
- package/dist/assets/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.js} +1 -1
- package/dist/index.d.ts +96 -29
- package/dist/index.html +2 -2
- package/dist/index.js +8443 -4305
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -32
- package/src/components/DesignPanelPromoteProvider.tsx +45 -0
- package/src/components/PanelTabButton.tsx +31 -0
- package/src/components/StudioPreviewArea.tsx +75 -3
- package/src/components/StudioRightPanel.tsx +111 -113
- package/src/components/editor/LayersPanel.tsx +2 -22
- package/src/components/editor/PromotableControl.tsx +105 -0
- package/src/components/editor/propertyPanelSections.tsx +42 -21
- package/src/components/panels/VariablesBindElement.tsx +266 -0
- package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
- package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
- package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
- package/src/components/panels/VariablesPanel.tsx +554 -0
- package/src/components/panels/VariablesRowAction.tsx +25 -0
- package/src/components/panels/VariablesValueControls.tsx +215 -0
- package/src/components/renders/useRenderQueue.ts +12 -0
- package/src/contexts/TimelineEditContext.tsx +7 -2
- package/src/contexts/VariablePromoteContext.tsx +138 -0
- package/src/contexts/variablePromoteHelpers.test.ts +124 -0
- package/src/contexts/variablePromoteHelpers.ts +53 -0
- package/src/contexts/variablePromoteIntegration.test.ts +80 -0
- package/src/hooks/previewVariablesStore.ts +34 -0
- package/src/hooks/timelineEditingHelpers.test.ts +104 -0
- package/src/hooks/timelineEditingHelpers.ts +386 -10
- package/src/hooks/useDomEditWiring.ts +9 -7
- package/src/hooks/useDomSelection.test.ts +36 -2
- package/src/hooks/useDomSelection.ts +56 -75
- package/src/hooks/useElementLifecycleOps.ts +57 -17
- package/src/hooks/usePreviewDocumentVersion.ts +27 -0
- package/src/hooks/useProjectCompositionVariables.ts +131 -0
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useStudioSdkSessions.ts +37 -0
- package/src/hooks/useTimelineEditing.test.tsx +963 -0
- package/src/hooks/useTimelineEditing.ts +122 -100
- package/src/hooks/useTimelineEditingTypes.ts +15 -0
- package/src/hooks/useTimelineGroupEditing.ts +370 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
- package/src/hooks/useVariablesPersist.ts +61 -0
- package/src/player/components/BeatStrip.tsx +19 -3
- package/src/player/components/Player.tsx +7 -1
- package/src/player/components/Timeline.test.ts +38 -0
- package/src/player/components/Timeline.tsx +102 -80
- package/src/player/components/TimelineCanvas.tsx +340 -296
- package/src/player/components/TimelineClip.test.tsx +11 -0
- package/src/player/components/TimelineClipDiamonds.tsx +40 -9
- package/src/player/components/TimelineDragGhost.tsx +59 -0
- package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
- package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
- package/src/player/components/TimelineLayerGutter.tsx +61 -0
- package/src/player/components/TimelineRuler.tsx +4 -2
- package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
- package/src/player/components/timelineCallbacks.ts +20 -2
- package/src/player/components/timelineClipDragPreview.ts +52 -0
- package/src/player/components/timelineDragDrop.ts +19 -3
- package/src/player/components/timelineDropIndicator.test.ts +48 -0
- package/src/player/components/timelineDropIndicator.ts +33 -0
- package/src/player/components/timelineEditing.test.ts +251 -1
- package/src/player/components/timelineEditing.ts +178 -19
- package/src/player/components/timelineGroupEditing.ts +159 -0
- package/src/player/components/timelineLayerDrag.test.ts +200 -0
- package/src/player/components/timelineLayerDrag.ts +383 -0
- package/src/player/components/timelineLayout.test.ts +34 -0
- package/src/player/components/timelineLayout.ts +47 -0
- package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
- package/src/player/components/timelineSnapTargets.test.ts +144 -0
- package/src/player/components/timelineSnapTargets.ts +164 -0
- package/src/player/components/timelineStacking.ts +62 -0
- package/src/player/components/timelineTrackOrder.test.ts +144 -0
- package/src/player/components/timelineTrackOrder.ts +173 -0
- package/src/player/components/useTimelineActiveClips.test.ts +20 -0
- package/src/player/components/useTimelineActiveClips.ts +15 -4
- package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
- package/src/player/components/useTimelineClipDrag.ts +172 -126
- package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
- package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
- package/src/player/hooks/useTimelinePlayer.ts +7 -0
- package/src/player/lib/layerOrdering.test.ts +77 -0
- package/src/player/lib/layerOrdering.ts +98 -0
- package/src/player/lib/playbackTypes.ts +3 -0
- package/src/player/lib/timelineDOM.test.ts +103 -0
- package/src/player/lib/timelineDOM.ts +80 -0
- package/src/player/lib/timelineElementHelpers.ts +1 -1
- package/src/player/store/playerStore.test.ts +92 -0
- package/src/player/store/playerStore.ts +69 -9
- package/src/utils/blockInstaller.ts +2 -14
- package/src/utils/editHistory.test.ts +61 -0
- package/src/utils/editHistory.ts +7 -1
- package/src/utils/rootDuration.test.ts +34 -0
- package/src/utils/rootDuration.ts +17 -0
- package/src/utils/sdkCutover.ts +45 -0
- package/src/utils/studioHelpers.test.ts +31 -0
- package/src/utils/studioHelpers.ts +29 -1
- package/src/utils/studioUrlState.test.ts +29 -0
- package/src/utils/studioUrlState.ts +16 -1
- package/dist/assets/index-BsLQ2qGn.js +0 -420
- package/dist/assets/index-DoVLXke0.css +0 -1
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import { TRACK_H } from "./timelineLayout";
|
|
9
|
+
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
|
|
10
|
+
import type { DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
|
|
11
|
+
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
12
|
+
|
|
13
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
function timelineElement(input: {
|
|
16
|
+
id: string;
|
|
17
|
+
tag?: string;
|
|
18
|
+
track: number;
|
|
19
|
+
zIndex: number;
|
|
20
|
+
start?: number;
|
|
21
|
+
duration?: number;
|
|
22
|
+
sourceDuration?: number;
|
|
23
|
+
playbackStart?: number;
|
|
24
|
+
playbackRate?: number;
|
|
25
|
+
timelineLocked?: boolean;
|
|
26
|
+
}): TimelineElement {
|
|
27
|
+
return {
|
|
28
|
+
id: input.id,
|
|
29
|
+
domId: input.id,
|
|
30
|
+
tag: input.tag ?? "div",
|
|
31
|
+
start: input.start ?? 0,
|
|
32
|
+
duration: input.duration ?? 2,
|
|
33
|
+
sourceDuration: input.sourceDuration,
|
|
34
|
+
playbackStart: input.playbackStart,
|
|
35
|
+
playbackRate: input.playbackRate,
|
|
36
|
+
track: input.track,
|
|
37
|
+
zIndex: input.zIndex,
|
|
38
|
+
stackingContextId: "root",
|
|
39
|
+
parentCompositionId: null,
|
|
40
|
+
compositionAncestors: ["root"],
|
|
41
|
+
sourceFile: "index.html",
|
|
42
|
+
timingSource: "authored",
|
|
43
|
+
timelineLocked: input.timelineLocked,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
document.body.innerHTML = "";
|
|
49
|
+
usePlayerStore.getState().reset();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function renderDragHarness(elements: TimelineElement[]) {
|
|
53
|
+
usePlayerStore.getState().setElements(elements);
|
|
54
|
+
const layers = buildStackingTimelineLayers(elements).rows;
|
|
55
|
+
const scroll = document.createElement("div");
|
|
56
|
+
document.body.append(scroll);
|
|
57
|
+
const onMoveElement = vi.fn();
|
|
58
|
+
const onResizeElement = vi.fn();
|
|
59
|
+
const onMoveElements = vi.fn();
|
|
60
|
+
const onResizeElements = vi.fn();
|
|
61
|
+
const onPreviewMoveElements = vi.fn();
|
|
62
|
+
const onPreviewResizeElements = vi.fn();
|
|
63
|
+
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
|
|
64
|
+
let setResizingClip: ((state: ResizingClipState | null) => void) | null = null;
|
|
65
|
+
|
|
66
|
+
function Harness() {
|
|
67
|
+
const hook = useTimelineClipDrag({
|
|
68
|
+
scrollRef: { current: scroll },
|
|
69
|
+
ppsRef: { current: 100 },
|
|
70
|
+
trackOrderRef: { current: layers.map((layer) => layer.id) },
|
|
71
|
+
timelineLayersRef: { current: layers },
|
|
72
|
+
timelineElementsRef: { current: elements },
|
|
73
|
+
onMoveElement,
|
|
74
|
+
onResizeElement,
|
|
75
|
+
onMoveElements,
|
|
76
|
+
onResizeElements,
|
|
77
|
+
onPreviewMoveElements,
|
|
78
|
+
onPreviewResizeElements,
|
|
79
|
+
onBlockedEditAttempt: vi.fn(),
|
|
80
|
+
setShowPopover: vi.fn(),
|
|
81
|
+
setRangeSelectionRef: { current: vi.fn() },
|
|
82
|
+
});
|
|
83
|
+
setDraggedClip = hook.setDraggedClip;
|
|
84
|
+
setResizingClip = hook.setResizingClip;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const host = document.createElement("div");
|
|
89
|
+
document.body.append(host);
|
|
90
|
+
const root = createRoot(host);
|
|
91
|
+
act(() => {
|
|
92
|
+
root.render(<Harness />);
|
|
93
|
+
});
|
|
94
|
+
if (!setDraggedClip) throw new Error("Expected drag setter");
|
|
95
|
+
if (!setResizingClip) throw new Error("Expected resize setter");
|
|
96
|
+
const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip;
|
|
97
|
+
const applyResizingClip: (state: ResizingClipState | null) => void = setResizingClip;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
layers,
|
|
101
|
+
onMoveElement,
|
|
102
|
+
onResizeElement,
|
|
103
|
+
onMoveElements,
|
|
104
|
+
onResizeElements,
|
|
105
|
+
onPreviewMoveElements,
|
|
106
|
+
onPreviewResizeElements,
|
|
107
|
+
storeElements() {
|
|
108
|
+
return usePlayerStore.getState().elements;
|
|
109
|
+
},
|
|
110
|
+
startDrag(element: TimelineElement, layerIndex: number) {
|
|
111
|
+
const layer =
|
|
112
|
+
layers[layerIndex] ??
|
|
113
|
+
layers.find((candidate) =>
|
|
114
|
+
candidate.elements.some((candidateElement) => candidateElement.id === element.id),
|
|
115
|
+
) ??
|
|
116
|
+
layers[0]!;
|
|
117
|
+
act(() => {
|
|
118
|
+
applyDraggedClip({
|
|
119
|
+
element,
|
|
120
|
+
originClientX: 0,
|
|
121
|
+
originClientY: 0,
|
|
122
|
+
originScrollLeft: 0,
|
|
123
|
+
originScrollTop: 0,
|
|
124
|
+
pointerClientX: 0,
|
|
125
|
+
pointerClientY: 0,
|
|
126
|
+
pointerOffsetX: 0,
|
|
127
|
+
pointerOffsetY: 0,
|
|
128
|
+
previewStart: element.start,
|
|
129
|
+
previewTrack: element.track,
|
|
130
|
+
previewLayerId: layer.id,
|
|
131
|
+
previewLayerIndex: layerIndex,
|
|
132
|
+
previewStackingReorder: null,
|
|
133
|
+
snapBeatTime: null,
|
|
134
|
+
snapGuideTime: null,
|
|
135
|
+
snapGuideKind: null,
|
|
136
|
+
started: false,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
startResize(element: TimelineElement, edge: "start" | "end") {
|
|
141
|
+
act(() => {
|
|
142
|
+
applyResizingClip({
|
|
143
|
+
element,
|
|
144
|
+
edge,
|
|
145
|
+
originClientX: 0,
|
|
146
|
+
previewStart: element.start,
|
|
147
|
+
previewDuration: element.duration,
|
|
148
|
+
previewPlaybackStart: element.playbackStart,
|
|
149
|
+
snapGuideTime: null,
|
|
150
|
+
snapGuideKind: null,
|
|
151
|
+
started: false,
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
movePointer(clientX: number, clientY: number) {
|
|
156
|
+
act(() => {
|
|
157
|
+
window.dispatchEvent(
|
|
158
|
+
new MouseEvent("pointermove", {
|
|
159
|
+
bubbles: true,
|
|
160
|
+
clientX,
|
|
161
|
+
clientY,
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
async dropPointer() {
|
|
167
|
+
await act(async () => {
|
|
168
|
+
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
unmount() {
|
|
172
|
+
act(() => root.unmount());
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe("useTimelineClipDrag", () => {
|
|
178
|
+
it("allows moving a clip past the current composition duration", async () => {
|
|
179
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 1 });
|
|
180
|
+
const harness = renderDragHarness([clip]);
|
|
181
|
+
|
|
182
|
+
harness.startDrag(clip, 0);
|
|
183
|
+
harness.movePointer(1100, 0);
|
|
184
|
+
await harness.dropPointer();
|
|
185
|
+
|
|
186
|
+
expect(harness.onMoveElement).toHaveBeenCalledWith(
|
|
187
|
+
clip,
|
|
188
|
+
expect.objectContaining({ start: 11 }),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
harness.unmount();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("moves every selected clip body by the same delta", async () => {
|
|
195
|
+
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
|
|
196
|
+
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 4, duration: 2 });
|
|
197
|
+
const harness = renderDragHarness([first, second]);
|
|
198
|
+
act(() => {
|
|
199
|
+
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
harness.startDrag(first, 0);
|
|
203
|
+
harness.movePointer(200, 0);
|
|
204
|
+
|
|
205
|
+
expect(harness.storeElements().map((element) => [element.id, element.start])).toEqual([
|
|
206
|
+
["first", 3],
|
|
207
|
+
["second", 6],
|
|
208
|
+
]);
|
|
209
|
+
expect(harness.onPreviewMoveElements).toHaveBeenLastCalledWith([
|
|
210
|
+
{ element: first, start: 3 },
|
|
211
|
+
{ element: second, start: 6 },
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
await harness.dropPointer();
|
|
215
|
+
|
|
216
|
+
expect(harness.onMoveElement).not.toHaveBeenCalled();
|
|
217
|
+
expect(harness.onMoveElements).toHaveBeenCalledTimes(1);
|
|
218
|
+
expect(harness.onMoveElements).toHaveBeenCalledWith([
|
|
219
|
+
{ element: first, start: 3 },
|
|
220
|
+
{ element: second, start: 6 },
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
harness.unmount();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("clamps a selected group move when the earliest member reaches zero", async () => {
|
|
227
|
+
const early = timelineElement({ id: "early", track: 0, zIndex: 1, start: 1, duration: 2 });
|
|
228
|
+
const grabbed = timelineElement({ id: "grabbed", track: 1, zIndex: 1, start: 4, duration: 2 });
|
|
229
|
+
const harness = renderDragHarness([early, grabbed]);
|
|
230
|
+
act(() => {
|
|
231
|
+
usePlayerStore.getState().setSelection(["early", "grabbed"], "grabbed");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
harness.startDrag(grabbed, 1);
|
|
235
|
+
harness.movePointer(-300, 0);
|
|
236
|
+
await harness.dropPointer();
|
|
237
|
+
|
|
238
|
+
expect(harness.onMoveElements).toHaveBeenCalledWith([
|
|
239
|
+
{ element: early, start: 0 },
|
|
240
|
+
{ element: grabbed, start: 3 },
|
|
241
|
+
]);
|
|
242
|
+
expect(harness.onMoveElement).not.toHaveBeenCalled();
|
|
243
|
+
|
|
244
|
+
harness.unmount();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("keeps body drag single-clip when the grabbed clip is not in the multi-selection", async () => {
|
|
248
|
+
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
|
|
249
|
+
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 4, duration: 2 });
|
|
250
|
+
const outside = timelineElement({ id: "outside", track: 2, zIndex: 1, start: 7, duration: 2 });
|
|
251
|
+
const harness = renderDragHarness([first, second, outside]);
|
|
252
|
+
act(() => {
|
|
253
|
+
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
harness.startDrag(outside, 2);
|
|
257
|
+
harness.movePointer(200, 0);
|
|
258
|
+
await harness.dropPointer();
|
|
259
|
+
|
|
260
|
+
expect(harness.onMoveElements).not.toHaveBeenCalled();
|
|
261
|
+
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
|
|
262
|
+
expect(harness.onMoveElement).toHaveBeenCalledWith(
|
|
263
|
+
outside,
|
|
264
|
+
expect.objectContaining({ start: 9 }),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
harness.unmount();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("does not form a group when a selected member is locked (grabbed clip moves alone)", async () => {
|
|
271
|
+
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
|
|
272
|
+
const locked = timelineElement({
|
|
273
|
+
id: "locked",
|
|
274
|
+
track: 1,
|
|
275
|
+
zIndex: 1,
|
|
276
|
+
start: 4,
|
|
277
|
+
duration: 2,
|
|
278
|
+
timelineLocked: true,
|
|
279
|
+
});
|
|
280
|
+
const harness = renderDragHarness([first, locked]);
|
|
281
|
+
act(() => {
|
|
282
|
+
usePlayerStore.getState().setSelection(["first", "locked"], "first");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
harness.startDrag(first, 0);
|
|
286
|
+
harness.movePointer(200, 0);
|
|
287
|
+
await harness.dropPointer();
|
|
288
|
+
|
|
289
|
+
// The locked member forbids the op, so no group forms: the grabbed clip moves
|
|
290
|
+
// alone (single-clip path) and the locked clip is never touched.
|
|
291
|
+
expect(harness.onMoveElements).not.toHaveBeenCalled();
|
|
292
|
+
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(harness.onMoveElement).toHaveBeenCalledWith(
|
|
294
|
+
first,
|
|
295
|
+
expect.objectContaining({ start: 3 }),
|
|
296
|
+
);
|
|
297
|
+
expect(harness.storeElements().find((el) => el.id === "locked")?.start).toBe(4);
|
|
298
|
+
|
|
299
|
+
harness.unmount();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("allows right-edge resize past the current composition duration", async () => {
|
|
303
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 1, start: 6, duration: 2 });
|
|
304
|
+
const harness = renderDragHarness([clip]);
|
|
305
|
+
|
|
306
|
+
harness.startResize(clip, "end");
|
|
307
|
+
harness.movePointer(400, 0);
|
|
308
|
+
await harness.dropPointer();
|
|
309
|
+
|
|
310
|
+
expect(harness.onResizeElement).toHaveBeenCalledWith(
|
|
311
|
+
clip,
|
|
312
|
+
expect.objectContaining({ start: 6, duration: 6 }),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
harness.unmount();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("resizes every selected start edge by the same delta", async () => {
|
|
319
|
+
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 4 });
|
|
320
|
+
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 5, duration: 3 });
|
|
321
|
+
const harness = renderDragHarness([first, second]);
|
|
322
|
+
act(() => {
|
|
323
|
+
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
harness.startResize(first, "start");
|
|
327
|
+
harness.movePointer(100, 0);
|
|
328
|
+
|
|
329
|
+
expect(
|
|
330
|
+
harness.storeElements().map((element) => [element.id, element.start, element.duration]),
|
|
331
|
+
).toEqual([
|
|
332
|
+
["first", 2, 3],
|
|
333
|
+
["second", 6, 2],
|
|
334
|
+
]);
|
|
335
|
+
expect(harness.onPreviewResizeElements).toHaveBeenLastCalledWith([
|
|
336
|
+
{ element: first, start: 2, duration: 3, playbackStart: undefined },
|
|
337
|
+
{ element: second, start: 6, duration: 2, playbackStart: undefined },
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
await harness.dropPointer();
|
|
341
|
+
|
|
342
|
+
expect(harness.onResizeElement).not.toHaveBeenCalled();
|
|
343
|
+
expect(harness.onResizeElements).toHaveBeenCalledTimes(1);
|
|
344
|
+
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
|
345
|
+
{ element: first, start: 2, duration: 3, playbackStart: undefined },
|
|
346
|
+
{ element: second, start: 6, duration: 2, playbackStart: undefined },
|
|
347
|
+
]);
|
|
348
|
+
|
|
349
|
+
harness.unmount();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("resizes every selected end edge by the same delta", async () => {
|
|
353
|
+
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 4 });
|
|
354
|
+
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 5, duration: 3 });
|
|
355
|
+
const harness = renderDragHarness([first, second]);
|
|
356
|
+
act(() => {
|
|
357
|
+
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
harness.startResize(first, "end");
|
|
361
|
+
harness.movePointer(100, 0);
|
|
362
|
+
await harness.dropPointer();
|
|
363
|
+
|
|
364
|
+
expect(harness.onResizeElement).not.toHaveBeenCalled();
|
|
365
|
+
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
|
366
|
+
{ element: first, start: 1, duration: 5, playbackStart: undefined },
|
|
367
|
+
{ element: second, start: 5, duration: 4, playbackStart: undefined },
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
harness.unmount();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("clamps selected start-edge resize at the most constrained duration", async () => {
|
|
374
|
+
const short = timelineElement({ id: "short", track: 0, zIndex: 1, start: 1, duration: 0.5 });
|
|
375
|
+
const long = timelineElement({ id: "long", track: 1, zIndex: 1, start: 4, duration: 2 });
|
|
376
|
+
const harness = renderDragHarness([short, long]);
|
|
377
|
+
act(() => {
|
|
378
|
+
usePlayerStore.getState().setSelection(["short", "long"], "short");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
harness.startResize(short, "start");
|
|
382
|
+
harness.movePointer(100, 0);
|
|
383
|
+
await harness.dropPointer();
|
|
384
|
+
|
|
385
|
+
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
|
386
|
+
{ element: short, start: 1.4, duration: 0.1, playbackStart: undefined },
|
|
387
|
+
{ element: long, start: 4.4, duration: 1.6, playbackStart: undefined },
|
|
388
|
+
]);
|
|
389
|
+
expect(harness.onResizeElement).not.toHaveBeenCalled();
|
|
390
|
+
|
|
391
|
+
harness.unmount();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("adjusts every selected media playback start during start-edge resize", async () => {
|
|
395
|
+
const audio = timelineElement({
|
|
396
|
+
id: "audio",
|
|
397
|
+
tag: "audio",
|
|
398
|
+
track: 0,
|
|
399
|
+
zIndex: 1,
|
|
400
|
+
start: 2,
|
|
401
|
+
duration: 3,
|
|
402
|
+
playbackStart: 1,
|
|
403
|
+
playbackRate: 1,
|
|
404
|
+
});
|
|
405
|
+
const video = timelineElement({
|
|
406
|
+
id: "video",
|
|
407
|
+
tag: "video",
|
|
408
|
+
track: 1,
|
|
409
|
+
zIndex: 1,
|
|
410
|
+
start: 5,
|
|
411
|
+
duration: 4,
|
|
412
|
+
playbackStart: 2,
|
|
413
|
+
playbackRate: 2,
|
|
414
|
+
});
|
|
415
|
+
const harness = renderDragHarness([audio, video]);
|
|
416
|
+
act(() => {
|
|
417
|
+
usePlayerStore.getState().setSelection(["audio", "video"], "audio");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
harness.startResize(audio, "start");
|
|
421
|
+
harness.movePointer(50, 0);
|
|
422
|
+
await harness.dropPointer();
|
|
423
|
+
|
|
424
|
+
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
|
425
|
+
{ element: audio, start: 2.5, duration: 2.5, playbackStart: 1.5 },
|
|
426
|
+
{ element: video, start: 5.5, duration: 3.5, playbackStart: 3 },
|
|
427
|
+
]);
|
|
428
|
+
|
|
429
|
+
harness.unmount();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("keeps handle resize single-clip when the grabbed clip is not in the multi-selection", async () => {
|
|
433
|
+
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
|
|
434
|
+
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 4, duration: 2 });
|
|
435
|
+
const outside = timelineElement({ id: "outside", track: 2, zIndex: 1, start: 7, duration: 2 });
|
|
436
|
+
const harness = renderDragHarness([first, second, outside]);
|
|
437
|
+
act(() => {
|
|
438
|
+
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
harness.startResize(outside, "end");
|
|
442
|
+
harness.movePointer(100, 0);
|
|
443
|
+
await harness.dropPointer();
|
|
444
|
+
|
|
445
|
+
expect(harness.onResizeElement).toHaveBeenCalledTimes(1);
|
|
446
|
+
expect(harness.onResizeElement).toHaveBeenCalledWith(
|
|
447
|
+
outside,
|
|
448
|
+
expect.objectContaining({ start: 7, duration: 3 }),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
harness.unmount();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("passes a new-lane stacking intent when a vertical drag targets an overlapping lane", async () => {
|
|
455
|
+
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
|
|
456
|
+
const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 });
|
|
457
|
+
const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
|
|
458
|
+
const harness = renderDragHarness([front, middle, back]);
|
|
459
|
+
|
|
460
|
+
harness.startDrag(back, 2);
|
|
461
|
+
harness.movePointer(0, -2 * TRACK_H);
|
|
462
|
+
await harness.dropPointer();
|
|
463
|
+
|
|
464
|
+
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
|
|
465
|
+
expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({
|
|
466
|
+
start: 0,
|
|
467
|
+
track: 2,
|
|
468
|
+
stackingReorder: {
|
|
469
|
+
contextKey: "root",
|
|
470
|
+
placement: { type: "above", layerId: harness.layers[0]!.id },
|
|
471
|
+
zIndexChanges: [{ key: "back", zIndex: 4 }],
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
harness.unmount();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("resolves lane stacking from the authored time span, independent of horizontal drag", async () => {
|
|
479
|
+
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
|
|
480
|
+
const back = timelineElement({ id: "back", track: 1, zIndex: 1 });
|
|
481
|
+
back.start = 0;
|
|
482
|
+
front.start = 0;
|
|
483
|
+
const harness = renderDragHarness([front, back]);
|
|
484
|
+
|
|
485
|
+
// Drag up one row AND rightward in time. The horizontal drift moves the
|
|
486
|
+
// clip out of overlap, but the two axes never fight: the vertical restack
|
|
487
|
+
// is resolved from the authored (overlapping) span, so it still inserts
|
|
488
|
+
// above the target lane rather than silently joining it.
|
|
489
|
+
harness.startDrag(back, 1);
|
|
490
|
+
harness.movePointer(200, -TRACK_H);
|
|
491
|
+
await harness.dropPointer();
|
|
492
|
+
|
|
493
|
+
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
|
|
494
|
+
expect(harness.onMoveElement.mock.calls[0]![1]).toMatchObject({
|
|
495
|
+
start: 2,
|
|
496
|
+
track: 1,
|
|
497
|
+
stackingReorder: {
|
|
498
|
+
contextKey: "root",
|
|
499
|
+
placement: { type: "above", layerId: harness.layers[0]!.id },
|
|
500
|
+
zIndexChanges: [{ key: "back", zIndex: 4 }],
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
harness.unmount();
|
|
505
|
+
});
|
|
506
|
+
});
|