@hyperframes/studio 0.7.86 → 0.7.87

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 (93) hide show
  1. package/dist/assets/{hyperframes-player-BL3qfGEQ.js → hyperframes-player-BFqB5AWJ.js} +1 -1
  2. package/dist/assets/{index-B5QGai9B.js → index-B1Vf4vCl.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-CISVI7UC.js → index-BblzZ6Av.js} +221 -221
  5. package/dist/assets/{index-C0Vr8jjc.js → index-D_JU4EeH.js} +1 -1
  6. package/dist/index.d.ts +14 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6265 -4922
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -8
  11. package/src/components/TimelineToolbar.tsx +14 -5
  12. package/src/components/nle/NLEContext.tsx +4 -1
  13. package/src/components/nle/TimelinePane.tsx +2 -0
  14. package/src/hooks/studioTestMode.test.ts +35 -0
  15. package/src/hooks/studioTestMode.ts +23 -0
  16. package/src/hooks/useGestureRecording.test.tsx +120 -0
  17. package/src/hooks/useGestureRecording.ts +49 -40
  18. package/src/hooks/useStudioTestHooks.test.tsx +161 -0
  19. package/src/hooks/useStudioTestHooks.ts +54 -7
  20. package/src/player/components/BeatStrip.tsx +21 -5
  21. package/src/player/components/CompositionThumbnail.test.ts +96 -2
  22. package/src/player/components/CompositionThumbnail.tsx +29 -16
  23. package/src/player/components/Timeline.test.ts +104 -0
  24. package/src/player/components/Timeline.tsx +141 -131
  25. package/src/player/components/Timeline.virtualization.test.tsx +400 -0
  26. package/src/player/components/TimelineCanvas.tsx +18 -10
  27. package/src/player/components/TimelineClip.tsx +4 -0
  28. package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
  29. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  30. package/src/player/components/TimelineLanes.test.tsx +15 -4
  31. package/src/player/components/TimelineLanes.tsx +81 -26
  32. package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
  33. package/src/player/components/TimelinePropertyLanes.tsx +1 -0
  34. package/src/player/components/TimelineRuler.tsx +21 -5
  35. package/src/player/components/TimelineTrackRow.tsx +46 -0
  36. package/src/player/components/TimelineTypes.ts +2 -0
  37. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
  38. package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
  39. package/src/player/components/timelineDiamondTypes.ts +5 -0
  40. package/src/player/components/timelineDragDrop.ts +5 -5
  41. package/src/player/components/timelineEditing.test.ts +32 -0
  42. package/src/player/components/timelineEditing.ts +47 -5
  43. package/src/player/components/timelineFocusIdentity.test.ts +21 -0
  44. package/src/player/components/timelineFocusIdentity.ts +21 -0
  45. package/src/player/components/timelineLaneProps.ts +9 -0
  46. package/src/player/components/timelineLayout.test.ts +59 -0
  47. package/src/player/components/timelineLayout.ts +202 -176
  48. package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
  49. package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
  50. package/src/player/components/timelineRulerGeometry.ts +142 -0
  51. package/src/player/components/timelineViewModel.ts +44 -0
  52. package/src/player/components/timelineViewportGeometry.ts +51 -0
  53. package/src/player/components/timelineZoom.test.ts +42 -28
  54. package/src/player/components/timelineZoom.ts +42 -25
  55. package/src/player/components/useTimelineActiveClips.test.ts +82 -79
  56. package/src/player/components/useTimelineActiveClips.ts +49 -55
  57. package/src/player/components/useTimelineClipDrag.ts +40 -230
  58. package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
  59. package/src/player/components/useTimelineGeometry.ts +4 -11
  60. package/src/player/components/useTimelinePlayhead.ts +30 -14
  61. package/src/player/components/useTimelineRangeSelection.ts +5 -4
  62. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  63. package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
  64. package/src/player/components/useTimelineRevealClip.ts +171 -47
  65. package/src/player/components/useTimelineRowVirtualization.ts +140 -0
  66. package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
  67. package/src/player/components/useTimelineScrollViewport.ts +87 -9
  68. package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
  69. package/src/player/components/useTimelineShiftModifier.ts +20 -0
  70. package/src/player/components/useTimelineTicks.ts +22 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
  72. package/src/player/components/useTimelineTrackLayout.ts +35 -18
  73. package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
  74. package/src/player/components/useTimelineVirtualRows.ts +126 -0
  75. package/src/player/hooks/timelinePlayerSync.ts +20 -0
  76. package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
  77. package/src/player/hooks/useTimelinePlayer.ts +8 -20
  78. package/src/player/lib/playbackScrub.ts +2 -2
  79. package/src/player/lib/timelineClipIndex.test.ts +169 -0
  80. package/src/player/lib/timelineClipIndex.ts +208 -0
  81. package/src/player/lib/timelineElementIndexes.test.ts +31 -0
  82. package/src/player/lib/timelineElementIndexes.ts +44 -0
  83. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
  84. package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
  85. package/src/player/lib/timelinePerformanceFixture.ts +167 -0
  86. package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
  87. package/src/player/lib/timelineViewportBudgets.ts +151 -0
  88. package/src/player/store/playerStore.test.ts +20 -3
  89. package/src/player/store/playerStore.ts +73 -45
  90. package/src/utils/studioHelpers.test.ts +48 -1
  91. package/src/utils/studioHelpers.ts +31 -34
  92. package/src/utils/studioUiPreferences.ts +3 -1
  93. package/dist/assets/index-D379YOKT.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useLayoutEffect, useRef } from "react";
