@hyperframes/studio 0.7.54 → 0.7.55

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 (35) hide show
  1. package/dist/assets/{index-pRhCpGPz.js → index-BRwkMj0w.js} +108 -108
  2. package/dist/assets/{index-uBY329wb.js → index-BXaqaVKt.js} +1 -1
  3. package/dist/assets/{index-CMHYjEZ5.js → index-CPetwHFV.js} +1 -1
  4. package/dist/index.html +1 -1
  5. package/dist/index.js +48 -3
  6. package/dist/index.js.map +1 -1
  7. package/package.json +7 -7
  8. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  9. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  10. package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
  11. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  12. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  13. package/src/hooks/useRazorSplit.history.test.tsx +173 -0
  14. package/src/player/components/timelineCollision.test.ts +437 -0
  15. package/src/player/components/timelineCollision.ts +263 -0
  16. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  17. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  18. package/src/player/components/timelineSnapping.test.ts +134 -0
  19. package/src/player/components/timelineSnapping.ts +110 -0
  20. package/src/player/components/timelineStackingSync.test.ts +244 -0
  21. package/src/player/components/timelineStackingSync.ts +331 -0
  22. package/src/player/components/timelineZones.test.ts +425 -0
  23. package/src/player/components/timelineZones.ts +198 -0
  24. package/src/player/components/timelineZoom.test.ts +67 -0
  25. package/src/player/components/timelineZoom.ts +51 -0
  26. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  27. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  28. package/src/utils/assetClickBehavior.test.ts +104 -0
  29. package/src/utils/assetClickBehavior.ts +75 -0
  30. package/src/utils/canvasNudgeGate.test.ts +31 -0
  31. package/src/utils/canvasNudgeGate.ts +32 -0
  32. package/src/utils/studioUiPreferences.test.ts +38 -0
  33. package/src/utils/studioUiPreferences.ts +27 -0
  34. package/src/utils/timelineInspector.test.ts +121 -0
  35. package/src/utils/timelineInspector.ts +32 -1
