@hyperframes/studio 0.8.91 → 0.8.93

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 (37) hide show
  1. package/dist/assets/{hyperframes-player-zT2Bdx_l.js → hyperframes-player-CkV9KK6A.js} +3 -3
  2. package/dist/assets/{index-DV-z19zB.js → index-BlLFipOk.js} +1 -1
  3. package/dist/assets/{index-V-0rfhe6.js → index-CYk0NwSv.js} +165 -165
  4. package/dist/assets/{index-DSOLcE8X.js → index-V3fzEfOD.js} +1 -1
  5. package/dist/index.d.ts +6 -11
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +8792 -8803
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  11. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  12. package/src/player/components/Timeline.rulerSeek.test.tsx +7 -7
  13. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  14. package/src/player/components/timelineClipDragCommit.test.ts +14 -58
  15. package/src/player/components/timelineClipDragCommit.ts +14 -37
  16. package/src/player/components/timelineClipDragPreview.test.ts +213 -22
  17. package/src/player/components/timelineClipDragPreview.ts +46 -81
  18. package/src/player/components/timelineClipDragTypes.ts +3 -8
  19. package/src/player/components/timelineCollision.test.ts +100 -302
  20. package/src/player/components/timelineCollision.ts +73 -160
  21. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  22. package/src/player/components/timelineDragDrop.ts +7 -9
  23. package/src/player/components/timelineDragLanding.ts +0 -49
  24. package/src/player/components/timelineLayout.test.ts +0 -25
  25. package/src/player/components/timelineLayout.ts +0 -16
  26. package/src/player/components/timelineMultiDragPreview.test.ts +34 -25
  27. package/src/player/components/timelineMultiDragPreview.ts +25 -27
  28. package/src/player/components/timelineProviderStateBuilders.ts +7 -4
  29. package/src/player/components/timelineShellMeta.tsx +99 -0
  30. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  31. package/src/player/components/useTimelineProviderState.tsx +29 -67
  32. package/src/player/hooks/useTimelinePlayer.seek.test.ts +29 -1
  33. package/src/player/hooks/useTimelinePlayer.ts +6 -0
  34. package/src/player/hooks/useTimelinePlayerLoop.ts +6 -2
  35. package/src/player/lib/playbackTypes.ts +1 -0
  36. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  37. package/src/utils/timelineDropTrackInsert.ts +36 -5
@@ -9,6 +9,8 @@ import {
9
9
  import type { DraggedClipState } from "./timelineClipDragTypes";
10
10
  import { commitDraggedClipMove, persistMoveEdits } from "./timelineClipDragCommit";
11
11
  import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
12
+ import { isMultiDragPassenger } from "./timelineMultiDragPreview";
13
+ import { resolveMultiDragPreview } from "./timelineProviderStateBuilders";
12
14
 
13
15
  // ─────────────────────────────────────────────────────────────────────────────
14
16
  // Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip
@@ -168,7 +170,7 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
168
170
  expect(next.previewTrack).toBe(0);
169
171
  });
170
172
 
171
- it("uses the expanded row midpoint when choosing the side for an automatic insert", () => {
173
+ it("an occupied aim in an expanded row stays on that row at the nearest free time", () => {
172
174
  const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H];
173
175
  const dragged = clip("dragged", 0, 0, 1, 3);
174
176
  const occupied = [dragged, clip("block-0", 0, 0, 1, 2), clip("block-1", 1, 0, 1, 1)];
@@ -195,11 +197,13 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
195
197
  ...ctx(rowHeights, occupied),
196
198
  trackOrder: [0, 1],
197
199
  });
198
- expect(next.insertRow).toBe(0);
200
+ expect(next.insertRow).toBeNull();
201
+ expect(next.previewTrack).toBe(0);
202
+ expect(next.previewStart).toBe(1);
199
203
  });
200
204
  });
201
205
 