2
- import { liveTime } from "../store/playerStore";
2
+ import { liveTime, type TimelineElement } from "../store/playerStore";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
4
 
5
5
  interface ActiveClipRecord {
@@ -13,7 +13,34 @@ interface ActiveClipRecord {
13
13
  interface UseTimelineActiveClipsInput {
14
14
  scrollRef: React.RefObject<HTMLDivElement | null>;
15
15
  currentTime: number;
16
- clipStateVersion: string;
16
+ clipStateVersion: unknown;
17
+ elementStateVersion: unknown;
18
+ }
19
+
20
+ function isTimelineIntervalActive(
21
+ interval: Pick<ActiveClipRecord, "start" | "end" | "hidden">,
22
+ time: number,
23
+ ): boolean {
24
+ return (
25
+ Number.isFinite(time) &&
26
+ !interval.hidden &&
27
+ Number.isFinite(interval.start) &&
28
+ Number.isFinite(interval.end) &&
29
+ time >= interval.start &&
30
+ time <= interval.end
31
+ );
32
+ }
33
+
34
+ /** Model-first active state. Rendered nodes receive this on their first mount. */
35
+ export function isTimelineClipActive(element: TimelineElement, time: number): boolean {
36
+ return isTimelineIntervalActive(
37
+ {
38
+ start: element.start,
39
+ end: element.start + Math.max(0, element.duration),
40
+ hidden: element.hidden === true,
41
+ },
42
+ time,
43
+ );
17
44
  }
18
45
 
19
46
  function readFiniteNumber(value: string | undefined): number | null {
@@ -27,66 +54,37 @@ function readClipRecord(element: Element): ActiveClipRecord | null {
27
54
  const id = element.dataset.elId;
28
55
  const start = readFiniteNumber(element.dataset.clipStart);
29
56
  const end = readFiniteNumber(element.dataset.clipEnd);
30
- const hidden = element.dataset.clipHidden === "true";
31
57
  if (!id || start === null || end === null) return null;
32
- return { id, start, end, hidden, element };
58
+ return { id, start, end, hidden: element.dataset.clipHidden === "true", element };
33
59
  }
34
60
 
35
61
  function collectTimelineClipRecords(container: HTMLElement): ActiveClipRecord[] {
36
- const records: ActiveClipRecord[] = [];
37
- for (const element of container.querySelectorAll('[data-clip="true"]')) {
38
- const record = readClipRecord(element);
39
- if (record) records.push(record);
40
- }
41
- return records;
42
- }
43
-
44
- function indexClipRecordsById(records: ActiveClipRecord[]): Map<string, ActiveClipRecord> {
45
- const recordsById = new Map<string, ActiveClipRecord>();
46
- for (const record of records) recordsById.set(record.id, record);
47
- return recordsById;
62
+ return [...container.querySelectorAll('[data-clip="true"]')]
63
+ .map(readClipRecord)
64
+ .filter((record): record is ActiveClipRecord => record !== null);
48
65
  }
49
66
 
50
67
  function getActiveClipIds(records: ActiveClipRecord[], time: number): Set<string> {
51
- const next = new Set<string>();
52
- if (!Number.isFinite(time)) return next;
53
- for (const record of records) {
54
- if (record.hidden) continue;
55
- if (time >= record.start && time <= record.end) next.add(record.id);
56
- }
57
- return next;
58
- }
59
-
60
- function setsMatch(left: Set<string>, right: Set<string>): boolean {
61
- if (left.size !== right.size) return false;
62
- for (const value of left) {
63
- if (!right.has(value)) return false;
64
- }
65
- return true;
68
+ return new Set(
69
+ records.filter((record) => isTimelineIntervalActive(record, time)).map((record) => record.id),
70
+ );
66
71
  }
67
72
 
68
73
  function applyActiveClipDiff(
69
74
  records: ActiveClipRecord[],
70
75
  previous: Set<string>,
71
76
  time: number,
72
- // Force every record's attribute to match its active state instead of only
73
- // touching clips whose active-state changed. Required whenever `records` were
74
- // freshly re-queried after a render: a clip that stayed active but got a new
75
- // DOM node (e.g. moved lanes on a reorder) would otherwise be skipped by the
76
- // diff and render without `data-active` despite still being at the playhead.
77
77
  syncAll = false,
78
- ) {
78
+ ): void {
79
79
  const next = getActiveClipIds(records, time);
80
- const changed = !setsMatch(previous, next);
81
80
  for (const record of records) {
82
- const wasActive = previous.has(record.id);
83
81
  const isActive = next.has(record.id);
84
- if (!syncAll && wasActive === isActive) continue;
85
- record.element.toggleAttribute("data-active", isActive);
82
+ if (syncAll || previous.has(record.id) !== isActive) {
83
+ record.element.toggleAttribute("data-active", isActive);
84
+ }
86
85
  }
87
86
  previous.clear();
88
87
  for (const id of next) previous.add(id);
89
- return changed;
90
88
  }
91
89
 
92
90
  export function updateTimelineActiveClipClasses(
@@ -94,30 +92,28 @@ export function updateTimelineActiveClipClasses(
94
92
  previous: Set<string>,
95
93
  time: number,
96
94
  syncAll = false,
97
- ) {
95
+ ): void {
98
96
  applyActiveClipDiff(collectTimelineClipRecords(container), previous, time, syncAll);
99
97
  }
100
98
 
99
+ /** Keeps the currently mounted clip window synchronized with the RAF playback clock. */
101
100
  export function useTimelineActiveClips({
102
101
  scrollRef,
103
102
  currentTime,
104
103
  clipStateVersion,
105
- }: UseTimelineActiveClipsInput) {
104
+ elementStateVersion,
105
+ }: UseTimelineActiveClipsInput): void {
106
106
  const recordsRef = useRef<ActiveClipRecord[]>([]);
107
- const recordsByIdRef = useRef(new Map<string, ActiveClipRecord>());
108
107
  const previousActiveIdsRef = useRef(new Set<string>());
109
-
110
108
  const refreshRecords = useCallback(
111
109
  (time: number) => {
112
110
  const scroll = scrollRef.current;
113
111
  if (!scroll) {
114
112
  recordsRef.current = [];
115
- recordsByIdRef.current.clear();
116
113
  previousActiveIdsRef.current.clear();
117
114
  return;
118
115
  }
119
116
  recordsRef.current = collectTimelineClipRecords(scroll);
120
- recordsByIdRef.current = indexClipRecordsById(recordsRef.current);
121
117
  applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time, true);
122
118
  },
123
119
  [scrollRef],
@@ -125,12 +121,10 @@ export function useTimelineActiveClips({
125
121
 
126
122
  useLayoutEffect(() => {
127
123
  refreshRecords(currentTime);
128
- }, [currentTime, clipStateVersion, refreshRecords]);
129
-
130
- useMountEffect(() => {
131
- const unsub = liveTime.subscribe((time) => {
124
+ }, [clipStateVersion, currentTime, elementStateVersion, refreshRecords]);
125
+ useMountEffect(() =>
126
+ liveTime.subscribe((time) => {
132
127
  applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
133
- });
134
- return unsub;
135
- });
128
+ }),
129
+ );
136
130
  }
@@ -3,18 +3,15 @@ import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import {
4
4
  applyTimelineAutoScrollStep,
5
5
  resolveTimelineAutoScrollLoopAction,
6
- resolveTimelineDragEscape,
7
6
  } from "./timelineEditing";
8
7
  import { usePlayerStore } from "../store/playerStore";
9
8
  import type { TimelineElement } from "../store/playerStore";
10
- import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
11
9
  import { mergeUserBeats } from "../../utils/beatEditing";
12
10
  import {
13
11
  buildTimelineGroupResizeMembers,
14
12
  type TimelineGroupResizeSession,
15
13
  } from "./timelineGroupEditing";
16
14
  import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping";
17
- import { commitDraggedClipMove } from "./timelineClipDragCommit";
18
15
  import type { StackingPatch } from "./timelineStackingSync";
19
16
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
20
17
  import {
@@ -28,11 +25,9 @@ import type {
28
25
  ResizingClipState,
29
26
  BlockedClipState,
30
27
  } from "./timelineClipDragTypes";
31
- import {
32
- beginTimelineOptimisticGesture,
33
- rollbackLatestTimelineOptimisticGesture,
34
- } from "./timelineOptimisticRevision";
35
- import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
28
+ import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
29
+ import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
30
+ import type { TimelineRowGeometry } from "./timelineLayout";
36
31
 
37
32
  export type {
38
33
  DraggedClipState,
@@ -48,7 +43,7 @@ interface UseTimelineClipDragInput {
48
43
  ppsRef: React.RefObject<number>;
49
44
  durationRef: React.RefObject<number>;
50
45
  trackOrderRef: React.RefObject<number[]>;
51
- rowHeightsRef?: React.RefObject<readonly number[]>;
46
+ rowGeometryRef?: React.RefObject<TimelineRowGeometry>;
52
47
  onMoveElement?: (
53
48
  element: TimelineElement,
54
49
  updates: Pick<TimelineElement, "start" | "track">,
@@ -86,7 +81,7 @@ export function useTimelineClipDrag({
86
81
  ppsRef,
87
82
  durationRef,
88
83
  trackOrderRef,
89
- rowHeightsRef,
84
+ rowGeometryRef,
90
85
  onMoveElement,
91
86
  onMoveElements,
92
87
  onResizeElement,
@@ -102,12 +97,11 @@ export function useTimelineClipDrag({
102
97
  const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
103
98
  const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
104
99
  const beatEdits = usePlayerStore((s) => s.beatEdits);
105
- const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0);
106
- const musicPlaybackStart = usePlayerStore(
107
- (s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0,
108
- );
109
- const musicDuration = usePlayerStore((s) => s.elements.find(isMusicTrack)?.duration ?? 0);
110
- const musicSrc = usePlayerStore((s) => s.elements.find(isMusicTrack)?.src ?? null);
100
+ const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
101
+ const musicStart = musicElement?.start ?? 0;
102
+ const musicPlaybackStart = musicElement?.playbackStart ?? 0;
103
+ const musicDuration = musicElement?.duration ?? 0;
104
+ const musicSrc = musicElement?.src ?? null;
111
105
 
112
106
  const adjustedBeatTimes = useMemo(() => {
113
107
  if (rawBeatTimes === EMPTY_BEAT_TIMES || musicDuration === 0) return EMPTY_BEAT_TIMES;
@@ -234,23 +228,21 @@ export function useTimelineClipDrag({
234
228
  // Build the audio-track set once per gesture (see snapTargetsCacheRef): it
235
229
  // only feeds zone-aware drop placement and is frozen while dragging.
236
230
  if (!dragAudioTracksRef.current) {
237
- dragAudioTracksRef.current = new Set(
238
- elementsRef.current.filter(isAudioTimelineElement).map((e) => e.track),
239
- );
231
+ dragAudioTracksRef.current = getTimelineElementIndexes(elementsRef.current).audioTracks;
240
232
  }
241
233
  return computeDragPreview(drag, clientX, clientY, {
242
234
  scroll: scrollRef.current,
243
235
  pps: ppsRef.current,
244
236
  duration: durationRef.current,
245
237
  trackOrder: trackOrderRef.current,
246
- rowHeights: rowHeightsRef?.current,
238
+ rowHeights: rowGeometryRef?.current.rowHeights,
247
239
  elements: elementsRef.current,
248
240
  selectedKeys: usePlayerStore.getState().selectedElementIds,
249
241
  buildSnapTargets,
250
242
  audioTracks: dragAudioTracksRef.current,
251
243
  });
252
244
  },
253
- [scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, buildSnapTargets],
245
+ [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, buildSnapTargets],
254
246
  );
255
247
 
256
248
  // Recompute the trim preview for a pointer x. Shared by the pointermove resize
@@ -363,215 +355,33 @@ export function useTimelineClipDrag({
363
355
  stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
364
356
 
365
357
  useMountEffect(() => {
366
- const clearSuppressedClick = () => {
367
- requestAnimationFrame(() => {
368
- suppressClickRef.current = false;
369
- });
370
- };
371
-
372
- /* ── pointermove branch handlers (dispatched by drag/resize/blocked) ── */
373
- const handleResizePointerMove = (e: PointerEvent, resize: ResizingClipState) => {
374
- const distance = Math.abs(e.clientX - resize.originClientX);
375
- if (!resize.started && distance < 2) return;
376
-
377
- setShowPopover(false);
378
- setRangeSelectionRef.current?.(null);
379
-
380
- applyResizePointerRef.current(resize, e.clientX);
381
- // Edge auto-scroll during a trim, exactly like the move branch — lets a
382
- // right-edge trim keep extending past the current viewport (the stepper
383
- // re-runs the scroll-compensated preview each frame).
384
- syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
385
- };
386
-
387
- const handleBlockedPointerMove = (e: PointerEvent, blocked: BlockedClipState) => {
388
- const distance = Math.hypot(
389
- e.clientX - blocked.originClientX,
390
- e.clientY - blocked.originClientY,
391
- );
392
- const threshold = blocked.intent === "move" ? 4 : 2;
393
- if (!blocked.started && distance < threshold) return;
394
- if (!blocked.started) {
395
- blocked.started = true;
396
- blockedClipRef.current = blocked;
397
- suppressClickRef.current = true;
398
- setShowPopover(false);
399
- setRangeSelectionRef.current?.(null);
400
- onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent);
401
- }
402
- };
403
-
404
- const handleDragPointerMove = (e: PointerEvent, drag: DraggedClipState) => {
405
- const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
406
- if (!drag.started && distance < 4) return;
407
-
408
- setShowPopover(false);
409
- setRangeSelectionRef.current?.(null);
410
-
411
- setDraggedClip((prev) =>
412
- prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev,
413
- );
414
- syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
415
- };
416
-
417
- const handleWindowPointerMove = (e: PointerEvent) => {
418
- const resize = resizingClipRef.current;
419
- if (resize) return handleResizePointerMove(e, resize);
420
- const blocked = blockedClipRef.current;
421
- if (blocked) return handleBlockedPointerMove(e, blocked);
422
- const drag = draggedClipRef.current;
423
- if (drag) handleDragPointerMove(e, drag);
424
- };
425
-
426
- /* ── pointerup commit handlers (dispatched by drag/resize/blocked) ──── */
427
- const commitResizePointerUp = (resize: ResizingClipState) => {
428
- resizingClipRef.current = null;
429
- setResizingClip(null);
430
- const groupSession = groupResizeRef.current;
431
- groupResizeRef.current = null;
432
- if (!resize.started) {
433
- // No preview ran, so no group store-mutation to undo; guard is defensive.
434
- if (groupSession) restoreGroupResizeMembers(groupSession);
435
- return;
436
- }
437
-
438
- suppressClickRef.current = true;
439
- clearSuppressedClick();
440
-
441
- if (groupSession) {
442
- commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current);
443
- return;
444
- }
445
-
446
- const hasChanged =
447
- resize.previewStart !== resize.element.start ||
448
- resize.previewDuration !== resize.element.duration ||
449
- resize.previewPlaybackStart !== resize.element.playbackStart;
450
- if (!hasChanged) return;
451
-
452
- const resizeKey = resize.element.key ?? resize.element.id;
453
- const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]);
454
- updateElement(resizeKey, {
455
- start: resize.previewStart,
456
- duration: resize.previewDuration,
457
- playbackStart: resize.previewPlaybackStart,
458
- });
459
-
460
- Promise.resolve(
461
- onResizeElementRef.current?.(resize.element, {
462
- start: resize.previewStart,
463
- duration: resize.previewDuration,
464
- playbackStart: resize.previewPlaybackStart,
465
- }),
466
- ).catch((error) => {
467
- rollbackLatestTimelineOptimisticGesture(updateElement, revision, [
468
- {
469
- key: resizeKey,
470
- updates: {
471
- start: resize.element.start,
472
- duration: resize.element.duration,
473
- playbackStart: resize.element.playbackStart,
474
- },
475
- },
476
- ]);
477
- console.error("[Timeline] Failed to persist clip resize", error);
478
- });
479
- };
480
-
481
- const finishBlockedPointerUp = (blocked: BlockedClipState) => {
482
- blockedClipRef.current = null;
483
- if (!blocked.started) return;
484
- clearSuppressedClick();
485
- };
486
-
487
- const commitDragPointerUp = (drag: DraggedClipState) => {
488
- draggedClipRef.current = null;
489
- setDraggedClip(null);
490
- if (!drag.started) return;
491
-
492
- suppressClickRef.current = true;
493
- clearSuppressedClick();
494
-
495
- // Commit the drag — insert (new track), main-track ripple (reflow contiguous),
496
- // a plain single-clip move, or a multi-selection move (every selected clip
497
- // shifts by the dragged clip's time delta). See timelineClipDragCommit.
498
- commitDraggedClipMove(drag, {
499
- elements: elementsRef.current,
500
- trackOrder: trackOrderRef.current,
501
- updateElement,
502
- onMoveElement: onMoveElementRef.current,
503
- onMoveElements: onMoveElementsRef.current,
504
- selectedKeys: usePlayerStore.getState().selectedElementIds,
505
- // Lane ↔ stacking: engages only when the timeline layer provisions both
506
- // deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
507
- readZIndex: readZIndexRef.current,
508
- onStackingPatches: onStackingPatchesRef.current,
509
- refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
510
- });
511
- };
512
-
513
- const handleWindowPointerUp = () => {
514
- stopClipDragAutoScrollRef.current();
515
-
516
- const resize = resizingClipRef.current;
517
- if (resize) return commitResizePointerUp(resize);
518
-
519
- const blocked = blockedClipRef.current;
520
- if (blocked) return finishBlockedPointerUp(blocked);
521
-
522
- const drag = draggedClipRef.current;
523
- if (!drag) {
524
- // Escape-cancel leaves the click suppressor armed so the click this
525
- // pointerup generates can't act on the clip; disarm it right after.
526
- if (suppressClickRef.current) clearSuppressedClick();
527
- return;
528
- }
529
- commitDragPointerUp(drag);
530
- };
531
-
532
- // Escape cancels the in-progress gesture: no commit, no undo entry. The
533
- // previews live only in the drag/resize state (the store is untouched
534
- // until the pointerup commit), so clearing them restores the pre-drag
535
- // timeline. Clip drags never take pointer capture (all tracking runs on
536
- // these window listeners), so there is no capture to release; the null
537
- // refs make the remaining pointermove/pointerup a no-op.
538
- const handleWindowKeyDown = (e: KeyboardEvent) => {
539
- const decision = resolveTimelineDragEscape({
540
- key: e.key,
541
- drag: draggedClipRef.current,
542
- resize: resizingClipRef.current,
543
- blocked: blockedClipRef.current,
544
- });
545
- if (!decision.cancel) return;
546
- e.preventDefault();
547
- e.stopPropagation();
548
- stopClipDragAutoScrollRef.current();
549
- draggedClipRef.current = null;
550
- setDraggedClip(null);
551
- resizingClipRef.current = null;
552
- setResizingClip(null);
553
- // Undo any group-resize preview store-mutation (non-grabbed members) so the
554
- // cancelled gesture restores the pre-drag timeline, like the single-clip path.
555
- const groupSession = groupResizeRef.current;
556
- groupResizeRef.current = null;
557
- if (groupSession) restoreGroupResizeMembers(groupSession);
558
- blockedClipRef.current = null;
559
- // The pointer is usually still down; keep the suppressor armed until the
560
- // eventual pointerup (which disarms it) so its click can't reselect.
561
- if (decision.suppressClick) suppressClickRef.current = true;
562
- };
563
-
564
- window.addEventListener("pointermove", handleWindowPointerMove);
565
- window.addEventListener("pointerup", handleWindowPointerUp);
566
- window.addEventListener("pointercancel", handleWindowPointerUp);
567
- window.addEventListener("keydown", handleWindowKeyDown, true);
568
- return () => {
569
- stopClipDragAutoScrollRef.current();
570
- window.removeEventListener("pointermove", handleWindowPointerMove);
571
- window.removeEventListener("pointerup", handleWindowPointerUp);
572
- window.removeEventListener("pointercancel", handleWindowPointerUp);
573
- window.removeEventListener("keydown", handleWindowKeyDown, true);
574
- };
358
+ return mountTimelineClipDragGestureLifecycle({
359
+ draggedClipRef,
360
+ resizingClipRef,
361
+ blockedClipRef,
362
+ groupResizeRef,
363
+ suppressClickRef,
364
+ elementsRef,
365
+ trackOrderRef,
366
+ setDraggedClip,
367
+ setResizingClip,
368
+ setShowPopover,
369
+ setRangeSelectionRef,
370
+ applyResizePointerRef,
371
+ syncClipDragAutoScrollRef,
372
+ stopClipDragAutoScrollRef,
373
+ updateDraggedClipPreviewRef,
374
+ restoreGroupResizeMembers,
375
+ updateElement,
376
+ onMoveElementRef,
377
+ onMoveElementsRef,
378
+ onResizeElementRef,
379
+ onResizeElementsRef,
380
+ onBlockedEditAttemptRef,
381
+ readZIndexRef,
382
+ onStackingPatchesRef,
383
+ refreshAfterLaneMoveRef,
384
+ });
575
385
  });
576
386
 
577
387
  return {
@@ -0,0 +1,95 @@
1
+ import { useMemo, type RefObject } from "react";
2
+ import { createTimelineClipIndex } from "../lib/timelineClipIndex";
3
+ import type { TimelineElement } from "../store/playerStore";
4
+ import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
5
+ import type { TimelineRowGeometry } from "./timelineLayout";
6
+ import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
7
+ import { useTimelineRevealClip } from "./useTimelineRevealClip";
8
+
9
+ interface UseTimelineClipRenderWindowInput {
10
+ tracks: Parameters<typeof createTimelineClipIndex>[0];
11
+ viewport: TimelineScrollViewportSnapshot;
12
+ pixelsPerSecond: number;
13
+ contentOrigin: number;
14
+ duration: number;
15
+ selectedElementId?: string;
16
+ draggedElementId?: string;
17
+ resizingElementId?: string;
18
+ revealElementId?: string;
19
+ focusedEaseElementId?: string;
20
+ clipContextMenuElementId?: string;
21
+ keyframeContextMenuElementId?: string;
22
+ focusedElementId?: string;
23
+ scrollRef: RefObject<HTMLDivElement | null>;
24
+ elements: readonly TimelineElement[];
25
+ rowGeometry: TimelineRowGeometry;
26
+ allowHorizontalReveal: boolean;
27
+ rowVirtualizationActive: boolean;
28
+ sessionEpoch: number;
29
+ }
30
+
31
+ export function useTimelineClipRenderWindow({
32
+ tracks,
33
+ viewport,
34
+ pixelsPerSecond,
35
+ contentOrigin,
36
+ duration,
37
+ selectedElementId,
38
+ draggedElementId,
39
+ resizingElementId,
40
+ revealElementId,
41
+ focusedEaseElementId,
42
+ clipContextMenuElementId,
43
+ keyframeContextMenuElementId,
44
+ focusedElementId,
45
+ scrollRef,
46
+ elements,
47
+ rowGeometry,
48
+ allowHorizontalReveal,
49
+ rowVirtualizationActive,
50
+ sessionEpoch,
51
+ }: UseTimelineClipRenderWindowInput) {
52
+ const clipIndex = useMemo(() => createTimelineClipIndex(tracks), [tracks]);
53
+ const renderTimeRange = useMemo(
54
+ () => getTimelineRenderTimeRange(viewport, pixelsPerSecond, contentOrigin, duration),
55
+ [contentOrigin, duration, pixelsPerSecond, viewport],
56
+ );
57
+ const pinnedClipIdentities = useMemo(
58
+ () =>
59
+ new Set(
60
+ [
61
+ selectedElementId,
62
+ draggedElementId,
63
+ resizingElementId,
64
+ revealElementId,
65
+ focusedEaseElementId,
66
+ clipContextMenuElementId,
67
+ keyframeContextMenuElementId,
68
+ focusedElementId,
69
+ ].filter((identity): identity is string => identity !== undefined),
70
+ ),
71
+ [
72
+ clipContextMenuElementId,
73
+ draggedElementId,
74
+ focusedEaseElementId,
75
+ focusedElementId,
76
+ keyframeContextMenuElementId,
77
+ resizingElementId,
78
+ revealElementId,
79
+ selectedElementId,
80
+ ],
81
+ );
82
+ useTimelineRevealClip({
83
+ scrollRef,
84
+ elements,
85
+ rowGeometry,
86
+ pixelsPerSecond,
87
+ contentOrigin,
88
+ allowHorizontal: allowHorizontalReveal,
89
+ deferFocusUntilViewportUpdate: rowVirtualizationActive,
90
+ focusedElementId,
91
+ viewportVersion: viewport,
92
+ sessionEpoch,
93
+ });
94
+ return { clipIndex, renderTimeRange, pinnedClipIdentities };
95
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, type RefObject } from "react";
1
+ import { useEffect, useRef, type RefObject } from "react";
2
2
  import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore";
3
3
  import { getTimelinePixelsPerSecond } from "./timelineZoom";
4
4
  import {
@@ -78,13 +78,6 @@ export function useTimelineGeometry({
78
78
  resizeGhostEndPx,
79
79
  });
80
80
  const displayDuration = pps > 0 ? displayContentWidth / pps : effectiveDuration;
81
- const clipStateVersion = useMemo(
82
- () =>
83
- expandedElements
84
- .map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`)
85
- .join("|"),
86
- [expandedElements],
87
- );
88
81
  const zoomModeRef = useRef(zoomMode);
89
82
  zoomModeRef.current = zoomMode;
90
83
  const manualZoomPercentRef = useRef(manualZoomPercent);
@@ -92,7 +85,7 @@ export function useTimelineGeometry({
92
85
  fitPpsRef.current = fitPps;
93
86
 
94
87
  // Restore the horizontal scroll offset after an edit re-derives the elements
95
- // (clipStateVersion changes) so the reload doesn't jump the view. Only in manual
88
+ // (the immutable element snapshot changes) so the reload doesn't jump the view. Only in manual
96
89
  // (pinned) mode — fit mode hides the x-scrollbar (scrollLeft is always 0) — and
97
90
  // never mid-drag (auto-scroll owns the offset then). rAF waits for the new layout
98
91
  // so the clamp reads the post-resync scrollWidth. zoomMode is a legitimate dep:
@@ -109,7 +102,7 @@ export function useTimelineGeometry({
109
102
  });
110
103
  return () => cancelAnimationFrame(raf);
111
104
  // eslint-disable-next-line react-hooks/exhaustive-deps
112
- }, [clipStateVersion, zoomMode]);
105
+ }, [expandedElements, zoomMode]);
113
106
  // Publish the live scale so edit handlers OUTSIDE <Timeline> (the keyboard-delete
114
107
  // path) can pin the zoom via pinTimelineZoomToCurrent without threading geometry.
115
108
  // In a useEffect (not the render body) so React-18 concurrent replay — Suspense
@@ -125,7 +118,7 @@ export function useTimelineGeometry({
125
118
  fitPps,
126
119
  displayContentWidth,
127
120
  displayDuration,
128
- clipStateVersion,
121
+ clipStateVersion: expandedElements,
129
122
  zoomModeRef,
130
123
  manualZoomPercentRef,
131
124
  };