@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -1,10 +1,7 @@
1
- import { useMemo, type RefObject } from "react";
1
+ import { useMemo } from "react";
2
2
  import { createTimelineClipIndex } from "../lib/timelineClipIndex";
3
- import type { TimelineElement } from "../store/playerStore";
4
3
  import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
5
- import type { TimelineRowGeometry } from "./timelineLayout";
6
4
  import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
7
- import { useTimelineRevealClip } from "./useTimelineRevealClip";
8
5
 
9
6
  interface UseTimelineClipRenderWindowInput {
10
7
  tracks: Parameters<typeof createTimelineClipIndex>[0];
@@ -14,18 +11,11 @@ interface UseTimelineClipRenderWindowInput {
14
11
  duration: number;
15
12
  selectedElementId?: string;
16
13
  draggedElementId?: string;
17
- resizingElementId?: string;
18
- revealElementId?: string;
14
+ resizingElementIds?: readonly string[];
15
+ focusedElementId?: string;
19
16
  focusedEaseElementId?: string;
20
17
  clipContextMenuElementId?: string;
21
18
  keyframeContextMenuElementId?: string;
22
- focusedElementId?: string;
23
- scrollRef: RefObject<HTMLDivElement | null>;
24
- elements: readonly TimelineElement[];
25
- rowGeometry: TimelineRowGeometry;
26
- allowHorizontalReveal: boolean;
27
- rowVirtualizationActive: boolean;
28
- sessionEpoch: number;
29
19
  }
30
20
 
31
21
  export function useTimelineClipRenderWindow({
@@ -36,18 +26,11 @@ export function useTimelineClipRenderWindow({
36
26
  duration,
37
27
  selectedElementId,
38
28
  draggedElementId,
39
- resizingElementId,
40
- revealElementId,
29
+ resizingElementIds,
30
+ focusedElementId,
41
31
  focusedEaseElementId,
42
32
  clipContextMenuElementId,
43
33
  keyframeContextMenuElementId,
44
- focusedElementId,
45
- scrollRef,
46
- elements,
47
- rowGeometry,
48
- allowHorizontalReveal,
49
- rowVirtualizationActive,
50
- sessionEpoch,
51
34
  }: UseTimelineClipRenderWindowInput) {
52
35
  const clipIndex = useMemo(() => createTimelineClipIndex(tracks), [tracks]);
53
36
  const renderTimeRange = useMemo(
@@ -60,12 +43,11 @@ export function useTimelineClipRenderWindow({
60
43
  [
61
44
  selectedElementId,
62
45
  draggedElementId,
63
- resizingElementId,
64
- revealElementId,
46
+ ...(resizingElementIds ?? []),
47
+ focusedElementId,
65
48
  focusedEaseElementId,
66
49
  clipContextMenuElementId,
67
50
  keyframeContextMenuElementId,
68
- focusedElementId,
69
51
  ].filter((identity): identity is string => identity !== undefined),
70
52
  ),
71
53
  [
@@ -74,22 +56,9 @@ export function useTimelineClipRenderWindow({
74
56
  focusedEaseElementId,
75
57
  focusedElementId,
76
58
  keyframeContextMenuElementId,
77
- resizingElementId,
78
- revealElementId,
59
+ resizingElementIds,
79
60
  selectedElementId,
80
61
  ],
81
62
  );
82
- useTimelineRevealClip({
83
- scrollRef,
84
- elements,
85
- rowGeometry,
86
- pixelsPerSecond,
87
- contentOrigin,
88
- allowHorizontal: allowHorizontalReveal,
89
- deferFocusUntilViewportUpdate: rowVirtualizationActive,
90
- focusedElementId,
91
- viewportVersion: viewport,
92
- sessionEpoch,
93
- });
94
63
  return { clipIndex, renderTimeRange, pinnedClipIdentities };
95
64
  }
@@ -0,0 +1,174 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useMemo, useRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { usePlayerStore } from "../store/playerStore";
7
+ import { createTimelineRowGeometry } from "./timelineLayout";
8
+ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
9
+ import { timelineClipFocusId, timelineTrackRowId } from "./timelineNavigationIdentity";
10
+ import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
11
+
12
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
13
+
14
+ const element = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
15
+ const elements = [element];
16
+ const syncScrollViewport = () => {};
17
+ const clipId = timelineClipFocusId("hero");
18
+ const rowId = timelineTrackRowId(1);
19
+ const rows: readonly TimelineLogicalRow[] = [
20
+ {
21
+ id: rowId,
22
+ kind: "row",
23
+ physicalTrackKey: 1,
24
+ logicalIndex: 0,
25
+ level: 1,
26
+ parentId: null,
27
+ elementId: null,
28
+ expandable: false,
29
+ expanded: false,
30
+ items: [{ id: clipId, kind: "clip", rowId, elementId: "hero", time: 21 }],
31
+ },
32
+ ];
33
+
34
+ function Harness({
35
+ mountedId,
36
+ logicalRows = rows,
37
+ projectId = "project-a",
38
+ }: {
39
+ mountedId?: string;
40
+ logicalRows?: readonly TimelineLogicalRow[];
41
+ projectId?: string;
42
+ }) {
43
+ const scrollRef = useRef<HTMLDivElement>(null);
44
+ const rowGeometry = useMemo(() => {
45
+ const rowKeys = [...new Set(logicalRows.map((row) => row.physicalTrackKey))];
46
+ return createTimelineRowGeometry(
47
+ rowKeys,
48
+ rowKeys.map(() => 48),
49
+ );
50
+ }, [logicalRows]);
51
+ const focus = useTimelineFocusCoordinator({
52
+ scrollRef,
53
+ logicalRows,
54
+ elements,
55
+ rowGeometry,
56
+ pixelsPerSecond: 100,
57
+ contentOrigin: 32,
58
+ allowHorizontal: true,
59
+ viewportVersion: mountedId,
60
+ projectId,
61
+ sessionEpoch: 1,
62
+ syncScrollViewport,
63
+ });
64
+ return (
65
+ <div
66
+ ref={(node) => {
67
+ scrollRef.current = node;
68
+ if (node) {
69
+ Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 });
70
+ Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 });
71
+ }
72
+ }}
73
+ data-focus={`${focus.focusedRowKey}:${focus.pinnedElementId}`}
74
+ >
75
+ {mountedId && <div data-timeline-focus-id={mountedId} tabIndex={-1} />}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ let host: HTMLDivElement;
81
+ let root: Root;
82
+ beforeEach(() => {
83
+ usePlayerStore.setState({ timelineProjectId: "project-a", timelineSessionEpoch: 1 });
84
+ host = document.createElement("div");
85
+ document.body.append(host);
86
+ root = createRoot(host);
87
+ });
88
+ afterEach(() => {
89
+ act(() => root.unmount());
90
+ usePlayerStore.getState().reset();
91
+ document.body.replaceChildren();
92
+ });
93
+
94
+ describe("useTimelineFocusCoordinator", () => {
95
+ it("pins and scrolls from model coordinates until mount, then permits repeat reveal", async () => {
96
+ usePlayerStore.getState().requestTimelineFocus(clipId);
97
+ await act(async () => root.render(<Harness />));
98
+ const scroll = host.firstElementChild as HTMLDivElement;
99
+ expect(scroll.scrollLeft).toBe(1_944);
100
+ expect(scroll.dataset.focus).toBe("1:hero");
101
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
102
+
103
+ await act(async () => root.render(<Harness mountedId={clipId} />));
104
+ const firstNonce = usePlayerStore.getState().timelineFocus?.nonce;
105
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
106
+ expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(clipId);
107
+
108
+ scroll.scrollLeft = 0;
109
+ await act(async () => usePlayerStore.getState().requestTimelineFocus(clipId));
110
+ await act(async () => root.render(<Harness mountedId={clipId} />));
111
+ expect(scroll.scrollLeft).toBe(1_944);
112
+ expect(usePlayerStore.getState().timelineFocus?.nonce).toBe((firstNonce ?? 0) + 1);
113
+ });
114
+
115
+ it("focuses the latest request when it replaces an unmounted request", async () => {
116
+ usePlayerStore.getState().requestTimelineFocus(rowId);
117
+ usePlayerStore.getState().requestTimelineFocus(clipId);
118
+
119
+ await act(async () => root.render(<Harness mountedId={clipId} />));
120
+ expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(clipId);
121
+ });
122
+
123
+ it("does not retry an unchanged unmounted target on an unrelated render", async () => {
124
+ usePlayerStore.getState().requestTimelineFocus(clipId);
125
+ await act(async () => root.render(<Harness />));
126
+ const scroll = host.firstElementChild as HTMLDivElement;
127
+ const querySelector = vi.spyOn(scroll, "querySelector");
128
+
129
+ await act(async () => root.render(<Harness />));
130
+
131
+ expect(querySelector).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it("ignores stale scope and never queries outside its own viewport", async () => {
135
+ usePlayerStore.getState().requestTimelineFocus(clipId);
136
+ await act(async () => root.render(<Harness mountedId={clipId} projectId="project-b" />));
137
+ expect(document.activeElement).not.toBe(host.querySelector("[data-timeline-focus-id]"));
138
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
139
+
140
+ const externalTarget = document.createElement("div");
141
+ externalTarget.dataset.timelineFocusId = clipId;
142
+ externalTarget.tabIndex = -1;
143
+ document.body.append(externalTarget);
144
+ await act(async () => root.render(<Harness />));
145
+ expect(document.activeElement).not.toBe(externalTarget);
146
+ });
147
+
148
+ it("persists a deterministic parent-row fallback when a focused clip disappears", async () => {
149
+ usePlayerStore.getState().requestTimelineFocus(clipId);
150
+ await act(async () => root.render(<Harness />));
151
+
152
+ const collapsedRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
153
+ await act(async () => root.render(<Harness logicalRows={collapsedRows} mountedId={rowId} />));
154
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(rowId);
155
+ expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(rowId);
156
+ });
157
+
158
+ it("persists the next surviving row when both a clip and its parent track disappear", async () => {
159
+ const nextRowId = timelineTrackRowId(2);
160
+ const nextRow: TimelineLogicalRow = {
161
+ ...rows[0]!,
162
+ id: nextRowId,
163
+ physicalTrackKey: 2,
164
+ items: [],
165
+ };
166
+ usePlayerStore.getState().requestTimelineFocus(clipId);
167
+ await act(async () => root.render(<Harness logicalRows={[...rows, nextRow]} />));
168
+
169
+ await act(async () => root.render(<Harness logicalRows={[nextRow]} mountedId={nextRowId} />));
170
+
171
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(nextRowId);
172
+ expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(nextRowId);
173
+ });
174
+ });
@@ -0,0 +1,221 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, type RefObject } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { usePlayerStore } from "../store/playerStore";
4
+ import type { TimelineFocusRequest } from "../store/timelineFocusState";
5
+ import type { TimelineRowGeometry } from "./timelineLayout";
6
+ import { CLIP_Y, RULER_H } from "./timelineLayout";
7
+ import {
8
+ locateTimelineLogicalTarget,
9
+ resolveTimelineFocusFallback,
10
+ type TimelineLogicalRow,
11
+ type TimelineLogicalTarget,
12
+ } from "./timelineKeyboardNavigation";
13
+ import { computeRevealScroll } from "./timelineRevealScroll";
14
+
15
+ interface TimelineFocusCoordinatorInput {
16
+ scrollRef: RefObject<HTMLDivElement | null>;
17
+ logicalRows: readonly TimelineLogicalRow[];
18
+ elements: readonly TimelineElement[];
19
+ rowGeometry: TimelineRowGeometry;
20
+ pixelsPerSecond: number;
21
+ contentOrigin: number;
22
+ allowHorizontal: boolean;
23
+ viewportVersion: unknown;
24
+ projectId: string | null;
25
+ sessionEpoch: number;
26
+ syncScrollViewport: (element: HTMLDivElement) => void;
27
+ }
28
+
29
+ export interface TimelineFocusCoordinatorState {
30
+ focusedTargetId: string | null;
31
+ focusedRowKey: number | undefined;
32
+ pinnedElementId: string | undefined;
33
+ }
34
+
35
+ interface ResolvedFocus {
36
+ target: TimelineLogicalTarget;
37
+ row: TimelineLogicalRow;
38
+ }
39
+
40
+ function isCurrentRequest(
41
+ request: TimelineFocusRequest | null,
42
+ projectId: string | null,
43
+ sessionEpoch: number,
44
+ ): request is TimelineFocusRequest {
45
+ return (
46
+ request !== null && request.projectId === projectId && request.sessionEpoch === sessionEpoch
47
+ );
48
+ }
49
+
50
+ function focusElement(container: HTMLDivElement, targetId: string): boolean {
51
+ // data-timeline-focus-id is the reserved DOM bridge between logical IDs and this actor.
52
+ const target = container.querySelector<HTMLElement>(
53
+ `[data-timeline-focus-id=${CSS.escape(targetId)}]`,
54
+ );
55
+ if (!target) return false;
56
+ if (target.ownerDocument.activeElement === target) return true;
57
+ target.setAttribute("data-reveal-highlight", "true");
58
+ target.focus({ preventScroll: true });
59
+ if (target.ownerDocument.activeElement !== target) {
60
+ target.removeAttribute("data-reveal-highlight");
61
+ return false;
62
+ }
63
+ // The reveal highlight is intentionally one-shot; ordinary refocus uses the standard focus ring.
64
+ target.addEventListener("blur", () => target.removeAttribute("data-reveal-highlight"), {
65
+ once: true,
66
+ });
67
+ return true;
68
+ }
69
+
70
+ // This is one atomic two-axis reveal calculation; each branch selects target geometry only.
71
+ // fallow-ignore-next-line complexity
72
+ function scrollToTarget(
73
+ container: HTMLDivElement,
74
+ resolution: ResolvedFocus,
75
+ elements: readonly TimelineElement[],
76
+ rowGeometry: TimelineRowGeometry,
77
+ pixelsPerSecond: number,
78
+ contentOrigin: number,
79
+ allowHorizontal: boolean,
80
+ ): boolean {
81
+ const rowIndex = rowGeometry.getRowIndex(resolution.row.physicalTrackKey);
82
+ if (rowIndex < 0) return false;
83
+ const elementId =
84
+ resolution.target.kind === "row" ? resolution.row.elementId : resolution.target.elementId;
85
+ const element = elementId
86
+ ? elements.find((candidate) => (candidate.key ?? candidate.id) === elementId)
87
+ : undefined;
88
+ const pointTime = resolution.target.kind === "row" ? null : resolution.target.time;
89
+ const left = element && resolution.target.kind === "clip" ? element.start : pointTime;
90
+ const right =
91
+ element && resolution.target.kind === "clip" ? element.start + element.duration : pointTime;
92
+ const rowTop = rowGeometry.getRowTop(rowIndex);
93
+ const target = computeRevealScroll({
94
+ scrollLeft: container.scrollLeft,
95
+ scrollTop: container.scrollTop,
96
+ viewportWidth: container.clientWidth,
97
+ viewportHeight: container.clientHeight,
98
+ clipLeft: contentOrigin + (left ?? 0) * pixelsPerSecond,
99
+ clipRight: contentOrigin + (right ?? 0) * pixelsPerSecond,
100
+ clipTop: rowTop + CLIP_Y,
101
+ clipBottom: rowTop + rowGeometry.getRowHeight(rowIndex) - CLIP_Y,
102
+ stickyLeft: contentOrigin,
103
+ stickyTop: RULER_H,
104
+ allowHorizontal: allowHorizontal && left !== null,
105
+ });
106
+ if (target.left !== null) container.scrollLeft = target.left;
107
+ if (target.top !== null) container.scrollTop = target.top;
108
+ return target.left !== null || target.top !== null;
109
+ }
110
+
111
+ /** Model-first focus actor; mounting is a consequence of its returned pins. */
112
+ // Resolution, fallback, reveal, and focus form one ordered state machine.
113
+ // fallow-ignore-next-line complexity
114
+ export function useTimelineFocusCoordinator({
115
+ scrollRef,
116
+ logicalRows,
117
+ elements,
118
+ rowGeometry,
119
+ pixelsPerSecond,
120
+ contentOrigin,
121
+ allowHorizontal,
122
+ viewportVersion,
123
+ projectId,
124
+ sessionEpoch,
125
+ syncScrollViewport,
126
+ }: TimelineFocusCoordinatorInput): TimelineFocusCoordinatorState {
127
+ const request = usePlayerStore((state) => state.timelineFocus);
128
+ const previousRowsRef = useRef(logicalRows);
129
+ const resolvedRef = useRef<{ nonce: number; id: string } | null>(null);
130
+ const appliedRef = useRef<{ nonce: number; id: string } | null>(null);
131
+ const resolution = useMemo<ResolvedFocus | null>(() => {
132
+ if (isCurrentRequest(request, projectId, sessionEpoch)) {
133
+ if (resolvedRef.current?.nonce !== request.nonce) {
134
+ resolvedRef.current = { nonce: request.nonce, id: request.id };
135
+ }
136
+ const resolvedId = resolvedRef.current.id;
137
+ let located = locateTimelineLogicalTarget(logicalRows, resolvedId);
138
+ if (!located) {
139
+ const fallback = resolveTimelineFocusFallback(
140
+ previousRowsRef.current,
141
+ logicalRows,
142
+ resolvedId,
143
+ );
144
+ if (fallback) {
145
+ // ponytail: Cache the fallback under this nonce so render-phase resolution
146
+ // converges before the effect persists the replacement request.
147
+ resolvedRef.current = { nonce: request.nonce, id: fallback.id };
148
+ located = locateTimelineLogicalTarget(logicalRows, fallback.id);
149
+ }
150
+ }
151
+ return located ? { target: located.target, row: located.row } : null;
152
+ }
153
+ resolvedRef.current = null;
154
+ return null;
155
+ }, [logicalRows, projectId, request, sessionEpoch]);
156
+
157
+ useLayoutEffect(() => {
158
+ previousRowsRef.current = logicalRows;
159
+ }, [logicalRows]);
160
+
161
+ // Apply a request exactly once after its logical target and DOM node are both ready.
162
+ // fallow-ignore-next-line complexity
163
+ useEffect(() => {
164
+ if (!isCurrentRequest(request, projectId, sessionEpoch)) return;
165
+ if (!resolution) {
166
+ usePlayerStore.getState().clearTimelineFocus(request.nonce);
167
+ return;
168
+ }
169
+ if (resolution.target.id !== request.id) {
170
+ usePlayerStore.getState().requestTimelineFocus(resolution.target.id);
171
+ return;
172
+ }
173
+ if (
174
+ appliedRef.current?.nonce === request.nonce &&
175
+ appliedRef.current.id === resolution.target.id
176
+ ) {
177
+ return;
178
+ }
179
+ const container = scrollRef.current;
180
+ if (!container) return;
181
+ if (
182
+ scrollToTarget(
183
+ container,
184
+ resolution,
185
+ elements,
186
+ rowGeometry,
187
+ pixelsPerSecond,
188
+ contentOrigin,
189
+ allowHorizontal,
190
+ )
191
+ ) {
192
+ syncScrollViewport(container);
193
+ }
194
+ if (!focusElement(container, resolution.target.id)) return;
195
+ appliedRef.current = { nonce: request.nonce, id: resolution.target.id };
196
+ }, [
197
+ allowHorizontal,
198
+ contentOrigin,
199
+ elements,
200
+ pixelsPerSecond,
201
+ projectId,
202
+ request,
203
+ resolution,
204
+ rowGeometry,
205
+ scrollRef,
206
+ sessionEpoch,
207
+ syncScrollViewport,
208
+ viewportVersion,
209
+ ]);
210
+
211
+ const pinnedElementId = resolution
212
+ ? resolution.target.kind === "row"
213
+ ? (resolution.row.elementId ?? undefined)
214
+ : resolution.target.elementId
215
+ : undefined;
216
+ return {
217
+ focusedTargetId: resolution?.target.id ?? null,
218
+ focusedRowKey: resolution?.row.physicalTrackKey,
219
+ pinnedElementId,
220
+ };
221
+ }