@hyperframes/studio 0.7.85 → 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 (95) hide show
  1. package/dist/assets/{hyperframes-player-CXVkvR6C.js → hyperframes-player-BFqB5AWJ.js} +1 -1
  2. package/dist/assets/{index-CVcz5MoC.js → index-B1Vf4vCl.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-DloBOPGY.js → index-BblzZ6Av.js} +221 -221
  5. package/dist/assets/{index-BZFIup06.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 +6266 -4923
  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/useTimelinePerformanceTelemetry.test.ts +68 -2
  61. package/src/player/components/useTimelinePerformanceTelemetry.ts +1 -1
  62. package/src/player/components/useTimelinePlayhead.ts +30 -14
  63. package/src/player/components/useTimelineRangeSelection.ts +5 -4
  64. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  65. package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
  66. package/src/player/components/useTimelineRevealClip.ts +171 -47
  67. package/src/player/components/useTimelineRowVirtualization.ts +140 -0
  68. package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
  69. package/src/player/components/useTimelineScrollViewport.ts +87 -9
  70. package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
  71. package/src/player/components/useTimelineShiftModifier.ts +20 -0
  72. package/src/player/components/useTimelineTicks.ts +22 -0
  73. package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
  74. package/src/player/components/useTimelineTrackLayout.ts +35 -18
  75. package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
  76. package/src/player/components/useTimelineVirtualRows.ts +126 -0
  77. package/src/player/hooks/timelinePlayerSync.ts +20 -0
  78. package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
  79. package/src/player/hooks/useTimelinePlayer.ts +8 -20
  80. package/src/player/lib/playbackScrub.ts +2 -2
  81. package/src/player/lib/timelineClipIndex.test.ts +169 -0
  82. package/src/player/lib/timelineClipIndex.ts +208 -0
  83. package/src/player/lib/timelineElementIndexes.test.ts +31 -0
  84. package/src/player/lib/timelineElementIndexes.ts +44 -0
  85. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
  86. package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
  87. package/src/player/lib/timelinePerformanceFixture.ts +167 -0
  88. package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
  89. package/src/player/lib/timelineViewportBudgets.ts +151 -0
  90. package/src/player/store/playerStore.test.ts +20 -3
  91. package/src/player/store/playerStore.ts +73 -45
  92. package/src/utils/studioHelpers.test.ts +48 -1
  93. package/src/utils/studioHelpers.ts +31 -34
  94. package/src/utils/studioUiPreferences.ts +3 -1
  95. package/dist/assets/index-D379YOKT.css +0 -1
@@ -1,7 +1,28 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { describe, expect, it } from "vitest";
4
- import { summarizeTimelinePerformance } from "./useTimelinePerformanceTelemetry";
3
+ import { act, createElement } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const trackStudioTimelinePerformance = vi.hoisted(() => vi.fn());
8
+
9
+ vi.mock("../../telemetry/events", () => ({
10
+ trackStudioTimelinePerformance,
11
+ }));
12
+
13
+ import {
14
+ summarizeTimelinePerformance,
15
+ useTimelinePerformanceTelemetry,
16
+ } from "./useTimelinePerformanceTelemetry";
17
+
18
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
19
+
20
+ afterEach(() => {
21
+ trackStudioTimelinePerformance.mockReset();
22
+ vi.useRealTimers();
23
+ vi.restoreAllMocks();
24
+ vi.unstubAllGlobals();
25
+ });
5
26
 
6
27
  describe("summarizeTimelinePerformance", () => {
7
28
  it("reports raw mounted work and p95 scroll timings", () => {
@@ -52,3 +73,48 @@ describe("summarizeTimelinePerformance", () => {
52
73
  ).toBeNull();
53
74
  });
54
75
  });
76
+
77
+ describe("useTimelinePerformanceTelemetry", () => {
78
+ it("measures callback delivery when the animation-frame timestamp predates the scroll", () => {
79
+ vi.useFakeTimers();
80
+ let frameCallback: FrameRequestCallback | undefined;
81
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
82
+ frameCallback = callback;
83
+ return 1;
84
+ });
85
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
86
+
87
+ let recordTimelineScroll: ((scroll: HTMLDivElement) => void) | undefined;
88
+ function Probe() {
89
+ recordTimelineScroll = useTimelinePerformanceTelemetry({
90
+ totalClipCount: 1,
91
+ totalRowCount: 1,
92
+ zoomMode: "manual",
93
+ }).recordTimelineScroll;
94
+ return null;
95
+ }
96
+
97
+ const root = createRoot(document.createElement("div"));
98
+ act(() => root.render(createElement(Probe)));
99
+
100
+ try {
101
+ vi.spyOn(performance, "now")
102
+ .mockReturnValueOnce(100)
103
+ .mockReturnValueOnce(108)
104
+ .mockReturnValueOnce(500);
105
+
106
+ recordTimelineScroll?.(document.createElement("div"));
107
+ frameCallback?.(99);
108
+ vi.advanceTimersByTime(400);
109
+
110
+ expect(trackStudioTimelinePerformance).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ scroll_frame_latency_p95_ms: 8,
113
+ scroll_frame_latency_max_ms: 8,
114
+ }),
115
+ );
116
+ } finally {
117
+ act(() => root.unmount());
118
+ }
119
+ });
120
+ });
@@ -87,7 +87,7 @@ export function useTimelinePerformanceTelemetry(context: TimelinePerformanceCont
87
87
  state.pendingScrollStartedAt = now;
88
88
  state.frameRequest = requestAnimationFrame((frameAt) => {
89
89
  state.frameRequest = 0;
90
- state.frameLatencies.push(frameAt - state.pendingScrollStartedAt);
90
+ state.frameLatencies.push(performance.now() - state.pendingScrollStartedAt);
91
91
  if (state.previousFrameAt !== null) {
92
92
  state.frameIntervals.push(frameAt - state.previousFrameAt);
93
93
  }
@@ -1,14 +1,16 @@
1
1
  import { useRef, useCallback, useEffect, useLayoutEffect } from "react";
2
- import { liveTime, type ZoomMode } from "../store/playerStore";
2
+ import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { getPinchTimelineZoomPercent } from "./timelineZoom";
5
5
  import {
6
+ getTimelinePlaybackFollowScrollLeft,
6
7
  getTimelinePlayheadLeft,
7
8
  getTimelineScrubTime,
8
9
  getTimelineScrollLeftForZoomTransition,
9
10
  getTimelineScrollLeftForZoomAnchor,
10
11
  shouldAutoScrollTimeline,
11
12
  } from "./timelineLayout";
13
+ import { applyTimelineHorizontalAutoScrollStep } from "./timelineEditing";
12
14
 
13
15
  interface UseTimelinePlayheadInput {
14
16
  playheadRef: React.RefObject<HTMLDivElement | null>;
@@ -121,9 +123,31 @@ export function useTimelinePlayhead({
121
123
  useMountEffect(() => {
122
124
  const unsub = liveTime.subscribe((t) => {
123
125
  if (!playheadRef.current || durationRef.current <= 0) return;
124
- // Playback deliberately does NOT scroll the viewport to chase the playhead —
125
- // the user's scroll position is theirs; the playhead may run off-screen.
126
- playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current, contentOriginRef.current)}px`;
126
+ const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current;
127
+ playheadRef.current.style.left = `${getTimelinePlayheadLeft(
128
+ t,
129
+ ppsRef.current,
130
+ contentOriginRef.current,
131
+ )}px`;
132
+ const scroll = scrollRef.current;
133
+ if (
134
+ !scroll ||
135
+ !usePlayerStore.getState().isPlaying ||
136
+ isDragging.current ||
137
+ zoomModeRef.current === "fit"
138
+ ) {
139
+ return;
140
+ }
141
+ const nextScrollLeft = getTimelinePlaybackFollowScrollLeft({
142
+ playheadX,
143
+ currentScrollLeft: scroll.scrollLeft,
144
+ viewportWidth: scroll.clientWidth,
145
+ contentOrigin: contentOriginRef.current,
146
+ maxScrollLeft: scroll.scrollWidth - scroll.clientWidth,
147
+ });
148
+ if (Math.abs(nextScrollLeft - scroll.scrollLeft) >= 0.5) {
149
+ scroll.scrollLeft = nextScrollLeft;
150
+ }
127
151
  });
128
152
  return unsub;
129
153
  });
@@ -157,16 +181,7 @@ export function useTimelinePlayhead({
157
181
  !shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth)
158
182
  )
159
183
  return;
160
- const rect = el.getBoundingClientRect();
161
- const edgeZone = 40;
162
- const maxSpeed = 12;
163
- let scrollDelta = 0;
164
- if (clientX < rect.left + edgeZone)
165
- scrollDelta = -maxSpeed * Math.max(0, 1 - (clientX - rect.left) / edgeZone);
166
- else if (clientX > rect.right - edgeZone)
167
- scrollDelta = maxSpeed * Math.max(0, 1 - (rect.right - clientX) / edgeZone);
168
- if (scrollDelta !== 0) {
169
- el.scrollLeft += scrollDelta;
184
+ if (applyTimelineHorizontalAutoScrollStep(el, clientX)) {
170
185
  seekFromX(clientX);
171
186
  dragScrollRaf.current = requestAnimationFrame(() => autoScrollDuringDrag(clientX));
172
187
  }
@@ -187,6 +202,7 @@ export function useTimelinePlayhead({
187
202
  e.deltaY,
188
203
  zoomModeRef.current,
189
204
  manualZoomPercentRef.current,
205
+ fitPpsRef.current,
190
206
  );
191
207
  if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual")
192
208
  return;
@@ -16,6 +16,7 @@ import {
16
16
  type MarqueeClipInput,
17
17
  } from "./timelineMarquee";
18
18
  import type { Rect } from "../../utils/marqueeGeometry";
19
+ import type { TimelineRowGeometry } from "./timelineLayout";
19
20
 
20
21
  interface UseTimelineRangeSelectionInput {
21
22
  scrollRef: React.RefObject<HTMLDivElement | null>;
@@ -30,7 +31,7 @@ interface UseTimelineRangeSelectionInput {
30
31
  setShowPopover: (v: boolean) => void;
31
32
  elementsRef: React.RefObject<TimelineElement[]>;
32
33
  trackOrderRef: React.RefObject<number[]>;
33
- rowHeightsRef: React.RefObject<readonly number[]>;
34
+ rowGeometryRef: React.RefObject<TimelineRowGeometry>;
34
35
  onSelectElement?: (element: TimelineElement | null) => void;
35
36
  contentOrigin: number;
36
37
  }
@@ -107,7 +108,7 @@ export function useTimelineRangeSelection({
107
108
  setShowPopover,
108
109
  elementsRef,
109
110
  trackOrderRef,
110
- rowHeightsRef,
111
+ rowGeometryRef,
111
112
  onSelectElement,
112
113
  contentOrigin,
113
114
  }: UseTimelineRangeSelectionInput) {
@@ -176,12 +177,12 @@ export function useTimelineRangeSelection({
176
177
  marquee,
177
178
  elementsRef.current ?? [],
178
179
  trackOrderRef.current ?? [],
179
- rowHeightsRef.current,
180
+ rowGeometryRef.current.rowHeights,
180
181
  ppsRef.current,
181
182
  contentOrigin,
182
183
  );
183
184
  },
184
- [toContentPoint, elementsRef, trackOrderRef, rowHeightsRef, ppsRef, contentOrigin],
185
+ [toContentPoint, elementsRef, trackOrderRef, rowGeometryRef, ppsRef, contentOrigin],
185
186
  );
186
187
 
187
188
  const stopMarqueeAutoScroll = useCallback(() => {
@@ -8,6 +8,7 @@ import React, { act } from "react";
8
8
  import { afterEach, describe, expect, it, vi } from "vitest";
9
9
  import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
10
10
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
11
+ import { getTimelineRowGeometry } from "./timelineLayout";
11
12
 
12
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
14
 
@@ -60,7 +61,7 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
60
61
  const isDragging = { current: false };
61
62
  const elementsRef = { current: [] };
62
63
  const trackOrderRef = { current: [] };
63
- const rowHeightsRef = { current: [] };
64
+ const rowGeometryRef = { current: getTimelineRowGeometry([]) };
64
65
 
65
66
  function Probe(): null {
66
67
  latest = useTimelineRangeSelection({
@@ -75,7 +76,7 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn
75
76
  setShowPopover: vi.fn(),
76
77
  elementsRef,
77
78
  trackOrderRef,
78
- rowHeightsRef,
79
+ rowGeometryRef,
79
80
  contentOrigin: 0,
80
81
  });
81
82
  return null;
@@ -0,0 +1,139 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import { createTimelineRowGeometry } from "./timelineLayout";
9
+ import { useTimelineRevealClip } from "./useTimelineRevealClip";
10
+
11
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
12
+
13
+ const element: TimelineElement = {
14
+ id: "hero",
15
+ tag: "div",
16
+ start: 20,
17
+ duration: 2,
18
+ track: 1,
19
+ };
20
+ const geometry = createTimelineRowGeometry([1], [48]);
21
+
22
+ function createHarnessRoot() {
23
+ const host = document.createElement("div");
24
+ document.body.append(host);
25
+ return { host, root: createRoot(host) };
26
+ }
27
+
28
+ interface HarnessProps {
29
+ mounted: boolean;
30
+ version: number;
31
+ width?: number;
32
+ height?: number;
33
+ deferFocusUntilViewportUpdate?: boolean;
34
+ focusedElementId?: string;
35
+ }
36
+
37
+ function Harness({
38
+ mounted,
39
+ version,
40
+ width = 300,
41
+ height = 100,
42
+ deferFocusUntilViewportUpdate = false,
43
+ focusedElementId,
44
+ }: HarnessProps) {
45
+ const scrollRef = useRef<HTMLDivElement>(null);
46
+ useTimelineRevealClip({
47
+ scrollRef,
48
+ elements: [element],
49
+ rowGeometry: geometry,
50
+ pixelsPerSecond: 100,
51
+ contentOrigin: 32,
52
+ allowHorizontal: true,
53
+ deferFocusUntilViewportUpdate,
54
+ focusedElementId,
55
+ viewportVersion: version,
56
+ sessionEpoch: 1,
57
+ });
58
+ return (
59
+ <div
60
+ ref={(node) => {
61
+ scrollRef.current = node;
62
+ if (node) {
63
+ Object.defineProperty(node, "clientWidth", { configurable: true, value: width });
64
+ Object.defineProperty(node, "clientHeight", { configurable: true, value: height });
65
+ }
66
+ }}
67
+ >
68
+ {mounted && <div data-el-id="hero" tabIndex={-1} />}
69
+ </div>
70
+ );
71
+ }
72
+
73
+ async function renderHarness(root: Root, props: HarnessProps): Promise<void> {
74
+ await act(async () => root.render(<Harness {...props} />));
75
+ }
76
+
77
+ afterEach(() => {
78
+ usePlayerStore.getState().reset();
79
+ document.body.replaceChildren();
80
+ });
81
+
82
+ describe("useTimelineRevealClip", () => {
83
+ it("scrolls from model coordinates, then consumes only after the clip mounts", async () => {
84
+ const { host, root } = createHarnessRoot();
85
+ usePlayerStore.getState().requestClipReveal("hero");
86
+
87
+ await renderHarness(root, { mounted: false, version: 0 });
88
+ const scroll = host.firstElementChild as HTMLDivElement;
89
+ expect(scroll.scrollLeft).toBe(1_944);
90
+ expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
91
+
92
+ await renderHarness(root, { mounted: true, version: 1 });
93
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
94
+ expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero");
95
+
96
+ scroll.scrollLeft = 0;
97
+ await act(async () => usePlayerStore.getState().requestClipReveal("hero"));
98
+ await renderHarness(root, { mounted: true, version: 2 });
99
+ expect(scroll.scrollLeft).toBe(1_944);
100
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
101
+ await act(async () => root.unmount());
102
+ });
103
+
104
+ it("consumes an invalid target without scrolling", async () => {
105
+ const { root } = createHarnessRoot();
106
+ usePlayerStore.getState().requestClipReveal("missing");
107
+ await renderHarness(root, { mounted: false, version: 0 });
108
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
109
+ await act(async () => root.unmount());
110
+ });
111
+
112
+ it("keeps a reveal pending through zero-size viewport and virtualized focus handoff", async () => {
113
+ const { host, root } = createHarnessRoot();
114
+ usePlayerStore.getState().requestClipReveal("hero");
115
+
116
+ await renderHarness(root, { mounted: true, version: 0, width: 0, height: 0 });
117
+ const scroll = host.firstElementChild as HTMLDivElement;
118
+ expect(scroll.scrollLeft).toBe(0);
119
+ expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
120
+ expect(document.activeElement?.getAttribute("data-el-id")).not.toBe("hero");
121
+
122
+ await renderHarness(root, {
123
+ mounted: true,
124
+ version: 1,
125
+ deferFocusUntilViewportUpdate: true,
126
+ });
127
+ expect(scroll.scrollLeft).toBe(1_944);
128
+ expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
129
+ await renderHarness(root, {
130
+ mounted: true,
131
+ version: 2,
132
+ deferFocusUntilViewportUpdate: true,
133
+ focusedElementId: "hero",
134
+ });
135
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
136
+ expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero");
137
+ await act(async () => root.unmount());
138
+ });
139
+ });
@@ -1,56 +1,180 @@
1
- /**
2
- * Consumes playerStore.clipRevealRequest: when another surface (the sidebar
3
- * asset card / audio row) asks for a clip to be revealed, smooth-scroll the
4
- * timeline's scroll container so that clip is visible — horizontally to its
5
- * time and vertically to its lane.
6
- *
7
- * The request is consumed (cleared) whether or not the clip node is found, so
8
- * a stale request can never replay a scroll later. Respects zoom mode: in
9
- * "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
10
- * only the vertical axis is scrolled there.
11
- */
12
- import { useEffect } from "react";
1
+ import { useEffect, useRef } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
13
3
  import { usePlayerStore } from "../store/playerStore";
14
- import { GUTTER, RULER_H } from "./timelineLayout";
4
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
5
+ import { CLIP_Y, RULER_H, type TimelineRowGeometry } from "./timelineLayout";
15
6
  import { computeRevealScroll } from "./timelineRevealScroll";
16
7
 
17
- export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
18
- const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
8
+ interface UseTimelineRevealClipInput {
9
+ scrollRef: React.RefObject<HTMLDivElement | null>;
10
+ elements: readonly TimelineElement[];
11
+ rowGeometry: TimelineRowGeometry;
12
+ pixelsPerSecond: number;
13
+ contentOrigin: number;
14
+ allowHorizontal: boolean;
15
+ deferFocusUntilViewportUpdate: boolean;
16
+ focusedElementId?: string;
17
+ viewportVersion: unknown;
18
+ sessionEpoch: number;
19
+ }
19
20
 
20
- useEffect(() => {
21
- if (!revealRequest) return;
22
- // Consume the request first — reveal is one-shot, even when the clip node
23
- // isn't currently rendered (e.g. drilled into a different composition).
21
+ function escapeSelectorValue(value: string): string {
22
+ return typeof CSS !== "undefined" && typeof CSS.escape === "function"
23
+ ? CSS.escape(value)
24
+ : value.replace(/["\\]/g, "\\$&");
25
+ }
26
+
27
+ function scrollToTimelineElement(
28
+ container: HTMLDivElement,
29
+ element: TimelineElement,
30
+ row: number,
31
+ rowGeometry: TimelineRowGeometry,
32
+ pixelsPerSecond: number,
33
+ contentOrigin: number,
34
+ allowHorizontal: boolean,
35
+ ): boolean {
36
+ const clipLeft = contentOrigin + element.start * pixelsPerSecond;
37
+ const target = computeRevealScroll({
38
+ scrollLeft: container.scrollLeft,
39
+ scrollTop: container.scrollTop,
40
+ viewportWidth: container.clientWidth,
41
+ viewportHeight: container.clientHeight,
42
+ clipLeft,
43
+ clipRight: clipLeft + Math.max(element.duration * pixelsPerSecond, 4),
44
+ clipTop: rowGeometry.getRowTop(row) + CLIP_Y,
45
+ clipBottom: rowGeometry.getRowTop(row) + rowGeometry.getRowHeight(row) - CLIP_Y,
46
+ stickyLeft: contentOrigin,
47
+ stickyTop: RULER_H,
48
+ allowHorizontal,
49
+ });
50
+ if (target.left !== null) container.scrollLeft = target.left;
51
+ if (target.top !== null) container.scrollTop = target.top;
52
+ const didScroll = target.left !== null || target.top !== null;
53
+ if (didScroll) container.dispatchEvent(new Event("scroll"));
54
+ return didScroll;
55
+ }
56
+
57
+ function focusRevealedElement(container: HTMLDivElement, elementId: string): boolean {
58
+ const clip = container.querySelector(`[data-el-id="${escapeSelectorValue(elementId)}"]`);
59
+ if (!(clip instanceof HTMLElement)) return false;
60
+ const alreadyHighlighted = clip.hasAttribute("data-reveal-highlight");
61
+ clip.setAttribute("data-reveal-highlight", "true");
62
+ clip.focus({ preventScroll: true });
63
+ if (document.activeElement !== clip) {
64
+ clip.removeAttribute("data-reveal-highlight");
65
+ return false;
66
+ }
67
+ if (!alreadyHighlighted) {
68
+ clip.addEventListener("blur", () => clip.removeAttribute("data-reveal-highlight"), {
69
+ once: true,
70
+ });
71
+ }
72
+ return true;
73
+ }
74
+
75
+ function focusAndConsumeReveal(
76
+ container: HTMLDivElement,
77
+ request: { elementId: string; nonce: number },
78
+ deferUntilFocusPin: boolean,
79
+ focusedElementId?: string,
80
+ ): void {
81
+ if (!focusRevealedElement(container, request.elementId)) return;
82
+ if (deferUntilFocusPin && focusedElementId !== request.elementId) return;
83
+ if (usePlayerStore.getState().clipRevealRequest === request) {
24
84
  usePlayerStore.getState().clearClipRevealRequest();
85
+ }
86
+ }
25
87
 
88
+ function resolveRevealTarget(
89
+ elements: readonly TimelineElement[],
90
+ rowGeometry: TimelineRowGeometry,
91
+ elementId: string,
92
+ ): { element: TimelineElement; row: number } | null {
93
+ const element = elements.find((candidate) => getTimelineElementIdentity(candidate) === elementId);
94
+ if (!element) return null;
95
+ const row = rowGeometry.getRowIndex(element.track);
96
+ return row < 0 ? null : { element, row };
97
+ }
98
+
99
+ function shouldScrollReveal(
100
+ previous: { request: { elementId: string; nonce: number }; sessionEpoch: number } | null,
101
+ request: { elementId: string; nonce: number },
102
+ sessionEpoch: number,
103
+ ): boolean {
104
+ return previous?.request !== request || previous.sessionEpoch !== sessionEpoch;
105
+ }
106
+
107
+ /** Coordinate-first reveal; the request remains pinned until its clip mounts. */
108
+ export function useTimelineRevealClip({
109
+ scrollRef,
110
+ elements,
111
+ rowGeometry,
112
+ pixelsPerSecond,
113
+ contentOrigin,
114
+ allowHorizontal,
115
+ deferFocusUntilViewportUpdate,
116
+ focusedElementId,
117
+ viewportVersion,
118
+ sessionEpoch,
119
+ }: UseTimelineRevealClipInput): void {
120
+ const revealRequest = usePlayerStore((state) => state.clipRevealRequest);
121
+ const scrolledRequestRef = useRef<{
122
+ request: { elementId: string; nonce: number };
123
+ sessionEpoch: number;
124
+ } | null>(null);
125
+
126
+ useEffect(() => {
127
+ if (!revealRequest) {
128
+ scrolledRequestRef.current = null;
129
+ return;
130
+ }
131
+ const target = resolveRevealTarget(elements, rowGeometry, revealRequest.elementId);
132
+ if (!target) {
133
+ usePlayerStore.getState().clearClipRevealRequest();
134
+ return;
135
+ }
26
136
  const container = scrollRef.current;
27
137
  if (!container) return;
28
- const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
29
- if (!(clip instanceof HTMLElement)) return;
30
-
31
- const containerRect = container.getBoundingClientRect();
32
- const clipRect = clip.getBoundingClientRect();
33
- const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
34
- const clipTop = clipRect.top - containerRect.top + container.scrollTop;
35
-
36
- const target = computeRevealScroll({
37
- scrollLeft: container.scrollLeft,
38
- scrollTop: container.scrollTop,
39
- viewportWidth: container.clientWidth,
40
- viewportHeight: container.clientHeight,
41
- clipLeft,
42
- clipRight: clipLeft + clipRect.width,
43
- clipTop,
44
- clipBottom: clipTop + clipRect.height,
45
- stickyLeft: GUTTER,
46
- stickyTop: RULER_H,
47
- allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
48
- });
49
- if (target.left === null && target.top === null) return;
50
- container.scrollTo({
51
- left: target.left ?? container.scrollLeft,
52
- top: target.top ?? container.scrollTop,
53
- behavior: "smooth",
54
- });
55
- }, [revealRequest, scrollRef]);
138
+ if (container.clientWidth <= 0 || container.clientHeight <= 0) return;
139
+
140
+ if (shouldScrollReveal(scrolledRequestRef.current, revealRequest, sessionEpoch)) {
141
+ scrolledRequestRef.current = { request: revealRequest, sessionEpoch };
142
+ const didScroll = scrollToTimelineElement(
143
+ container,
144
+ target.element,
145
+ target.row,
146
+ rowGeometry,
147
+ pixelsPerSecond,
148
+ contentOrigin,
149
+ allowHorizontal,
150
+ );
151
+ // Keep the reveal pin alive until the scroll snapshot catches up. If the
152
+ // request were consumed here, horizontal windowing could unmount and
153
+ // recreate the focused clip between the programmatic scroll and the next
154
+ // viewport publication.
155
+ if (didScroll && deferFocusUntilViewportUpdate) return;
156
+ }
157
+
158
+ // Focus is the durable row/clip pin. Do not consume the reveal pin until
159
+ // the focus listener has published that replacement, or windowing can
160
+ // briefly unmount and recreate the element between the two owners.
161
+ focusAndConsumeReveal(
162
+ container,
163
+ revealRequest,
164
+ deferFocusUntilViewportUpdate,
165
+ focusedElementId,
166
+ );
167
+ }, [
168
+ allowHorizontal,
169
+ contentOrigin,
170
+ deferFocusUntilViewportUpdate,
171
+ elements,
172
+ focusedElementId,
173
+ pixelsPerSecond,
174
+ revealRequest,
175
+ rowGeometry,
176
+ scrollRef,
177
+ sessionEpoch,
178
+ viewportVersion,
179
+ ]);
56
180
  }