@@ -0,0 +1,437 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import {
4
+ clampTrackToZone,
5
+ isInsertAllowedForZone,
6
+ isLaneFree,
7
+ resolveInsertRow,
8
+ resolvePlacement,
9
+ resolveZoneDropPlacement,
10
+ timeRangesOverlap,
11
+ } from "./timelineCollision";
12
+
13
+ function el(id: string, track: number, start: number, duration: number): TimelineElement {
14
+ return { id, tag: "video", start, duration, track };
15
+ }
16
+
17
+ describe("timeRangesOverlap", () => {
18
+ it("detects overlap and treats touching edges as free (half-open)", () => {
19
+ expect(timeRangesOverlap(0, 2, 1, 3)).toBe(true);
20
+ expect(timeRangesOverlap(0, 2, 2, 4)).toBe(false); // touching at 2
21
+ expect(timeRangesOverlap(2, 4, 0, 2)).toBe(false);
22
+ });
23
+ });
24
+
25
+ describe("isLaneFree", () => {
26
+ const els = [el("a", 0, 0, 5), el("b", 1, 2, 3)];
27
+
28
+ it("is free when nothing overlaps on the track", () => {
29
+ expect(isLaneFree(els, 2, 0, 5, null)).toBe(true);
30
+ expect(isLaneFree(els, 0, 6, 8, null)).toBe(true); // same track, no time overlap
31
+ });
32
+
33
+ it("is occupied when a clip overlaps on the same track", () => {
34
+ expect(isLaneFree(els, 0, 1, 3, null)).toBe(false);
35
+ });
36
+
37
+ it("ignores the excluded (dragged) clip", () => {
38
+ expect(isLaneFree(els, 0, 1, 3, "a")).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe("resolvePlacement", () => {
43
+ const trackOrder = [0, 1, 2, 3];
44
+
45
+ it("keeps the desired lane when it is free", () => {
46
+ const els = [el("a", 2, 0, 4)];
47
+ expect(
48
+ resolvePlacement({
49
+ elements: els,
50
+ desiredTrack: 1,
51
+ start: 0,
52
+ duration: 4,
53
+ trackOrder,
54
+ excludeKey: null,
55
+ }),
56
+ ).toEqual({
57
+ track: 1,
58
+ needsInsert: false,
59
+ });
60
+ });
61
+
62
+ it("pushes up to the nearest free lane above when the target is occupied", () => {
63
+ // desired = 2 occupied; 1 free above → land on 1
64
+ const els = [el("blocker", 2, 0, 4)];
65
+ expect(
66
+ resolvePlacement({
67
+ elements: els,
68
+ desiredTrack: 2,
69
+ start: 1,
70
+ duration: 2,
71
+ trackOrder,
72
+ excludeKey: null,
73
+ }),
74
+ ).toEqual({ track: 1, needsInsert: false });
75
+ });
76
+
77
+ it("prefers up even when a lane below is also free", () => {
78
+ // desired 2 occupied; both 1 (up) and 3 (down) free → up wins
79
+ const els = [el("blocker", 2, 0, 5)];
80
+ expect(
81
+ resolvePlacement({
82
+ elements: els,
83
+ desiredTrack: 2,
84
+ start: 0,
85
+ duration: 3,
86
+ trackOrder,
87
+ excludeKey: null,
88
+ }),
89
+ ).toEqual({ track: 1, needsInsert: false });
90
+ });
91
+
92
+ it("falls to a lane below when every lane above is occupied", () => {
93
+ // desired 1 occupied; 0 occupied above; 2 free below → land on 2
94
+ const els = [el("x", 0, 0, 5), el("y", 1, 0, 5)];
95
+ expect(
96
+ resolvePlacement({
97
+ elements: els,
98
+ desiredTrack: 1,
99
+ start: 1,
100
+ duration: 2,
101
+ trackOrder,
102
+ excludeKey: null,
103
+ }),
104
+ ).toEqual({ track: 2, needsInsert: false });
105
+ });
106
+
107
+ it("signals needsInsert when no lane is free", () => {
108
+ const els = [el("a", 0, 0, 9), el("b", 1, 0, 9), el("c", 2, 0, 9), el("d", 3, 0, 9)];
109
+ expect(
110
+ resolvePlacement({
111
+ elements: els,
112
+ desiredTrack: 2,
113
+ start: 1,
114
+ duration: 2,
115
+ trackOrder,
116
+ excludeKey: null,
117
+ }),
118
+ ).toEqual({ track: 2, needsInsert: true });
119
+ });
120
+
121
+ it("signals needsInsert when the desired track is occupied but absent from the zone's lanes (#2195)", () => {
122
+ // desiredTrack 5 is occupied yet not in trackOrder (its kind-zone has no lane
123
+ // here). Landing on it would overlap, so the empty-zone branch must insert —
124
+ // not silently land on the occupied track (the placement hole).
125
+ const els = [el("blocker", 5, 0, 5)];
126
+ expect(
127
+ resolvePlacement({
128
+ elements: els,
129
+ desiredTrack: 5,
130
+ start: 1,
131
+ duration: 2,
132
+ trackOrder: [],
133
+ excludeKey: null,
134
+ }),
135
+ ).toEqual({ track: 5, needsInsert: true });
136
+ });
137
+
138
+ it("signals needsInsert when the desired track is FREE but absent from the zone's lanes (#2195 free-span hole)", () => {
139
+ // desiredTrack 5 is FREE (no overlap) yet not in trackOrder — its kind-zone has
140
+ // no lane here. The old code short-circuited on isLaneFree and landed on the
141
+ // foreign-zone lane; the zone check must win and signal an insert.
142
+ const els = [el("elsewhere", 9, 0, 5)];
143
+ expect(
144
+ resolvePlacement({
145
+ elements: els,
146
+ desiredTrack: 5,
147
+ start: 1,
148
+ duration: 2,
149
+ trackOrder: [],
150
+ excludeKey: null,
151
+ }),
152
+ ).toEqual({ track: 5, needsInsert: true });
153
+ });
154
+
155
+ it("placeholder-scenario excludes the dragged clip so it does not collide with itself", () => {
156
+ const els = [el("self", 1, 0, 5)];
157
+ expect(
158
+ resolvePlacement({
159
+ elements: els,
160
+ desiredTrack: 1,
161
+ start: 0,
162
+ duration: 5,
163
+ trackOrder,
164
+ excludeKey: "self",
165
+ }),
166
+ ).toEqual({ track: 1, needsInsert: false });
167
+ });
168
+ });
169
+
170
+ describe("resolveInsertRow", () => {
171
+ const n = 3; // three lanes: rows 0,1,2
172
+
173
+ it("targets the lane (null) when over its middle band", () => {
174
+ expect(resolveInsertRow(1.5, n, 0.22)).toBe(null); // dead center of lane 1
175
+ });
176
+
177
+ it("inserts at the top boundary of a lane when near its top edge", () => {
178
+ expect(resolveInsertRow(1.1, n, 0.22)).toBe(1); // just into lane 1 → boundary above it
179
+ });
180
+
181
+ it("inserts at the bottom boundary of a lane when near its bottom edge", () => {
182
+ expect(resolveInsertRow(1.9, n, 0.22)).toBe(2); // near bottom of lane 1 → boundary below
183
+ });
184
+
185
+ it("inserts above the top lane when the pointer is above everything", () => {
186
+ expect(resolveInsertRow(-0.5, n, 0.22)).toBe(0);
187
+ });
188
+
189
+ it("inserts below the bottom lane when the pointer is past the last lane", () => {
190
+ expect(resolveInsertRow(3.4, n, 0.22)).toBe(3);
191
+ });
192
+ });
193
+
194
+ // (The production insert-band suite imports INSERT_BOUNDARY_BAND from timelineLayout,
195
+ // which lands with the timeline glue swap — the full suite re-lands there.)
196
+ describe("clampTrackToZone", () => {
197
+ // trackOrder [0,1,2,3]: rows 0,1 = visual; rows 2,3 = audio (audioRow = 2).
198
+ const order = [0, 1, 2, 3];
199
+
200
+ it("is a no-op when there is no audio zone", () => {
201
+ expect(clampTrackToZone(3, order, -1, false)).toBe(3);
202
+ });
203
+
204
+ it("keeps a visual clip in the visual zone", () => {
205
+ expect(clampTrackToZone(1, order, 2, false)).toBe(1); // already visual
206
+ expect(clampTrackToZone(3, order, 2, false)).toBe(1); // in audio → last visual lane
207
+ });
208
+
209
+ it("keeps an audio clip in the audio zone", () => {
210
+ expect(clampTrackToZone(2, order, 2, true)).toBe(2); // already audio
211
+ expect(clampTrackToZone(0, order, 2, true)).toBe(2); // in visual → first audio lane
212
+ });
213
+ });
214
+
215
+ describe("isInsertAllowedForZone", () => {
216
+ // audioRow = 2
217
+ it("allows any insert when there is no audio zone", () => {
218
+ expect(isInsertAllowedForZone(0, -1, false)).toBe(true);
219
+ expect(isInsertAllowedForZone(3, -1, true)).toBe(true);
220
+ });
221
+
222
+ it("allows a visual insert only at/above the audio zone top", () => {
223
+ expect(isInsertAllowedForZone(0, 2, false)).toBe(true);
224
+ expect(isInsertAllowedForZone(2, 2, false)).toBe(true); // bottom of the visual zone
225
+ expect(isInsertAllowedForZone(3, 2, false)).toBe(false); // inside the audio zone
226
+ });
227
+
228
+ it("allows an audio insert only at/below the audio zone top (audio clips make audio tracks)", () => {
229
+ expect(isInsertAllowedForZone(2, 2, true)).toBe(true);
230
+ expect(isInsertAllowedForZone(4, 2, true)).toBe(true); // below the bottom
231
+ expect(isInsertAllowedForZone(1, 2, true)).toBe(false); // inside the visual zone
232
+ });
233
+ });
234
+
235
+ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overlap)", () => {
236
+ // order [0,1,2] visual + [3] audio. audioRow = 3.
237
+ const order = [0, 1, 2, 3];
238
+ const audioTracks = new Set([3]);
239
+ const base = {
240
+ order,
241
+ audioTracks,
242
+ deliberateInsertRow: null as number | null,
243
+ start: 2,
244
+ duration: 2,
245
+ dragKey: "x",
246
+ isAudio: false,
247
+ };
248
+
249
+ it("lands on the aimed track when it is free at that time", () => {
250
+ expect(
251
+ resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 10, 3)], desiredTrack: 1 }),
252
+ ).toEqual({ track: 1, insertRow: null });
253
+ });
254
+
255
+ it("relocates UP to the nearest free track when the aimed spot overlaps a clip", () => {
256
+ expect(
257
+ resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 0, 5)], desiredTrack: 1 }),
258
+ ).toEqual({ track: 0, insertRow: null });
259
+ });
260
+
261
+ it("relocates DOWN when the tracks above are also occupied", () => {
262
+ expect(
263
+ resolveZoneDropPlacement({
264
+ ...base,
265
+ elements: [el("a", 0, 0, 5), el("b", 1, 0, 5)],
266
+ desiredTrack: 1,
267
+ }),
268
+ ).toEqual({ track: 2, insertRow: null });
269
+ });
270
+
271
+ it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
272
+ expect(
273
+ resolveZoneDropPlacement({
274
+ ...base,
275
+ elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)],
276
+ desiredTrack: 1,
277
+ }),
278
+ ).toEqual({ track: 1, insertRow: 2 });
279
+ });
280
+
281
+ // Every visual lane (0,1,2) occupied across the drop span → no free lane exists.
282
+ const allVisualOccupied = () => [el("a", 0, 0, 10), el("b", 1, 0, 10), el("c", 2, 0, 10)];
283
+
284
+ it("occupied aim with NO free lane creates an adjacent track — never snaps back (UX rule 3)", () => {
285
+ // The clip must NOT return to its origin; it gets a fresh track adjacent to the
286
+ // aim. Default bias = below the aimed row.
287
+ const result = resolveZoneDropPlacement({
288
+ ...base,
289
+ elements: allVisualOccupied(),
290
+ desiredTrack: 1,
291
+ });
292
+ expect(result.insertRow).not.toBeNull(); // a track is created, not an origin snap-back
293
+ expect(result).toEqual({ track: 1, insertRow: 2 }); // adjacent, below the aimed row
294
+ });
295
+
296
+ it("opens the new adjacent track ABOVE the aimed row when the pointer is in its upper half (UX rule 3)", () => {
297
+ expect(
298
+ resolveZoneDropPlacement({
299
+ ...base,
300
+ elements: allVisualOccupied(),
301
+ desiredTrack: 1,
302
+ preferInsertAbove: true,
303
+ }),
304
+ ).toEqual({ track: 1, insertRow: 1 }); // boundary ABOVE lane 1
305
+ // Same aim, pointer in the lower half → the track opens BELOW the aimed row.
306
+ expect(
307
+ resolveZoneDropPlacement({
308
+ ...base,
309
+ elements: allVisualOccupied(),
310
+ desiredTrack: 1,
311
+ preferInsertAbove: false,
312
+ }),
313
+ ).toEqual({ track: 1, insertRow: 2 });
314
+ });
315
+
316
+ it("shares a track for sequential (non-overlapping) clips", () => {
317
+ expect(
318
+ resolveZoneDropPlacement({
319
+ ...base,
320
+ elements: [el("a", 1, 0, 2)],
321
+ desiredTrack: 1,
322
+ start: 2,
323
+ }),
324
+ ).toEqual({ track: 1, insertRow: null });
325
+ });
326
+
327
+ it("clamps a visual clip OUT of the audio zone before placing", () => {
328
+ expect(resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 3 })).toEqual({
329
+ track: 2,
330
+ insertRow: null,
331
+ });
332
+ });
333
+
334
+ it("clamps an audio clip INTO the audio zone before placing", () => {
335
+ expect(
336
+ resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 0, isAudio: true }),
337
+ ).toEqual({ track: 3, insertRow: null });
338
+ });
339
+
340
+ it("upward create-drag off the top lane inserts at the TOP of the visual zone, never below audio (reviewer repro)", () => {
341
+ // 3-visual + 1-audio timeline. Dragging a top-lane visual clip up past the
342
+ // create threshold emits the sentinel desiredTrack = minTrack-1 = -1. The
343
+ // old hoist did order.indexOf(-1) = -1 and fell to below = order.length = 4,
344
+ // creating the new track BELOW the audio zone — repro {track:-1, insertRow:4}.
345
+ // It must now anchor to the top boundary of the visual zone.
346
+ expect(
347
+ resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: -1 }),
348
+ ).toEqual({ track: -1, insertRow: 0 });
349
+ });
350
+
351
+ it("downward create-drag off the bottom visual lane inserts at the audio boundary, never below it", () => {
352
+ // Sentinel desiredTrack = maxTrack+1 = 4 for a downward create-drag. A visual
353
+ // clip must land at the bottom of the visual zone (row 3 = just above audio),
354
+ // not past the audio lanes.
355
+ expect(
356
+ resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: 4 }),
357
+ ).toEqual({ track: 4, insertRow: 3 });
358
+ });
359
+
360
+ it("audio create-drag with an out-of-range aim inserts inside the audio zone", () => {
361
+ // An audio clip aimed above its zone (sentinel below its own min lane) anchors
362
+ // to the audio zone's top boundary (row 3), not into the visual zone.
363
+ expect(
364
+ resolveZoneDropPlacement({
365
+ ...base,
366
+ elements: [el("a", 3, 0, 10)],
367
+ desiredTrack: -1,
368
+ isAudio: true,
369
+ }),
370
+ ).toEqual({ track: -1, insertRow: 3 });
371
+ });
372
+
373
+ it("honors a deliberate boundary insert in the clip's own zone", () => {
374
+ expect(
375
+ resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 1 }),
376
+ ).toEqual({ track: 1, insertRow: 1 });
377
+ });
378
+
379
+ it("ignores a deliberate insert that lands in the WRONG zone (visual into audio)", () => {
380
+ expect(
381
+ resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 4 }),
382
+ ).toEqual({ track: 1, insertRow: null });
383
+ });
384
+
385
+ it("lets an AUDIO clip create a new audio track via a boundary insert", () => {
386
+ expect(
387
+ resolveZoneDropPlacement({
388
+ ...base,
389
+ elements: [],
390
+ desiredTrack: 3,
391
+ isAudio: true,
392
+ deliberateInsertRow: 4,
393
+ }),
394
+ ).toEqual({ track: 3, insertRow: 4 });
395
+ });
396
+
397
+ it("Abhai repro: audio dropped on a visual-only timeline over an occupied span → insert, no overlap (#2195)", () => {
398
+ // No audio zone exists yet (audioTracks empty). The audio clip is aimed at the
399
+ // sole visual lane, which is occupied at the drop time. The zone-drop must
400
+ // create the audio zone's first lane (insertRow set) rather than stacking the
401
+ // audio clip onto the occupied visual track — the no-overlap invariant.
402
+ const result = resolveZoneDropPlacement({
403
+ order: [0],
404
+ audioTracks: new Set<number>(),
405
+ elements: [el("v", 0, 0, 5)],
406
+ desiredTrack: 0,
407
+ deliberateInsertRow: null,
408
+ start: 1,
409
+ duration: 2,
410
+ dragKey: "audio",
411
+ isAudio: true,
412
+ });
413
+ expect(result.insertRow).not.toBeNull();
414
+ expect(result).toEqual({ track: 0, insertRow: 1 });
415
+ });
416
+
417
+ it("free-span repro: audio dropped on a FREE stretch of a visual-only timeline → insert, not a visual lane (#2195)", () => {
418
+ // Same visual-only timeline, but the audio clip is aimed at a stretch the sole
419
+ // visual lane is FREE at. The zone check must still fire an insert (create the
420
+ // audio zone) rather than short-circuiting on isLaneFree and dropping the audio
421
+ // clip onto the visual lane — the kind-zone hole where the free-lane fast path
422
+ // beat the wrong-zone check.
423
+ const result = resolveZoneDropPlacement({
424
+ order: [0],
425
+ audioTracks: new Set<number>(),
426
+ elements: [el("v", 0, 0, 5)],
427
+ desiredTrack: 0,
428
+ deliberateInsertRow: null,
429
+ start: 10, // past the visual clip's end → the visual lane is FREE here
430
+ duration: 2,
431
+ dragKey: "audio",
432
+ isAudio: true,
433
+ });
434
+ expect(result.insertRow).not.toBeNull();
435
+ expect(result).toEqual({ track: 0, insertRow: 1 });
436
+ });
437
+ });
@@ -0,0 +1,263 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+
3
+ /**
4
+ * Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
5
+ * the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a
6
+ * clip from appearing to land in the wrong zone mid-drag (which normalizeToZones
7
+ * would then snap back). `audioRow` = index in `trackOrder` of the first audio
8
+ * lane, or -1 when there is no audio zone yet (then it's a no-op).
9
+ */
10
+ export function clampTrackToZone(
11
+ targetTrack: number,
12
+ trackOrder: number[],
13
+ audioRow: number,
14
+ isAudio: boolean,
15
+ ): number {
16
+ if (audioRow < 0) return targetTrack;
17
+ const row = trackOrder.indexOf(targetTrack);
18
+ if (row < 0) return targetTrack;
19
+ if (isAudio) return row >= audioRow ? targetTrack : (trackOrder[audioRow] ?? targetTrack);
20
+ return row < audioRow ? targetTrack : (trackOrder[audioRow - 1] ?? targetTrack);
21
+ }
22
+
23
+ /**
24
+ * Whether a new-track insert at boundary `insertRow` is allowed for a clip of the
25
+ * given kind. Visual clips may only insert visual lanes (boundary at/above the top
26
+ * of the audio zone); audio clips may only insert audio lanes (boundary at/below
27
+ * it) — so audio clips CAN create a new audio track, and neither kind inserts into
28
+ * the other's zone. `audioRow` = first audio lane row, or -1 (no audio zone) → any.
29
+ */
30
+ export function isInsertAllowedForZone(
31
+ insertRow: number,
32
+ audioRow: number,
33
+ isAudio: boolean,
34
+ ): boolean {
35
+ if (audioRow < 0) return true;
36
+ return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
37
+ }
38
+
39
+ /**
40
+ * The full drop-placement decision for a dragged clip — one pure, testable unit.
41
+ * Enforces: NO time-overlap on a single track; a clip stays in its kind-zone;
42
+ * a new track is created only when needed. Order of resolution:
43
+ * 1. Deliberate boundary insert (pointer near a lane edge), if it's in the
44
+ * clip's own zone → create a new track there.
45
+ * 2. Otherwise land on a lane: clamp the aimed track to the clip's zone, take it
46
+ * if free at [start, start+duration), else the nearest FREE lane in the zone
47
+ * (prefer up), else auto-create a new track right below the aimed lane.
48
+ * `audioTracks` = the set of track indices that currently hold audio (so the fn
49
+ * needs no element-kind import). Returns the landing `track` and, when a new track
50
+ * should be created, the `insertRow` boundary (else null).
51
+ *
52
+ * `preferInsertAbove` biases the auto-created track (occupied-aim → new adjacent
53
+ * track) toward the boundary ABOVE the aimed row instead of below it, so the new
54
+ * lane opens on whichever side of the aimed clip the pointer is nearer (the drag
55
+ * preview passes the pointer's sub-row half). A clip whose aimed span is occupied
56
+ * never snaps back to its origin — it relocates to a free lane, or (none free)
57
+ * gets a fresh track next to the aim. Default (below) preserves prior behaviour.
58
+ */
59
+ /**
60
+ * Insert-row boundary for an out-of-range aim — a `desired` track that isn't a
61
+ * real lane: the sentinel minTrack-1 an upward create-drag emits (#2214-adjacent
62
+ * repro) or a beyond-the-bottom index a downward one does. Anchors the new track
63
+ * to a boundary of the clip's OWN kind-zone so a visual insert can never land
64
+ * past the audio zone (the old below = order.length fallback dropped it BELOW the
65
+ * audio lanes). Above the zone (`desired` < the zone's min lane) → the zone's TOP
66
+ * boundary; otherwise → its BOTTOM boundary (for a visual clip, the top of the
67
+ * audio zone). `zoneTracks` = this kind's lanes, in `order` sequence.
68
+ */
69
+ function outOfRangeZoneInsertRow(
70
+ order: number[],
71
+ zoneTracks: number[],
72
+ audioRow: number,
73
+ desired: number,
74
+ ): number {
75
+ // No lane of this kind yet: fall to the split (audioRow) or the very top.
76
+ // A visual-only timeline has audioRow -1 (top); an all-audio one has it at 0.
77
+ if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow;
78
+ // zoneTracks preserves `order` sequence, so its ends map to the zone boundary
79
+ // rows: above the zone's min lane → its top boundary, else its bottom.
80
+ const zoneTop = order.indexOf(zoneTracks[0]);
81
+ const zoneBottom = order.indexOf(zoneTracks[zoneTracks.length - 1]) + 1;
82
+ return desired < Math.min(...zoneTracks) ? zoneTop : zoneBottom;
83
+ }
84
+
85
+ export function resolveZoneDropPlacement(input: {
86
+ order: number[];
87
+ audioTracks: ReadonlySet<number>;
88
+ elements: TimelineElement[];
89
+ desiredTrack: number;
90
+ deliberateInsertRow: number | null;
91
+ start: number;
92
+ duration: number;
93
+ dragKey: string;
94
+ isAudio: boolean;
95
+ preferInsertAbove?: boolean;
96
+ }): { track: number; insertRow: number | null } {
97
+ const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
98
+ const { start, duration, dragKey, isAudio, preferInsertAbove } = input;
99
+ const audioRow = order.findIndex((t) => audioTracks.has(t));
100
+
101
+ if (
102
+ deliberateInsertRow !== null &&
103
+ isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
104
+ ) {
105
+ return { track: desiredTrack, insertRow: deliberateInsertRow };
106
+ }
107
+
108
+ const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
109
+ const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
110
+ const placement = resolvePlacement({
111
+ elements,
112
+ desiredTrack: desired,
113
+ start,
114
+ duration,
115
+ trackOrder: zoneTracks,
116
+ excludeKey: dragKey,
117
+ });
118
+ if (placement.needsInsert) {
119
+ const desiredRow = order.indexOf(desired);
120
+ if (desiredRow < 0) {
121
+ return {
122
+ track: desired,
123
+ insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
124
+ };
125
+ }
126
+ // Prefer the gap NEAREST the pointer: insert above the aimed row when the
127
+ // pointer sits in its upper half AND that boundary is in the clip's own zone
128
+ // (else the visual/audio split would be crossed) — otherwise fall to below.
129
+ // `desired` is clamped into the zone, so both boundaries stay in-zone.
130
+ const insertRow =
131
+ preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio)
132
+ ? desiredRow
133
+ : desiredRow + 1;
134
+ return { track: desired, insertRow };
135
+ }
136
+ return { track: placement.track, insertRow: null };
137
+ }
138
+
139
+ /**
140
+ * Fallback half-width (fraction of a track height) of the insert band straddling
141
+ * a lane boundary — used only when the caller passes no explicit band. Production
142
+ * threads the geometry-exact `INSERT_BOUNDARY_BAND` (timelineLayout.ts, = the clip
143
+ * inset `CLIP_Y / TRACK_H`) so the band matches the rendered inter-clip gutter and
144
+ * NEVER reaches into a clip body. Kept in sync with that constant; do not widen it
145
+ * back toward the old 0.32 (which armed an insert across ~64% of every row — the
146
+ * misfire that turned a plain horizontal drag into a phantom track insert).
147
+ */
148
+ const INSERT_BAND = 3 / 48;
149
+
150
+ /**
151
+ * Decide whether a vertical drag is inserting a new track at a lane boundary.
152
+ * `rowFloat` is the pointer's position in track-height units from the top of the
153
+ * first lane (0 = top of lane 0). Returns the boundary row to insert at
154
+ * (0 = above the top lane, `trackCount` = below the bottom), or null when the
155
+ * pointer is over a lane's middle band (a normal move/target).
156
+ */
157
+ export function resolveInsertRow(
158
+ rowFloat: number,
159
+ trackCount: number,
160
+ band: number = INSERT_BAND,
161
+ ): number | null {
162
+ if (trackCount === 0) return 0;
163
+ if (rowFloat <= 0) return 0;
164
+ if (rowFloat >= trackCount) return trackCount;
165
+ const lane = Math.floor(rowFloat);
166
+ const frac = rowFloat - lane;
167
+ if (frac < band) return lane;
168
+ if (frac > 1 - band) return lane + 1;
169
+ return null;
170
+ }
171
+
172
+ /** Half-open overlap test: [aStart, aEnd) intersects [bStart, bEnd). */
173
+ export function timeRangesOverlap(
174
+ aStart: number,
175
+ aEnd: number,
176
+ bStart: number,
177
+ bEnd: number,
178
+ ): boolean {
179
+ return aStart < bEnd && bStart < aEnd;
180
+ }
181
+
182
+ /**
183
+ * True when no clip on `track` overlaps [start, end) — excluding the clip
184
+ * identified by `excludeKey` (the one being dragged).
185
+ */
186
+ export function isLaneFree(
187
+ elements: TimelineElement[],
188
+ track: number,
189
+ start: number,
190
+ end: number,
191
+ excludeKey: string | null,
192
+ ): boolean {
193
+ return !elements.some(
194
+ (el) =>
195
+ (el.key ?? el.id) !== excludeKey &&
196
+ el.track === track &&
197
+ timeRangesOverlap(start, end, el.start, el.start + el.duration),
198
+ );
199
+ }
200
+
201
+ export interface PlacementInput {
202
+ elements: TimelineElement[];
203
+ desiredTrack: number;
204
+ start: number;
205
+ duration: number;
206
+ trackOrder: number[];
207
+ excludeKey: string | null;
208
+ }
209
+
210
+ export interface PlacementResult {
211
+ /** The lane the clip should land on. */
212
+ track: number;
213
+ /**
214
+ * True when no existing lane was free and the caller should insert a new
215
+ * track instead of landing on `track` (which is then the desired lane as a
216
+ * last-resort fallback). Consumed in later stages (2b/2c); stage 2a ignores it.
217
+ */
218
+ needsInsert: boolean;
219
+ }
220
+
221
+ /**
222
+ * Resolve where a dragged clip should land, avoiding overlap. If the desired
223
+ * lane is free, keep it. Otherwise search the nearest free lane, **preferring
224
+ * up** (all lanes above, nearest first), then down. If none is free, signal an
225
+ * insert and fall back to the desired lane.
226
+ */
227
+ export function resolvePlacement({
228
+ elements,
229
+ desiredTrack,
230
+ start,
231
+ duration,
232
+ trackOrder,
233
+ excludeKey,
234
+ }: PlacementInput): PlacementResult {
235
+ const end = start + duration;
236
+ const idx = trackOrder.indexOf(desiredTrack);
237
+ // desiredTrack is not one of the zone's lanes — the clip's kind-zone has no lane
238
+ // yet (e.g. an audio clip dropped on a visual-only timeline). This MUST be checked
239
+ // BEFORE the isLaneFree short-circuit below: a free-aimed span on a foreign-zone
240
+ // lane (an audio clip aimed at an empty stretch of a visual-only timeline) is
241
+ // "free" only because that lane belongs to the wrong zone. Landing there would
242
+ // put the clip in the wrong kind-zone, so signal an insert to create the zone's
243
+ // first lane instead — regardless of whether the aimed span is occupied (#2195).
244
+ if (idx === -1) return { track: desiredTrack, needsInsert: true };
245
+
246
+ if (isLaneFree(elements, desiredTrack, start, end, excludeKey)) {
247
+ return { track: desiredTrack, needsInsert: false };
248
+ }
249
+
250
+ // Prefer up: nearest lane above first, then the rest above.
251
+ for (let up = idx - 1; up >= 0; up--) {
252
+ if (isLaneFree(elements, trackOrder[up], start, end, excludeKey)) {
253
+ return { track: trackOrder[up], needsInsert: false };
254
+ }
255
+ }
256
+ // Then down: nearest lane below first.
257
+ for (let down = idx + 1; down < trackOrder.length; down++) {
258
+ if (isLaneFree(elements, trackOrder[down], start, end, excludeKey)) {
259
+ return { track: trackOrder[down], needsInsert: false };
260
+ }
261
+ }
262
+ return { track: desiredTrack, needsInsert: true };
263
+ }