@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
@@ -0,0 +1,267 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useRef } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, 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 { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
10
+
11
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
12
+ configurable: true,
13
+ value: true,
14
+ });
15
+
16
+ const rows: readonly TimelineLogicalRow[] = [
17
+ {
18
+ id: "track-1",
19
+ kind: "row",
20
+ physicalTrackKey: 1,
21
+ logicalIndex: 0,
22
+ level: 1,
23
+ parentId: null,
24
+ elementId: "clip-1",
25
+ expandable: true,
26
+ expanded: false,
27
+ items: [{ id: "clip-1", kind: "clip", rowId: "track-1", elementId: "clip-1", time: 1 }],
28
+ },
29
+ {
30
+ id: "track-2",
31
+ kind: "row",
32
+ physicalTrackKey: 2,
33
+ logicalIndex: 1,
34
+ level: 1,
35
+ parentId: null,
36
+ elementId: "clip-2",
37
+ expandable: false,
38
+ expanded: false,
39
+ items: [{ id: "clip-2", kind: "clip", rowId: "track-2", elementId: "clip-2", time: 2 }],
40
+ },
41
+ {
42
+ id: "track-3",
43
+ kind: "row",
44
+ physicalTrackKey: 3,
45
+ logicalIndex: 2,
46
+ level: 1,
47
+ parentId: null,
48
+ elementId: null,
49
+ expandable: false,
50
+ expanded: false,
51
+ items: [],
52
+ },
53
+ ];
54
+
55
+ interface HarnessProps {
56
+ focusedTargetId?: string | null;
57
+ logicalRows?: readonly TimelineLogicalRow[];
58
+ rowHeights?: readonly number[];
59
+ onToggleRow?: (target: TimelineLogicalRow) => void;
60
+ }
61
+
62
+ function Harness({
63
+ focusedTargetId = null,
64
+ logicalRows = rows,
65
+ rowHeights = logicalRows.map(() => 48),
66
+ onToggleRow = vi.fn(),
67
+ }: HarnessProps) {
68
+ const scrollRef = useRef<HTMLDivElement>(null);
69
+ const keyboard = useTimelineKeyboardActor({
70
+ logicalRows,
71
+ focusedTargetId,
72
+ rowGeometry: createTimelineRowGeometry(
73
+ [...new Set(logicalRows.map((row) => row.physicalTrackKey))],
74
+ rowHeights,
75
+ ),
76
+ scrollRef,
77
+ onToggleRow,
78
+ });
79
+ return (
80
+ <div ref={scrollRef} onFocus={keyboard.onFocus} onKeyDown={keyboard.onKeyDown}>
81
+ {logicalRows
82
+ .flatMap((row) => [row, ...row.items])
83
+ .map((target) =>
84
+ target.kind === "row" ? (
85
+ <div
86
+ key={target.id}
87
+ data-timeline-focus-id={target.id}
88
+ tabIndex={target.id === keyboard.rovingTargetId ? 0 : -1}
89
+ >
90
+ {target.id}
91
+ <button data-native-control={target.id} type="button">
92
+ Action
93
+ </button>
94
+ </div>
95
+ ) : (
96
+ <button
97
+ key={target.id}
98
+ data-timeline-focus-id={target.id}
99
+ tabIndex={target.id === keyboard.rovingTargetId ? 0 : -1}
100
+ >
101
+ {target.id}
102
+ </button>
103
+ ),
104
+ )}
105
+ </div>
106
+ );
107
+ }
108
+
109
+ function renderHarness(props: React.ComponentProps<typeof Harness> = {}) {
110
+ const host = document.createElement("div");
111
+ document.body.append(host);
112
+ const root = createRoot(host);
113
+ act(() => root.render(<Harness {...props} />));
114
+ return { host, root };
115
+ }
116
+
117
+ function key(target: Element, value: string, init: KeyboardEventInit = {}) {
118
+ const event = new KeyboardEvent("keydown", {
119
+ bubbles: true,
120
+ cancelable: true,
121
+ key: value,
122
+ ...init,
123
+ });
124
+ act(() => target.dispatchEvent(event));
125
+ return event;
126
+ }
127
+
128
+ afterEach(() => {
129
+ document.body.innerHTML = "";
130
+ usePlayerStore.setState({ timelineFocus: null, timelineFocusNonce: 0 });
131
+ });
132
+
133
+ describe("useTimelineKeyboardActor", () => {
134
+ it("exposes exactly one roving target and persists focused logical identity", () => {
135
+ const { host, root } = renderHarness({ focusedTargetId: "clip-2" });
136
+ expect(host.querySelectorAll('[data-timeline-focus-id][tabindex="0"]')).toHaveLength(1);
137
+ expect(host.querySelectorAll("[data-native-control]")).toHaveLength(3);
138
+ expect(
139
+ [...host.querySelectorAll<HTMLElement>("[data-native-control]")].every(
140
+ (el) => el.tabIndex === 0,
141
+ ),
142
+ ).toBe(true);
143
+ const target = host.querySelector<HTMLElement>('[data-timeline-focus-id="track-3"]')!;
144
+ act(() => target.focus());
145
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-3");
146
+ act(() => root.unmount());
147
+ });
148
+
149
+ it("requests navigation focus without clicking or seeking", () => {
150
+ const { host, root } = renderHarness({ focusedTargetId: "clip-1" });
151
+ const target = host.querySelector<HTMLElement>('[data-timeline-focus-id="clip-1"]')!;
152
+ const click = vi.fn();
153
+ target.addEventListener("click", click);
154
+ const event = key(target, "ArrowDown");
155
+ expect(event.defaultPrevented).toBe(true);
156
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe("clip-2");
157
+ expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
158
+ expect(click).not.toHaveBeenCalled();
159
+ act(() => root.unmount());
160
+ });
161
+
162
+ it("supports modified timeline boundaries and viewport-sized paging", () => {
163
+ const { host, root } = renderHarness({ focusedTargetId: "clip-2" });
164
+ const viewport = host.firstElementChild as HTMLDivElement;
165
+ Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 48 });
166
+ const target = host.querySelector<HTMLElement>('[data-timeline-focus-id="clip-2"]')!;
167
+ key(target, "End", { metaKey: true });
168
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-3");
169
+ key(target, "PageUp");
170
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe("clip-1");
171
+ act(() => root.unmount());
172
+ });
173
+
174
+ it("sizes paging around off-screen focus instead of the current viewport", () => {
175
+ const logicalRows: readonly TimelineLogicalRow[] = [
176
+ rows[0]!,
177
+ {
178
+ ...rows[0]!,
179
+ id: "property-1",
180
+ logicalIndex: 1,
181
+ level: 2,
182
+ parentId: "track-1",
183
+ expandable: false,
184
+ items: [],
185
+ },
186
+ {
187
+ ...rows[0]!,
188
+ id: "property-2",
189
+ logicalIndex: 2,
190
+ level: 2,
191
+ parentId: "track-1",
192
+ expandable: false,
193
+ items: [],
194
+ },
195
+ { ...rows[1]!, logicalIndex: 3 },
196
+ { ...rows[2]!, logicalIndex: 4 },
197
+ ];
198
+ const { host, root } = renderHarness({
199
+ focusedTargetId: "track-3",
200
+ logicalRows,
201
+ rowHeights: [104, 48, 48],
202
+ });
203
+ const viewport = host.firstElementChild as HTMLDivElement;
204
+ Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 47 });
205
+ const target = host.querySelector<HTMLElement>('[data-timeline-focus-id="track-3"]')!;
206
+ key(target, "PageUp");
207
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe("track-2");
208
+ act(() => root.unmount());
209
+ });
210
+
211
+ it("supports APG disclosure arrows plus Enter and Space", () => {
212
+ const onToggleRow = vi.fn();
213
+ const { host, root } = renderHarness({ focusedTargetId: "track-1", onToggleRow });
214
+ const row = host.querySelector<HTMLElement>('[data-timeline-focus-id="track-1"]')!;
215
+ const clip = host.querySelector<HTMLElement>('[data-timeline-focus-id="clip-1"]')!;
216
+ const nativeClick = vi.fn();
217
+ clip.addEventListener("click", nativeClick);
218
+ expect(key(row, "ArrowRight").defaultPrevented).toBe(true);
219
+ expect(onToggleRow).toHaveBeenCalledWith(rows[0]);
220
+ expect(key(row, " ").defaultPrevented).toBe(true);
221
+ expect(onToggleRow).toHaveBeenCalledWith(rows[0]);
222
+ const enter = key(clip, "Enter");
223
+ expect(enter.defaultPrevented).toBe(false);
224
+ // dispatchEvent does not synthesize a browser default action, so model it explicitly.
225
+ if (!enter.defaultPrevented) act(() => clip.click());
226
+ expect(nativeClick).toHaveBeenCalledOnce();
227
+ expect(onToggleRow).toHaveBeenCalledTimes(2);
228
+ act(() => root.unmount());
229
+ });
230
+
231
+ it("collapses expanded rows and leaves native header controls to the browser", () => {
232
+ const onToggleRow = vi.fn();
233
+ const expandedRows = [{ ...rows[0]!, expanded: true }, ...rows.slice(1)];
234
+ const { host, root } = renderHarness({
235
+ focusedTargetId: "track-1",
236
+ logicalRows: expandedRows,
237
+ onToggleRow,
238
+ });
239
+ const row = host.querySelector<HTMLElement>('[data-timeline-focus-id="track-1"]')!;
240
+ expect(key(row, "ArrowLeft").defaultPrevented).toBe(true);
241
+ expect(onToggleRow).toHaveBeenCalledWith(expandedRows[0]);
242
+
243
+ usePlayerStore.setState({ timelineFocus: null, timelineFocusNonce: 0 });
244
+ const control = host.querySelector<HTMLElement>('[data-native-control="track-1"]')!;
245
+ const nativeClick = vi.fn();
246
+ control.addEventListener("click", nativeClick);
247
+ act(() => control.focus());
248
+ expect(usePlayerStore.getState().timelineFocus).toBeNull();
249
+ const enter = key(control, "Enter");
250
+ expect(enter.defaultPrevented).toBe(false);
251
+ // dispatchEvent does not synthesize a browser default action, so model it explicitly.
252
+ if (!enter.defaultPrevented) act(() => control.click());
253
+ expect(nativeClick).toHaveBeenCalledOnce();
254
+ expect(onToggleRow).toHaveBeenCalledOnce();
255
+ act(() => root.unmount());
256
+ });
257
+
258
+ it("dispatches the existing scoped context-menu callback", () => {
259
+ const { host, root } = renderHarness({ focusedTargetId: "clip-1" });
260
+ const target = host.querySelector<HTMLElement>('[data-timeline-focus-id="clip-1"]')!;
261
+ const context = vi.fn((event: Event) => event.preventDefault());
262
+ target.addEventListener("contextmenu", context);
263
+ key(target, "F10", { shiftKey: true });
264
+ expect(context).toHaveBeenCalledOnce();
265
+ act(() => root.unmount());
266
+ });
267
+ });
@@ -0,0 +1,147 @@
1
+ import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
2
+ import { usePlayerStore } from "../store/playerStore";
3
+ import type { TimelineRowGeometry } from "./timelineLayout";
4
+ import {
5
+ isTimelineNavigationKey,
6
+ locateTimelineLogicalTarget,
7
+ resolveTimelineNavigationTarget,
8
+ type TimelineLogicalRow,
9
+ } from "./timelineKeyboardNavigation";
10
+
11
+ interface TimelineKeyboardActorInput {
12
+ logicalRows: readonly TimelineLogicalRow[];
13
+ focusedTargetId: string | null;
14
+ rowGeometry: TimelineRowGeometry;
15
+ scrollRef: RefObject<HTMLDivElement | null>;
16
+ onToggleRow: (target: TimelineLogicalRow) => void;
17
+ }
18
+
19
+ function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
20
+ if (!(event.target instanceof Element)) return null;
21
+ // Header actions stay native Tab stops because they have no row-level shortcut.
22
+ // The nearest interactive ancestor wins so their events never masquerade as row events.
23
+ const target = event.target.closest<HTMLElement>(
24
+ "button, input, select, textarea, a[href], [contenteditable], [data-timeline-focus-id]",
25
+ );
26
+ return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null;
27
+ }
28
+
29
+ function viewportPageSize(
30
+ logicalRowCountByTrack: ReadonlyMap<number, number>,
31
+ focusedTrackKey: number,
32
+ geometry: TimelineRowGeometry,
33
+ viewport: HTMLDivElement | null,
34
+ ): number {
35
+ if (!viewport || logicalRowCountByTrack.size === 0) return 1;
36
+ const focusedRow = geometry.getRowIndex(focusedTrackKey);
37
+ const first = Math.max(
38
+ 0,
39
+ focusedRow >= 0 ? focusedRow : Math.floor(geometry.getRowFromY(viewport.scrollTop)),
40
+ );
41
+ const pageTop = focusedRow >= 0 ? geometry.getRowTop(focusedRow) : viewport.scrollTop;
42
+ // Stay just inside the viewport so an exact row boundary does not count the next row.
43
+ const last = Math.min(
44
+ geometry.rowKeys.length - 1,
45
+ Math.floor(geometry.getRowFromY(pageTop + Math.max(0, viewport.clientHeight - 0.001))),
46
+ );
47
+ let count = 0;
48
+ for (let index = first; index <= last; index += 1) {
49
+ count += logicalRowCountByTrack.get(geometry.rowKeys[index]!) ?? 0;
50
+ }
51
+ return Math.max(1, count);
52
+ }
53
+
54
+ function openContextMenu(target: HTMLElement): void {
55
+ const bounds = target.getBoundingClientRect();
56
+ target.dispatchEvent(
57
+ new MouseEvent("contextmenu", {
58
+ bubbles: true,
59
+ cancelable: true,
60
+ clientX: bounds.left + bounds.width / 2,
61
+ clientY: bounds.top + bounds.height / 2,
62
+ }),
63
+ );
64
+ }
65
+
66
+ /** The timeline's sole keyboard actor; controls only describe their logical identity. */
67
+ export function useTimelineKeyboardActor({
68
+ logicalRows,
69
+ focusedTargetId,
70
+ rowGeometry,
71
+ scrollRef,
72
+ onToggleRow,
73
+ }: TimelineKeyboardActorInput) {
74
+ const rovingTargetId =
75
+ (focusedTargetId && locateTimelineLogicalTarget(logicalRows, focusedTargetId)?.target.id) ??
76
+ logicalRows[0]?.id ??
77
+ null;
78
+ const logicalRowCountByTrack = useMemo(() => {
79
+ const counts = new Map<number, number>();
80
+ for (const row of logicalRows) {
81
+ counts.set(row.physicalTrackKey, (counts.get(row.physicalTrackKey) ?? 0) + 1);
82
+ }
83
+ return counts;
84
+ }, [logicalRows]);
85
+
86
+ const onFocus = useCallback(
87
+ (event: FocusEvent<HTMLElement>) => {
88
+ const id = eventTarget(event)?.dataset.timelineFocusId;
89
+ if (id && id !== focusedTargetId) usePlayerStore.getState().requestTimelineFocus(id);
90
+ },
91
+ // ponytail: This closure must see the current id or coordinator-driven focus bumps the nonce twice.
92
+ [focusedTargetId],
93
+ );
94
+
95
+ const onKeyDown = useCallback(
96
+ // One handler owns navigation, context-menu, and disclosure keyboard semantics.
97
+ // fallow-ignore-next-line complexity
98
+ (event: KeyboardEvent<HTMLElement>) => {
99
+ const targetElement = eventTarget(event);
100
+ const id = targetElement?.dataset.timelineFocusId;
101
+ if (!targetElement || !id) return;
102
+ const located = locateTimelineLogicalTarget(logicalRows, id);
103
+ if (!located) return;
104
+
105
+ if (isTimelineNavigationKey(event.key)) {
106
+ if (
107
+ located.target.kind === "row" &&
108
+ ((event.key === "ArrowRight" && located.target.expandable && !located.target.expanded) ||
109
+ (event.key === "ArrowLeft" && located.target.expandable && located.target.expanded))
110
+ ) {
111
+ event.preventDefault();
112
+ onToggleRow(located.target);
113
+ return;
114
+ }
115
+ const next = resolveTimelineNavigationTarget(logicalRows, id, event.key, {
116
+ pageSize: viewportPageSize(
117
+ logicalRowCountByTrack,
118
+ located.row.physicalTrackKey,
119
+ rowGeometry,
120
+ scrollRef.current,
121
+ ),
122
+ timelineBoundary: event.ctrlKey || event.metaKey,
123
+ });
124
+ event.preventDefault();
125
+ if (next && next.id !== id) usePlayerStore.getState().requestTimelineFocus(next.id);
126
+ return;
127
+ }
128
+ if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) {
129
+ event.preventDefault();
130
+ openContextMenu(targetElement);
131
+ return;
132
+ }
133
+ if (
134
+ (event.key !== "Enter" && event.key !== " ") ||
135
+ located.target.kind !== "row" ||
136
+ !located.target.expandable
137
+ ) {
138
+ return;
139
+ }
140
+ event.preventDefault();
141
+ onToggleRow(located.target);
142
+ },
143
+ [logicalRowCountByTrack, logicalRows, onToggleRow, rowGeometry, scrollRef],
144
+ );
145
+
146
+ return { rovingTargetId, onFocus, onKeyDown };
147
+ }
@@ -5,14 +5,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
6
6
  import type { TimelineElement } from "../store/playerStore";
