@hyperframes/studio 0.7.86 → 0.7.88

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-B03_wTTS.js} +1 -1
  2. package/dist/assets/{index-B5QGai9B.js → index-BNshnsvB.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-C0Vr8jjc.js → index-D7KcL_t3.js} +1 -1
  5. package/dist/assets/{index-CISVI7UC.js → index-DbY124Po.js} +221 -221
  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,202 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useRef } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
+
9
+ let resizeCallback: ResizeObserverCallback | null = null;
10
+ class MockResizeObserver {
11
+ constructor(callback: ResizeObserverCallback) {
12
+ resizeCallback = callback;
13
+ }
14
+ observe() {}
15
+ disconnect() {}
16
+ }
17
+
18
+ const originalResizeObserver = globalThis.ResizeObserver;
19
+
20
+ beforeEach(() => {
21
+ vi.useFakeTimers();
22
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.useRealTimers();
27
+ vi.unstubAllEnvs();
28
+ globalThis.ResizeObserver = originalResizeObserver;
29
+ resizeCallback = null;
30
+ document.body.innerHTML = "";
31
+ });
32
+
33
+ interface Harness {
34
+ hook: () => ReturnType<
35
+ typeof import("./useTimelineScrollViewport").useTimelineScrollViewport
36
+ > | null;
37
+ element: HTMLDivElement;
38
+ values: {
39
+ left: number;
40
+ top: number;
41
+ width: number;
42
+ height: number;
43
+ scrollWidth: number;
44
+ scrollHeight: number;
45
+ };
46
+ unmount: () => void;
47
+ }
48
+
49
+ /**
50
+ * The row-virtualization flag is a module constant, so the env stub has to be in
51
+ * place before the hook module is imported. Each harness therefore resets the
52
+ * module registry and imports fresh, which is what lets one file exercise both
53
+ * flag states.
54
+ */
55
+ async function mountHarness(rowVirtualizationEnabled: boolean): Promise<Harness> {
56
+ vi.stubEnv(
57
+ "VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED",
58
+ rowVirtualizationEnabled ? "1" : "0",
59
+ );
60
+ vi.resetModules();
61
+ const { useTimelineScrollViewport } = await import("./useTimelineScrollViewport");
62
+
63
+ let current: ReturnType<typeof useTimelineScrollViewport> | null = null;
64
+ function Probe() {
65
+ current = useTimelineScrollViewport(useRef<HTMLDivElement>(null), []);
66
+ return null;
67
+ }
68
+
69
+ const root = createRoot(document.createElement("div"));
70
+ act(() => root.render(React.createElement(Probe)));
71
+
72
+ const element = document.createElement("div");
73
+ const values = {
74
+ left: 0,
75
+ top: 0,
76
+ width: 640,
77
+ height: 240,
78
+ scrollWidth: 1200,
79
+ scrollHeight: 800,
80
+ };
81
+ Object.defineProperties(element, {
82
+ scrollLeft: { configurable: true, get: () => values.left },
83
+ scrollTop: { configurable: true, get: () => values.top },
84
+ clientWidth: { configurable: true, get: () => values.width },
85
+ clientHeight: { configurable: true, get: () => values.height },
86
+ scrollWidth: { configurable: true, get: () => values.scrollWidth },
87
+ scrollHeight: { configurable: true, get: () => values.scrollHeight },
88
+ });
89
+
90
+ return { hook: () => current, element, values, unmount: () => act(() => root.unmount()) };
91
+ }
92
+
93
+ function attachScrollElement({ hook, element }: Harness): void {
94
+ act(() => hook()?.setScrollRef(element));
95
+ }
96
+
97
+ function publishResize(harness: Harness, width: number): void {
98
+ harness.values.width = width;
99
+ act(() => resizeCallback?.([], {} as ResizeObserver));
100
+ }
101
+
102
+ function publishScroll(
103
+ harness: Harness,
104
+ { left, top }: { left: number; top: number },
105
+ isScrolling = true,
106
+ ): void {
107
+ harness.values.left = left;
108
+ harness.values.top = top;
109
+ act(() => harness.hook()?.syncScrollViewport(harness.element, isScrolling));
110
+ act(() => vi.advanceTimersByTime(16));
111
+ }
112
+
113
+ function expectResizePublication(harness: Harness): void {
114
+ attachScrollElement(harness);
115
+ expect(harness.hook()?.viewport.clientWidth).toBe(640);
116
+
117
+ publishResize(harness, 800);
118
+ expect(harness.hook()?.viewport.clientWidth).toBe(800);
119
+ }
120
+
121
+ describe("useTimelineScrollViewport with row virtualization on", () => {
122
+ it("publishes resize, scroll, and settled snapshots", async () => {
123
+ const harness = await mountHarness(true);
124
+ const { hook, unmount } = harness;
125
+
126
+ expectResizePublication(harness);
127
+
128
+ publishScroll(harness, { left: 120, top: 48 });
129
+ expect(hook()?.viewport).toMatchObject({ scrollLeft: 120, scrollTop: 48, isScrolling: true });
130
+
131
+ act(() => vi.advanceTimersByTime(100));
132
+ expect(hook()?.viewport.isScrolling).toBe(false);
133
+ unmount();
134
+ });
135
+ });
136
+
137
+ describe("useTimelineScrollViewport with row virtualization off", () => {
138
+ it("publishes nothing while scrolling", async () => {
139
+ const harness = await mountHarness(false);
140
+ const { hook, unmount } = harness;
141
+
142
+ attachScrollElement(harness);
143
+ const before = hook()?.viewport;
144
+
145
+ publishScroll(harness, { left: 120, top: 48 });
146
+
147
+ expect(hook()?.viewport).toBe(before);
148
+ expect(hook()?.viewport.scrollLeft).toBe(0);
149
+ unmount();
150
+ });
151
+
152
+ it("never reports isScrolling, so the settle timer has nothing to undo", async () => {
153
+ const harness = await mountHarness(false);
154
+ const { hook, unmount } = harness;
155
+
156
+ attachScrollElement(harness);
157
+ publishScroll(harness, { left: 0, top: 0 });
158
+ act(() => vi.advanceTimersByTime(500));
159
+
160
+ expect(hook()?.viewport.isScrolling).toBe(false);
161
+ unmount();
162
+ });
163
+
164
+ it("schedules no frame or timer for a scroll sync", async () => {
165
+ const harness = await mountHarness(false);
166
+ const { hook, element, unmount } = harness;
167
+ attachScrollElement(harness);
168
+
169
+ const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame");
170
+ const timerSpy = vi.spyOn(globalThis, "setTimeout");
171
+ act(() => hook()?.syncScrollViewport(element, true));
172
+
173
+ expect(rafSpy).not.toHaveBeenCalled();
174
+ expect(timerSpy).not.toHaveBeenCalled();
175
+ rafSpy.mockRestore();
176
+ timerSpy.mockRestore();
177
+ unmount();
178
+ });
179
+
180
+ it("still publishes resize-driven snapshots", async () => {
181
+ const harness = await mountHarness(false);
182
+ const { unmount } = harness;
183
+
184
+ expectResizePublication(harness);
185
+ unmount();
186
+ });
187
+
188
+ it("still publishes programmatic non-scrolling syncs", async () => {
189
+ const harness = await mountHarness(false);
190
+ const { hook, unmount } = harness;
191
+
192
+ attachScrollElement(harness);
193
+ publishScroll(harness, { left: 240, top: 96 }, false);
194
+
195
+ expect(hook()?.viewport).toMatchObject({
196
+ scrollLeft: 240,
197
+ scrollTop: 96,
198
+ isScrolling: false,
199
+ });
200
+ unmount();
201
+ });
202
+ });
@@ -1,6 +1,45 @@
1
1
  import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { shouldShowTimelineShortcutHint } from "./timelineLayout";
