@hyperframes/studio 0.8.90 → 0.8.92

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 (102) hide show
  1. package/dist/assets/{hyperframes-player-DDjER_ii.js → hyperframes-player-DKJPDHUp.js} +1 -1
  2. package/dist/assets/{index-BKE3_N4v.js → index-C5_j5-3u.js} +178 -178
  3. package/dist/assets/{index-5SFeUXP8.js → index-CHZ4kFWm.js} +1 -1
  4. package/dist/assets/{index-CVsCxnlz.js → index-DpzpqJ_j.js} +1 -1
  5. package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
  6. package/dist/chunk-QPSOTK6M.js.map +1 -0
  7. package/dist/index.d.ts +18 -14
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +9190 -8916
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineToolbar.test.tsx +12 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +5 -1
  15. package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
  16. package/src/components/editor/elementPositionOffset.test.ts +62 -0
  17. package/src/components/editor/elementPositionOffset.ts +64 -0
  18. package/src/components/editor/motionPathCommit.test.ts +25 -5
  19. package/src/components/editor/motionPathSelection.ts +10 -3
  20. package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
  21. package/src/components/editor/useLayerRevealOverride.ts +2 -1
  22. package/src/components/nle/NLEContext.tsx +1 -1
  23. package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
  24. package/src/hooks/domEditPersistFailure.ts +8 -6
  25. package/src/hooks/elementOffsetStager.ts +65 -0
  26. package/src/hooks/gestureTransaction.ts +4 -2
  27. package/src/hooks/gsapDragCommit.ts +1 -0
  28. package/src/hooks/gsapEditOutcome.ts +2 -1
  29. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +50 -14
  31. package/src/hooks/gsapShared.test.ts +1 -1
  32. package/src/hooks/gsapShared.ts +16 -12
  33. package/src/hooks/gsapTweenReach.ts +30 -0
  34. package/src/hooks/timelineEditingHelpers.ts +2 -1
  35. package/src/hooks/useClipboard.source.test.ts +58 -0
  36. package/src/hooks/useClipboard.ts +12 -3
  37. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  38. package/src/hooks/useDomEditCommits.ts +2 -0
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
  40. package/src/hooks/useDomEditSession.ts +2 -0
  41. package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
  42. package/src/hooks/useDomGeometryCommit.ts +42 -1
  43. package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
  44. package/src/hooks/useDomGeometryCommits.ts +14 -1
  45. package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
  46. package/src/hooks/useGsapAwareEditing.ts +44 -7
  47. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
  48. package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
  49. package/src/hooks/useRazorSplit.test.ts +5 -5
  50. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  51. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  52. package/src/hooks/useTimelineEditing.test.tsx +384 -0
  53. package/src/hooks/useTimelineEditing.ts +12 -12
  54. package/src/hooks/useTimelineGroupEditing.ts +16 -7
  55. package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
  56. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  57. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  58. package/src/player/components/TimelineProvider.tsx +1 -0
  59. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  60. package/src/player/components/TimelineTypes.ts +1 -1
  61. package/src/player/components/timelineClipDragCommit.test.ts +32 -66
  62. package/src/player/components/timelineClipDragCommit.ts +19 -37
  63. package/src/player/components/timelineClipDragPreview.test.ts +283 -24
  64. package/src/player/components/timelineClipDragPreview.ts +51 -83
  65. package/src/player/components/timelineClipDragTypes.ts +3 -8
  66. package/src/player/components/timelineCollision.test.ts +100 -302
  67. package/src/player/components/timelineCollision.ts +73 -160
  68. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  69. package/src/player/components/timelineDragDrop.ts +7 -9
  70. package/src/player/components/timelineDragLanding.ts +0 -49
  71. package/src/player/components/timelineEditing.ts +2 -1
  72. package/src/player/components/timelineGroupEditing.ts +7 -2
  73. package/src/player/components/timelineLayout.test.ts +0 -25
  74. package/src/player/components/timelineLayout.ts +0 -16
  75. package/src/player/components/timelineMultiDragPreview.test.ts +34 -23
  76. package/src/player/components/timelineMultiDragPreview.ts +26 -25
  77. package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
  78. package/src/player/components/timelineProviderStateBuilders.ts +14 -6
  79. package/src/player/components/timelineShellMeta.tsx +99 -0
  80. package/src/player/components/timelineZMirror.test.ts +2 -2
  81. package/src/player/components/timelineZMirror.ts +1 -1
  82. package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
  83. package/src/player/components/useTimelineClipDrag.ts +3 -2
  84. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  85. package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
  86. package/src/player/components/useTimelinePlayhead.ts +56 -27
  87. package/src/player/components/useTimelineProviderState.tsx +29 -66
  88. package/src/player/components/useTimelineZoom.ts +9 -1
  89. package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -4
  91. package/src/player/lib/timelineDOM.test.ts +79 -0
  92. package/src/player/lib/timelineDOM.ts +16 -7
  93. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  94. package/src/player/lib/timelineIframeHelpers.ts +12 -3
  95. package/src/player/store/liveTime.ts +6 -0
  96. package/src/player/store/playerStore.ts +2 -0
  97. package/src/player/store/timelineElement.ts +28 -3
  98. package/src/utils/razorSplitTransaction.test.ts +1 -1
  99. package/src/utils/razorSplitTransaction.ts +3 -3
  100. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  101. package/src/utils/timelineDropTrackInsert.ts +36 -5
  102. package/dist/chunk-KU4F7QZ3.js.map +0 -1
