@hyperframes/studio 0.8.110 → 0.8.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/assets/{hyperframes-player-c6WR3RsF.js → hyperframes-player-CBpg41JM.js} +1 -1
  2. package/dist/assets/{index-BJpQzV6h.js → index-BFitquwi.js} +1 -1
  3. package/dist/assets/{index-Co6W8IVk.js → index-BKy_birQ.js} +194 -194
  4. package/dist/assets/{index-C0KfbPAo.js → index-CZfrSyU9.js} +1 -1
  5. package/dist/assets/{index-C7apTvpr.css → index-DbfKVTId.css} +1 -1
  6. package/dist/{chunk-PQFEBX32.js → chunk-BIUXP3PG.js} +2 -2
  7. package/dist/{chunk-6LUNLTHR.js → chunk-K47E2ZJA.js} +13 -8
  8. package/dist/chunk-K47E2ZJA.js.map +1 -0
  9. package/dist/{chunk-QUXZVHOJ.js → chunk-VWT7STY3.js} +2 -2
  10. package/dist/{domEditingLayers-JQPQGGT7.js → domEditingLayers-V67MS4WP.js} +3 -3
  11. package/dist/index.d.ts +81 -46
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +7295 -6844
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +10 -8
  18. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
  19. package/src/hooks/timelineEditingHelpers.ts +7 -0
  20. package/src/hooks/timelineMoveAdapter.test.ts +12 -0
  21. package/src/hooks/timelineMoveAdapter.ts +9 -4
  22. package/src/hooks/useTimelineGroupEditing.test.tsx +77 -0
  23. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  24. package/src/index.ts +8 -0
  25. package/src/main.tsx +4 -0
  26. package/src/player/components/ClipBadges.tsx +5 -5
  27. package/src/player/components/Timeline.ghostLane.test.tsx +8 -61
  28. package/src/player/components/Timeline.trackPadding.test.tsx +11 -56
  29. package/src/player/components/TimelineCanvas.tsx +6 -2
  30. package/src/player/components/TimelineClip.filmstrip.test.tsx +156 -0
  31. package/src/player/components/TimelineClip.test.tsx +7 -7
  32. package/src/player/components/TimelineClip.tsx +23 -7
  33. package/src/player/components/TimelineClipFades.tsx +41 -43
  34. package/src/player/components/TimelineGestureOverlay.test.tsx +41 -0
  35. package/src/player/components/TimelineGestureOverlay.tsx +59 -21
  36. package/src/player/components/TimelineLanes.tsx +18 -16
  37. package/src/player/components/timelineCallbacks.ts +6 -4
  38. package/src/player/components/timelineClipChildren.tsx +7 -13
  39. package/src/player/components/timelineClipDragCommit.test.ts +51 -0
  40. package/src/player/components/timelineClipDragCommit.ts +63 -21
  41. package/src/player/components/timelineClipDragGestureLifecycle.ts +18 -2
  42. package/src/player/components/timelineClipDragPreview.test.ts +47 -31
  43. package/src/player/components/timelineClipDragPreview.ts +81 -17
  44. package/src/player/components/timelineClipDragTypes.ts +3 -3
  45. package/src/player/components/timelineClipFadeGeometry.test.ts +29 -0
  46. package/src/player/components/timelineClipFadeGeometry.ts +74 -0
  47. package/src/player/components/timelineCollision.ts +51 -11
  48. package/src/player/components/timelineDragLanding.ts +20 -6
  49. package/src/player/components/timelineLaneProps.ts +3 -8
  50. package/src/player/components/timelineMotionStyles.test.ts +6 -13
  51. package/src/player/components/timelineMountFixtures.tsx +65 -0
  52. package/src/player/components/timelineOptimisticRevision.ts +15 -4
  53. package/src/player/components/timelineTheme.ts +2 -0
  54. package/src/player/components/timelineTrackDisplay.ts +1 -1
  55. package/src/player/components/timelineTrackInsertLayout.test.ts +249 -0
  56. package/src/player/components/timelineTrackInsertLayout.ts +71 -0
  57. package/src/player/components/timelineZMirror.ts +5 -11
  58. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +163 -0
  59. package/src/player/components/useTimelineClipDrag.ts +116 -12
  60. package/src/player/components/useTimelineDisplayLayout.test.tsx +59 -0
  61. package/src/player/components/useTimelineKeyboardActor.ts +86 -7
  62. package/src/player/components/useTimelineProviderState.tsx +3 -1
  63. package/src/player/components/useTimelineRowVirtualization.test.tsx +56 -0
  64. package/src/player/components/useTimelineRowVirtualization.ts +3 -1
  65. package/src/player/components/useTimelineTrackDerivations.test.ts +14 -1
  66. package/src/player/components/useTimelineTrackDerivations.ts +32 -18
  67. package/src/player/components/useTimelineTrackLayout.ts +43 -14
  68. package/src/player/hooks/timelinePlayerSync.test.ts +7 -0
  69. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  70. package/src/player/lib/automationStoreSync.test.ts +15 -0
  71. package/src/player/lib/automationStoreSync.ts +13 -5
  72. package/src/player/lib/storedVolume.ts +7 -0
  73. package/src/player/lib/thumbnailPageLifecycle.test.ts +123 -0
  74. package/src/player/lib/thumbnailPageLifecycle.ts +27 -0
  75. package/src/player/lib/thumbnailScheduler.ts +20 -2
  76. package/src/player/lib/timelineElementHelpers.ts +2 -8
  77. package/src/styles/components.css +71 -19
  78. package/src/styles/theme.css +4 -1
  79. package/src/utils/studioPendingEdits.test.ts +18 -0
  80. package/dist/chunk-6LUNLTHR.js.map +0 -1
  81. /package/dist/{chunk-PQFEBX32.js.map → chunk-BIUXP3PG.js.map} +0 -0
  82. /package/dist/{chunk-QUXZVHOJ.js.map → chunk-VWT7STY3.js.map} +0 -0
  83. /package/dist/{domEditingLayers-JQPQGGT7.js.map → domEditingLayers-V67MS4WP.js.map} +0 -0
