@hyperframes/studio 0.8.110 → 0.8.112
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-c6WR3RsF.js → hyperframes-player-CBpg41JM.js} +1 -1
- package/dist/assets/{index-BJpQzV6h.js → index-BFitquwi.js} +1 -1
- package/dist/assets/{index-Co6W8IVk.js → index-BKy_birQ.js} +194 -194
- package/dist/assets/{index-C0KfbPAo.js → index-CZfrSyU9.js} +1 -1
- package/dist/assets/{index-C7apTvpr.css → index-DbfKVTId.css} +1 -1
- package/dist/{chunk-PQFEBX32.js → chunk-BIUXP3PG.js} +2 -2
- package/dist/{chunk-6LUNLTHR.js → chunk-K47E2ZJA.js} +13 -8
- package/dist/chunk-K47E2ZJA.js.map +1 -0
- package/dist/{chunk-QUXZVHOJ.js → chunk-VWT7STY3.js} +2 -2
- package/dist/{domEditingLayers-JQPQGGT7.js → domEditingLayers-V67MS4WP.js} +3 -3
- package/dist/index.d.ts +81 -46
- package/dist/index.html +2 -2
- package/dist/index.js +7295 -6844
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +10 -8
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
- package/src/hooks/timelineEditingHelpers.ts +7 -0
- package/src/hooks/timelineMoveAdapter.test.ts +12 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -4
- package/src/hooks/useTimelineGroupEditing.test.tsx +77 -0
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +8 -0
- package/src/main.tsx +4 -0
- package/src/player/components/ClipBadges.tsx +5 -5
- package/src/player/components/Timeline.ghostLane.test.tsx +8 -61
- package/src/player/components/Timeline.trackPadding.test.tsx +11 -56
- package/src/player/components/TimelineCanvas.tsx +6 -2
- package/src/player/components/TimelineClip.filmstrip.test.tsx +156 -0
- package/src/player/components/TimelineClip.test.tsx +7 -7
- package/src/player/components/TimelineClip.tsx +23 -7
- package/src/player/components/TimelineClipFades.tsx +41 -43
- package/src/player/components/TimelineGestureOverlay.test.tsx +41 -0
- package/src/player/components/TimelineGestureOverlay.tsx +59 -21
- package/src/player/components/TimelineLanes.tsx +18 -16
- package/src/player/components/timelineCallbacks.ts +6 -4
- package/src/player/components/timelineClipChildren.tsx +7 -13
- package/src/player/components/timelineClipDragCommit.test.ts +51 -0
- package/src/player/components/timelineClipDragCommit.ts +63 -21
- package/src/player/components/timelineClipDragGestureLifecycle.ts +18 -2
- package/src/player/components/timelineClipDragPreview.test.ts +47 -31
- package/src/player/components/timelineClipDragPreview.ts +81 -17
- package/src/player/components/timelineClipDragTypes.ts +3 -3
- package/src/player/components/timelineClipFadeGeometry.test.ts +29 -0
- package/src/player/components/timelineClipFadeGeometry.ts +74 -0
- package/src/player/components/timelineCollision.ts +51 -11
- package/src/player/components/timelineDragLanding.ts +20 -6
- package/src/player/components/timelineLaneProps.ts +3 -8
- package/src/player/components/timelineMotionStyles.test.ts +6 -13
- package/src/player/components/timelineMountFixtures.tsx +65 -0
- package/src/player/components/timelineOptimisticRevision.ts +15 -4
- package/src/player/components/timelineTheme.ts +2 -0
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackInsertLayout.test.ts +249 -0
- package/src/player/components/timelineTrackInsertLayout.ts +71 -0
- package/src/player/components/timelineZMirror.ts +5 -11
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +163 -0
- package/src/player/components/useTimelineClipDrag.ts +116 -12
- package/src/player/components/useTimelineDisplayLayout.test.tsx +59 -0
- package/src/player/components/useTimelineKeyboardActor.ts +86 -7
- package/src/player/components/useTimelineProviderState.tsx +3 -1
- package/src/player/components/useTimelineRowVirtualization.test.tsx +56 -0
- package/src/player/components/useTimelineRowVirtualization.ts +3 -1
- package/src/player/components/useTimelineTrackDerivations.test.ts +14 -1
- package/src/player/components/useTimelineTrackDerivations.ts +32 -18
- package/src/player/components/useTimelineTrackLayout.ts +43 -14
- package/src/player/hooks/timelinePlayerSync.test.ts +7 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/lib/automationStoreSync.test.ts +15 -0
- package/src/player/lib/automationStoreSync.ts +13 -5
- package/src/player/lib/storedVolume.ts +7 -0
- package/src/player/lib/thumbnailPageLifecycle.test.ts +123 -0
- package/src/player/lib/thumbnailPageLifecycle.ts +27 -0
- package/src/player/lib/thumbnailScheduler.ts +20 -2
- package/src/player/lib/timelineElementHelpers.ts +2 -8
- package/src/styles/components.css +71 -19
- package/src/styles/theme.css +4 -1
- package/src/utils/studioPendingEdits.test.ts +18 -0
- package/dist/chunk-6LUNLTHR.js.map +0 -1
- /package/dist/{chunk-PQFEBX32.js.map → chunk-BIUXP3PG.js.map} +0 -0
- /package/dist/{chunk-QUXZVHOJ.js.map → chunk-VWT7STY3.js.map} +0 -0
- /package/dist/{domEditingLayers-JQPQGGT7.js.map → domEditingLayers-V67MS4WP.js.map} +0 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { expect, it, vi } from "vitest";
|
|
2
|
+
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
|
3
|
+
import { computeDragPreview } from "./timelineClipDragPreview";
|
|
4
|
+
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
|
5
|
+
import { timelineAudioRow, resolveZoneDropPlacement } from "./timelineCollision";
|
|
6
|
+
import { createTimelineRowGeometry } from "./timelineLayout";
|
|
7
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
8
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
9
|
+
|
|
10
|
+
it.each([
|
|
11
|
+
{ order: [0, 0.5, 1, 3, 2, 4], seam: 4 },
|
|
12
|
+
{ order: [0, 0.5, 2, 4], seam: 2 },
|
|
13
|
+
])(
|
|
14
|
+
"inserts below an expanded or collapsed group block in displayed order ($seam)",
|
|
15
|
+
async ({ order, seam }) => {
|
|
16
|
+
const layout = buildTimelineTrackInsertLayout(order, [
|
|
17
|
+
{ anchorKey: 0.5, memberTracks: [1, 3] },
|
|
18
|
+
]);
|
|
19
|
+
const elements: TimelineElement[] = [0, 1, 2, 3, 4].map((track) => ({
|
|
20
|
+
id: `clip-${track}`,
|
|
21
|
+
domId: `clip-${track}`,
|
|
22
|
+
tag: "audio",
|
|
23
|
+
start: 0,
|
|
24
|
+
duration: 1,
|
|
25
|
+
track,
|
|
26
|
+
audioGroup: track === 1 || track === 3 ? "group" : undefined,
|
|
27
|
+
}));
|
|
28
|
+
elements.push({ id: "tail", domId: "tail", tag: "audio", start: 3, duration: 1, track: 4 });
|
|
29
|
+
const hero = elements[4];
|
|
30
|
+
const drag: DraggedClipState = {
|
|
31
|
+
pointerId: 1,
|
|
32
|
+
element: hero,
|
|
33
|
+
originClientX: 100,
|
|
34
|
+
originClientY: 48 + order.indexOf(hero.track) * 48,
|
|
35
|
+
originScrollLeft: 0,
|
|
36
|
+
originScrollTop: 0,
|
|
37
|
+
pointerClientX: 100,
|
|
38
|
+
pointerClientY: 48 + order.indexOf(hero.track) * 48,
|
|
39
|
+
pointerOffsetX: 0,
|
|
40
|
+
pointerOffsetY: 0,
|
|
41
|
+
previewStart: 0,
|
|
42
|
+
previewTrack: 4,
|
|
43
|
+
desiredTrack: 4,
|
|
44
|
+
insertRow: null,
|
|
45
|
+
snapTime: null,
|
|
46
|
+
snapType: null,
|
|
47
|
+
started: true,
|
|
48
|
+
};
|
|
49
|
+
const geometry = createTimelineRowGeometry(
|
|
50
|
+
order,
|
|
51
|
+
order.map(() => 48),
|
|
52
|
+
{ top: 0 },
|
|
53
|
+
);
|
|
54
|
+
const context = {
|
|
55
|
+
scroll: null,
|
|
56
|
+
pps: 100,
|
|
57
|
+
duration: 10,
|
|
58
|
+
trackOrder: order,
|
|
59
|
+
rowGeometry: geometry,
|
|
60
|
+
elements,
|
|
61
|
+
selectedKeys: new Set<string>(),
|
|
62
|
+
buildSnapTargets: () => [],
|
|
63
|
+
allowedInsertRows: layout.allowedRows,
|
|
64
|
+
groupTracks: layout.groupTracks,
|
|
65
|
+
};
|
|
66
|
+
const ontoHeader = computeDragPreview(drag, 100, geometry.getRowTop(1) + 24, context);
|
|
67
|
+
expect(ontoHeader.insertRow).toBeNull();
|
|
68
|
+
expect(ontoHeader.previewTrack).toBe(order.length === 6 ? 1 : 4);
|
|
69
|
+
if (order.length === 6) {
|
|
70
|
+
expect(layout.allowedRows.has(2)).toBe(false);
|
|
71
|
+
expect(layout.allowedRows.has(3)).toBe(false);
|
|
72
|
+
const ontoMember = computeDragPreview(drag, 100, 130, context);
|
|
73
|
+
expect(ontoMember.insertRow).toBeNull();
|
|
74
|
+
expect(ontoMember.previewTrack).toBe(1);
|
|
75
|
+
}
|
|
76
|
+
const preview = computeDragPreview(drag, 100, geometry.getRowTop(seam), context);
|
|
77
|
+
expect(preview.insertRow).toBe(seam);
|
|
78
|
+
const moved = new Map(elements.map((element) => [element.id, element]));
|
|
79
|
+
const persist = vi.fn();
|
|
80
|
+
commitDraggedClipMove(preview, {
|
|
81
|
+
elements,
|
|
82
|
+
trackOrder: order,
|
|
83
|
+
trackInsertLayout: layout,
|
|
84
|
+
updateElement: (id, updates) => {
|
|
85
|
+
moved.set(id, { ...moved.get(id)!, ...updates });
|
|
86
|
+
},
|
|
87
|
+
onMoveElements: persist,
|
|
88
|
+
});
|
|
89
|
+
await Promise.resolve();
|
|
90
|
+
expect(persist).toHaveBeenCalledOnce();
|
|
91
|
+
expect(moved.get("clip-1")?.track).toBe(1);
|
|
92
|
+
expect(moved.get("clip-3")?.track).toBe(2);
|
|
93
|
+
expect(moved.get("clip-4")?.track).toBe(3);
|
|
94
|
+
expect(moved.get("clip-2")?.track).toBe(4);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
it.each([{ order: [0, 0.5] }, { order: [0, 0.5, 2] }])(
|
|
99
|
+
"keeps a collapsed audio group at the zone boundary ($order)",
|
|
100
|
+
({ order }) => {
|
|
101
|
+
const layout = buildTimelineTrackInsertLayout(order, [
|
|
102
|
+
{ anchorKey: 0.5, memberTracks: [1, 3] },
|
|
103
|
+
]);
|
|
104
|
+
expect(timelineAudioRow(order, new Set([1, 3, 2]), layout.groupTracks)).toBe(1);
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
it.each([
|
|
109
|
+
{ insertRow: 0, target: 0 },
|
|
110
|
+
{ insertRow: null, target: 2 },
|
|
111
|
+
])(
|
|
112
|
+
"detaches only new-track drops, retaining member-track behavior ($insertRow)",
|
|
113
|
+
async ({ insertRow, target }) => {
|
|
114
|
+
const hero: TimelineElement = {
|
|
115
|
+
id: "hero",
|
|
116
|
+
domId: "hero",
|
|
117
|
+
tag: "audio",
|
|
118
|
+
start: 0,
|
|
119
|
+
duration: 1,
|
|
120
|
+
track: 1,
|
|
121
|
+
audioGroup: "voice",
|
|
122
|
+
};
|
|
123
|
+
const member: TimelineElement = {
|
|
124
|
+
id: "member",
|
|
125
|
+
domId: "member",
|
|
126
|
+
tag: "audio",
|
|
127
|
+
start: 2,
|
|
128
|
+
duration: 1,
|
|
129
|
+
track: 2,
|
|
130
|
+
audioGroup: "voice",
|
|
131
|
+
};
|
|
132
|
+
const elements = [hero, member];
|
|
133
|
+
const order = [0.5, 1, 2];
|
|
134
|
+
const layout = buildTimelineTrackInsertLayout(order, [
|
|
135
|
+
{ anchorKey: 0.5, memberTracks: [1, 2] },
|
|
136
|
+
]);
|
|
137
|
+
const moved = new Map(elements.map((element) => [element.id, element]));
|
|
138
|
+
const persist = vi.fn();
|
|
139
|
+
commitDraggedClipMove(
|
|
140
|
+
{
|
|
141
|
+
pointerId: null,
|
|
142
|
+
element: hero,
|
|
143
|
+
insertRow,
|
|
144
|
+
started: true,
|
|
145
|
+
originClientX: 0,
|
|
146
|
+
originClientY: 0,
|
|
147
|
+
originScrollLeft: 0,
|
|
148
|
+
originScrollTop: 0,
|
|
149
|
+
pointerClientX: 0,
|
|
150
|
+
pointerClientY: 0,
|
|
151
|
+
pointerOffsetX: 0,
|
|
152
|
+
pointerOffsetY: 0,
|
|
153
|
+
previewStart: 0,
|
|
154
|
+
previewTrack: target,
|
|
155
|
+
desiredTrack: target,
|
|
156
|
+
snapTime: null,
|
|
157
|
+
snapType: null,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
elements,
|
|
161
|
+
trackOrder: order,
|
|
162
|
+
trackInsertLayout: layout,
|
|
163
|
+
onMoveElements: persist,
|
|
164
|
+
updateElement: (id, updates) => {
|
|
165
|
+
moved.set(id, { ...moved.get(id)!, ...updates });
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
await Promise.resolve();
|
|
170
|
+
expect(moved.get("hero")?.audioGroup).toBe(insertRow === null ? "voice" : undefined);
|
|
171
|
+
expect(moved.get("hero")?.track).toBe(target);
|
|
172
|
+
expect(moved.get("member")?.audioGroup).toBe("voice");
|
|
173
|
+
const edit = persist.mock.calls[0][0].find(
|
|
174
|
+
(edit: { element: TimelineElement }) => edit.element.id === "hero",
|
|
175
|
+
);
|
|
176
|
+
expect(edit.updates.audioGroup).toBe(insertRow === null ? undefined : null);
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
it("creates the first visual lane above an all-audio timeline", () => {
|
|
181
|
+
expect(
|
|
182
|
+
resolveZoneDropPlacement({
|
|
183
|
+
order: [0, 1],
|
|
184
|
+
audioTracks: new Set([0, 1]),
|
|
185
|
+
elements: [],
|
|
186
|
+
desiredTrack: 0,
|
|
187
|
+
deliberateInsertRow: null,
|
|
188
|
+
start: 2,
|
|
189
|
+
duration: 1,
|
|
190
|
+
dragKey: "new-visual",
|
|
191
|
+
isAudio: false,
|
|
192
|
+
}),
|
|
193
|
+
).toEqual({ track: 0, insertRow: 0, start: 2 });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("keeps audible-video groups in the visual zone for header drops and edge inserts", () => {
|
|
197
|
+
const order = [0, 0.5, 1, 2];
|
|
198
|
+
const layout = buildTimelineTrackInsertLayout(order, [{ anchorKey: 0.5, memberTracks: [1] }]);
|
|
199
|
+
const input = {
|
|
200
|
+
order,
|
|
201
|
+
audioTracks: new Set<number>(),
|
|
202
|
+
groupTracks: layout.groupTracks,
|
|
203
|
+
allowedInsertRows: layout.allowedRows,
|
|
204
|
+
elements: [],
|
|
205
|
+
desiredTrack: 0.5,
|
|
206
|
+
deliberateInsertRow: null,
|
|
207
|
+
start: 2,
|
|
208
|
+
duration: 1,
|
|
209
|
+
dragKey: "video",
|
|
210
|
+
isAudio: false,
|
|
211
|
+
origin: { track: 2, start: 2 },
|
|
212
|
+
};
|
|
213
|
+
expect(timelineAudioRow(order, input.audioTracks, layout.groupTracks)).toBe(-1);
|
|
214
|
+
expect(resolveZoneDropPlacement(input)).toEqual({ track: 1, insertRow: null, start: 2 });
|
|
215
|
+
expect(resolveZoneDropPlacement({ ...input, deliberateInsertRow: 3 })).toEqual({
|
|
216
|
+
track: 0.5,
|
|
217
|
+
insertRow: 3,
|
|
218
|
+
start: 2,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it.each([
|
|
223
|
+
{ isAudio: false, target: 1 },
|
|
224
|
+
{ isAudio: true, target: 3 },
|
|
225
|
+
])(
|
|
226
|
+
"maps a mixed-kind group header to its first visible member of the clip kind ($isAudio)",
|
|
227
|
+
({ isAudio, target }) => {
|
|
228
|
+
const order = [0, 0.5, 1, 3, 2];
|
|
229
|
+
const layout = buildTimelineTrackInsertLayout(order, [
|
|
230
|
+
{ anchorKey: 0.5, memberTracks: [1, 3] },
|
|
231
|
+
]);
|
|
232
|
+
expect(
|
|
233
|
+
resolveZoneDropPlacement({
|
|
234
|
+
order,
|
|
235
|
+
audioTracks: new Set([3]),
|
|
236
|
+
groupTracks: layout.groupTracks,
|
|
237
|
+
allowedInsertRows: layout.allowedRows,
|
|
238
|
+
elements: [],
|
|
239
|
+
desiredTrack: 0.5,
|
|
240
|
+
deliberateInsertRow: null,
|
|
241
|
+
start: 2,
|
|
242
|
+
duration: 1,
|
|
243
|
+
dragKey: "clip",
|
|
244
|
+
isAudio,
|
|
245
|
+
origin: { track: 2, start: 2 },
|
|
246
|
+
}),
|
|
247
|
+
).toEqual({ track: target, insertRow: null, start: 2 });
|
|
248
|
+
},
|
|
249
|
+
);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
2
|
+
import { timelineAudioRow, isInsertAllowedForZone } from "./timelineCollision";
|
|
3
|
+
|
|
4
|
+
export function timelineTrackOrderChanged(
|
|
5
|
+
previous: readonly number[],
|
|
6
|
+
current: readonly number[],
|
|
7
|
+
): boolean {
|
|
8
|
+
return current.length !== previous.length || current.some((key, row) => key !== previous[row]);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TimelineTrackInsertLayout {
|
|
12
|
+
allowedRows: ReadonlySet<number>;
|
|
13
|
+
trackOrder: number[];
|
|
14
|
+
topologyRows: number[];
|
|
15
|
+
groupTracks: ReadonlyMap<number, readonly number[]>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function keyboardPickupInsertRow(
|
|
19
|
+
row: number,
|
|
20
|
+
allowed: ReadonlySet<number> | undefined,
|
|
21
|
+
): number {
|
|
22
|
+
while (row > 0 && allowed && !allowed.has(row)) row--;
|
|
23
|
+
return row;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function nextKeyboardInsertRow(input: {
|
|
27
|
+
current: number;
|
|
28
|
+
step: -1 | 1;
|
|
29
|
+
order: number[];
|
|
30
|
+
layout: TimelineTrackInsertLayout | undefined;
|
|
31
|
+
audioTracks: ReadonlySet<number>;
|
|
32
|
+
isAudio: boolean;
|
|
33
|
+
}): number | null {
|
|
34
|
+
const { current, step, order, layout, audioTracks, isAudio } = input;
|
|
35
|
+
let row = Math.max(0, Math.min(order.length, current + step));
|
|
36
|
+
const allowed = layout?.allowedRows;
|
|
37
|
+
while (allowed?.has(row) === false) {
|
|
38
|
+
row += step;
|
|
39
|
+
if (row < 0 || row > order.length) return null;
|
|
40
|
+
}
|
|
41
|
+
const audioRow = timelineAudioRow(order, audioTracks, layout?.groupTracks);
|
|
42
|
+
return isInsertAllowedForZone(row, audioRow, isAudio, allowed) ? row : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Groups own one contiguous insertion block, including their collapsed members. */
|
|
46
|
+
export function buildTimelineTrackInsertLayout(
|
|
47
|
+
order: number[],
|
|
48
|
+
groups: readonly Pick<TimelineTrackGroupInfo, "anchorKey" | "memberTracks">[],
|
|
49
|
+
): TimelineTrackInsertLayout {
|
|
50
|
+
const allowedRows = new Set(Array.from({ length: order.length + 1 }, (_, row) => row));
|
|
51
|
+
const anchors = new Map(groups.map((group) => [group.anchorKey, group.memberTracks]));
|
|
52
|
+
const positions = new Map(order.map((key, row) => [key, row]));
|
|
53
|
+
for (const group of groups) {
|
|
54
|
+
const anchor = positions.get(group.anchorKey);
|
|
55
|
+
if (anchor === undefined) continue;
|
|
56
|
+
const end = Math.max(anchor, ...group.memberTracks.map((key) => positions.get(key) ?? anchor));
|
|
57
|
+
for (let row = anchor + 1; row <= end; row++) allowedRows.delete(row);
|
|
58
|
+
}
|
|
59
|
+
const tracks = new Set<number>();
|
|
60
|
+
const topologyRows = [0];
|
|
61
|
+
for (const key of order) {
|
|
62
|
+
for (const track of anchors.get(key) ?? [key]) tracks.add(track);
|
|
63
|
+
topologyRows.push(tracks.size);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
allowedRows,
|
|
67
|
+
trackOrder: [...tracks],
|
|
68
|
+
topologyRows,
|
|
69
|
+
groupTracks: anchors,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -3,6 +3,7 @@ import { classifyZone } from "./timelineZones";
|
|
|
3
3
|
import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
|
|
4
4
|
import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
|
|
5
5
|
import { samePaintScope } from "./timelineStackingSync";
|
|
6
|
+
import { timelineTrackOrder } from "./timelineTrackDisplay";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Mirror a canvas z-order action (Bring to Front / Bring Forward / Send Backward /
|
|
@@ -111,13 +112,6 @@ function expandedChildAllowedLanes(
|
|
|
111
112
|
);
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
/** Ascending unique display lanes of `elements` — identical to how Timeline.tsx
|
|
115
|
-
* builds `trackOrder`, so `insertRow` indexes the same boundary space. Exported
|
|
116
|
-
* so the mirror wiring can hand commitZMirrorLaneMove the matching trackOrder. */
|
|
117
|
-
export function displayTrackOrder(elements: TimelineElement[]): number[] {
|
|
118
|
-
return [...new Set(elements.map((el) => el.track))].sort((a, b) => a - b);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
115
|
/**
|
|
122
116
|
* Resolve the timeline lane move that mirrors a z-order action on `element`.
|
|
123
117
|
* Returns null when no timeline mirror applies: audio / zero-length clips, no
|
|
@@ -146,8 +140,8 @@ export function resolveZMirrorLaneMove(input: ZMirrorInput): ZMirrorLaneMove {
|
|
|
146
140
|
const referenceLane = resolveReferenceLane(input, overlapSet, up);
|
|
147
141
|
if (referenceLane == null) return null;
|
|
148
142
|
|
|
149
|
-
const order =
|
|
150
|
-
const visualLanes =
|
|
143
|
+
const order = timelineTrackOrder(elements);
|
|
144
|
+
const visualLanes = timelineTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
|
|
151
145
|
const refIdx = visualLanes.indexOf(referenceLane);
|
|
152
146
|
if (refIdx === -1) return null; // reference is not a visual lane — no mirror
|
|
153
147
|
|
|
@@ -251,8 +245,8 @@ export function resolveRepositionLaneMove(input: ZRepositionInput): ZMirrorLaneM
|
|
|
251
245
|
}
|
|
252
246
|
if (aboveLane == null && belowLane == null) return null;
|
|
253
247
|
|
|
254
|
-
const order =
|
|
255
|
-
const visualLanes =
|
|
248
|
+
const order = timelineTrackOrder(elements);
|
|
249
|
+
const visualLanes = timelineTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
|
|
256
250
|
const allowedLanes = expandedChildAllowedLanes(element, elements);
|
|
257
251
|
const args = {
|
|
258
252
|
elements,
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
6
|
+
import { createTimelineRowGeometry } from "./timelineLayout";
|
|
7
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
|
+
import { configureTimelineTestViewport } from "./timelineTestViewport";
|
|
9
|
+
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
|
10
|
+
|
|
11
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
usePlayerStore.getState().reset();
|
|
14
|
+
document.body.replaceChildren();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function mount(order = [0, 1, 2]) {
|
|
18
|
+
const elements: TimelineElement[] = [
|
|
19
|
+
{ id: "top", tag: "div", start: 0, duration: 3, track: 0 },
|
|
20
|
+
{ id: "hero", domId: "hero", tag: "div", start: 2, duration: 3, track: 1 },
|
|
21
|
+
{ id: "tail", tag: "div", start: 6, duration: 3, track: 1 },
|
|
22
|
+
{ id: "bottom", tag: "div", start: 0, duration: 3, track: 2 },
|
|
23
|
+
];
|
|
24
|
+
const groups = order.includes(0.5) ? [{ anchorKey: 0.5, memberTracks: [1, 3] }] : [];
|
|
25
|
+
if (groups.length) {
|
|
26
|
+
for (const element of elements) element.tag = "audio";
|
|
27
|
+
elements[1].audioGroup = "voice";
|
|
28
|
+
elements[2].audioGroup = "voice";
|
|
29
|
+
elements.push({
|
|
30
|
+
id: "member",
|
|
31
|
+
tag: "audio",
|
|
32
|
+
start: 0,
|
|
33
|
+
duration: 2,
|
|
34
|
+
track: 3,
|
|
35
|
+
audioGroup: "voice",
|
|
36
|
+
});
|
|
37
|
+
elements.push({ id: "extra", tag: "audio", start: 0, duration: 2, track: 4 });
|
|
38
|
+
}
|
|
39
|
+
for (const element of elements) element.domId = element.id;
|
|
40
|
+
usePlayerStore.getState().setElements(elements);
|
|
41
|
+
const scroll = document.createElement("div");
|
|
42
|
+
configureTimelineTestViewport(scroll, 400);
|
|
43
|
+
const clip = document.createElement("div");
|
|
44
|
+
clip.dataset.elId = "hero";
|
|
45
|
+
clip.dataset.timelineFocusId = "clip-hero";
|
|
46
|
+
clip.tabIndex = 0;
|
|
47
|
+
scroll.append(clip);
|
|
48
|
+
document.body.append(scroll);
|
|
49
|
+
const host = document.createElement("div");
|
|
50
|
+
const root = createRoot(host);
|
|
51
|
+
const onMoveElements = vi.fn();
|
|
52
|
+
const trackOrderRef = { current: order };
|
|
53
|
+
const trackInsertLayoutRef = { current: buildTimelineTrackInsertLayout(order, groups) };
|
|
54
|
+
let state: ReturnType<typeof useTimelineClipDrag>;
|
|
55
|
+
function Probe() {
|
|
56
|
+
state = useTimelineClipDrag({
|
|
57
|
+
scrollRef: { current: scroll },
|
|
58
|
+
ppsRef: { current: 100 },
|
|
59
|
+
durationRef: { current: 12 },
|
|
60
|
+
trackOrderRef,
|
|
61
|
+
trackInsertLayoutRef,
|
|
62
|
+
rowGeometryRef: {
|
|
63
|
+
current: createTimelineRowGeometry(
|
|
64
|
+
trackOrderRef.current,
|
|
65
|
+
trackOrderRef.current.map(() => 48),
|
|
66
|
+
{ top: 0 },
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
onMoveElement: vi.fn(),
|
|
70
|
+
onMoveElements,
|
|
71
|
+
setShowPopover: () => {},
|
|
72
|
+
setRangeSelectionRef: { current: null },
|
|
73
|
+
});
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
act(() => root.render(<Probe />));
|
|
77
|
+
clip.focus();
|
|
78
|
+
const key = (key: string) =>
|
|
79
|
+
act(() => {
|
|
80
|
+
clip.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
key,
|
|
84
|
+
clip,
|
|
85
|
+
onMoveElements,
|
|
86
|
+
root,
|
|
87
|
+
elements,
|
|
88
|
+
changeOrder(next: number[]) {
|
|
89
|
+
trackOrderRef.current = next;
|
|
90
|
+
trackInsertLayoutRef.current = buildTimelineTrackInsertLayout(next, groups);
|
|
91
|
+
act(() => root.render(<Probe />));
|
|
92
|
+
},
|
|
93
|
+
get drag() {
|
|
94
|
+
return state.draggedClip;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function expectCanceledPickup(view: ReturnType<typeof mount>) {
|
|
100
|
+
expect(view.drag).toBeNull();
|
|
101
|
+
expect(view.onMoveElements).not.toHaveBeenCalled();
|
|
102
|
+
expect(usePlayerStore.getState().elements).toEqual(view.elements);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it("cancels pickup when collapsing a group changes the displayed seam", () => {
|
|
106
|
+
const view = mount([0, 0.5, 1, 3, 2, 4]);
|
|
107
|
+
view.key(" ");
|
|
108
|
+
view.key("ArrowDown");
|
|
109
|
+
expect(view.drag?.insertRow).toBe(4);
|
|
110
|
+
view.changeOrder([0, 0.5, 2, 4]);
|
|
111
|
+
expect(view.drag).toBeNull();
|
|
112
|
+
view.key("Enter");
|
|
113
|
+
expectCanceledPickup(view);
|
|
114
|
+
act(() => view.root.unmount());
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("picks up with Space, chooses a seam, then commits at that index once with Enter", async () => {
|
|
118
|
+
const view = mount();
|
|
119
|
+
view.key(" ");
|
|
120
|
+
expect(view.drag).toMatchObject({
|
|
121
|
+
pointerId: null,
|
|
122
|
+
started: true,
|
|
123
|
+
insertRow: 1,
|
|
124
|
+
previewStart: 2,
|
|
125
|
+
});
|
|
126
|
+
view.key("ArrowUp");
|
|
127
|
+
expect(view.drag?.insertRow).toBe(0);
|
|
128
|
+
await act(async () => view.key("Enter"));
|
|
129
|
+
expect(view.drag).toBeNull();
|
|
130
|
+
expect(view.onMoveElements).toHaveBeenCalledTimes(1);
|
|
131
|
+
const moved = usePlayerStore.getState().elements;
|
|
132
|
+
expect(moved.find((e) => e.id === "hero")?.track).toBe(0);
|
|
133
|
+
expect(moved.find((e) => e.id === "top")?.track).toBe(1);
|
|
134
|
+
view.key("Enter");
|
|
135
|
+
expect(view.onMoveElements).toHaveBeenCalledTimes(1);
|
|
136
|
+
act(() => view.root.unmount());
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("Escape cancels the opened lane without moving a clip or adding a track", () => {
|
|
140
|
+
const view = mount();
|
|
141
|
+
view.key(" ");
|
|
142
|
+
view.key("ArrowDown");
|
|
143
|
+
expect(view.drag?.insertRow).toBe(2);
|
|
144
|
+
view.key("Escape");
|
|
145
|
+
expectCanceledPickup(view);
|
|
146
|
+
act(() => view.root.unmount());
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("leaves Space on a nested fade slider to the slider", () => {
|
|
150
|
+
const view = mount();
|
|
151
|
+
const slider = document.createElement("div");
|
|
152
|
+
slider.setAttribute("role", "slider");
|
|
153
|
+
slider.tabIndex = 0;
|
|
154
|
+
view.clip.append(slider);
|
|
155
|
+
slider.focus();
|
|
156
|
+
const event = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true });
|
|
157
|
+
act(() => {
|
|
158
|
+
slider.dispatchEvent(event);
|
|
159
|
+
});
|
|
160
|
+
expect(view.drag).toBeNull();
|
|
161
|
+
expect(event.defaultPrevented).toBe(false);
|
|
162
|
+
act(() => view.root.unmount());
|
|
163
|
+
});
|