@@ -7,8 +7,10 @@ import {
7
7
  type DragPreviewContext,
8
8
  } from "./timelineClipDragPreview";
9
9
  import type { DraggedClipState } from "./timelineClipDragTypes";
10
- import { commitDraggedClipMove } from "./timelineClipDragCommit";
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],
@@ -456,14 +538,191 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
456
538
  const child: TimelineElement = {
457
539
  ...clip("child", 2, childStart, 4, 5),
458
540
  expandedHostKey: "host",
459
- expandedParentStart: hostStart,
460
541
  };
461
542
  const elements = [host, child];
462
543
  const keys = new Set(["host", "child"]);
463
544
  const ghost = preview(child, elements, 2, 0.5, keys);
464
545
  expect(ghost.previewTrack).toBe(0);
465
- expect(ghost.previewStart).toBe(childStart - hostStart);
466
- expect(committedStart(ghost, host, elements, keys)).toBe(0);
546
+ expect(ghost.previewStart).toBe(childStart);
547
+ expect(committedStart(ghost, host, elements, keys)).toBe(hostStart);
467
548
  }
468
549
  });
469
550
  });
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
+
661
+ describe("a nested clip's drop stops at its host's start in the preview", () => {
662
+ // Host at 2 s: the clip may not start before 2 unless it was authored there.
663
+ const logo: TimelineElement = { ...clip("logo", 0, 5, 5, 0, "div"), parentCompositionStart: 2 };
664
+ const saved = async (element: TimelineElement, start: number) => {
665
+ const updateElement = vi.fn();
666
+ await persistMoveEdits([{ element, updates: { start, track: element.track } }], {
667
+ elements: [element],
668
+ trackOrder: [0, 1, 2],
669
+ updateElement,
670
+ onMoveElements: vi.fn(async () => {}),
671
+ });
672
+ return updateElement.mock.calls[0]?.[1]?.start;
673
+ };
674
+
675
+ it("holds a drop that snapping would put before the host, and saves the same start", async () => {
676
+ const { drag, clientX, clientY } = horizontalDrag(logo, 0.5, -3.9);
677
+ const next = computeDragPreview(drag, clientX, clientY, {
678
+ ...ctx(undefined, [logo]),
679
+ buildSnapTargets: () => [{ time: 1.9, type: "beat" }],
680
+ });
681
+ expect(next.previewStart).toBe(2);
682
+ expect(next.snapTime).toBeNull();
683
+ expect(await saved(logo, next.previewStart)).toBe(2);
684
+ });
685
+
686
+ it("snaps from the floor, so an edge just past the host's start still catches", () => {
687
+ const { drag, clientX, clientY } = horizontalDrag(logo, 0.5, -3.9);
688
+ const next = computeDragPreview(drag, clientX, clientY, {
689
+ ...ctx(undefined, [logo]),
690
+ buildSnapTargets: () => [{ time: 2.1, type: "beat" }],
691
+ });
692
+ expect(next).toMatchObject({ previewStart: 2.1, snapTime: 2.1 });
693
+ });
694
+
695
+ it("keeps a mixed group's spacing when the nested member meets its floor", () => {
696
+ // Grab the top-level member: only the group clamp knows the nested one's floor.
697
+ const outro = clip("outro", 1, 8, 2, 0, "div");
698
+ const { drag, clientX, clientY } = horizontalDrag(outro, 1.5, -7);
699
+ const next = computeDragPreview(drag, clientX, clientY, {
700
+ ...ctx(undefined, [logo, outro]),
701
+ selectedKeys: new Set(["logo", "outro"]),
702
+ });
703
+ expect(next.previewStart).toBe(5);
704
+ });
705
+
706
+ it("leaves a clip authored before its host's start in place when grabbed", async () => {
707
+ const early: TimelineElement = { ...logo, id: "early", key: "early", domId: "early", start: 1 };
708
+ const { drag, clientX, clientY } = horizontalDrag(early, 0.5, 0);
709
+ expect(computeDragPreview(drag, clientX, clientY, ctx(undefined, [early])).previewStart).toBe(
710
+ 1,
711
+ );
712
+ const trim = computeResizePreview(
713
+ {
714
+ element: early,
715
+ edge: "start",
716
+ originClientX: 0,
717
+ previewStart: 1,
718
+ previewDuration: 5,
719
+ started: true,
720
+ pointerId: 0,
721
+ },
722
+ 0,
723
+ { scroll: fakeScroll(), pps: PPS, buildSnapTargets: () => [] },
724
+ );
725
+ expect(trim).toMatchObject({ previewStart: 1, previewDuration: 5 });
726
+ expect(await saved(early, 1)).toBe(1);
727
+ });
728
+ });
@@ -5,12 +5,8 @@ import {
5
5
  resolveTimelineMinDuration,
6
6
  } from "./timelineGroupEditing";