202
- describe("computeDragPreview — magnetic first clip on an empty main track", () => {
206
+ describe("computeDragPreview — a clip landing on an empty main track keeps its released start", () => {
203
207
  // v-lower sits alone on lane 1; lane 0 (the main track) is empty.
204
208
  const vLower = clip("v-lower", 1, 10, 4, 5);
205
209
 
@@ -236,11 +240,11 @@ describe("computeDragPreview — magnetic first clip on an empty main track", ()
236
240
  });
237
241
  }
238
242
 
239
- it("dragging straight up onto the empty main track snaps the preview start to 0", () => {
243
+ it("dragging straight up onto the empty main track keeps the start", () => {
240
244
  const next = dragUpToMainTrack([vLower]);
241
245
  expect(next.previewTrack).toBe(0);
242
246
  expect(next.insertRow).toBeNull();
243
- expect(next.previewStart).toBe(0);
247
+ expect(next.previewStart).toBe(10);
244
248
  });
245
249
 
246
250
  it("does not touch the start once the main track already holds a clip", () => {
@@ -249,13 +253,13 @@ describe("computeDragPreview — magnetic first clip on an empty main track", ()
249
253
  expect(next.previewStart).toBe(10); // unchanged — main track wasn't empty
250
254
  });
251
255
 
252
- it("aiming the top gutter over an empty main track snaps the ghost: the insert renumbers onto track 0", () => {
256
+ it("aiming the space above an empty main track opens a track there and keeps the start", () => {
253
257
  const next = dragUpToMainTrack([vLower], -0.6);
254
258
  expect(next.insertRow).toBe(0);
255
- expect(next.previewStart).toBe(0);
259
+ expect(next.previewStart).toBe(10);
256
260
  });
257
261
 
258
- it("does not retime the rest of a multi-selection when the grabbed clip lands on the empty main track", () => {
262
+ it("does not retime a multi-selection whose grabbed clip lands on the empty main track", () => {
259
263
  const vOther = clip("v-other", 1, 15, 3, 5);
260
264
  const next = dragUpToMainTrack([vLower, vOther], 0.5, new Set(["v-lower", "v-other"]));
261
265
  expect(next.previewTrack).toBe(0);
@@ -425,29 +429,107 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
425
429
 
426
430
  it("plain move onto the empty main track", () => {
427
431
  const ghost = preview(lower, [lower], 1, 0.5);
428
- expect(ghost.previewStart).toBe(0);
432
+ expect(ghost.previewStart).toBe(10);
429
433
  expect(committedStart(ghost, lower, [lower])).toBe(ghost.previewStart);
430
434
  });
431
435
 
432
- it("top-gutter insert that pushes the old track-0 clip down lands on track 0 at 0", () => {
436
+ it("top-gutter insert that pushes the old track-0 clip down keeps the pointer start", () => {
433
437
  const oldMain = clip("old-main", 0, 0, 3, 5);
434
438
  const elements = [oldMain, lower];
435
439
  const ghost = preview(lower, elements, 1, -0.6);
436
440
  expect(ghost.insertRow).toBe(0);
437
- expect(ghost.previewStart).toBe(0);
438
- expect(committedStart(ghost, lower, elements)).toBe(0);
439
- });
440
-
441
- it("top-gutter insert with a clip staying on track 0 keeps the pointer start", () => {
442
- const stays = clip("stays", 0, 0, 3, 5);
443
- const elements = [stays, lower];
444
- // Insert between lane 0 and 1: the resident track-0 clip does not move.
445
- const ghost = preview(lower, elements, 1, 1.0);
446
441
  expect(ghost.previewStart).toBe(10);
447
442
  expect(committedStart(ghost, lower, elements)).toBe(10);
448
443
  });
449
444
 
450
- it("expanded child dragged with its host: the host commits at 0 and the ghost matches", () => {
445
+ it("a move onto an occupied row lands at its nearest free time, ghost and commit alike", () => {
446
+ const blocker = clip("blocker", 0, 8, 4, 5);
447
+ const elements = [blocker, lower];
448
+ const ghost = preview(lower, elements, 1, 0.5);
449
+ expect(ghost.insertRow).toBeNull();
450
+ expect(ghost.previewTrack).toBe(0);
451
+ expect(ghost.previewStart).toBe(12);
452
+ expect(committedStart(ghost, lower, elements)).toBe(12);
453
+ });
454
+
455
+ describe("a drop onto an occupied row stays on that row", () => {
456
+ const title = clip("title", 0, 0, 10, 3, "text");
457
+ const subtitle = clip("subtitle", 1, 0, 6, 2, "text");
458
+ const tag = clip("tag", 2, 2, 5, 1, "text");
459
+ const rows = [title, subtitle, tag];
460
+
461
+ it("Tag dropped on the Subtitle row lands right after Subtitle, with no new track", () => {
462
+ const ghost = preview(tag, rows, 2, 1.5);
463
+ expect(ghost).toMatchObject({ insertRow: null, previewTrack: 1, previewStart: 6 });
464
+ expect(committedStart(ghost, tag, rows)).toBe(6);
465
+ });
466
+
467
+ it("Tag dropped on the top Title row stays on track 0 after Title, not snapped to 0", () => {
468
+ const ghost = preview(tag, rows, 2, 0.5);
469
+ expect(ghost).toMatchObject({ insertRow: null, previewTrack: 0, previewStart: 10 });
470
+ expect(committedStart(ghost, tag, rows)).toBe(10);
471
+ });
472
+
473
+ it("the edge of a row is still that row, not a new track", () => {
474
+ for (const edge of [0.02, 0.98, 1.02]) {
475
+ const ghost = preview(tag, rows, 2, edge);
476
+ expect(ghost.insertRow).toBeNull();
477
+ expect(ghost.previewTrack).toBe(Math.floor(edge));
478
+ }
479
+ });
480
+ });
481
+
482
+ describe("a clip released on or near its own spot stays there", () => {
483
+ // Frame-aligned edges with three decimals: no centisecond start fits m's own slot.
484
+ const a = clip("a", 0, 0, 3.333, 1);
485
+ const m = clip("m", 0, 3.333, 3.333, 1);
486
+ const b = clip("b", 0, 6.666, 4, 1);
487
+ const row = [a, m, b];
488
+
489
+ it("keeps its start and writes nothing when released where it started", () => {
490
+ const ghost = preview(m, row, 0, 0.5);
491
+ expect(ghost).toMatchObject({ previewTrack: 0, insertRow: null, previewStart: 3.333 });
492
+ expect(committedStart(ghost, m, row)).toBeUndefined();
493
+ });
494
+
495
+ it("goes back to its own spot from a small nudge, snapped or not", () => {
496
+ const snapped = [{ time: 3.333, type: "clip-edge" as const }];
497
+ for (const targets of [[], snapped]) {
498
+ const { drag, clientX, clientY } = horizontalDrag(m, 0.5, 0.1);
499
+ const ghost = computeDragPreview(drag, clientX, clientY, {
500
+ ...ctx(undefined, row),
501
+ buildSnapTargets: () => targets,
502
+ });
503
+ expect(ghost).toMatchObject({ previewTrack: 0, previewStart: 3.333 });
504
+ }
505
+ });
506
+
507
+ it("counts its own start only on its own row, where keeping it rewrites nothing", () => {
508
+ // Row 1 has the same off-grid slot, but a move there writes a rounded start that would overlap.
509
+ const c = clip("c", 1, 0, 3.333, 1);
510
+ const d = clip("d", 1, 6.666, 4, 1);
511
+ const ghost = preview(m, [...row, c, d], 0, 1.5);
512
+ expect(ghost).toMatchObject({ previewTrack: 1, insertRow: null, previewStart: 10.67 });
513
+ });
514
+ });
515
+
516
+ it("drops the snap guide when the row moves the clip off the snapped time", () => {
517
+ const a = clip("a", 0, 0, 6, 1);
518
+ const b = clip("b", 1, 3, 2, 1);
519
+ const { drag, clientX } = horizontalDrag(b, 1.5, 0);
520
+ const ghost = computeDragPreview(drag, clientX, yForRow(0.5), {
521
+ ...ctx(undefined, [a, b]),
522
+ buildSnapTargets: () => [{ time: 3, type: "beat" }],
523
+ });
524
+ expect(ghost).toMatchObject({
525
+ previewTrack: 0,
526
+ previewStart: 6,
527
+ snapTime: null,
528
+ snapType: null,
529
+ });
530
+ });
531
+
532
+ it("expanded child dragged with its host: the host keeps its start and the ghost matches", () => {
451
533
  for (const [hostStart, childStart] of [
452
534
  [30, 32],
453
535
  [20, 22],
@@ -461,12 +543,121 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
461
543
  const keys = new Set(["host", "child"]);
462
544
  const ghost = preview(child, elements, 2, 0.5, keys);
463
545
  expect(ghost.previewTrack).toBe(0);
464
- expect(ghost.previewStart).toBe(childStart - hostStart);
465
- expect(committedStart(ghost, host, elements, keys)).toBe(0);
546
+ expect(ghost.previewStart).toBe(childStart);
547
+ expect(committedStart(ghost, host, elements, keys)).toBe(hostStart);
466
548
  }
467
549
  });
468
550
  });
469
551
 
552
+ describe("computeDragPreview — a group move keeps its shape", () => {
553
+ it("never bumps the grabbed clip further left than the group's 0 limit", () => {
554
+ const a = clip("a", 1, 0.5, 1, 1);
555
+ const b = clip("b", 2, 7, 2, 1);
556
+ const c = clip("c", 0, 6.4, 3.6, 1);
557
+ const elements = [c, a, b];
558
+ const selectedKeys = new Set(["a", "b"]);
559
+ const { drag } = horizontalDrag(b, 2.5, 0);
560
+ // Up two rows and 0.5 s left: the group limit allows 6.5 s, which overlaps c.
561
+ const ghost = computeDragPreview(drag, 800 - 0.5 * PPS, yForRow(0.5), {
562
+ ...ctx(undefined, elements),
563
+ selectedKeys,
564
+ });
565
+ expect(ghost).toMatchObject({ previewTrack: 0, insertRow: null, previewStart: 10 });
566
+ const onMoveElements = vi.fn();
567
+ commitDraggedClipMove(ghost, {
568
+ elements,
569
+ trackOrder: [0, 1, 2],
570
+ updateElement: vi.fn(),
571
+ onMoveElement: vi.fn(),
572
+ onMoveElements,
573
+ selectedKeys,
574
+ });
575
+ const edits = onMoveElements.mock.calls[0][0] as Array<{
576
+ element: TimelineElement;
577
+ updates: { start: number };
578
+ }>;
579
+ const moved = Object.fromEntries(
580
+ edits.map((e) => [e.element.id, e.updates.start - e.element.start]),
581
+ );
582
+ expect(moved).toEqual({ a: 3, b: 3 });
583
+ });
584
+
585
+ // Drags `grabbed` by `seconds` onto `row` and commits; returns each written clip's start.
586
+ function groupMove(
587
+ grabbed: TimelineElement,
588
+ elements: TimelineElement[],
589
+ selectedKeys: ReadonlySet<string>,
590
+ row: number,
591
+ seconds: number,
592
+ ) {
593
+ const { drag } = horizontalDrag(grabbed, grabbed.track + 0.5, 0);
594
+ const ghost = computeDragPreview(drag, 800 + seconds * PPS, yForRow(row + 0.5), {
595
+ ...ctx(undefined, elements),
596
+ selectedKeys,
597
+ });
598
+ const onMoveElements = vi.fn();
599
+ commitDraggedClipMove(ghost, {
600
+ elements,
601
+ trackOrder: [0, 1, 2],
602
+ updateElement: vi.fn(),
603
+ onMoveElement: vi.fn(),
604
+ onMoveElements,
605
+ selectedKeys,
606
+ });
607
+ const edits = onMoveElements.mock.calls[0][0] as Array<{
608
+ element: TimelineElement;
609
+ updates: { start: number; track: number };
610
+ }>;
611
+ return Object.fromEntries(edits.map((e) => [e.element.id, e.updates]));
612
+ }
613
+
614
+ it("lets a group whose leftmost clip starts off the centisecond grid reach 0 exactly", () => {
615
+ const a = clip("a", 1, 0.333, 1, 1);
616
+ const b = clip("b", 0, 7, 2, 1);
617
+ const written = groupMove(b, [a, b], new Set(["a", "b"]), 0, -20);
618
+ expect(written.a.start).toBe(0);
619
+ expect(written.b.start).toBeCloseTo(6.667, 6);
620
+ });
621
+
622
+ it("does not treat the clips moving with it as obstacles", () => {
623
+ const a = clip("a", 0, 0, 2, 1);
624
+ const b = clip("b", 0, 3, 2, 1);
625
+ const { drag, clientX, clientY } = horizontalDrag(a, 0.5, 2);
626
+ const ghost = computeDragPreview(drag, clientX, clientY, {
627
+ ...ctx(undefined, [a, b]),
628
+ selectedKeys: new Set(["a", "b"]),
629
+ });
630
+ // b moves 2 s too, so a may take 2 to 4 s; were b an obstacle, a would stop at 1 s.
631
+ expect(ghost).toMatchObject({ previewTrack: 0, previewStart: 2 });
632
+ });
633
+
634
+ it("treats a locked clip swept into the selection as an obstacle, since it does not move", () => {
635
+ const locked: TimelineElement = { ...clip("locked", 0, 4, 2, 1), timelineLocked: true };
636
+ const b = clip("b", 1, 0, 1, 1);
637
+ const written = groupMove(b, [locked, b], new Set(["locked", "b"]), 0, 4.5);
638
+ // 4.5 s overlaps the locked 4-6 s clip; 3 s and 6 s are equally near, and a tie goes later.
639
+ expect(written).toEqual({ b: { start: 6, track: 0 } });
640
+ });
641
+ });
642
+
643
+ describe("resolveMultiDragPreview — the live ghosts follow the clips that move", () => {
644
+ it("slides the movable selected clips but not a locked one swept into the selection", () => {
645
+ const locked: TimelineElement = { ...clip("locked", 0, 4, 2, 1), timelineLocked: true };
646
+ const b = clip("b", 1, 0, 1, 1);
647
+ const rider = clip("rider", 2, 8, 1, 1);
648
+ const elements = [locked, b, rider];
649
+ const selectedKeys = new Set(["locked", "b", "rider"]);
650
+ const { drag } = horizontalDrag(b, 1.5, 0);
651
+ const ghost = computeDragPreview(drag, 800 + 4.5 * PPS, yForRow(0.5), {
652
+ ...ctx(undefined, elements),
653
+ selectedKeys,
654
+ });
655
+ const preview = resolveMultiDragPreview(ghost, selectedKeys, elements);
656
+ expect(preview && isMultiDragPassenger("rider", preview)).toBe(true);
657
+ expect(preview && isMultiDragPassenger("locked", preview)).toBe(false);
658
+ });
659
+ });
660
+
470
661
  describe("a nested clip's drop stops at its host's start in the preview", () => {
471
662
  // Host at 2 s: the clip may not start before 2 unless it was authored there.
472
663
  const logo: TimelineElement = { ...clip("logo", 0, 5, 5, 0, "div"), parentCompositionStart: 2 };
@@ -6,12 +6,7 @@ import {
6
6
  } from "./timelineGroupEditing";
7
7
  import type { TimelineElement } from "../store/playerStore";
8
8
  import { clampToHostStart } from "../store/timelineElement";
9
- import {
10
- getTimelineInsertBoundaryBand,
11
- getTimelineRowFromY,
12
- getTimelineRowHeight,
13
- getTimelineRowPositionFromY,
14
- } from "./timelineLayout";
9
+ import { getTimelineRowFromY } from "./timelineLayout";
15
10
  import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
16
11
  import {
17
12
  TIMELINE_SNAP_PX,
@@ -25,9 +20,8 @@ import {
25
20
  applyTimelineGroupResizePreview,
26
21
  type TimelineGroupResizeSession,
27
22
  } from "./timelineGroupEditing";
28
- import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
23
+ import { groupMoveFloor, resolveGroupMovers } from "./timelineMultiDragPreview";
29
24
  import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
30
- import { resolveDragLandingStart } from "./timelineDragLanding";
31
25
  import { STUDIO_PREVIEW_FPS } from "../lib/time";
32
26
 
33
27
  /** Snap-target builder closure supplied by the hook (closes over refs + store). */
@@ -78,74 +72,61 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
78
72
  return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
79
73
  }
80
74
 
81
- /**
82
- * Rigid group move: when the grabbed clip is part of a multi-selection, the
83
- * WHOLE formation shifts by its delta on commit (see timelineClipDragCommit).
84
- * Clamp that delta here, and a lone clip's as a group of one, so the grabbed
85
- * clip STOPS the instant any member would cross its floor (clampToHostStart),
86
- * exactly as it lands on commit. Lane changes still apply to the grabbed clip
87
- * only, so only the start (x) is constrained.
88
- */
89
- function resolveGroupClampedStart(
90
- snapStart: number,
91
- element: TimelineElement,
92
- dragKey: string,
93
- elements: TimelineElement[],
94
- selectedKeys: ReadonlySet<string>,
95
- ): number {
96
- const inGroup = selectedKeys.size > 1 && selectedKeys.has(dragKey);
97
- const members = inGroup ? elements.filter((e) => selectedKeys.has(e.key ?? e.id)) : [element];
98
- const floors = members.map((e) => ({ start: e.start, minStart: clampToHostStart(e, 0) }));
99
- return element.start + clampGroupMoveDelta(snapStart - element.start, floors);
100
- }
101
-
102
- /**
103
- * The whole drop decision (no same-track overlap, zone-respecting, relocate or
104
- * create) — one tested pure function, so what runs here is what's verified.
105
- */
75
+ /** The drop decision for the pointer's row (see resolveZoneDropPlacement). */
106
76
  function resolveDropPlacement(
107
77
  drag: DraggedClipState,
108
78
  clientY: number,
109
79
  previewStart: number,
110
80
  desiredTrack: number,
111
81
  ctx: DragPreviewContext,
112
- ): { track: number; insertRow: number | null } {
82
+ group: GroupDrag,
83
+ ): { track: number; insertRow: number | null; start: number } {
113
84
  const { scroll, trackOrder, rowHeights, elements } = ctx;
114
- // rowFloat = the pointer's position in track-heights from the top lane; a
115
- // near-boundary hover requests a deliberate new-track insert. Uses the
116
- // shared row→y inverse so the top breathing pad is subtracted consistently.
117
- const rowPosition = scroll
118
- ? getTimelineRowPositionFromY(
85
+ const rowFloat = scroll
86
+ ? getTimelineRowFromY(
119
87
  clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
120
88
  rowHeights,
121
89
  )
122
- : { rowFloat: 0, row: 0, fraction: 0, rowHeight: getTimelineRowHeight(0, rowHeights) };
123
- // Geometry-exact band (the clip inset divided by this row's actual height) so
124
- // an insert only arms in the visible gutter between clip bodies.
125
- const rawInsertRow = resolveInsertRow(
126
- rowPosition.rowFloat,
127
- trackOrder.length,
128
- getTimelineInsertBoundaryBand(rowPosition.rowHeight),
129
- );
130
- // Pointer sub-row half: when a drop must auto-create a track (aimed span
131
- // occupied, no free lane), open it on the side the pointer is nearer.
132
- const preferInsertAbove = rowPosition.fraction < 0.5;
90
+ : 0;
91
+ const dragKey = drag.element.key ?? drag.element.id;
133
92
  const audioTracks =
134
93
  ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
135
94
  return resolveZoneDropPlacement({
136
95
  order: trackOrder,
137
96
  audioTracks,
138
- elements,
97
+ elements: group.obstacles,
139
98
  desiredTrack,
140
- deliberateInsertRow: rawInsertRow,
99
+ deliberateInsertRow: resolveInsertRow(rowFloat, trackOrder.length),
141
100
  start: previewStart,
142
101
  duration: drag.element.duration,
143
- dragKey: drag.element.key ?? drag.element.id,
102
+ dragKey,
144
103
  isAudio: isAudioTimelineElement(drag.element),
145
- preferInsertAbove,
104
+ minStart: group.floor,
105
+ origin: { track: drag.element.track, start: drag.element.start },
146
106
  });
147
107
  }
148
108
 
109
+ interface GroupDrag {
110
+ /** Clips the grabbed clip may not overlap: everything but the clips that move with it. */
111
+ obstacles: TimelineElement[];
112
+ /** Lowest start the grabbed clip may take (see groupMoveFloor). */
113
+ floor: number;
114
+ }
115
+
116
+ function resolveGroupDrag(drag: DraggedClipState, ctx: DragPreviewContext): GroupDrag {
117
+ const movers = resolveGroupMovers(
118
+ ctx.elements,
119
+ ctx.selectedKeys,
120
+ drag.element.key ?? drag.element.id,
121
+ );
122
+ if (!movers) return { obstacles: ctx.elements, floor: groupMoveFloor(drag.element, []) };
123
+ const moving = new Set(movers.map((e) => e.key ?? e.id));
124
+ return {
125
+ obstacles: ctx.elements.filter((e) => !moving.has(e.key ?? e.id)),
126
+ floor: groupMoveFloor(drag.element, movers),
127
+ };
128
+ }
129
+
149
130
  /** Recompute the dragged-clip preview (move + snap + group clamp + drop placement). */
150
131
  export function computeDragPreview(
151
132
  drag: DraggedClipState,
@@ -153,7 +134,7 @@ export function computeDragPreview(
153
134
  clientY: number,
154
135
  ctx: DragPreviewContext,
155
136
  ): DraggedClipState {
156
- const { scroll, pps, duration, trackOrder, elements, selectedKeys, buildSnapTargets } = ctx;
137
+ const { scroll, pps, duration, trackOrder, buildSnapTargets } = ctx;
157
138
  const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
158
139
  const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
159
140
  const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
@@ -196,40 +177,24 @@ export function computeDragPreview(
196
177
  // rendered extent (see dragMaxStart) — the composition grows on commit.
197
178
  dragMaxStart + drag.element.duration,
198
179
  );
199
- const dragKey = drag.element.key ?? drag.element.id;
200
- const previewStart = resolveGroupClampedStart(
201
- snap.start,
202
- drag.element,
203
- dragKey,
204
- elements,
205
- selectedKeys,
206
- );
207
- const { track: previewTrack, insertRow } = resolveDropPlacement(
208
- drag,
209
- clientY,
210
- previewStart,
211
- nextMove.track,
212
- ctx,
213
- );
214
- const placed = { ...drag, previewStart, previewTrack, insertRow };
215
- const snappedStart = resolveDragLandingStart(placed, {
216
- elements,
217
- trackOrder,
218
- selectedKeys,
219
- });
180
+ // A group moves rigidly: the grabbed clip stops where any mover would cross its host's start.
181
+ const group = resolveGroupDrag(drag, ctx);
182
+ const previewStart = Math.max(snap.start, group.floor);
183
+ const placement = resolveDropPlacement(drag, clientY, previewStart, nextMove.track, ctx, group);
184
+ const { track: previewTrack, insertRow } = placement;
220
185
  return {
221
186
  ...drag,
222
187
  started: true,
223
188
  pointerClientX: clientX,
224
189
  pointerClientY: clientY,
225
- previewStart: snappedStart,
190
+ previewStart: placement.start,
226
191
  previewTrack,
227
- // The lane the POINTER aims at (pre-collision): the commit reads it to tell a
228
- // deliberate vertical lane change from a horizontal drag merely bumped sideways.
192
+ // The lane the POINTER aims at (before the zone clamp): the commit reads it to
193
+ // tell a deliberate vertical lane change from a horizontal drag.
229
194
  desiredTrack: nextMove.track,
230
195
  insertRow,
231
- snapTime: previewStart === snap.start ? snap.snapTime : null,
232
- snapType: previewStart === snap.start ? snap.snapType : null,
196
+ snapTime: placement.start === snap.start ? snap.snapTime : null,
197
+ snapType: placement.start === snap.start ? snap.snapType : null,
233
198
  };
234
199
  }
235
200
 
@@ -18,14 +18,9 @@ export interface DraggedClipState {
18
18
  previewStart: number;
19
19
  previewTrack: number;
20
20
  /**
21
- * The lane the POINTER aims at (from the drag's vertical position), before
22
- * collision resolution. `previewTrack` is where the clip actually LANDS —
23
- * which may differ from `desiredTrack` when the aimed span is occupied and the
24
- * collision rules bump the dragged clip to a free lane. The commit reads this
25
- * to tell a deliberate VERTICAL lane change (pointer aimed at another lane →
26
- * stacking sync allowed) from a plain HORIZONTAL drag whose clip merely got
27
- * bumped sideways (never touches z). Optional: when absent the commit falls
28
- * back to `previewTrack` (pre-existing behaviour).
21
+ * The lane the POINTER aims at, before the kind-zone clamp picks `previewTrack`.
22
+ * The commit reads it to tell a deliberate vertical lane change (stacking sync
23
+ * allowed) from a horizontal drag. Absent: the commit falls back to `previewTrack`.
29
24
  */
30
25
  desiredTrack?: number;
31
26
  /**