4
+ import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag";
5
+
6
+ export interface TimelineScrollViewportSnapshot {
7
+ readonly scrollLeft: number;
8
+ readonly scrollTop: number;
9
+ readonly clientWidth: number;
10
+ readonly clientHeight: number;
11
+ readonly scrollWidth: number;
12
+ readonly scrollHeight: number;
13
+ readonly isScrolling: boolean;
14
+ }
15
+
16
+ const EMPTY_VIEWPORT: TimelineScrollViewportSnapshot = Object.freeze({
17
+ scrollLeft: 0,
18
+ scrollTop: 0,
19
+ clientWidth: 0,
20
+ clientHeight: 0,
21
+ scrollWidth: 0,
22
+ scrollHeight: 0,
23
+ isScrolling: false,
24
+ });
25
+
26
+ function readTimelineScrollViewport(
27
+ element: Pick<
28
+ HTMLElement,
29
+ "scrollLeft" | "scrollTop" | "clientWidth" | "clientHeight" | "scrollWidth" | "scrollHeight"
30
+ >,
31
+ isScrolling: boolean,
32
+ ): TimelineScrollViewportSnapshot {
33
+ return {
34
+ scrollLeft: element.scrollLeft,
35
+ scrollTop: element.scrollTop,
36
+ clientWidth: element.clientWidth,
37
+ clientHeight: element.clientHeight,
38
+ scrollWidth: element.scrollWidth,
39
+ scrollHeight: element.scrollHeight,
40
+ isScrolling,
41
+ };
42
+ }
4
43
 
