@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
@@ -0,0 +1,97 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import type { SetStateAction } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
6
+ import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
7
+ import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
8
+
9
+ afterEach(() => {
10
+ document.body.replaceChildren();
11
+ usePlayerStore.getState().reset();
12
+ });
13
+
14
+ describe("timeline clip drag gesture lifecycle", () => {
15
+ it("finishes a drag after virtualization unmounts its source row", () => {
16
+ const element: TimelineElement = {
17
+ id: "clip-1",
18
+ tag: "div",
19
+ start: 0,
20
+ duration: 2,
21
+ track: 0,
22
+ };
23
+ const drag: DraggedClipState = {
24
+ element,
25
+ originClientX: 0,
26
+ originClientY: 0,
27
+ originScrollLeft: 0,
28
+ originScrollTop: 0,
29
+ pointerClientX: 0,
30
+ pointerClientY: 0,
31
+ pointerOffsetX: 0,
32
+ pointerOffsetY: 0,
33
+ previewStart: 1,
34
+ previewTrack: 0,
35
+ desiredTrack: 0,
36
+ insertRow: null,
37
+ snapTime: null,
38
+ snapType: null,
39
+ started: true,
40
+ };
41
+ const draggedClipRef = { current: drag as DraggedClipState | null };
42
+ const resizingClipRef = { current: null as ResizingClipState | null };
43
+ const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
44
+ draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
45
+ };
46
+ const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
47
+ ...previous,
48
+ previewStart: 3,
49
+ }));
50
+ const onMoveElement = vi.fn();
51
+ const stopAutoScroll = vi.fn();
52
+ const dispose = mountTimelineClipDragGestureLifecycle({
53
+ draggedClipRef,
54
+ resizingClipRef,
55
+ blockedClipRef: { current: null },
56
+ groupResizeRef: { current: null },
57
+ suppressClickRef: { current: false },
58
+ elementsRef: { current: [element] },
59
+ trackOrderRef: { current: [0] },
60
+ setDraggedClip,
61
+ setResizingClip: () => {},
62
+ setShowPopover: () => {},
63
+ setRangeSelectionRef: { current: null },
64
+ applyResizePointerRef: { current: () => {} },
65
+ syncClipDragAutoScrollRef: { current: () => {} },
66
+ stopClipDragAutoScrollRef: { current: stopAutoScroll },
67
+ updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
68
+ restoreGroupResizeMembers: () => {},
69
+ updateElement: vi.fn(),
70
+ onMoveElementRef: { current: onMoveElement },
71
+ onMoveElementsRef: { current: undefined },
72
+ onResizeElementRef: { current: undefined },
73
+ onResizeElementsRef: { current: undefined },
74
+ onBlockedEditAttemptRef: { current: undefined },
75
+ readZIndexRef: { current: undefined },
76
+ onStackingPatchesRef: { current: undefined },
77
+ refreshAfterLaneMoveRef: { current: undefined },
78
+ });
79
+
80
+ const sourceRow = document.createElement("div");
81
+ sourceRow.dataset.timelineRow = "";
82
+ sourceRow.append(document.createElement("div"));
83
+ document.body.append(sourceRow);
84
+ sourceRow.remove();
85
+
86
+ window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
87
+ expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1);
88
+ expect(draggedClipRef.current?.previewStart).toBe(3);
89
+
90
+ window.dispatchEvent(new MouseEvent("pointerup"));
91
+ expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
92
+ expect(draggedClipRef.current).toBeNull();
93
+ expect(stopAutoScroll).toHaveBeenCalledTimes(1);
94
+
95
+ dispose();
96
+ });
97
+ });
@@ -0,0 +1,261 @@
1
+ import type { Dispatch, RefObject, SetStateAction } from "react";
2
+ import { resolveTimelineDragEscape } from "./timelineEditing";
3
+ import { commitDraggedClipMove } from "./timelineClipDragCommit";
4
+ import type {
5
+ BlockedClipState,
6
+ DraggedClipState,
7
+ ResizingClipState,
8
+ } from "./timelineClipDragTypes";
9
+ import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
10
+ import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
11
+ import {
12
+ beginTimelineOptimisticGesture,
13
+ rollbackLatestTimelineOptimisticGesture,
14
+ } from "./timelineOptimisticRevision";
15
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
16
+ import type { StackingPatch } from "./timelineStackingSync";
17
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
18
+
19
+ type UpdateElement = ReturnType<typeof usePlayerStore.getState>["updateElement"];
20
+
21
+ interface TimelineClipDragGestureLifecycleInput {
22
+ draggedClipRef: RefObject<DraggedClipState | null>;
23
+ resizingClipRef: RefObject<ResizingClipState | null>;
24
+ blockedClipRef: RefObject<BlockedClipState | null>;
25
+ groupResizeRef: RefObject<TimelineGroupResizeSession | null>;
26
+ suppressClickRef: RefObject<boolean>;
27
+ elementsRef: RefObject<TimelineElement[]>;
28
+ trackOrderRef: RefObject<number[]>;
29
+ setDraggedClip: Dispatch<SetStateAction<DraggedClipState | null>>;
30
+ setResizingClip: Dispatch<SetStateAction<ResizingClipState | null>>;
31
+ setShowPopover: (show: boolean) => void;
32
+ setRangeSelectionRef: RefObject<((selection: null) => void) | null>;
33
+ applyResizePointerRef: RefObject<(resize: ResizingClipState, clientX: number) => void>;
34
+ syncClipDragAutoScrollRef: RefObject<(clientX: number, clientY: number) => void>;
35
+ stopClipDragAutoScrollRef: RefObject<() => void>;
36
+ updateDraggedClipPreviewRef: RefObject<
37
+ (drag: DraggedClipState, clientX: number, clientY: number) => DraggedClipState
38
+ >;
39
+ restoreGroupResizeMembers: (session: TimelineGroupResizeSession, all?: boolean) => void;
40
+ updateElement: UpdateElement;
41
+ onMoveElementRef: RefObject<TimelineEditCallbacks["onMoveElement"]>;
42
+ onMoveElementsRef: RefObject<TimelineEditCallbacks["onMoveElements"]>;
43
+ onResizeElementRef: RefObject<TimelineEditCallbacks["onResizeElement"]>;
44
+ onResizeElementsRef: RefObject<TimelineEditCallbacks["onResizeElements"]>;
45
+ onBlockedEditAttemptRef: RefObject<
46
+ ((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined
47
+ >;
48
+ readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>;
49
+ onStackingPatchesRef: RefObject<
50
+ ((patches: StackingPatch[]) => Promise<unknown> | void) | undefined
51
+ >;
52
+ refreshAfterLaneMoveRef: RefObject<(() => void) | undefined>;
53
+ }
54
+
55
+ export function mountTimelineClipDragGestureLifecycle({
56
+ // The explicit destructuring mirrors the single call site's dependency object by design.
57
+ // fallow-ignore-next-line code-duplication
58
+ draggedClipRef,
59
+ resizingClipRef,
60
+ blockedClipRef,
61
+ groupResizeRef,
62
+ suppressClickRef,
63
+ elementsRef,
64
+ trackOrderRef,
65
+ setDraggedClip,
66
+ setResizingClip,
67
+ setShowPopover,
68
+ setRangeSelectionRef,
69
+ applyResizePointerRef,
70
+ syncClipDragAutoScrollRef,
71
+ stopClipDragAutoScrollRef,
72
+ updateDraggedClipPreviewRef,
73
+ restoreGroupResizeMembers,
74
+ updateElement,
75
+ onMoveElementRef,
76
+ onMoveElementsRef,
77
+ onResizeElementRef,
78
+ onResizeElementsRef,
79
+ onBlockedEditAttemptRef,
80
+ readZIndexRef,
81
+ onStackingPatchesRef,
82
+ refreshAfterLaneMoveRef,
83
+ }: TimelineClipDragGestureLifecycleInput): () => void {
84
+ const clearSuppressedClick = () => {
85
+ requestAnimationFrame(() => {
86
+ suppressClickRef.current = false;
87
+ });
88
+ };
89
+
90
+ const handleResizePointerMove = (event: PointerEvent, resize: ResizingClipState) => {
91
+ const distance = Math.abs(event.clientX - resize.originClientX);
92
+ if (!resize.started && distance < 2) return;
93
+ setShowPopover(false);
94
+ setRangeSelectionRef.current?.(null);
95
+ applyResizePointerRef.current(resize, event.clientX);
96
+ syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
97
+ };
98
+
99
+ const handleBlockedPointerMove = (event: PointerEvent, blocked: BlockedClipState) => {
100
+ const distance = Math.hypot(
101
+ event.clientX - blocked.originClientX,
102
+ event.clientY - blocked.originClientY,
103
+ );
104
+ const threshold = blocked.intent === "move" ? 4 : 2;
105
+ if (!blocked.started && distance < threshold) return;
106
+ if (!blocked.started) {
107
+ blocked.started = true;
108
+ blockedClipRef.current = blocked;
109
+ suppressClickRef.current = true;
110
+ setShowPopover(false);
111
+ setRangeSelectionRef.current?.(null);
112
+ onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent);
113
+ }
114
+ };
115
+
116
+ const handleDragPointerMove = (event: PointerEvent, drag: DraggedClipState) => {
117
+ const distance = Math.hypot(
118
+ event.clientX - drag.originClientX,
119
+ event.clientY - drag.originClientY,
120
+ );
121
+ if (!drag.started && distance < 4) return;
122
+ setShowPopover(false);
123
+ setRangeSelectionRef.current?.(null);
124
+ setDraggedClip((previous) =>
125
+ previous
126
+ ? updateDraggedClipPreviewRef.current(previous, event.clientX, event.clientY)
127
+ : previous,
128
+ );
129
+ syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
130
+ };
131
+
132
+ const handleWindowPointerMove = (event: PointerEvent) => {
133
+ const resize = resizingClipRef.current;
134
+ if (resize) return handleResizePointerMove(event, resize);
135
+ const blocked = blockedClipRef.current;
136
+ if (blocked) return handleBlockedPointerMove(event, blocked);
137
+ const drag = draggedClipRef.current;
138
+ if (drag) handleDragPointerMove(event, drag);
139
+ };
140
+
141
+ const commitResizePointerUp = (resize: ResizingClipState) => {
142
+ resizingClipRef.current = null;
143
+ setResizingClip(null);
144
+ const groupSession = groupResizeRef.current;
145
+ groupResizeRef.current = null;
146
+ if (!resize.started) {
147
+ if (groupSession) restoreGroupResizeMembers(groupSession);
148
+ return;
149
+ }
150
+ suppressClickRef.current = true;
151
+ clearSuppressedClick();
152
+ if (groupSession) {
153
+ commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current);
154
+ return;
155
+ }
156
+ const hasChanged =
157
+ resize.previewStart !== resize.element.start ||
158
+ resize.previewDuration !== resize.element.duration ||
159
+ resize.previewPlaybackStart !== resize.element.playbackStart;
160
+ if (!hasChanged) return;
161
+
162
+ const resizeKey = resize.element.key ?? resize.element.id;
163
+ const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]);
164
+ updateElement(resizeKey, {
165
+ start: resize.previewStart,
166
+ duration: resize.previewDuration,
167
+ playbackStart: resize.previewPlaybackStart,
168
+ });
169
+ Promise.resolve(
170
+ onResizeElementRef.current?.(resize.element, {
171
+ start: resize.previewStart,
172
+ duration: resize.previewDuration,
173
+ playbackStart: resize.previewPlaybackStart,
174
+ }),
175
+ ).catch((error) => {
176
+ rollbackLatestTimelineOptimisticGesture(updateElement, revision, [
177
+ {
178
+ key: resizeKey,
179
+ updates: {
180
+ start: resize.element.start,
181
+ duration: resize.element.duration,
182
+ playbackStart: resize.element.playbackStart,
183
+ },
184
+ },
185
+ ]);
186
+ console.error("[Timeline] Failed to persist clip resize", error);
187
+ });
188
+ };
189
+
190
+ const finishBlockedPointerUp = (blocked: BlockedClipState) => {
191
+ blockedClipRef.current = null;
192
+ if (blocked.started) clearSuppressedClick();
193
+ };
194
+
195
+ const commitDragPointerUp = (drag: DraggedClipState) => {
196
+ draggedClipRef.current = null;
197
+ setDraggedClip(null);
198
+ if (!drag.started) return;
199
+ suppressClickRef.current = true;
200
+ clearSuppressedClick();
201
+ commitDraggedClipMove(drag, {
202
+ elements: elementsRef.current,
203
+ trackOrder: trackOrderRef.current,
204
+ updateElement,
205
+ onMoveElement: onMoveElementRef.current,
206
+ onMoveElements: onMoveElementsRef.current,
207
+ selectedKeys: usePlayerStore.getState().selectedElementIds,
208
+ readZIndex: readZIndexRef.current,
209
+ onStackingPatches: onStackingPatchesRef.current,
210
+ refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
211
+ });
212
+ };
213
+
214
+ const handleWindowPointerUp = () => {
215
+ stopClipDragAutoScrollRef.current();
216
+ const resize = resizingClipRef.current;
217
+ if (resize) return commitResizePointerUp(resize);
218
+ const blocked = blockedClipRef.current;
219
+ if (blocked) return finishBlockedPointerUp(blocked);
220
+ const drag = draggedClipRef.current;
221
+ if (!drag) {
222
+ if (suppressClickRef.current) clearSuppressedClick();
223
+ return;
224
+ }
225
+ commitDragPointerUp(drag);
226
+ };
227
+
228
+ const handleWindowKeyDown = (event: KeyboardEvent) => {
229
+ const decision = resolveTimelineDragEscape({
230
+ key: event.key,
231
+ drag: draggedClipRef.current,
232
+ resize: resizingClipRef.current,
233
+ blocked: blockedClipRef.current,
234
+ });
235
+ if (!decision.cancel) return;
236
+ event.preventDefault();
237
+ event.stopPropagation();
238
+ stopClipDragAutoScrollRef.current();
239
+ draggedClipRef.current = null;
240
+ setDraggedClip(null);
241
+ resizingClipRef.current = null;
242
+ setResizingClip(null);
243
+ const groupSession = groupResizeRef.current;
244
+ groupResizeRef.current = null;
245
+ if (groupSession) restoreGroupResizeMembers(groupSession);
246
+ blockedClipRef.current = null;
247
+ if (decision.suppressClick) suppressClickRef.current = true;
248
+ };
249
+
250
+ window.addEventListener("pointermove", handleWindowPointerMove);
251
+ window.addEventListener("pointerup", handleWindowPointerUp);
252
+ window.addEventListener("pointercancel", handleWindowPointerUp);
253
+ window.addEventListener("keydown", handleWindowKeyDown, true);
254
+ return () => {
255
+ stopClipDragAutoScrollRef.current();
256
+ window.removeEventListener("pointermove", handleWindowPointerMove);
257
+ window.removeEventListener("pointerup", handleWindowPointerUp);
258
+ window.removeEventListener("pointercancel", handleWindowPointerUp);
259
+ window.removeEventListener("keydown", handleWindowKeyDown, true);
260
+ };
261
+ }
@@ -29,6 +29,8 @@ export interface TimelineClipDiamondsProps {
29
29
  keyframesData: KeyframeCacheEntry;
30
30
  clipWidthPx: number;
31
31
  clipHeightPx: number;
32
+ /** Needed to compare the playhead to keyframes in output-frame time. */
33
+ clipDuration: number;
32
34
  /** Beat-dot strip is shown on this track → shrink diamonds + drop them into
33
35
  * the bottom half so they clear the strip at the top. */
34
36
  beatsActive?: boolean;
@@ -70,7 +72,10 @@ export interface TimelineDiamondLaneProps extends Omit<
70
72
  | "onContextMenuKeyframe"
71
73
  | "onMoveKeyframe"
72
74
  | "onSelectSegment"
75
+ | "clipDuration"
73
76
  > {
77
+ /** Isolated lanes may omit timing; without it no playhead diamond is marked. */
78
+ clipDuration?: number;
74
79
  groupAware?: boolean;
75
80
  globalEase?: string;
76
81
  onSelectSegment?: (target: TimelineKeyframeTarget) => void;
@@ -5,7 +5,7 @@ import {
5
5
  TIMELINE_COMPOSITION_MIME,
6
6
  } from "../../utils/timelineCompositionDrop";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
- import { resolveTimelineAssetDrop } from "./timelineLayout";
8
+ import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
9
9
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
10
10
 
11
11
  interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
@@ -13,7 +13,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
13
13
  ppsRef: RefObject<number>;
14
14
  durationRef: RefObject<number>;
15
15
  trackOrderRef: RefObject<number[]>;
16
- rowHeightsRef: RefObject<readonly number[]>;
16
+ rowGeometryRef: RefObject<TimelineRowGeometry>;
17
17
  contentOrigin: number;
18
18
  }
19
19
 
@@ -56,7 +56,7 @@ export function useTimelineAssetDrop({
56
56
  ppsRef,
57
57
  durationRef,
58
58
  trackOrderRef,
59
- rowHeightsRef,
59
+ rowGeometryRef,
60
60
  contentOrigin,
61
61
  onFileDrop,
62
62
  onAssetDrop,
@@ -93,7 +93,7 @@ export function useTimelineAssetDrop({
93
93
  pixelsPerSecond: ppsRef.current,
94
94
  duration: durationRef.current,
95
95
  clampStartToDuration: !usePointerStart,
96
- rowHeights: rowHeightsRef.current,
96
+ rowHeights: rowGeometryRef.current.rowHeights,
97
97
  trackOrder: trackOrderRef.current,
98
98
  },
99
99
  clientX,
@@ -104,7 +104,7 @@ export function useTimelineAssetDrop({
104
104
  track: pointer.track,
105
105
  };
106
106
  },
107
- [scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, contentOrigin],
107
+ [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin],
108
108
  );
109
109
 
110
110
  const handleAssetDrop = useCallback(
@@ -669,6 +669,38 @@ describe("resolveTimelineAutoScroll", () => {
669
669
  ),
670
670
  ).toEqual({ x: 9, y: 6 });
671
671
  });
672
+
673
+ it("uses the time-grid edge instead of the viewport edge when labels are sticky", () => {
674
+ expect(
675
+ resolveTimelineAutoScroll(
676
+ {
677
+ left: 100,
678
+ top: 100,
679
+ right: 700,
680
+ bottom: 400,
681
+ },
682
+ 340,
683
+ 250,
684
+ 232,
685
+ ),
686
+ ).toEqual({ x: -10, y: 0 });
687
+ });
688
+
689
+ it("caps auto-scroll speed when the pointer moves inside the sticky labels", () => {
690
+ expect(
691
+ resolveTimelineAutoScroll(
692
+ {
693
+ left: 100,
694
+ top: 100,
695
+ right: 700,
696
+ bottom: 400,
697
+ },
698
+ 120,
699
+ 250,
700
+ 232,
701
+ ),
702
+ ).toEqual({ x: -12, y: 0 });
703
+ });
672
704
  });