7
7
  import { usePlayerStore } from "../store/playerStore";
8
+ import * as telemetry from "../../telemetry/events";
8
9
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
9
10
  import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
10
11
 
11
12
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
13
 
13
- const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
14
- vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
15
-
16
14
  const ELEMENT: TimelineElement = {
17
15
  id: "clip-1",
18
16
  label: "Hero card",
@@ -46,8 +44,8 @@ const COLLIDING_TARGET: TimelineKeyframeTarget = {
46
44
 
47
45
  afterEach(() => {
48
46
  document.body.innerHTML = "";
49
- trackStudioSegmentEaseEdit.mockClear();
50
- usePlayerStore.setState({ focusedEaseSegment: null });
47
+ vi.restoreAllMocks();
48
+ usePlayerStore.setState({ focusedEaseSegment: null, timelineSessionEpoch: 0 });
51
49
  });
52
50
 
53
51
  /**
@@ -78,6 +76,9 @@ function mountHandlers(options: Partial<Parameters<typeof useTimelineKeyframeHan
78
76
 
79
77
  describe("useTimelineKeyframeHandlers", () => {
80
78
  it("tracks opening the segment ease editor when a timeline segment is selected", () => {
79
+ const trackStudioSegmentEaseEdit = vi
80
+ .spyOn(telemetry, "trackStudioSegmentEaseEdit")
81
+ .mockImplementation(() => {});
81
82
  const { root, handlers } = mountHandlers();
82
83
  act(() => handlers.onSelectSegment?.(ELEMENT.id, TARGET));
83
84
 
@@ -90,7 +91,7 @@ describe("useTimelineKeyframeHandlers", () => {
90
91
  const { root, handlers } = mountHandlers();
91
92
  act(() => handlers.onSelectSegment?.(ELEMENT.id, COLLIDING_TARGET));
92
93
 
93
- expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
94
+ expect(usePlayerStore.getState().focusedEaseSegment).toMatchObject({
94
95
  animationId: "position-tween",
95
96
  collidingAnimationTargets: [
96
97
  { animationId: "position-tween", tweenPercentage: 100 },
@@ -111,7 +112,7 @@ describe("useTimelineKeyframeHandlers", () => {
111
112
  // Selecting a segment must NOT move the playhead.
112
113
  act(() => handlers.onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
113
114
  expect(onSeek).not.toHaveBeenCalled();
114
- expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
115
+ expect(usePlayerStore.getState().focusedEaseSegment).toMatchObject({
115
116
  animationId: "position-tween",
116
117
  tweenPercentage: 100,
117
118
  elementId: ELEMENT.id,
@@ -125,4 +126,39 @@ describe("useTimelineKeyframeHandlers", () => {
125
126
  expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
126
127
  act(() => root.unmount());
127
128
  });
129
+
130
+ it("scopes a keyframe context target to the opening timeline session", () => {
131
+ const setKfContextMenu = vi.fn();
132
+ usePlayerStore.setState({ timelineSessionEpoch: 4 });
133
+
134
+ function Harness() {
135
+ const { onContextMenuKeyframe } = useTimelineKeyframeHandlers({
136
+ expandedElements: [ELEMENT],
137
+ keyframeCache: new Map(),
138
+ setSelectedElementId: vi.fn(),
139
+ setKfContextMenu,
140
+ toggleSelectedKeyframe: vi.fn(),
141
+ });
142
+ return (
143
+ <button
144
+ type="button"
145
+ onContextMenu={(event) => onContextMenuKeyframe(event, ELEMENT.id, TARGET)}
146
+ />
147
+ );
148
+ }
149
+
150
+ const root = mountReactHarness(<Harness />);
151
+ const button = document.querySelector("button");
152
+ expect(button).not.toBeNull();
153
+ act(() => {
154
+ button?.dispatchEvent(
155
+ new MouseEvent("contextmenu", { bubbles: true, clientX: 10, clientY: 20 }),
156
+ );
157
+ });
158
+
159
+ expect(setKfContextMenu).toHaveBeenCalledWith(
160
+ expect.objectContaining({ elementId: ELEMENT.id, sessionEpoch: 4, x: 14, y: 22 }),
161
+ );
162
+ act(() => root.unmount());
163
+ });
128
164
  });