7
7
  import type { TimelineElement } from "../store/playerStore";
8
- import {
9
- getTimelineInsertBoundaryBand,
10
- getTimelineRowFromY,
11
- getTimelineRowHeight,
12
- getTimelineRowPositionFromY,
13
- } from "./timelineLayout";
8
+ import { clampToHostStart } from "../store/timelineElement";
9
+ import { getTimelineRowFromY } from "./timelineLayout";
14
10
  import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
15
11
  import {
16
12
  TIMELINE_SNAP_PX,
@@ -24,9 +20,8 @@ import {
24
20
  applyTimelineGroupResizePreview,
25
21
  type TimelineGroupResizeSession,
26
22
  } from "./timelineGroupEditing";
27
- import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
23
+ import { groupMoveFloor, resolveGroupMovers } from "./timelineMultiDragPreview";
28
24
  import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
29
- import { resolveDragLandingStart } from "./timelineDragLanding";
30
25
  import { STUDIO_PREVIEW_FPS } from "../lib/time";
31
26
 
32
27
  /** Snap-target builder closure supplied by the hook (closes over refs + store). */
@@ -77,74 +72,61 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
77
72
  return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
78
73
  }
79
74
 
80
- /**
81
- * Rigid group move: when the grabbed clip is part of a multi-selection, the
82
- * WHOLE formation shifts by its delta on commit (see timelineClipDragCommit).
83
- * Clamp that delta here — against every selected member's start — so the
84
- * grabbed clip can't out-run the group: it STOPS the instant any member would
85
- * cross 0, exactly as it lands on commit. Lane changes still apply to the
86
- * grabbed clip only, so only the start (x) is constrained.
87
- */
88
- function resolveGroupClampedStart(
89
- snapStart: number,
90
- element: TimelineElement,
91
- dragKey: string,
92
- elements: TimelineElement[],
93
- selectedKeys: ReadonlySet<string>,
94
- ): number {
95
- if (selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return snapStart;
96
- const memberStarts = elements.filter((e) => selectedKeys.has(e.key ?? e.id)).map((e) => e.start);
97
- const clampedDelta = clampGroupMoveDelta(snapStart - element.start, memberStarts);
98
- return element.start + clampedDelta;
99
- }
100
-
101
- /**
102
- * The whole drop decision (no same-track overlap, zone-respecting, relocate or
103
- * create) — one tested pure function, so what runs here is what's verified.
104
- */
75
+ /** The drop decision for the pointer's row (see resolveZoneDropPlacement). */
105
76
  function resolveDropPlacement(
106
77
  drag: DraggedClipState,
107
78
  clientY: number,
108
79
  previewStart: number,
109
80
  desiredTrack: number,
110
81
  ctx: DragPreviewContext,
111
- ): { track: number; insertRow: number | null } {
82
+ group: GroupDrag,
83
+ ): { track: number; insertRow: number | null; start: number } {
112
84
  const { scroll, trackOrder, rowHeights, elements } = ctx;
113
- // rowFloat = the pointer's position in track-heights from the top lane; a
114
- // near-boundary hover requests a deliberate new-track insert. Uses the
115
- // shared row→y inverse so the top breathing pad is subtracted consistently.
116
- const rowPosition = scroll
117
- ? getTimelineRowPositionFromY(
85
+ const rowFloat = scroll
86
+ ? getTimelineRowFromY(
118
87
  clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
119
88
  rowHeights,
120
89
  )
121
- : { rowFloat: 0, row: 0, fraction: 0, rowHeight: getTimelineRowHeight(0, rowHeights) };
122
- // Geometry-exact band (the clip inset divided by this row's actual height) so
123
- // an insert only arms in the visible gutter between clip bodies.
124
- const rawInsertRow = resolveInsertRow(
125
- rowPosition.rowFloat,
126
- trackOrder.length,
127
- getTimelineInsertBoundaryBand(rowPosition.rowHeight),
128
- );
129
- // Pointer sub-row half: when a drop must auto-create a track (aimed span
130
- // occupied, no free lane), open it on the side the pointer is nearer.
131
- const preferInsertAbove = rowPosition.fraction < 0.5;
90
+ : 0;
91
+ const dragKey = drag.element.key ?? drag.element.id;
132
92
  const audioTracks =
133
93
  ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
134
94
  return resolveZoneDropPlacement({
135
95
  order: trackOrder,
136
96
  audioTracks,
137
- elements,
97
+ elements: group.obstacles,
138
98
  desiredTrack,
139
- deliberateInsertRow: rawInsertRow,
99
+ deliberateInsertRow: resolveInsertRow(rowFloat, trackOrder.length),
140
100
  start: previewStart,
141
101
  duration: drag.element.duration,
142
- dragKey: drag.element.key ?? drag.element.id,
102
+ dragKey,
143
103
  isAudio: isAudioTimelineElement(drag.element),
144
- preferInsertAbove,
104
+ minStart: group.floor,
105
+ origin: { track: drag.element.track, start: drag.element.start },
145
106
  });
146
107
  }
147
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
+
148
130
  /** Recompute the dragged-clip preview (move + snap + group clamp + drop placement). */
149
131
  export function computeDragPreview(
150
132
  drag: DraggedClipState,
@@ -152,7 +134,7 @@ export function computeDragPreview(
152
134
  clientY: number,
153
135
  ctx: DragPreviewContext,
154
136
  ): DraggedClipState {
155
- const { scroll, pps, duration, trackOrder, elements, selectedKeys, buildSnapTargets } = ctx;
137
+ const { scroll, pps, duration, trackOrder, buildSnapTargets } = ctx;
156
138
  const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
157
139
  const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
158
140
  const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
@@ -173,6 +155,7 @@ export function computeDragPreview(
173
155
  originScrollLeft: drag.originScrollLeft,
174
156
  currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
175
157
  pixelsPerSecond: pps,
158
+ minStart: clampToHostStart(drag.element, 0),
176
159
  maxStart: dragMaxStart,
177
160
  trackOrder,
178
161
  },
@@ -194,40 +177,24 @@ export function computeDragPreview(
194
177
  // rendered extent (see dragMaxStart) — the composition grows on commit.
195
178
  dragMaxStart + drag.element.duration,
196
179
  );
197
- const dragKey = drag.element.key ?? drag.element.id;
198
- const previewStart = resolveGroupClampedStart(
199
- snap.start,
200
- drag.element,
201
- dragKey,
202
- elements,
203
- selectedKeys,
204
- );
205
- const { track: previewTrack, insertRow } = resolveDropPlacement(
206
- drag,
207
- clientY,
208
- previewStart,
209
- nextMove.track,
210
- ctx,
211
- );
212
- const placed = { ...drag, previewStart, previewTrack, insertRow };
213
- const snappedStart = resolveDragLandingStart(placed, {
214
- elements,
215
- trackOrder,
216
- selectedKeys,
217
- });
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;
218
185
  return {
219
186
  ...drag,
220
187
  started: true,
221
188
  pointerClientX: clientX,
222
189
  pointerClientY: clientY,
223
- previewStart: snappedStart,
190
+ previewStart: placement.start,
224
191
  previewTrack,
225
- // The lane the POINTER aims at (pre-collision): the commit reads it to tell a
226
- // 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.
227
194
  desiredTrack: nextMove.track,
228
195
  insertRow,
229
- snapTime: snap.snapTime,
230
- snapType: snap.snapType,
196
+ snapTime: placement.start === snap.start ? snap.snapTime : null,
197
+ snapType: placement.start === snap.start ? snap.snapType : null,
231
198
  };
232
199
  }
233
200
 
@@ -293,7 +260,7 @@ export function computeResizePreview(
293
260
  duration: resize.element.duration,
294
261
  originClientX: resize.originClientX,
295
262
  pixelsPerSecond: pps,
296
- minStart: 0,
263
+ minStart: clampToHostStart(resize.element, 0),
297
264
  maxEnd,
298
265
  playbackStart:
299
266
  resize.edge === "start" && canSeedPlaybackStart
@@ -336,7 +303,8 @@ export function computeResizePreview(
336
303
  const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
337
304
  const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
338
305
  const delta = snapped - nextResize.start;
339
- const bounds = clipStartTrimDeltaBounds(clip, 0, resolveTimelineMinDuration());
306
+ const floor = clampToHostStart(resize.element, 0);
307
+ const bounds = clipStartTrimDeltaBounds(clip, floor, resolveTimelineMinDuration());
340
308
  if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
341
309
  if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
342
310
  snap = target;
@@ -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
  /**