673
705
 
674
706
  describe("buildTimelineAgentPrompt", () => {
@@ -84,21 +84,24 @@ export function resolveTimelineAutoScroll(
84
84
  bounds: TimelineAutoScrollBounds,
85
85
  clientX: number,
86
86
  clientY: number,
87
+ leftInset = 0,
87
88
  ): { x: number; y: number } {
88
89
  const getAxisDelta = (start: number, end: number, pointer: number) => {
89
90
  if (pointer < start + AUTO_SCROLL_EDGE_ZONE) {
90
- const proximity = Math.max(0, 1 - (pointer - start) / AUTO_SCROLL_EDGE_ZONE);
91
+ const proximity = Math.min(1, Math.max(0, 1 - (pointer - start) / AUTO_SCROLL_EDGE_ZONE));
91
92
  return -Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
92
93
  }
93
94
  if (pointer > end - AUTO_SCROLL_EDGE_ZONE) {
94
- const proximity = Math.max(0, 1 - (end - pointer) / AUTO_SCROLL_EDGE_ZONE);
95
+ const proximity = Math.min(1, Math.max(0, 1 - (end - pointer) / AUTO_SCROLL_EDGE_ZONE));
95
96
  return Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
96
97
  }
97
98
  return 0;
98
99
  };
99
100
 
101
+ const horizontalStart = Math.min(bounds.right, bounds.left + Math.max(0, leftInset));
102
+
100
103
  return {
101
- x: getAxisDelta(bounds.left, bounds.right, clientX),
104
+ x: getAxisDelta(horizontalStart, bounds.right, clientX),
102
105
  y: getAxisDelta(bounds.top, bounds.bottom, clientY),
103
106
  };
104
107
  }
@@ -485,7 +488,41 @@ export function applyTimelineAutoScrollStep(
485
488
  clientX: number,
486
489
  clientY: number,
487
490
  ): boolean {
488
- const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
491
+ return applyTimelineAutoScrollDelta(
492
+ scroll,
493
+ resolveTimelineAutoScroll(
494
+ scroll.getBoundingClientRect(),
495
+ clientX,
496
+ clientY,
497
+ getTimelineAutoScrollLeftInset(scroll),
498
+ ),
499
+ );
500
+ }
501
+
502
+ /** Apply one horizontal edge-scroll step while scrubbing the ruler/playhead. */
503
+ export function applyTimelineHorizontalAutoScrollStep(
504
+ scroll: HTMLElement,
505
+ clientX: number,
506
+ ): boolean {
507
+ const bounds = scroll.getBoundingClientRect();
508
+ const delta = resolveTimelineAutoScroll(
509
+ bounds,
510
+ clientX,
511
+ bounds.top + (bounds.bottom - bounds.top) / 2,
512
+ getTimelineAutoScrollLeftInset(scroll),
513
+ );
514
+ return applyTimelineAutoScrollDelta(scroll, { x: delta.x, y: 0 });
515
+ }
516
+
517
+ function getTimelineAutoScrollLeftInset(scroll: HTMLElement): number {
518
+ const inset = Number(scroll.dataset.timelineAutoScrollLeftInset);
519
+ return Number.isFinite(inset) ? Math.max(0, inset) : 0;
520
+ }
521
+
522
+ function applyTimelineAutoScrollDelta(
523
+ scroll: HTMLElement,
524
+ delta: { x: number; y: number },
525
+ ): boolean {
489
526
  if (delta.x === 0 && delta.y === 0) return false;
490
527
  const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
491
528
  const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
@@ -510,7 +547,12 @@ export function resolveTimelineAutoScrollLoopAction(
510
547
  rafActive: boolean,
511
548
  ): "start" | "stop" | "none" {
512
549
  if (!scroll) return "none";
513
- const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
550
+ const delta = resolveTimelineAutoScroll(
551
+ scroll.getBoundingClientRect(),
552
+ clientX,
553
+ clientY,
554
+ getTimelineAutoScrollLeftInset(scroll),
555
+ );
514
556
  if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none";
515
557
  return rafActive ? "none" : "start";
516
558
  }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { resolveTimelineFocusIdentity } from "./timelineFocusIdentity";
4
+
5
+ describe("timeline focus identity", () => {
6
+ const elements: TimelineElement[] = [
7
+ { id: "clip/a", key: "stable:key", tag: "div", start: 0, duration: 1, track: 7.5 },
8
+ ];
9
+
10
+ it("resolves a stable element id and fractional display row from model identity", () => {
11
+ expect(resolveTimelineFocusIdentity(elements, "stable:key")).toEqual({
12
+ elementId: "stable:key",
13
+ rowKey: 7.5,
14
+ });
15
+ });
16
+
17
+ it("does not invent focus for a missing or cleared identity", () => {
18
+ expect(resolveTimelineFocusIdentity(elements, "missing")).toBeNull();
19
+ expect(resolveTimelineFocusIdentity(elements, null)).toBeNull();
20
+ });
21
+ });
@@ -0,0 +1,21 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
3
+
4
+ export interface TimelineFocusIdentity {
5
+ readonly elementId: string;
6
+ readonly rowKey: number;
7
+ }
8
+
9
+ /** Resolve logical focus from model identity; mount state is deliberately irrelevant. */
10
+ export function resolveTimelineFocusIdentity(
11
+ elements: readonly TimelineElement[],
12
+ elementId: string | null,
13
+ ): TimelineFocusIdentity | null {
14
+ if (!elementId) return null;
15
+ const element = getTimelineElementIndexes(elements).byKey.get(elementId);
16
+ if (!element) return null;
17
+ return {
18
+ elementId,
19
+ rowKey: element.track,
20
+ };
21
+ }
@@ -6,6 +6,9 @@ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
6
6
  import type { TimelineTheme } from "./timelineTheme";
7
7
  import type { TrackVisualStyle } from "./timelineIcons";
8
8
  import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
9
+ import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
10
+ import type { TimelineRowGeometry } from "./timelineLayout";
11
+ import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
9
12
 
10
13
  /**
11
14
  * Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
@@ -22,6 +25,12 @@ export interface TimelineLaneBaseProps {
22
25
  theme: TimelineTheme;
23
26
  displayTrackOrder: number[];
24
27
  rowHeights: readonly number[];
28
+ rowGeometry: TimelineRowGeometry;
29
+ virtualRows: readonly TimelineVirtualRow[];
30
+ rowsVirtualized: boolean;
31
+ clipIndex: TimelineClipIndex;
32
+ renderTimeRange: TimelineTimeRange;
33
+ pinnedClipIdentities: ReadonlySet<string>;
25
34
  trackOrder: number[];
26
35
  tracks: [number, TimelineElement[]][];
27
36
  trackStyles: Map<number, TrackVisualStyle>;
@@ -15,9 +15,50 @@ import {
15
15
  getTimelineRowFromY,
16
16
  getTimelineRowOffsets,
17
17
  getTimelineCanvasHeight,
18
+ createTimelineRowGeometry,
19
+ getTimelineRowGeometry,
18
20
  trackHeights,
19
21
  resolveTimelineAssetDrop,
22
+ getTimelineBeatEntries,
20
23
  } from "./timelineLayout";
24
+ import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
25
+ import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
26
+
27
+ describe("horizontal timeline window", () => {
28
+ it("adds the shared quarter-viewport overscan on each side and clamps to duration", () => {
29
+ expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual(
30
+ { start: 0, end: 7.25 },
31
+ );
32
+ expect(
33
+ getTimelineRenderTimeRange({ scrollLeft: 1_900, clientWidth: 500 }, 100, 200, 20),
34
+ ).toEqual({ start: 15.75, end: 20 });
35
+ });
36
+
37
+ it("generates globally aligned ticks directly inside the bounded window", () => {
38
+ const ticks = generateTicks(10_000, 100, undefined, { start: 500.2, end: 501.8 });
39
+ const interval = getTimelineMajorTickInterval(10_000, 100);
40
+ expect(ticks.major.every((time) => time >= 500.2 && time <= 501.8)).toBe(true);
41
+ expect(
42
+ ticks.major.every((time) => Math.abs(time / interval - Math.round(time / interval)) < 1e-6),
43
+ ).toBe(true);
44
+ expect(ticks.major.length + ticks.minor.length).toBeLessThan(100);
45
+ });
46
+
47
+ it("slices beat records with original strength indexes and unions a pinned beat", () => {
48
+ expect(
49
+ getTimelineBeatEntries(
50
+ [0, 1, 2, 3],
51
+ [0.1, 0.2, 0.3, 0.4],
52
+ { start: 1, end: 3 },
53
+ new Set([3]),
54
+ ),
55
+ ).toEqual([
56
+ { index: 1, time: 1, strength: 0.2 },
57
+ { index: 2, time: 2, strength: 0.3 },
58
+ { index: 3, time: 3, strength: 0.4 },
59
+ ]);
60
+ });
61
+ });
21
62
 
22
63
  /** N collapsed rows, the shape every caller passes when nothing is expanded. */
23
64
  const baseRows = (count: number) => Array.from({ length: count }, () => TRACK_H);
@@ -64,6 +105,24 @@ describe("variable timeline row geometry", () => {
64
105
  RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD,
65
106
  );
66
107
  });
108
+
109
+ it("reuses one immutable geometry snapshot for one height array", () => {
110
+ const heights = trackHeights(tracks, new Set(["b"]));
111
+ const first = getTimelineRowGeometry(heights);
112
+ expect(getTimelineRowGeometry(heights)).toBe(first);
113
+ expect(Object.isFrozen(first)).toBe(true);
114
+ expect(Object.isFrozen(first.rowOffsets)).toBe(true);
115
+ });
116
+
117
+ it("looks up row boundaries through the precomputed geometry", () => {
118
+ const geometry = createTimelineRowGeometry([4, 8, 12], [48, 104, 76]);
119
+ expect(getTimelineRowGeometry(geometry.rowHeights)).toBe(geometry);
120
+ expect(geometry.getRowIndex(8)).toBe(1);
121
+ expect(geometry.getRowFromY(geometry.getRowTop(1))).toBe(1);
122
+ expect(geometry.getRowFromY(geometry.getRowTop(2) - 0.001)).toBeLessThan(2);
123
+ expect(geometry.getRowFromY(geometry.getRowTop(2))).toBe(2);
124
+ expect(geometry.canvasHeight).toBe(RULER_H + TRACKS_TOP_PAD + 228 + TRACKS_BOTTOM_PAD);
125
+ });
67
126
  });
68
127
 
69
128
  describe("collapsed timeline row geometry characterization", () => {