5
44
  /**
6
45
  * The timeline scroll container's viewport plumbing — extracted verbatim from
@@ -14,14 +53,46 @@ export function useTimelineScrollViewport(
14
53
  scrollRef: RefObject<HTMLDivElement | null>,
15
54
  resyncShortcutHintOn: ReadonlyArray<unknown>,
16
55
  ): {
17
- viewportWidth: number;
56
+ viewport: TimelineScrollViewportSnapshot;
18
57
  showShortcutHint: boolean;
19
58
  setScrollRef: (el: HTMLDivElement | null) => void;
59
+ syncScrollViewport: (el: HTMLDivElement, isScrolling?: boolean) => void;
20
60
  } {
21
- const [viewportWidth, setViewportWidth] = useState(0);
61
+ const [viewport, setViewport] = useState<TimelineScrollViewportSnapshot>(EMPTY_VIEWPORT);
22
62
  const [showShortcutHint, setShowShortcutHint] = useState(true);
23
63
  const roRef = useRef<ResizeObserver | null>(null);
24
64
  const shortcutHintRafRef = useRef(0);
65
+ const viewportRafRef = useRef(0);
66
+ const scrollSettledTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
67
+ const scrollingRef = useRef(false);
68
+
69
+ const syncScrollViewport = useCallback((el: HTMLDivElement, isScrolling = false) => {
70
+ // Row virtualization is the only consumer of the per-frame scroll snapshot.
71
+ // With the flag off, publishing it re-rendered every mounted clip on every
72
+ // scroll frame and bought nothing, so the scroll path stops here and
73
+ // `isScrolling` stays false. Resize-driven and programmatic syncs arrive
74
+ // through the immediate path below and still publish.
75
+ if (isScrolling && !STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED) return;
76
+ scrollingRef.current = isScrolling;
77
+ const publish = () => {
78
+ viewportRafRef.current = 0;
79
+ setViewport(readTimelineScrollViewport(el, scrollingRef.current));
80
+ };
81
+ if (isScrolling) {
82
+ if (!viewportRafRef.current) viewportRafRef.current = requestAnimationFrame(publish);
83
+ } else {
84
+ if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
85
+ publish();
86
+ return;
87
+ }
88
+ if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current);
89
+ scrollSettledTimerRef.current = setTimeout(() => {
90
+ scrollSettledTimerRef.current = null;
91
+ scrollingRef.current = false;
92
+ if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
93
+ publish();
94
+ }, 100);
95
+ }, []);
25
96
 
26
97
  const syncShortcutHintVisibility = useCallback(() => {
27
98
  const scroll = scrollRef.current;
@@ -45,23 +116,30 @@ export function useTimelineScrollViewport(
45
116
  roRef.current = null;
46
117
  }
47
118
  scrollRef.current = el;
48
- if (!el) return;
119
+ if (!el) {
120
+ if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current);
121
+ scrollSettledTimerRef.current = null;
122
+ scrollingRef.current = false;
123
+ return;
124
+ }
49
125
 
50
- const syncScrollViewport = () => {
51
- setViewportWidth(el.clientWidth);
126
+ const syncResize = () => {
127
+ syncScrollViewport(el, scrollingRef.current);
52
128
  scheduleShortcutHintVisibilitySync();
53
129
  };
54
130
 
55
- syncScrollViewport();
56
- roRef.current = new ResizeObserver(syncScrollViewport);
131
+ syncResize();
132
+ roRef.current = new ResizeObserver(syncResize);
57
133
  roRef.current.observe(el);
58
134
  },
59
- [scrollRef, scheduleShortcutHintVisibilitySync],
135
+ [scrollRef, scheduleShortcutHintVisibilitySync, syncScrollViewport],
60
136
  );
61
137
 
62
138
  useMountEffect(() => () => {
63
139
  roRef.current?.disconnect();
64
140
  if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
141
+ if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
142
+ if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current);
65
143
  });
66
144
 
67
145
  useEffect(() => {
@@ -69,5 +147,5 @@ export function useTimelineScrollViewport(
69
147
  // eslint-disable-next-line react-hooks/exhaustive-deps
70
148
  }, [syncShortcutHintVisibility, ...resyncShortcutHintOn]);
71
149
 
72
- return { viewportWidth, showShortcutHint, setScrollRef };
150
+ return { viewport, showShortcutHint, setScrollRef, syncScrollViewport };
73
151
  }
@@ -0,0 +1,29 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
4
+
5
+ export function useTimelineSelectionLifecycle(
6
+ elements: TimelineElement[],
7
+ selectedElementId: string | null,
8
+ setShowPopover: (show: boolean) => void,
9
+ clearRangeSelection: () => void,
10
+ ): void {
11
+ const selectedElement = useMemo(
12
+ () =>
13
+ elements.find((element) => getTimelineElementIdentity(element) === selectedElementId) ?? null,
14
+ [elements, selectedElementId],
15
+ );
16
+ const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
17
+ selectedElementRef.current = selectedElement;
18
+ const previousSelectedRef = useRef(selectedElementRef.current);
19
+ // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
20
+ useEffect(() => {
21
+ const previous = previousSelectedRef.current;
22
+ const current = selectedElementRef.current;
23
+ previousSelectedRef.current = current;
24
+ if (previous && !current) {
25
+ setShowPopover(false);
26
+ clearRangeSelection();
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,20 @@
1
+ import { useState } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+
4
+ export function useTimelineShiftModifier(): boolean {
5
+ const [shiftHeld, setShiftHeld] = useState(false);
6
+ useMountEffect(() => {
7
+ const handleKey = (event: KeyboardEvent) =>
8
+ event.key === "Shift" && setShiftHeld(event.type === "keydown");
9
+ const handleBlur = () => setShiftHeld(false);
10
+ window.addEventListener("keydown", handleKey);
11
+ window.addEventListener("keyup", handleKey);
12
+ window.addEventListener("blur", handleBlur);
13
+ return () => {
14
+ window.removeEventListener("keydown", handleKey);
15
+ window.removeEventListener("keyup", handleKey);
16
+ window.removeEventListener("blur", handleBlur);
17
+ };
18
+ });
19
+ return shiftHeld;
20
+ }
@@ -0,0 +1,22 @@
1
+ import { useMemo } from "react";
2
+ import { STUDIO_PREVIEW_FPS } from "../lib/time";
3
+ import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences";
4
+ import type { TimelineTimeRange } from "../lib/timelineClipIndex";
5
+ import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
6
+
7
+ export function useTimelineTicks(
8
+ duration: number,
9
+ pixelsPerSecond: number,
10
+ timeDisplayMode: TimelineTimeDisplayMode,
11
+ renderTimeRange?: TimelineTimeRange,
12
+ ) {
13
+ const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
14
+ const ticks = useMemo(
15
+ () => generateTicks(duration, pixelsPerSecond, frameRate, renderTimeRange),
16
+ [duration, frameRate, pixelsPerSecond, renderTimeRange],
17
+ );
18
+ return {
19
+ ...ticks,
20
+ majorTickInterval: getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate),
21
+ };
22
+ }
@@ -15,6 +15,28 @@ afterEach(() => {
15
15
  usePlayerStore.getState().reset();
16
16
  });
17
17
 
18
+ function renderTrackLayout(
19
+ elements: TimelineElement[],
20
+ animations: Map<string, GsapAnimation[]>,
21
+ ): {
22
+ layout: ReturnType<typeof useTimelineTrackLayout>;
23
+ unmount: () => void;
24
+ } {
25
+ usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
26
+
27
+ let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
28
+ function Probe() {
29
+ layout = useTimelineTrackLayout(elements, animations, null, new Set());
30
+ return null;
31
+ }
32
+
33
+ const root = createRoot(document.createElement("div"));
34
+ act(() => root.render(React.createElement(Probe)));
35
+ if (!layout) throw new Error("Timeline track layout did not render");
36
+
37
+ return { layout, unmount: () => act(() => root.unmount()) };
38
+ }
39
+
18
40
  describe("useTimelineTrackLayout", () => {
19
41
  it("counts a flat tween lane and reserves its expanded row height", () => {
20
42
  const elements: TimelineElement[] = [
@@ -36,20 +58,13 @@ describe("useTimelineTrackLayout", () => {
36
58
  ],
37
59
  ],
38
60
  ]);
39
- usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
61
+ const { layout, unmount } = renderTrackLayout(elements, animations);
40
62
 
41
- let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
42
- function Probe() {
43
- layout = useTimelineTrackLayout(elements, animations, null, new Set());
44
- return null;
45
- }
46
-
47
- const root = createRoot(document.createElement("div"));
48
- act(() => root.render(React.createElement(Probe)));
49
-
50
- expect(layout?.laneCounts.get("clip-1")).toBe(1);
51
- expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
52
- act(() => root.unmount());
63
+ expect(layout.laneCounts.get("clip-1")).toBe(1);
64
+ expect(layout.rowHeights).toEqual([TRACK_H + LANE_H]);
65
+ expect(layout.rowGeometry.rowKeys).toEqual([0]);
66
+ expect(layout.rowGeometry.canvasHeight).toBeGreaterThan(TRACK_H + LANE_H);
67
+ unmount();
53
68
  });
54
69
 
55
70
  // The row height reserved here and the lanes actually rendered are two
@@ -69,20 +84,11 @@ describe("useTimelineTrackLayout", () => {
69
84
  properties: { x: 420, opacity: 1 },
70
85
  };
71
86
  const animations = new Map<string, GsapAnimation[]>([["clip-1", [mixed]]]);
72
- usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
73
-
74
- let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
75
- function Probe() {
76
- layout = useTimelineTrackLayout(elements, animations, null, new Set());
77
- return null;
78
- }
79
-
80
- const root = createRoot(document.createElement("div"));
81
- act(() => root.render(React.createElement(Probe)));
87
+ const { layout, unmount } = renderTrackLayout(elements, animations);
82
88
 
83
89
  expect(getTimelinePropertyLanes([mixed], 0, 1)).toHaveLength(2);
84
- expect(layout?.laneCounts.get("clip-1")).toBe(2);
85
- expect(layout?.rowHeights).toEqual([TRACK_H + 2 * LANE_H]);
86
- act(() => root.unmount());
90
+ expect(layout.laneCounts.get("clip-1")).toBe(2);
91
+ expect(layout.rowHeights).toEqual([TRACK_H + 2 * LANE_H]);
92
+ unmount();
87
93
  });
88
94
  });
@@ -6,8 +6,8 @@ import type { DraggedClipState } from "./timelineClipDragTypes";
6
6
  import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
7
7
  import {
8
8
  TRACK_H,
9
- getTimelineCanvasHeight,
10
- getTimelineRowHeight,
9
+ createTimelineRowGeometry,
10
+ type TimelineRowGeometry,
11
11
  trackHeights,
12
12
  type TimelineTrackHeightClip,
13
13
  } from "./timelineLayout";
@@ -72,7 +72,7 @@ function useTimelineRowHeights(
72
72
  selectedElementIds: ReadonlySet<string>,
73
73
  ) {
74
74
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
75
- const { laneCounts, rowHeights } = useMemo(() => {
75
+ const { laneCounts, rowGeometry } = useMemo(() => {
76
76
  const laneCounts = computeLaneCounts(tracks, gsapAnimations);
77
77
  // Row height follows only the active keyframe clip, so a track with several
78
78
  // keyframed elements never reserves empty lanes for the ones not shown.
@@ -87,14 +87,23 @@ function useTimelineRowHeights(
87
87
  const clipId = active.key ?? active.id;
88
88
  return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }];
89
89
  });
90
+ const rowHeights = trackHeights(heightTracks, expandedClipIds);
90
91
  return {
91
92
  laneCounts,
92
- rowHeights: trackHeights(heightTracks, expandedClipIds),
93
+ rowGeometry: createTimelineRowGeometry(
94
+ tracks.map(([track]) => track),
95
+ rowHeights,
96
+ ),
93
97
  };
94
98
  }, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
95
- const rowHeightsRef = useRef<readonly number[]>(rowHeights);
96
- rowHeightsRef.current = rowHeights;
97
- return { laneCounts, rowHeights, rowHeightsRef };
99
+ const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
100
+ rowGeometryRef.current = rowGeometry;
101
+ return {
102
+ laneCounts,
103
+ rowGeometry,
104
+ rowGeometryRef,
105
+ rowHeights: rowGeometry.rowHeights,
106
+ };
98
107
  }
99
108
 
100
109
  export function useTimelineTrackLayout(
@@ -106,7 +115,7 @@ export function useTimelineTrackLayout(
106
115
  const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
107
116
  const trackOrderRef = useRef(trackOrder);
108
117
  trackOrderRef.current = trackOrder;
109
- const { laneCounts, rowHeights, rowHeightsRef } = useTimelineRowHeights(
118
+ const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
110
119
  tracks,
111
120
  gsapAnimations,
112
121
  selectedElementId,
@@ -119,23 +128,23 @@ export function useTimelineTrackLayout(
119
128
  trackOrder,
120
129
  trackOrderRef,
121
130
  laneCounts,
131
+ rowGeometry,
132
+ rowGeometryRef,
122
133
  rowHeights,
123
- rowHeightsRef,
124
134
  };
125
135
  }
126
136
 
127
137
  function useDisplayRowHeights(
128
138
  displayTrackOrder: readonly number[],
129
- trackOrder: readonly number[],
130
- rowHeights: readonly number[],
139
+ rowGeometry: TimelineRowGeometry,
131
140
  ) {
132
141
  return useMemo(
133
142
  () =>
134
143
  displayTrackOrder.map((track) => {
135
- const row = trackOrder.indexOf(track);
136
- return row < 0 ? TRACK_H : getTimelineRowHeight(row, rowHeights);
144
+ const row = rowGeometry.getRowIndex(track);
145
+ return row < 0 ? TRACK_H : rowGeometry.getRowHeight(row);
137
146
  }),
138
- [displayTrackOrder, trackOrder, rowHeights],
147
+ [displayTrackOrder, rowGeometry],
139
148
  );
140
149
  }
141
150
 
@@ -149,10 +158,18 @@ function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder:
149
158
  export function useTimelineDisplayLayout(
150
159
  draggedClip: DraggedClipState | null,
151
160
  trackOrder: number[],
152
- rowHeights: readonly number[],
161
+ rowGeometry: TimelineRowGeometry,
153
162
  ) {
154
163
  const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder);
155
- const displayRowHeights = useDisplayRowHeights(displayTrackOrder, trackOrder, rowHeights);
156
- const totalH = getTimelineCanvasHeight(displayRowHeights);
157
- return { displayTrackOrder, displayRowHeights, totalH };
164
+ const displayRowHeights = useDisplayRowHeights(displayTrackOrder, rowGeometry);
165
+ const displayRowGeometry = useMemo(
166
+ () => createTimelineRowGeometry(displayTrackOrder, displayRowHeights),
167
+ [displayTrackOrder, displayRowHeights],
168
+ );
169
+ return {
170
+ displayTrackOrder,
171
+ displayRowHeights: displayRowGeometry.rowHeights,
172
+ rowGeometry: displayRowGeometry,
173
+ totalH: displayRowGeometry.canvasHeight,
174
+ };
158
175
  }