@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.
Files changed (83) hide show
  1. package/dist/assets/{hyperframes-player-c6WR3RsF.js → hyperframes-player-CBpg41JM.js} +1 -1
  2. package/dist/assets/{index-BJpQzV6h.js → index-BFitquwi.js} +1 -1
  3. package/dist/assets/{index-Co6W8IVk.js → index-BKy_birQ.js} +194 -194
  4. package/dist/assets/{index-C0KfbPAo.js → index-CZfrSyU9.js} +1 -1
  5. package/dist/assets/{index-C7apTvpr.css → index-DbfKVTId.css} +1 -1
  6. package/dist/{chunk-PQFEBX32.js → chunk-BIUXP3PG.js} +2 -2
  7. package/dist/{chunk-6LUNLTHR.js → chunk-K47E2ZJA.js} +13 -8
  8. package/dist/chunk-K47E2ZJA.js.map +1 -0
  9. package/dist/{chunk-QUXZVHOJ.js → chunk-VWT7STY3.js} +2 -2
  10. package/dist/{domEditingLayers-JQPQGGT7.js → domEditingLayers-V67MS4WP.js} +3 -3
  11. package/dist/index.d.ts +81 -46
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +7295 -6844
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +10 -8
  18. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
  19. package/src/hooks/timelineEditingHelpers.ts +7 -0
  20. package/src/hooks/timelineMoveAdapter.test.ts +12 -0
  21. package/src/hooks/timelineMoveAdapter.ts +9 -4
  22. package/src/hooks/useTimelineGroupEditing.test.tsx +77 -0
  23. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  24. package/src/index.ts +8 -0
  25. package/src/main.tsx +4 -0
  26. package/src/player/components/ClipBadges.tsx +5 -5
  27. package/src/player/components/Timeline.ghostLane.test.tsx +8 -61
  28. package/src/player/components/Timeline.trackPadding.test.tsx +11 -56
  29. package/src/player/components/TimelineCanvas.tsx +6 -2
  30. package/src/player/components/TimelineClip.filmstrip.test.tsx +156 -0
  31. package/src/player/components/TimelineClip.test.tsx +7 -7
  32. package/src/player/components/TimelineClip.tsx +23 -7
  33. package/src/player/components/TimelineClipFades.tsx +41 -43
  34. package/src/player/components/TimelineGestureOverlay.test.tsx +41 -0
  35. package/src/player/components/TimelineGestureOverlay.tsx +59 -21
  36. package/src/player/components/TimelineLanes.tsx +18 -16
  37. package/src/player/components/timelineCallbacks.ts +6 -4
  38. package/src/player/components/timelineClipChildren.tsx +7 -13
  39. package/src/player/components/timelineClipDragCommit.test.ts +51 -0
  40. package/src/player/components/timelineClipDragCommit.ts +63 -21
  41. package/src/player/components/timelineClipDragGestureLifecycle.ts +18 -2
  42. package/src/player/components/timelineClipDragPreview.test.ts +47 -31
  43. package/src/player/components/timelineClipDragPreview.ts +81 -17
  44. package/src/player/components/timelineClipDragTypes.ts +3 -3
  45. package/src/player/components/timelineClipFadeGeometry.test.ts +29 -0
  46. package/src/player/components/timelineClipFadeGeometry.ts +74 -0
  47. package/src/player/components/timelineCollision.ts +51 -11
  48. package/src/player/components/timelineDragLanding.ts +20 -6
  49. package/src/player/components/timelineLaneProps.ts +3 -8
  50. package/src/player/components/timelineMotionStyles.test.ts +6 -13
  51. package/src/player/components/timelineMountFixtures.tsx +65 -0
  52. package/src/player/components/timelineOptimisticRevision.ts +15 -4
  53. package/src/player/components/timelineTheme.ts +2 -0
  54. package/src/player/components/timelineTrackDisplay.ts +1 -1
  55. package/src/player/components/timelineTrackInsertLayout.test.ts +249 -0
  56. package/src/player/components/timelineTrackInsertLayout.ts +71 -0
  57. package/src/player/components/timelineZMirror.ts +5 -11
  58. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +163 -0
  59. package/src/player/components/useTimelineClipDrag.ts +116 -12
  60. package/src/player/components/useTimelineDisplayLayout.test.tsx +59 -0
  61. package/src/player/components/useTimelineKeyboardActor.ts +86 -7
  62. package/src/player/components/useTimelineProviderState.tsx +3 -1
  63. package/src/player/components/useTimelineRowVirtualization.test.tsx +56 -0
  64. package/src/player/components/useTimelineRowVirtualization.ts +3 -1
  65. package/src/player/components/useTimelineTrackDerivations.test.ts +14 -1
  66. package/src/player/components/useTimelineTrackDerivations.ts +32 -18
  67. package/src/player/components/useTimelineTrackLayout.ts +43 -14
  68. package/src/player/hooks/timelinePlayerSync.test.ts +7 -0
  69. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  70. package/src/player/lib/automationStoreSync.test.ts +15 -0
  71. package/src/player/lib/automationStoreSync.ts +13 -5
  72. package/src/player/lib/storedVolume.ts +7 -0
  73. package/src/player/lib/thumbnailPageLifecycle.test.ts +123 -0
  74. package/src/player/lib/thumbnailPageLifecycle.ts +27 -0
  75. package/src/player/lib/thumbnailScheduler.ts +20 -2
  76. package/src/player/lib/timelineElementHelpers.ts +2 -8
  77. package/src/styles/components.css +71 -19
  78. package/src/styles/theme.css +4 -1
  79. package/src/utils/studioPendingEdits.test.ts +18 -0
  80. package/dist/chunk-6LUNLTHR.js.map +0 -1
  81. /package/dist/{chunk-PQFEBX32.js.map → chunk-BIUXP3PG.js.map} +0 -0
  82. /package/dist/{chunk-QUXZVHOJ.js.map → chunk-VWT7STY3.js.map} +0 -0
  83. /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 = displayTrackOrder(elements);
150
- const visualLanes = displayTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
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 = displayTrackOrder(elements);
255
- const visualLanes = displayTrackOrder(elements.filter((el) => classifyZone(el) === "visual"));
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
+ });