@@ -16,6 +16,7 @@ import type { StackingPatch } from "./timelineStackingSync";
16
16
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
17
17
  import {
18
18
  computeDragPreview,
19
+ createKeyboardClipDrag,
19
20
  computeResizePreview,
20
21
  trimPreviewTime,
21
22
  previewGroupResize,
@@ -29,6 +30,19 @@ import type {
29
30
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
30
31
  import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink";
31
32
  import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
33
+ import { useTimelineClipCapabilities } from "./timelineReadOnly";
34
+ import { timelineClipFocusId } from "./timelineNavigationIdentity";
35
+ import {
36
+ handleClipPickupKeyboardEvent,
37
+ scrollKeyboardInsertRow,
38
+ timelineKeyboardEventTarget,
39
+ } from "./useTimelineKeyboardActor";
40
+ import {
41
+ timelineTrackOrderChanged,
42
+ nextKeyboardInsertRow,
43
+ keyboardPickupInsertRow,
44
+ type TimelineTrackInsertLayout,
45
+ } from "./timelineTrackInsertLayout";
32
46
  import type { TimelineRowGeometry } from "./timelineLayout";
33
47
  import {
34
48
  mountTimelineClipDragGestureLifecycle,
@@ -50,17 +64,10 @@ interface UseTimelineClipDragInput {
50
64
  ppsRef: React.RefObject<number>;
51
65
  durationRef: React.RefObject<number>;
52
66
  trackOrderRef: React.RefObject<number[]>;
67
+ trackInsertLayoutRef?: React.RefObject<TimelineTrackInsertLayout | undefined>;
53
68
  rowGeometryRef?: React.RefObject<TimelineRowGeometry>;
54
- onMoveElement?: (
55
- element: TimelineElement,
56
- updates: Pick<TimelineElement, "start" | "track">,
57
- ) => Promise<void> | void;
58
- onMoveElements?: (
59
- edits: Array<{
60
- element: TimelineElement;
61
- updates: Pick<TimelineElement, "start" | "track">;
62
- }>,
63
- ) => Promise<void> | void;
69
+ onMoveElement?: TimelineEditCallbacks["onMoveElement"];
70
+ onMoveElements?: TimelineEditCallbacks["onMoveElements"];
64
71
  onResizeElement?: (
65
72
  element: TimelineElement,
66
73
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
@@ -92,6 +99,7 @@ export function useTimelineClipDrag({
92
99
  ppsRef,
93
100
  durationRef,
94
101
  trackOrderRef,
102
+ trackInsertLayoutRef,
95
103
  rowGeometryRef,
96
104
  onMoveElement,
97
105
  onMoveElements,
@@ -107,6 +115,8 @@ export function useTimelineClipDrag({
107
115
  refreshAfterLaneMove,
108
116
  sessionEpoch = 0,
109
117
  }: UseTimelineClipDragInput) {
118
+ const getClipCapabilities = useTimelineClipCapabilities();
119
+ const commitGestureRef = useRef<() => void>(() => {});
110
120
  const updateElement = usePlayerStore((s) => s.updateElement);
111
121
  const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
112
122
  const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
@@ -207,7 +217,7 @@ export function useTimelineClipDrag({
207
217
  const cancelGestureRef = useRef<
208
218
  (options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
209
219
  >(() => false);
210
- const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number) => {
220
+ const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number | null) => {
211
221
  if (lifecycleRef.current.phase === "active") cancelGestureRef.current();
212
222
  lifecycleRef.current = {
213
223
  kind,
@@ -310,14 +320,25 @@ export function useTimelineClipDrag({
310
320
  pps: ppsRef.current,
311
321
  duration: durationRef.current,
312
322
  trackOrder: trackOrderRef.current,
323
+ allowedInsertRows: trackInsertLayoutRef?.current?.allowedRows,
324
+ groupTracks: trackInsertLayoutRef?.current?.groupTracks,
313
325
  rowHeights: rowGeometryRef?.current.rowHeights,
326
+ rowGeometry: rowGeometryRef?.current,
314
327
  elements: elementsRef.current,
315
328
  selectedKeys: gestureSelectedKeysRef.current,
316
329
  buildSnapTargets,
317
330
  audioTracks: dragAudioTracksRef.current,
318
331
  });
319
332
  },
320
- [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, buildSnapTargets],
333
+ [
334
+ scrollRef,
335
+ ppsRef,
336
+ durationRef,
337
+ trackOrderRef,
338
+ trackInsertLayoutRef,
339
+ rowGeometryRef,
340
+ buildSnapTargets,
341
+ ],
321
342
  );
322
343
 
323
344
  // Recompute the trim preview for a pointer x. Shared by the pointermove resize
@@ -445,6 +466,8 @@ export function useTimelineClipDrag({
445
466
 
446
467
  useMountEffect(() =>
447
468
  mountTimelineClipDragGestureLifecycle({
469
+ trackInsertLayoutRef,
470
+ commitGestureRef,
448
471
  onStackingPatchesRef,
449
472
  refreshAfterLaneMoveRef,
450
473
  readZIndexRef,
@@ -479,10 +502,91 @@ export function useTimelineClipDrag({
479
502
  }),
480
503
  );
481
504
 
505
+ useEffect(() => {
506
+ const movePickup = (drag: DraggedClipState, step: -1 | 1, viewport: HTMLDivElement | null) => {
507
+ const audioTracks = getTimelineElementIndexes(elementsRef.current).audioTracks;
508
+ const row = nextKeyboardInsertRow({
509
+ current: drag.insertRow ?? 0,
510
+ step,
511
+ order: trackOrderRef.current,
512
+ layout: trackInsertLayoutRef?.current,
513
+ audioTracks,
514
+ isAudio: audioTracks.has(drag.element.track),
515
+ });
516
+ if (row === null) return;
517
+ publishDraggedClip({ ...drag, insertRow: row });
518
+ if (viewport && rowGeometryRef)
519
+ scrollKeyboardInsertRow(viewport, rowGeometryRef.current, row);
520
+ };
521
+ const activePickup = (
522
+ event: KeyboardEvent,
523
+ drag: DraggedClipState,
524
+ viewport: HTMLDivElement | null,
525
+ ) => {
526
+ handleClipPickupKeyboardEvent(event, {
527
+ move: (step) => movePickup(drag, step, viewport),
528
+ commit: commitGestureRef.current,
529
+ cancel: cancelGestureRef.current,
530
+ focus: () =>
531
+ usePlayerStore
532
+ .getState()
533
+ .requestTimelineFocus(timelineClipFocusId(drag.element.key ?? drag.element.id)),
534
+ });
535
+ };
536
+ const pickup = (event: KeyboardEvent, viewport: HTMLDivElement) => {
537
+ const target = timelineKeyboardEventTarget(event.target, viewport);
538
+ const key = target?.dataset.elId;
539
+ if (key === undefined || !onMoveElementRef.current) return;
540
+ const element = getTimelineElementIndexes(elementsRef.current).byKey.get(key);
541
+ if (!element || !getClipCapabilities(element).canMove) return;
542
+ event.preventDefault();
543
+ event.stopImmediatePropagation();
544
+ setShowPopover(false);
545
+ setRangeSelectionRef.current?.(null);
546
+ const row = keyboardPickupInsertRow(
547
+ trackOrderRef.current.indexOf(element.track),
548
+ trackInsertLayoutRef?.current?.allowedRows,
549
+ );
550
+ setDraggedClip(createKeyboardClipDrag(element, row, viewport.scrollLeft, viewport.scrollTop));
551
+ };
552
+ const handleKeyDown = (event: KeyboardEvent) => {
553
+ const drag = draggedClipRef.current;
554
+ const viewport = scrollRef.current;
555
+ if (drag) {
556
+ if (drag.pointerId === null) activePickup(event, drag, viewport);
557
+ return;
558
+ }
559
+ if (resizingClipRef.current) return;
560
+ if (event.key !== " " || event.repeat) return;
561
+ if (viewport) pickup(event, viewport);
562
+ };
563
+ window.addEventListener("keydown", handleKeyDown, true);
564
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
565
+ }, [
566
+ getClipCapabilities,
567
+ publishDraggedClip,
568
+ rowGeometryRef,
569
+ scrollRef,
570
+ setDraggedClip,
571
+ setRangeSelectionRef,
572
+ setShowPopover,
573
+ trackOrderRef,
574
+ trackInsertLayoutRef,
575
+ ]);
576
+
482
577
  useEffect(() => {
483
578
  cancelGestureRef.current();
484
579
  }, [sessionEpoch]);
485
580
 
581
+ const previousOrderRef = useRef(trackOrderRef.current);
582
+ useEffect(() => {
583
+ const order = trackOrderRef.current;
584
+ const previous = previousOrderRef.current;
585
+ previousOrderRef.current = order;
586
+ if (draggedClipRef.current && timelineTrackOrderChanged(previous, order))
587
+ cancelGestureRef.current();
588
+ });
589
+
486
590
  return {
487
591
  draggedClip,
488
592
  setDraggedClip,
@@ -0,0 +1,59 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it } from "vitest";
5
+ import { useTimelineDisplayLayout } from "./useTimelineTrackLayout";
6
+ import { createTimelineRowGeometry } from "./timelineLayout";
7
+ import type { DraggedClipState } from "./timelineClipDragTypes";
8
+
9
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
+
11
+ it("opens one full lane at the insertion index without changing committed tracks", () => {
12
+ const order = [7, 3, 9];
13
+ const geometry = createTimelineRowGeometry(order, [48, 104, 48], { top: 0 });
14
+ const host = document.createElement("div");
15
+ const root = createRoot(host);
16
+ const drag: DraggedClipState = {
17
+ pointerId: 1,
18
+ element: { id: "clip", tag: "div", start: 1, duration: 2, track: 9 },
19
+ originClientX: 0,
20
+ originClientY: 0,
21
+ originScrollLeft: 0,
22
+ originScrollTop: 0,
23
+ pointerClientX: 0,
24
+ pointerClientY: 0,
25
+ pointerOffsetX: 0,
26
+ pointerOffsetY: 0,
27
+ previewStart: 1,
28
+ previewTrack: 3,
29
+ insertRow: 1,
30
+ snapTime: null,
31
+ snapType: null,
32
+ started: true,
33
+ };
34
+ function Probe({ active }: { active: DraggedClipState | null }) {
35
+ const layout = useTimelineDisplayLayout(active, order, geometry);
36
+ return (
37
+ <output>
38
+ {JSON.stringify({
39
+ order: layout.displayTrackOrder,
40
+ pinned: layout.draggedRowKey,
41
+ heights: layout.displayRowHeights,
42
+ below: layout.rowGeometry.getRowTop(2),
43
+ })}
44
+ </output>
45
+ );
46
+ }
47
+ act(() => root.render(<Probe active={drag} />));
48
+ const seen = JSON.parse(host.textContent!);
49
+ expect(seen.order).toHaveLength(4);
50
+ expect(seen.pinned).toBe(-1);
51
+ expect(seen.order.filter((key: number) => order.includes(key))).toEqual(order);
52
+ expect(order.includes(seen.order[1])).toBe(false);
53
+ expect(seen.heights).toEqual([48, 48, 104, 48]);
54
+ expect(seen.below).toBe(120);
55
+ expect(order).toEqual([7, 3, 9]);
56
+ act(() => root.render(<Probe active={null} />));
57
+ expect(JSON.parse(host.textContent!).order).toEqual(order);
58
+ act(() => root.unmount());
59
+ });
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
- import type { TimelineRowGeometry } from "./timelineLayout";
3
+ import { RULER_H, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
4
4
  import {
5
5
  isTimelineNavigationKey,
6
6
  locateTimelineLogicalTarget,
@@ -17,14 +17,92 @@ interface TimelineKeyboardActorInput {
17
17
  onDrillDown?: (element: TimelineElement) => void;
18
18
  }
19
19
 
20
- function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
21
- if (!(event.target instanceof Element)) return null;
20
+ type ClipPickupAction =
21
+ | { kind: "move"; step: -1 | 1 }
22
+ | { kind: "commit" }
23
+ | { kind: "cancel" }
24
+ | { kind: "focus" }
25
+ | { kind: "consume" };
26
+
27
+ function clipPickupKeyboardAction(event: {
28
+ key: string;
29
+ repeat: boolean;
30
+ }): ClipPickupAction | null {
31
+ switch (event.key) {
32
+ case "ArrowUp":
33
+ return { kind: "move", step: -1 };
34
+ case "ArrowDown":
35
+ return { kind: "move", step: 1 };
36
+ case "Enter":
37
+ return { kind: event.repeat ? "focus" : "commit" };
38
+ case "Escape":
39
+ return { kind: "cancel" };
40
+ case " ":
41
+ return { kind: "consume" };
42
+ default:
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function handleClipPickupKeyboardEvent(
48
+ event: globalThis.KeyboardEvent,
49
+ actions: {
50
+ move: (step: -1 | 1) => void;
51
+ commit: () => void;
52
+ cancel: () => void;
53
+ focus: () => void;
54
+ },
55
+ ): void {
56
+ const action = clipPickupKeyboardAction(event);
57
+ if (!action) return;
58
+ event.preventDefault();
59
+ event.stopImmediatePropagation();
60
+ switch (action.kind) {
61
+ case "move":
62
+ actions.move(action.step);
63
+ break;
64
+ case "commit":
65
+ actions.commit();
66
+ actions.focus();
67
+ break;
68
+ case "cancel":
69
+ actions.cancel();
70
+ actions.focus();
71
+ break;
72
+ case "focus":
73
+ actions.focus();
74
+ break;
75
+ case "consume":
76
+ break;
77
+ default: {
78
+ const unreachable: never = action;
79
+ throw new Error(`Unknown pickup action: ${unreachable}`);
80
+ }
81
+ }
82
+ }
83
+
84
+ export function scrollKeyboardInsertRow(
85
+ viewport: HTMLDivElement,
86
+ geometry: TimelineRowGeometry,
87
+ row: number,
88
+ ): void {
89
+ const top = geometry.getRowTop(row);
90
+ if (top < viewport.scrollTop + RULER_H) viewport.scrollTop = Math.max(0, top - RULER_H);
91
+ else if (top + TRACK_H > viewport.scrollTop + viewport.clientHeight)
92
+ viewport.scrollTop = top + TRACK_H - viewport.clientHeight;
93
+ }
94
+
95
+ export function timelineKeyboardEventTarget(
96
+ target: EventTarget | null,
97
+ container: HTMLElement,
98
+ ): HTMLElement | null {
99
+ if (!(target instanceof Element)) return null;
22
100
  // Header actions stay native Tab stops because they have no row-level shortcut.
23
101
  // The nearest interactive ancestor wins so their events never masquerade as row events.
24
- const target = event.target.closest<HTMLElement>(
102
+ const nearest = target.closest<HTMLElement>(
25
103
  "button, input, select, textarea, a[href], [contenteditable], [role='slider'], [data-timeline-focus-id]",
26
104
  );
27
- return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null;
105
+ return nearest?.dataset.timelineFocusId && container.contains(nearest) ? nearest : null;
28
106
  }
29
107
 
30
108
  function viewportPageSize(
@@ -87,7 +165,8 @@ export function useTimelineKeyboardActor({
87
165
 
88
166
  const onFocus = useCallback(
89
167
  (event: FocusEvent<HTMLElement>) => {
90
- const id = eventTarget(event)?.dataset.timelineFocusId;
168
+ const id = timelineKeyboardEventTarget(event.target, event.currentTarget)?.dataset
169
+ .timelineFocusId;
91
170
  if (id && id !== focusedTargetId) usePlayerStore.getState().requestTimelineFocus(id);
92
171
  },
93
172
  // ponytail: This closure must see the current id or coordinator-driven focus bumps the nonce twice.
@@ -98,7 +177,7 @@ export function useTimelineKeyboardActor({
98
177
  // One handler owns navigation, context-menu, and disclosure keyboard semantics.
99
178
  // fallow-ignore-next-line complexity
100
179
  (event: KeyboardEvent<HTMLElement>) => {
101
- const targetElement = eventTarget(event);
180
+ const targetElement = timelineKeyboardEventTarget(event.target, event.currentTarget);
102
181
  const id = targetElement?.dataset.timelineFocusId;
103
182
  if (!targetElement || !id) return;
104
183
  const located = locateTimelineLogicalTarget(logicalRows, id);
@@ -155,6 +155,7 @@ export function useTimelineProviderState({
155
155
  trackStyles,
156
156
  trackOrder,
157
157
  trackOrderRef,
158
+ trackInsertLayoutRef,
158
159
  laneCounts,
159
160
  rowGeometry,
160
161
  rowGeometryRef,
@@ -213,6 +214,7 @@ export function useTimelineProviderState({
213
214
  ppsRef,
214
215
  durationRef,
215
216
  trackOrderRef,
217
+ trackInsertLayoutRef,
216
218
  rowGeometryRef,
217
219
  onMoveElement: pinnedOnMoveElement,
218
220
  onMoveElements: pinnedOnMoveElements,
@@ -288,7 +290,7 @@ export function useTimelineProviderState({
288
290
  allowHorizontal: zoomMode === "manual",
289
291
  viewport,
290
292
  sessionEpoch,
291
- draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
293
+ draggedRowKey: displayLayout.draggedRowKey,
292
294
  resizingElementIds,
293
295
  clipContextMenuRowKey: clipContextMenu?.element.track,
294
296
  keyframeContextMenuRowKey: kfContextMenu?.element.track,
@@ -0,0 +1,56 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it } from "vitest";
5
+ import { createTimelineRowGeometry, type TimelineRowGeometry } from "./timelineLayout";
6
+ import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
7
+
8
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
9
+
10
+ it("leaves the viewport still when a live preview lane is removed before the committed row arrives", () => {
11
+ const scroll = document.createElement("div");
12
+ scroll.scrollTop = 160;
13
+ const scrollRef = { current: scroll };
14
+ const lastScrollLeftRef = { current: 0 };
15
+ const host = document.createElement("div");
16
+ const root = createRoot(host);
17
+ const normal = createTimelineRowGeometry([0, 1, 2, 3, 4, 5], [48, 48, 48, 48, 48, 48]);
18
+ const preview = createTimelineRowGeometry([0, -1, 1, 2, 3, 4, 5], [48, 48, 48, 48, 48, 48, 48]);
19
+ const committed = createTimelineRowGeometry([0, 1, 2, 3, 4, 5, 6], [48, 48, 48, 48, 48, 48, 48]);
20
+ function Probe({
21
+ geometry,
22
+ draggedRowKey,
23
+ }: {
24
+ geometry: TimelineRowGeometry;
25
+ draggedRowKey?: number;
26
+ }) {
27
+ useTimelineRowVirtualization({
28
+ scrollRef,
29
+ rowGeometry: geometry,
30
+ draggedRowKey,
31
+ sessionEpoch: 0,
32
+ viewport: {
33
+ scrollTop: 160,
34
+ scrollLeft: 0,
35
+ clientWidth: 800,
36
+ clientHeight: 200,
37
+ scrollWidth: 800,
38
+ scrollHeight: 600,
39
+ isScrolling: false,
40
+ },
41
+ elements: [],
42
+ selectedElementId: null,
43
+ lastScrollLeftRef,
44
+ syncScrollViewport: () => {},
45
+ });
46
+ return null;
47
+ }
48
+ act(() => root.render(<Probe geometry={normal} />));
49
+ act(() => root.render(<Probe geometry={preview} draggedRowKey={-1} />));
50
+ expect(scroll.scrollTop).toBe(160);
51
+ act(() => root.render(<Probe geometry={normal} />));
52
+ expect(scroll.scrollTop).toBe(160);
53
+ act(() => root.render(<Probe geometry={committed} />));
54
+ expect(scroll.scrollTop).toBe(160);
55
+ act(() => root.unmount());
56
+ });
@@ -103,6 +103,7 @@ export function useTimelineRowVirtualization({
103
103
  });
104
104
 
105
105
  const previousLayoutRef = useRef(rowGeometry);
106
+ const previousDraggedRowKeyRef = useRef(draggedRowKey);
106
107
  const previousSessionEpochRef = useRef(sessionEpoch);
107
108
  useLayoutEffect(() => {
108
109
  const scroll = scrollRef.current;
@@ -120,7 +121,7 @@ export function useTimelineRowVirtualization({
120
121
  previousGeometry,
121
122
  rowGeometry,
122
123
  scroll.scrollTop,
123
- draggedRowKey !== undefined,
124
+ draggedRowKey !== undefined || previousDraggedRowKeyRef.current !== undefined,
124
125
  );
125
126
  if (nextScrollTop !== scroll.scrollTop) {
126
127
  scroll.scrollTop = nextScrollTop;
@@ -128,6 +129,7 @@ export function useTimelineRowVirtualization({
128
129
  }
129
130
  }
130
131
  previousLayoutRef.current = rowGeometry;
132
+ previousDraggedRowKeyRef.current = draggedRowKey;
131
133
  }, [draggedRowKey, lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
132
134
 
133
135
  return {
@@ -15,7 +15,7 @@ import React, { act } from "react";
15
15
  import { createRoot } from "react-dom/client";
16
16
  import { afterEach, describe, expect, it } from "vitest";
17
17
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
18
- import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
18
+ import { displayTrackOrder, useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
19
19
 
20
20
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
21
21
 
@@ -100,4 +100,17 @@ describe("useTimelineTrackDerivations", () => {
100
100
  const { groups } = derive([clip("sfx-1", 0, { audioGroup: "sfx" })]);
101
101
  expect(groups[0]).toMatchObject({ id: "sfx", label: "sfx", volume: 1, hidden: false });
102
102
  });
103
+
104
+ it("gives hosts the drawn row order, members left out while their group is collapsed", () => {
105
+ const elements = [
106
+ clip("vo-1", 0, { audioGroup: "voiceover" }),
107
+ clip("music", 1),
108
+ clip("vo-2", 2, { audioGroup: "voiceover" }),
109
+ ];
110
+ expect(displayTrackOrder(elements, new Set())).toEqual(derive(elements).trackOrder);
111
+
112
+ usePlayerStore.setState({ collapsedGroupIds: new Set(["voiceover"]) });
113
+ expect(derive(elements).trackOrder).toEqual([-0.5, 1]);
114
+ expect(displayTrackOrder(elements, new Set(["voiceover"]))).toEqual([-0.5, 1]);
115
+ });
103
116
  });
@@ -170,12 +170,38 @@ function groupTimelineTracks(
170
170
  return { tracks, groups, trackGroupOf };
171
171
  }
172
172
 
173
+ /** The timeline's rows top to bottom, as drawn: tracks by number, each audio group's anchor
174
+ * row with its members under it. */
175
+ function timelineDisplayTracks(
176
+ elements: readonly TimelineElement[],
177
+ collapsedGroupIds: ReadonlySet<string>,
178
+ ) {
179
+ const byTrack = new Map<number, TimelineElement[]>();
180
+ for (const el of elements) {
181
+ const list = byTrack.get(el.track) ?? [];
182
+ list.push(el);
183
+ byTrack.set(el.track, list);
184
+ }
185
+ const rawTracks = Array.from(byTrack.entries()).sort(([a], [b]) => a - b);
186
+ const grouped = groupTimelineTracks(rawTracks, collapsedGroupIds);
187
+ return { ...grouped, trackOrder: grouped.tracks.map(([trackNum]) => trackNum) };
188
+ }
189
+
190
+ /**
191
+ * The track number of each timeline row, top to bottom: pass the player store's `elements` and
192
+ * `collapsedGroupIds`. A group's anchor row is its first member's track minus 0.5.
193
+ */
194
+ export function displayTrackOrder(
195
+ elements: readonly TimelineElement[],
196
+ collapsedGroupIds: ReadonlySet<string>,
197
+ ): number[] {
198
+ return timelineDisplayTracks(elements, collapsedGroupIds).trackOrder;
199
+ }
200
+
173
201
  /**
174
202
  * Per-render track derivations Timeline.tsx feeds the canvas/lanes: the lane →
175
203
  * clip grouping (`tracks`, group-aware order), per-lane visual styles, the
176
- * matching `trackOrder`, and audio-group membership. Extracted from
177
- * Timeline.tsx as a cohesive unit (600-line studio cap); each memo keys on the
178
- * expanded display element set exactly as before.
204
+ * matching `trackOrder`, and audio-group membership.
179
205
  */
180
206
  export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
181
207
  tracks: [number, TimelineElement[]][];
@@ -184,20 +210,10 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
184
210
  groups: TimelineTrackGroupInfo[];
185
211
  trackGroupOf: Map<number, TimelineTrackGroupInfo>;
186
212
  } {
187
- const rawTracks = useMemo(() => {
188
- const map = new Map<number, TimelineElement[]>();
189
- for (const el of expandedElements) {
190
- const list = map.get(el.track) ?? [];
191
- list.push(el);
192
- map.set(el.track, list);
193
- }
194
- return Array.from(map.entries()).sort(([a], [b]) => a - b);
195
- }, [expandedElements]);
196
-
197
213
  const collapsedGroupIds = usePlayerStore((s) => s.collapsedGroupIds);
198
- const { tracks, groups, trackGroupOf } = useMemo(
199
- () => groupTimelineTracks(rawTracks, collapsedGroupIds),
200
- [rawTracks, collapsedGroupIds],
214
+ const { tracks, groups, trackGroupOf, trackOrder } = useMemo(
215
+ () => timelineDisplayTracks(expandedElements, collapsedGroupIds),
216
+ [expandedElements, collapsedGroupIds],
201
217
  );
202
218
 
203
219
  const trackStyles = useMemo(() => {
@@ -208,7 +224,5 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
208
224
  return map;
209
225
  }, [tracks]);
210
226
 
211
- const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
212
-
213
227
  return { tracks, trackStyles, trackOrder, groups, trackGroupOf };
214
228
  }
@@ -6,6 +6,7 @@ import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneDa
6
6
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
7
  import type { DraggedClipState } from "./timelineClipDragTypes";
8
8
  import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
9
+ import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
9
10
  import {
10
11
  TRACK_H,
11
12
  createTimelineRowGeometry,
@@ -246,6 +247,12 @@ export function useTimelineTrackLayout(
246
247
  useTimelineTrackDerivations(expandedElements);
247
248
  const trackOrderRef = useRef(trackOrder);
248
249
  trackOrderRef.current = trackOrder;
250
+ const trackInsertLayout = useMemo(
251
+ () => (groups.length > 0 ? buildTimelineTrackInsertLayout(trackOrder, groups) : undefined),
252
+ [trackOrder, groups],
253
+ );
254
+ const trackInsertLayoutRef = useRef(trackInsertLayout);
255
+ trackInsertLayoutRef.current = trackInsertLayout;
249
256
  const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
250
257
  tracks,
251
258
  gsapAnimations,
@@ -260,6 +267,7 @@ export function useTimelineTrackLayout(
260
267
  trackStyles,
261
268
  trackOrder,
262
269
  trackOrderRef,
270
+ trackInsertLayoutRef,
263
271
  laneCounts,
264
272
  rowGeometry,
265
273
  rowGeometryRef,
@@ -283,27 +291,40 @@ function useDisplayRowHeights(
283
291
  );
284
292
  }
285
293
 
294
+ function insertPreviewTrack(trackOrder: number[], preview: number) {
295
+ const result: number[] = [];
296
+ let inserted = false;
297
+ for (const key of trackOrder) {
298
+ if (!inserted && Number.isInteger(key) && key > preview) {
299
+ result.push(preview);
300
+ inserted = true;
301
+ }
302
+ result.push(key);
303
+ }
304
+ if (!inserted) result.push(preview);
305
+ return result;
306
+ }
307
+
286
308
  function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
309
+ const started = draggedClip?.started === true;
310
+ const insertRow = draggedClip?.insertRow ?? null;
311
+ const previewTrack = draggedClip?.previewTrack;
287
312
  return useMemo(() => {
288
- if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
313
+ if (!started || previewTrack === undefined) return trackOrder;
314
+ if (insertRow !== null) {
315
+ const pendingKey = trackOrder.reduce((lowest, key) => Math.min(lowest, key), 0) - 1;
316
+ const displayOrder = [...trackOrder];
317
+ displayOrder.splice(insertRow, 0, pendingKey);
318
+ return displayOrder;
319
+ }
320
+ if (trackOrder.includes(previewTrack)) return trackOrder;
289
321
  // A group's members sit out of raw numeric order (pulled under their
290
322
  // anchor row), so a plain numeric sort here would undo that grouping the
291
323
  // moment a clip drags onto a brand-new track. Insert the new preview
292
324
  // track only relative to other REAL (integer) tracks, leaving any
293
325
  // fractional group-anchor keys exactly where grouping placed them.
294
- const preview = draggedClip.previewTrack;
295
- const result: number[] = [];
296
- let inserted = false;
297
- for (const key of trackOrder) {
298
- if (!inserted && Number.isInteger(key) && key > preview) {
299
- result.push(preview);
300
- inserted = true;
301
- }
302
- result.push(key);
303
- }
304
- if (!inserted) result.push(preview);
305
- return result;
306
- }, [draggedClip, trackOrder]);
326
+ return insertPreviewTrack(trackOrder, previewTrack);
327
+ }, [started, insertRow, previewTrack, trackOrder]);
307
328
  }
308
329
 
309
330
  export function useTimelineDisplayLayout(
@@ -317,7 +338,15 @@ export function useTimelineDisplayLayout(
317
338
  () => createTimelineRowGeometry(displayTrackOrder, displayRowHeights, rowGeometry.padding),
318
339
  [displayTrackOrder, displayRowHeights, rowGeometry.padding],
319
340
  );
341
+ let draggedRowKey: number | undefined;
342
+ if (draggedClip?.started) {
343
+ draggedRowKey =
344
+ draggedClip.insertRow !== null
345
+ ? displayRowGeometry.rowKeys[draggedClip.insertRow]
346
+ : draggedClip.previewTrack;
347
+ }
320
348
  return {
349
+ draggedRowKey,
321
350
  displayTrackOrder,
322
351
  displayRowHeights: displayRowGeometry.rowHeights,
323
352
  rowGeometry: displayRowGeometry,