@hyperframes/studio 0.7.92 → 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.
- package/dist/assets/{hyperframes-player-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CDTN2ZVa.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +20 -20
- package/dist/index.html +2 -2
- package/dist/index.js +7055 -5321
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +9 -3
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +24 -5
- package/src/player/components/Timeline.tsx +40 -40
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +15 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineLanes.test.tsx +64 -6
- package/src/player/components/TimelineLanes.tsx +81 -64
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineDiamondTypes.ts +14 -7
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRowVirtualization.ts +16 -26
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +29 -22
- package/src/player/store/playerStore.ts +24 -29
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-DIntkrQl.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- 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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
5
|
+
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
|
6
|
+
import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
|
|
9
|
+
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
|
|
10
|
+
|
|
11
|
+
interface TimelineLogicalFocusInput {
|
|
12
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
13
|
+
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
|
|
14
|
+
layout: { displayTrackOrder: readonly number[]; rowGeometry: TimelineRowGeometry };
|
|
15
|
+
laneCounts: ReadonlyMap<string, number>;
|
|
16
|
+
selectedElementId: string | null;
|
|
17
|
+
selectedElementIds: ReadonlySet<string>;
|
|
18
|
+
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
|
|
19
|
+
elements: readonly TimelineElement[];
|
|
20
|
+
pixelsPerSecond: number;
|
|
21
|
+
contentOrigin: number;
|
|
22
|
+
allowHorizontal: boolean;
|
|
23
|
+
viewport: TimelineScrollViewportSnapshot;
|
|
24
|
+
sessionEpoch: number;
|
|
25
|
+
draggedRowKey?: number;
|
|
26
|
+
resizingElementIds?: readonly string[];
|
|
27
|
+
clipContextMenuRowKey?: number;
|
|
28
|
+
keyframeContextMenuRowKey?: number;
|
|
29
|
+
lastScrollLeftRef: RefObject<number>;
|
|
30
|
+
syncScrollViewport: (element: HTMLDivElement) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
|
|
34
|
+
const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
|
|
35
|
+
const projectId = usePlayerStore((state) => state.timelineProjectId);
|
|
36
|
+
const logicalRows = useTimelineLogicalRows({
|
|
37
|
+
tracks: input.tracks,
|
|
38
|
+
displayTrackOrder: input.layout.displayTrackOrder,
|
|
39
|
+
laneCounts: input.laneCounts,
|
|
40
|
+
selectedElementId: input.selectedElementId,
|
|
41
|
+
selectedElementIds: input.selectedElementIds,
|
|
42
|
+
expandedClipIds,
|
|
43
|
+
gsapAnimations: input.gsapAnimations,
|
|
44
|
+
});
|
|
45
|
+
const focus = useTimelineFocusCoordinator({
|
|
46
|
+
scrollRef: input.scrollRef,
|
|
47
|
+
logicalRows,
|
|
48
|
+
elements: input.elements,
|
|
49
|
+
rowGeometry: input.layout.rowGeometry,
|
|
50
|
+
pixelsPerSecond: input.pixelsPerSecond,
|
|
51
|
+
contentOrigin: input.contentOrigin,
|
|
52
|
+
allowHorizontal: input.allowHorizontal,
|
|
53
|
+
viewportVersion: input.viewport,
|
|
54
|
+
projectId,
|
|
55
|
+
sessionEpoch: input.sessionEpoch,
|
|
56
|
+
syncScrollViewport: input.syncScrollViewport,
|
|
57
|
+
});
|
|
58
|
+
const rows = useTimelineRowVirtualization({
|
|
59
|
+
scrollRef: input.scrollRef,
|
|
60
|
+
viewport: input.viewport,
|
|
61
|
+
rowGeometry: input.layout.rowGeometry,
|
|
62
|
+
sessionEpoch: input.sessionEpoch,
|
|
63
|
+
elements: input.elements,
|
|
64
|
+
selectedElementId: input.selectedElementId,
|
|
65
|
+
focusedRowKey: focus.focusedRowKey,
|
|
66
|
+
draggedRowKey: input.draggedRowKey,
|
|
67
|
+
resizingElementIds: input.resizingElementIds,
|
|
68
|
+
clipContextMenuRowKey: input.clipContextMenuRowKey,
|
|
69
|
+
keyframeContextMenuRowKey: input.keyframeContextMenuRowKey,
|
|
70
|
+
lastScrollLeftRef: input.lastScrollLeftRef,
|
|
71
|
+
syncScrollViewport: input.syncScrollViewport,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
logicalRows,
|
|
75
|
+
...focus,
|
|
76
|
+
rowVirtualizationActive: rows.enabled,
|
|
77
|
+
virtualRows: rows.virtualRows,
|
|
78
|
+
timelineFocusProps: rows.timelineFocusProps,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } 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 type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
9
|
+
import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
|
|
10
|
+
|
|
11
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
12
|
+
|
|
13
|
+
const tracks = Array.from(
|
|
14
|
+
{ length: 1_000 },
|
|
15
|
+
(_, track) =>
|
|
16
|
+
[
|
|
17
|
+
track,
|
|
18
|
+
[{ id: `clip-${track}`, tag: "div", track, start: track, duration: 1 }],
|
|
19
|
+
] as const satisfies readonly [number, readonly TimelineElement[]],
|
|
20
|
+
);
|
|
21
|
+
const displayTrackOrder = tracks.map(([track]) => track);
|
|
22
|
+
const laneCounts = new Map<string, number>();
|
|
23
|
+
const selectedElementIds = new Set<string>();
|
|
24
|
+
const expandedClipIds = new Set<string>();
|
|
25
|
+
const gsapAnimations = new Map();
|
|
26
|
+
|
|
27
|
+
function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]> }) {
|
|
28
|
+
usePlayerStore((state) => state.requestedSeekTime);
|
|
29
|
+
const logicalRows = useTimelineLogicalRows({
|
|
30
|
+
tracks,
|
|
31
|
+
displayTrackOrder,
|
|
32
|
+
laneCounts,
|
|
33
|
+
selectedElementId: null,
|
|
34
|
+
selectedElementIds,
|
|
35
|
+
expandedClipIds,
|
|
36
|
+
gsapAnimations,
|
|
37
|
+
});
|
|
38
|
+
snapshots.push(logicalRows);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => usePlayerStore.getState().reset());
|
|
43
|
+
|
|
44
|
+
describe("useTimelineLogicalRows", () => {
|
|
45
|
+
it("preserves the dense logical model across an unrelated store update", () => {
|
|
46
|
+
const host = document.createElement("div");
|
|
47
|
+
const root = createRoot(host);
|
|
48
|
+
const snapshots: Array<readonly TimelineLogicalRow[]> = [];
|
|
49
|
+
act(() => root.render(<Harness snapshots={snapshots} />));
|
|
50
|
+
const first = snapshots.at(-1);
|
|
51
|
+
|
|
52
|
+
act(() => usePlayerStore.setState({ requestedSeekTime: 1 }));
|
|
53
|
+
|
|
54
|
+
expect(snapshots.at(-1)).toBe(first);
|
|
55
|
+
act(() => root.unmount());
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
|
|
5
|
+
|
|
6
|
+
interface TimelineLogicalRowsInput {
|
|
7
|
+
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
|
|
8
|
+
displayTrackOrder: readonly number[];
|
|
9
|
+
laneCounts: ReadonlyMap<string, number>;
|
|
10
|
+
selectedElementId: string | null;
|
|
11
|
+
selectedElementIds: ReadonlySet<string>;
|
|
12
|
+
expandedClipIds: ReadonlySet<string>;
|
|
13
|
+
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Shared by rendering and focus coordination; stable input refs preserve memo identity. */
|
|
17
|
+
export function useTimelineLogicalRows({
|
|
18
|
+
tracks,
|
|
19
|
+
displayTrackOrder,
|
|
20
|
+
laneCounts,
|
|
21
|
+
selectedElementId,
|
|
22
|
+
selectedElementIds,
|
|
23
|
+
expandedClipIds,
|
|
24
|
+
gsapAnimations,
|
|
25
|
+
}: TimelineLogicalRowsInput) {
|
|
26
|
+
return useMemo(
|
|
27
|
+
() =>
|
|
28
|
+
buildTimelineLogicalRows({
|
|
29
|
+
tracks,
|
|
30
|
+
displayTrackOrder,
|
|
31
|
+
laneCounts,
|
|
32
|
+
selectedElementId,
|
|
33
|
+
selectedElementIds,
|
|
34
|
+
expandedClipIds,
|
|
35
|
+
gsapAnimations,
|
|
36
|
+
}),
|
|
37
|
+
[
|
|
38
|
+
displayTrackOrder,
|
|
39
|
+
expandedClipIds,
|
|
40
|
+
gsapAnimations,
|
|
41
|
+
laneCounts,
|
|
42
|
+
selectedElementId,
|
|
43
|
+
selectedElementIds,
|
|
44
|
+
tracks,
|
|
45
|
+
],
|
|
46
|
+
);
|
|
47
|
+
}
|