@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
|
@@ -59,11 +59,15 @@ describe("timeline performance fixture", () => {
|
|
|
59
59
|
expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it.each(PROFILES)(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
it.each(PROFILES)(
|
|
63
|
+
"generates an identical 50k %s fixture",
|
|
64
|
+
(profile) => {
|
|
65
|
+
const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
|
|
66
|
+
const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
|
|
67
|
+
expect(second).toEqual(first);
|
|
68
|
+
},
|
|
69
|
+
30_000,
|
|
70
|
+
);
|
|
67
71
|
|
|
68
72
|
it.each(PROFILES)("builds the %s 1k scale profile", (profile) => {
|
|
69
73
|
const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile });
|
|
@@ -90,7 +94,7 @@ describe("timeline performance fixture", () => {
|
|
|
90
94
|
usePlayerStore.setState({
|
|
91
95
|
isPlaying: true,
|
|
92
96
|
requestedSeekTime: 42,
|
|
93
|
-
|
|
97
|
+
timelineFocus: { id: "stale", projectId: null, sessionEpoch: 0, nonce: 7 },
|
|
94
98
|
clipManifest: [],
|
|
95
99
|
lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]),
|
|
96
100
|
});
|
|
@@ -108,7 +112,7 @@ describe("timeline performance fixture", () => {
|
|
|
108
112
|
expect(usePlayerStore.getState()).toMatchObject({
|
|
109
113
|
isPlaying: false,
|
|
110
114
|
requestedSeekTime: null,
|
|
111
|
-
|
|
115
|
+
timelineFocus: null,
|
|
112
116
|
clipManifest: null,
|
|
113
117
|
duration: 600,
|
|
114
118
|
timelineReady: true,
|
|
@@ -44,6 +44,8 @@ export function LayerDisclosureRow({
|
|
|
44
44
|
>
|
|
45
45
|
<button
|
|
46
46
|
type="button"
|
|
47
|
+
// ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
|
|
48
|
+
tabIndex={-1}
|
|
47
49
|
aria-expanded={isExpanded}
|
|
48
50
|
aria-controls={lanesId}
|
|
49
51
|
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
|
|
@@ -244,6 +244,25 @@ describe("Timeline provider boundary", () => {
|
|
|
244
244
|
expect(propertyLane.style.background).toBe("");
|
|
245
245
|
expect(propertyLane.style.border).toBe("");
|
|
246
246
|
expect(propertyLane.style.borderRadius).toBe("");
|
|
247
|
+
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
|
|
248
|
+
const semanticRows = treegrid?.querySelectorAll<HTMLElement>('[role="row"]') ?? [];
|
|
249
|
+
expect(treegrid?.getAttribute("aria-rowcount")).toBe("3");
|
|
250
|
+
expect([...semanticRows].map((row) => row.getAttribute("aria-rowindex"))).toEqual([
|
|
251
|
+
"1",
|
|
252
|
+
"2",
|
|
253
|
+
"3",
|
|
254
|
+
]);
|
|
255
|
+
expect(semanticRows[0]?.getAttribute("aria-level")).toBe("1");
|
|
256
|
+
expect(semanticRows[0]?.getAttribute("aria-expanded")).toBe("true");
|
|
257
|
+
expect(semanticRows[1]?.getAttribute("aria-level")).toBe("2");
|
|
258
|
+
expect(semanticRows[1]?.textContent).toContain("position");
|
|
259
|
+
expect(semanticRows[1]?.querySelector('[role="rowheader"]')?.getAttribute("aria-owns")).toBe(
|
|
260
|
+
headerLane.id,
|
|
261
|
+
);
|
|
262
|
+
expect(semanticRows[1]?.querySelector('[role="gridcell"]')?.getAttribute("aria-owns")).toBe(
|
|
263
|
+
propertyLane.id,
|
|
264
|
+
);
|
|
265
|
+
expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false);
|
|
247
266
|
expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
|
|
248
267
|
expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
|
|
249
268
|
expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`);
|
|
@@ -295,12 +314,12 @@ describe("Timeline provider boundary", () => {
|
|
|
295
314
|
const root = createRoot(host);
|
|
296
315
|
act(() => root.render(React.createElement(Timeline)));
|
|
297
316
|
|
|
298
|
-
const
|
|
299
|
-
const rows =
|
|
317
|
+
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
|
|
318
|
+
const rows = treegrid?.querySelectorAll('[role="row"]') ?? [];
|
|
300
319
|
expect(rows).toHaveLength(12);
|
|
301
|
-
expect(
|
|
302
|
-
expect(rows[0]?.getAttribute("aria-
|
|
303
|
-
expect(rows[11]?.getAttribute("aria-
|
|
320
|
+
expect(treegrid?.getAttribute("aria-rowcount")).toBe("12");
|
|
321
|
+
expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
|
|
322
|
+
expect(rows[11]?.getAttribute("aria-rowindex")).toBe("12");
|
|
304
323
|
|
|
305
324
|
act(() => root.unmount());
|
|
306
325
|
});
|
|
@@ -41,10 +41,10 @@ import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
|
|
41
41
|
import { useTimelineTicks } from "./useTimelineTicks";
|
|
42
42
|
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
43
43
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
44
|
-
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
|
|
45
44
|
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
|
|
46
45
|
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
|
47
46
|
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
|
|
47
|
+
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
|
|
48
48
|
|
|
49
49
|
export {
|
|
50
50
|
shouldAutoScrollTimeline,
|
|
@@ -122,7 +122,6 @@ export const Timeline = memo(function Timeline({
|
|
|
122
122
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
123
123
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
124
124
|
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
|
125
|
-
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
|
|
126
125
|
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
127
126
|
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
|
128
127
|
const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]);
|
|
@@ -171,7 +170,6 @@ export const Timeline = memo(function Timeline({
|
|
|
171
170
|
const ppsRef = useRef(100);
|
|
172
171
|
const durationRef = useRef(effectiveDuration);
|
|
173
172
|
durationRef.current = effectiveDuration;
|
|
174
|
-
// Declared before the fitPps derivation so the edit-pin wrappers can close over it.
|
|
175
173
|
const fitPpsRef = useRef(100);
|
|
176
174
|
const {
|
|
177
175
|
pinZoomBeforeEdit,
|
|
@@ -269,14 +267,36 @@ export const Timeline = memo(function Timeline({
|
|
|
269
267
|
expandedElements.length,
|
|
270
268
|
displayLayout.totalH,
|
|
271
269
|
]);
|
|
272
|
-
const
|
|
270
|
+
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
|
|
271
|
+
useTimelineGeometry({
|
|
272
|
+
viewportWidth: viewport.clientWidth,
|
|
273
|
+
effectiveDuration,
|
|
274
|
+
zoomMode,
|
|
275
|
+
manualZoomPercent,
|
|
276
|
+
ppsRef,
|
|
277
|
+
fitPpsRef,
|
|
278
|
+
draggedClip,
|
|
279
|
+
resizingClip,
|
|
280
|
+
expandedElements,
|
|
281
|
+
isDragging,
|
|
282
|
+
scrollRef,
|
|
283
|
+
lastScrollLeftRef,
|
|
284
|
+
contentOrigin,
|
|
285
|
+
});
|
|
286
|
+
const timelineFocus = useTimelineLogicalFocus({
|
|
273
287
|
scrollRef,
|
|
288
|
+
tracks,
|
|
289
|
+
layout: displayLayout,
|
|
290
|
+
laneCounts,
|
|
291
|
+
selectedElementId,
|
|
292
|
+
selectedElementIds,
|
|
293
|
+
gsapAnimations,
|
|
294
|
+
elements: expandedElements,
|
|
295
|
+
pixelsPerSecond: pps,
|
|
296
|
+
contentOrigin,
|
|
297
|
+
allowHorizontal: zoomMode === "manual",
|
|
274
298
|
viewport,
|
|
275
|
-
rowGeometry: displayLayout.rowGeometry,
|
|
276
299
|
sessionEpoch,
|
|
277
|
-
elements: expandedElements,
|
|
278
|
-
selectedElementId,
|
|
279
|
-
revealElementId: clipRevealRequest?.elementId ?? null,
|
|
280
300
|
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
|
|
281
301
|
resizingElementIds,
|
|
282
302
|
clipContextMenuRowKey: clipContextMenu?.element.track,
|
|
@@ -284,7 +304,6 @@ export const Timeline = memo(function Timeline({
|
|
|
284
304
|
lastScrollLeftRef,
|
|
285
305
|
syncScrollViewport,
|
|
286
306
|
});
|
|
287
|
-
const { enabled: rowVirtualizationActive, virtualRows, focusedElementId } = rowWindow;
|
|
288
307
|
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
|
|
289
308
|
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
|
|
290
309
|
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
|
|
@@ -297,22 +316,7 @@ export const Timeline = memo(function Timeline({
|
|
|
297
316
|
setKfContextMenu,
|
|
298
317
|
toggleSelectedKeyframe,
|
|
299
318
|
});
|
|
300
|
-
|
|
301
|
-
useTimelineGeometry({
|
|
302
|
-
viewportWidth: viewport.clientWidth,
|
|
303
|
-
effectiveDuration,
|
|
304
|
-
zoomMode,
|
|
305
|
-
manualZoomPercent,
|
|
306
|
-
ppsRef,
|
|
307
|
-
fitPpsRef,
|
|
308
|
-
draggedClip,
|
|
309
|
-
resizingClip,
|
|
310
|
-
expandedElements,
|
|
311
|
-
isDragging,
|
|
312
|
-
scrollRef,
|
|
313
|
-
lastScrollLeftRef,
|
|
314
|
-
contentOrigin,
|
|
315
|
-
});
|
|
319
|
+
|
|
316
320
|
const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
|
|
317
321
|
tracks,
|
|
318
322
|
viewport,
|
|
@@ -322,7 +326,7 @@ export const Timeline = memo(function Timeline({
|
|
|
322
326
|
selectedElementId: selectedElementId ?? undefined,
|
|
323
327
|
draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
|
|
324
328
|
resizingElementIds,
|
|
325
|
-
|
|
329
|
+
focusedElementId: timelineFocus.pinnedElementId,
|
|
326
330
|
focusedEaseElementId: focusedEaseSegment?.elementId,
|
|
327
331
|
clipContextMenuElementId: clipContextMenu
|
|
328
332
|
? getTimelineElementIdentity(clipContextMenu.element)
|
|
@@ -330,13 +334,6 @@ export const Timeline = memo(function Timeline({
|
|
|
330
334
|
keyframeContextMenuElementId: kfContextMenu
|
|
331
335
|
? getTimelineElementIdentity(kfContextMenu.element)
|
|
332
336
|
: undefined,
|
|
333
|
-
focusedElementId,
|
|
334
|
-
scrollRef,
|
|
335
|
-
elements: expandedElements,
|
|
336
|
-
rowGeometry: displayLayout.rowGeometry,
|
|
337
|
-
allowHorizontalReveal: zoomMode === "manual",
|
|
338
|
-
rowVirtualizationActive,
|
|
339
|
-
sessionEpoch,
|
|
340
337
|
});
|
|
341
338
|
useTimelineActiveClips({
|
|
342
339
|
scrollRef,
|
|
@@ -423,7 +420,7 @@ export const Timeline = memo(function Timeline({
|
|
|
423
420
|
displayDuration,
|
|
424
421
|
pps,
|
|
425
422
|
timeDisplayMode,
|
|
426
|
-
rowVirtualizationActive ? renderTimeRange : undefined,
|
|
423
|
+
timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
|
|
427
424
|
);
|
|
428
425
|
|
|
429
426
|
const getPreviewElement = useCallback(
|
|
@@ -469,13 +466,12 @@ export const Timeline = memo(function Timeline({
|
|
|
469
466
|
recordTimelineScroll(e.currentTarget);
|
|
470
467
|
syncScrollViewport(e.currentTarget, true);
|
|
471
468
|
}}
|
|
472
|
-
{...
|
|
469
|
+
{...timelineFocus.timelineFocusProps}
|
|
473
470
|
onDragOver={assetDrop.handleAssetDragOver}
|
|
474
471
|
onDragLeave={assetDrop.handleAssetDragLeave}
|
|
475
472
|
onDrop={assetDrop.handleAssetDrop}
|
|
476
473
|
onPointerDown={(e) => {
|
|
477
|
-
//
|
|
478
|
-
// their own clicks — scrubbing here would preventDefault and eat them.
|
|
474
|
+
// Interactive controls own their clicks; scrubbing would preventDefault and eat them.
|
|
479
475
|
if (e.target instanceof Element && e.target.closest("button, input, select, a")) return;
|
|
480
476
|
if (splitAllAtPointer(e)) return;
|
|
481
477
|
handlePointerDown(e);
|
|
@@ -502,8 +498,10 @@ export const Timeline = memo(function Timeline({
|
|
|
502
498
|
displayTrackOrder={displayLayout.displayTrackOrder}
|
|
503
499
|
rowHeights={displayLayout.displayRowHeights}
|
|
504
500
|
rowGeometry={displayLayout.rowGeometry}
|
|
505
|
-
virtualRows={virtualRows}
|
|
506
|
-
|
|
501
|
+
virtualRows={timelineFocus.virtualRows}
|
|
502
|
+
logicalRows={timelineFocus.logicalRows}
|
|
503
|
+
focusedTargetId={timelineFocus.focusedTargetId}
|
|
504
|
+
rowsVirtualized={timelineFocus.rowVirtualizationActive}
|
|
507
505
|
clipIndex={clipIndex}
|
|
508
506
|
renderTimeRange={renderTimeRange}
|
|
509
507
|
pinnedClipIdentities={pinnedClipIdentities}
|
|
@@ -522,7 +520,9 @@ export const Timeline = memo(function Timeline({
|
|
|
522
520
|
scrollRef={scrollRef}
|
|
523
521
|
// Windowing drops content to mount a row cheaply; unvirtualized it is pure cost.
|
|
524
522
|
renderClipContent={
|
|
525
|
-
rowVirtualizationActive && viewport.isScrolling
|
|
523
|
+
timelineFocus.rowVirtualizationActive && viewport.isScrolling
|
|
524
|
+
? undefined
|
|
525
|
+
: renderClipContent
|
|
526
526
|
}
|
|
527
527
|
renderClipOverlay={renderClipOverlay}
|
|
528
528
|
playheadRef={playheadRef}
|
|
@@ -132,7 +132,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
|
|
132
132
|
|
|
133
133
|
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 2 }));
|
|
134
134
|
|
|
135
|
-
const rows = host.querySelectorAll(
|
|
135
|
+
const rows = host.querySelectorAll("[data-timeline-row]");
|
|
136
136
|
expect(rows.length).toBeGreaterThan(0);
|
|
137
137
|
expect(rows.length).toBeLessThanOrEqual(16);
|
|
138
138
|
|
|
@@ -201,16 +201,17 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
|
|
201
201
|
|
|
202
202
|
await settleUntil(
|
|
203
203
|
() =>
|
|
204
|
-
(host.querySelector('[role="
|
|
205
|
-
0,
|
|
204
|
+
(host.querySelector('[role="treegrid"]')?.querySelectorAll('[role="row"]').length ?? 0) > 0,
|
|
206
205
|
);
|
|
207
|
-
const
|
|
208
|
-
const rows =
|
|
206
|
+
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
|
|
207
|
+
const rows = treegrid?.querySelectorAll('[role="row"]') ?? [];
|
|
209
208
|
expect(rows.length).toBeGreaterThan(0);
|
|
210
209
|
expect(rows.length).toBeLessThanOrEqual(16);
|
|
211
|
-
expect(rows[0]?.getAttribute("aria-
|
|
212
|
-
expect(
|
|
213
|
-
expect(
|
|
210
|
+
expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
|
|
211
|
+
expect(treegrid?.getAttribute("aria-rowcount")).toBe("1000");
|
|
212
|
+
expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
|
|
213
|
+
expect(treegrid?.querySelectorAll('[data-timeline-focus-id][tabindex="0"]')).toHaveLength(1);
|
|
214
|
+
expect(treegrid?.parentElement?.style.height).toBe(
|
|
214
215
|
`${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
|
|
215
216
|
);
|
|
216
217
|
|
|
@@ -226,7 +227,44 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
|
|
226
227
|
scroller.dispatchEvent(new Event("scroll"));
|
|
227
228
|
});
|
|
228
229
|
}
|
|
229
|
-
expect(
|
|
230
|
+
expect(treegrid?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
|
|
231
|
+
expect(document.activeElement).toBe(focusedControl);
|
|
232
|
+
|
|
233
|
+
act(() => root.unmount());
|
|
234
|
+
usePlayerStore.getState().reset();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("keeps focus pinning active after the scroll viewport remounts", async () => {
|
|
238
|
+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
|
239
|
+
import("./Timeline"),
|
|
240
|
+
import("../store/playerStore"),
|
|
241
|
+
]);
|
|
242
|
+
usePlayerStore.setState({
|
|
243
|
+
duration: 60,
|
|
244
|
+
timelineReady: true,
|
|
245
|
+
elements: clipsByTrack(1_000),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 9 }));
|
|
249
|
+
await settleUntil(() => host.querySelectorAll("[data-timeline-row]").length > 0);
|
|
250
|
+
const firstScroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
251
|
+
|
|
252
|
+
await act(async () => usePlayerStore.setState({ timelineReady: false }));
|
|
253
|
+
expect(host.querySelector("[data-timeline-scroll-viewport]")).toBeNull();
|
|
254
|
+
await act(async () => usePlayerStore.setState({ timelineReady: true }));
|
|
255
|
+
await settleUntil(() => host.querySelectorAll("[data-timeline-row]").length > 0);
|
|
256
|
+
|
|
257
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
258
|
+
const firstRow = host.querySelector<HTMLElement>('[data-timeline-row-key="0"]');
|
|
259
|
+
const focusedControl = firstRow?.querySelector<HTMLButtonElement>("button");
|
|
260
|
+
expect(scroller).not.toBe(firstScroller);
|
|
261
|
+
expect(focusedControl).not.toBeNull();
|
|
262
|
+
act(() => focusedControl?.focus());
|
|
263
|
+
if (scroller) {
|
|
264
|
+
scroller.scrollTop = 500 * 48;
|
|
265
|
+
await dispatchScroll(scroller);
|
|
266
|
+
}
|
|
267
|
+
expect(host.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
|
|
230
268
|
expect(document.activeElement).toBe(focusedControl);
|
|
231
269
|
|
|
232
270
|
act(() => root.unmount());
|
|
@@ -242,6 +280,8 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
|
|
242
280
|
usePlayerStore.setState({
|
|
243
281
|
duration: 1_000,
|
|
244
282
|
timelineReady: true,
|
|
283
|
+
timelineProjectId: "project-a",
|
|
284
|
+
timelineSessionEpoch: 4,
|
|
245
285
|
zoomMode: "manual",
|
|
246
286
|
manualZoomPercent: 2_000,
|
|
247
287
|
selectedElementId: "clip-490",
|
|
@@ -286,10 +326,13 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
|
|
286
326
|
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
|
|
287
327
|
expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
|
|
288
328
|
|
|
289
|
-
|
|
329
|
+
const { timelineClipFocusId } = await import("./timelineNavigationIdentity");
|
|
330
|
+
await act(async () =>
|
|
331
|
+
usePlayerStore.getState().requestTimelineFocus(timelineClipFocusId("clip-300")),
|
|
332
|
+
);
|
|
290
333
|
await advanceFrame();
|
|
291
334
|
await act(async () => {});
|
|
292
|
-
expect(usePlayerStore.getState().
|
|
335
|
+
expect(usePlayerStore.getState().timelineFocus?.id).toBe(timelineClipFocusId("clip-300"));
|
|
293
336
|
const focusedClip = host.querySelector('[data-el-id="clip-300"]');
|
|
294
337
|
expect(document.activeElement).toBe(focusedClip);
|
|
295
338
|
await advanceFrame();
|
|
@@ -36,6 +36,7 @@ function renderClip({
|
|
|
36
36
|
const host = document.createElement("div");
|
|
37
37
|
document.body.append(host);
|
|
38
38
|
const root = createRoot(host);
|
|
39
|
+
const onClick = vi.fn();
|
|
39
40
|
|
|
40
41
|
act(() => {
|
|
41
42
|
root.render(
|
|
@@ -50,7 +51,7 @@ function renderClip({
|
|
|
50
51
|
isComposition={false}
|
|
51
52
|
onHoverStart={vi.fn()}
|
|
52
53
|
onHoverEnd={vi.fn()}
|
|
53
|
-
onClick={
|
|
54
|
+
onClick={onClick}
|
|
54
55
|
onDoubleClick={vi.fn()}
|
|
55
56
|
>
|
|
56
57
|
<div data-custom-content="true" />
|
|
@@ -58,7 +59,7 @@ function renderClip({
|
|
|
58
59
|
);
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
return { host, root };
|
|
62
|
+
return { host, onClick, root };
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
describe("TimelineClip", () => {
|
|
@@ -113,4 +114,18 @@ describe("TimelineClip", () => {
|
|
|
113
114
|
|
|
114
115
|
act(() => root.unmount());
|
|
115
116
|
});
|
|
117
|
+
|
|
118
|
+
it("is a roving native button with explicit selection semantics", () => {
|
|
119
|
+
const { host, onClick, root } = renderClip({
|
|
120
|
+
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 2, track: 0 },
|
|
121
|
+
isSelected: true,
|
|
122
|
+
});
|
|
123
|
+
const clip = host.querySelector<HTMLButtonElement>(".timeline-clip")!;
|
|
124
|
+
expect(clip.type).toBe("button");
|
|
125
|
+
expect(clip.tabIndex).toBe(-1);
|
|
126
|
+
expect(clip.getAttribute("aria-pressed")).toBe("true");
|
|
127
|
+
act(() => clip.click());
|
|
128
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
129
|
+
act(() => root.unmount());
|
|
130
|
+
});
|
|
116
131
|
});
|
|
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
3
3
|
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
|
|
4
4
|
import type { TimelineEditCapabilities } from "./timelineEditing";
|
|
5
5
|
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
6
|
+
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
6
7
|
|
|
7
8
|
interface TimelineClipProps {
|
|
8
9
|
el: TimelineElement;
|
|
@@ -18,6 +19,7 @@ interface TimelineClipProps {
|
|
|
18
19
|
capabilities: TimelineEditCapabilities;
|
|
19
20
|
theme?: TimelineTheme;
|
|
20
21
|
isComposition: boolean;
|
|
22
|
+
tabIndex?: 0 | -1;
|
|
21
23
|
onHoverStart: () => void;
|
|
22
24
|
onHoverEnd: () => void;
|
|
23
25
|
onPointerDown?: (e: React.PointerEvent) => void;
|
|
@@ -43,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
43
45
|
capabilities,
|
|
44
46
|
theme = defaultTimelineTheme,
|
|
45
47
|
isComposition,
|
|
48
|
+
tabIndex = -1,
|
|
46
49
|
onHoverStart,
|
|
47
50
|
onHoverEnd,
|
|
48
51
|
onPointerDown,
|
|
@@ -82,19 +85,28 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
82
85
|
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
|
83
86
|
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
|
|
84
87
|
cursor: "default",
|
|
88
|
+
appearance: "none",
|
|
89
|
+
color: "inherit",
|
|
90
|
+
font: "inherit",
|
|
91
|
+
padding: 0,
|
|
92
|
+
textAlign: "left",
|
|
85
93
|
transform: isDragging ? "translateY(-1px)" : undefined,
|
|
86
94
|
};
|
|
87
95
|
|
|
88
96
|
return (
|
|
89
|
-
<
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
90
99
|
data-clip={isGestureActor ? undefined : "true"}
|
|
91
100
|
data-el-id={isGestureActor ? undefined : (el.key ?? el.id)}
|
|
101
|
+
data-timeline-focus-id={isGestureActor ? undefined : timelineClipFocusId(el.key ?? el.id)}
|
|
92
102
|
data-clip-start={el.start}
|
|
93
103
|
data-clip-end={el.start + el.duration}
|
|
94
104
|
data-clip-hidden={el.hidden ? "true" : undefined}
|
|
95
105
|
data-active={isActive ? "" : undefined}
|
|
96
106
|
aria-hidden={isGestureActor ? "true" : undefined}
|
|
97
|
-
tabIndex={isGestureActor ? undefined :
|
|
107
|
+
tabIndex={isGestureActor ? undefined : tabIndex}
|
|
108
|
+
aria-label={`${displayLabel}, ${startLabel} to ${endLabel} seconds`}
|
|
109
|
+
aria-pressed={isGestureActor ? undefined : isSelected}
|
|
98
110
|
className={clipClassName}
|
|
99
111
|
style={style}
|
|
100
112
|
title={
|
|
@@ -176,6 +188,6 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
176
188
|
</span>
|
|
177
189
|
)}
|
|
178
190
|
{children}
|
|
179
|
-
</
|
|
191
|
+
</button>
|
|
180
192
|
);
|
|
181
193
|
});
|
|
@@ -46,7 +46,7 @@ function createTimelineHost() {
|
|
|
46
46
|
return host;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function renderDiamonds(onClickKeyframe = vi.fn()) {
|
|
49
|
+
function renderDiamonds(onClickKeyframe = vi.fn(), onShiftClickKeyframe = vi.fn()) {
|
|
50
50
|
const host = createTimelineHost();
|
|
51
51
|
const root = createRoot(host);
|
|
52
52
|
act(() => {
|
|
@@ -61,19 +61,60 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
|
|
|
61
61
|
}}
|
|
62
62
|
clipWidthPx={200}
|
|
63
63
|
clipHeightPx={48}
|
|
64
|
-
clipDuration={10}
|
|
65
64
|
accentColor="#4ba3d2"
|
|
66
65
|
isSelected
|
|
67
66
|
currentPercentage={0}
|
|
68
67
|
elementId="clip-1"
|
|
68
|
+
clipStart={10}
|
|
69
|
+
clipDuration={10}
|
|
69
70
|
selectedKeyframes={new Set()}
|
|
70
71
|
onClickKeyframe={onClickKeyframe}
|
|
72
|
+
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
71
73
|
/>,
|
|
72
74
|
);
|
|
73
75
|
});
|
|
74
|
-
return { host, root, onClickKeyframe };
|
|
76
|
+
return { host, root, onClickKeyframe, onShiftClickKeyframe };
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
it("pins authored keyframes outside the clip to an inspectable boundary marker", () => {
|
|
80
|
+
const host = createTimelineHost();
|
|
81
|
+
const root = createRoot(host);
|
|
82
|
+
act(() => {
|
|
83
|
+
root.render(
|
|
84
|
+
<TimelineDiamondLane
|
|
85
|
+
keyframesData={{
|
|
86
|
+
format: "percentage",
|
|
87
|
+
keyframes: [
|
|
88
|
+
{ percentage: -40, properties: { x: 0 }, propertyGroup: "position" },
|
|
89
|
+
{ percentage: -20, properties: { x: 25 }, propertyGroup: "position" },
|
|
90
|
+
{ percentage: 5, properties: { x: 50 }, propertyGroup: "position" },
|
|
91
|
+
{ percentage: 120, properties: { x: 100 }, propertyGroup: "position" },
|
|
92
|
+
],
|
|
93
|
+
}}
|
|
94
|
+
clipWidthPx={200}
|
|
95
|
+
clipHeightPx={48}
|
|
96
|
+
accentColor="#4ba3d2"
|
|
97
|
+
isSelected
|
|
98
|
+
currentPercentage={5}
|
|
99
|
+
elementId="clip-1"
|
|
100
|
+
clipStart={10}
|
|
101
|
+
clipDuration={10}
|
|
102
|
+
selectedKeyframes={new Set()}
|
|
103
|
+
/>,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const before = host.querySelectorAll<HTMLButtonElement>('[data-keyframe-outside-clip="before"]');
|
|
108
|
+
const after = host.querySelector<HTMLButtonElement>('[data-keyframe-outside-clip="after"]');
|
|
109
|
+
expect(Array.from(before, (marker) => marker.style.left)).toEqual(["-35px", "-23px"]);
|
|
110
|
+
expect(before[0]?.getAttribute("aria-label")).toBe("position keyframe at 6s (before clip)");
|
|
111
|
+
expect(after?.style.left).toBe("201px");
|
|
112
|
+
expect(after?.getAttribute("aria-label")).toBe("position keyframe at 22s (after clip)");
|
|
113
|
+
expect(host.querySelectorAll('button[aria-label*="keyframe at"]')).toHaveLength(4);
|
|
114
|
+
|
|
115
|
+
act(() => root.unmount());
|
|
116
|
+
});
|
|
117
|
+
|
|
77
118
|
function renderRetimeLane(
|
|
78
119
|
onMoveKeyframe = vi.fn().mockResolvedValue(true),
|
|
79
120
|
strict = false,
|
|
@@ -261,6 +302,33 @@ describe("TimelineClipDiamonds", () => {
|
|
|
261
302
|
act(() => root.unmount());
|
|
262
303
|
});
|
|
263
304
|
|
|
305
|
+
it("gives keyframes time-based names and native keyboard selection semantics", () => {
|
|
306
|
+
const { host, root, onClickKeyframe } = renderDiamonds();
|
|
307
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
|
|
308
|
+
expect(diamond.getAttribute("aria-label")).toBe("Motion keyframe at 15s");
|
|
309
|
+
expect(diamond.getAttribute("aria-pressed")).toBe("false");
|
|
310
|
+
act(() => diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 })));
|
|
311
|
+
expect(onClickKeyframe).toHaveBeenCalledWith(
|
|
312
|
+
"clip-1",
|
|
313
|
+
expect.objectContaining({ percentage: 50 }),
|
|
314
|
+
);
|
|
315
|
+
act(() => root.unmount());
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("uses Shift+Space's native click for additive keyframe selection", () => {
|
|
319
|
+
const { host, root, onClickKeyframe, onShiftClickKeyframe } = renderDiamonds();
|
|
320
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
|
|
321
|
+
act(() =>
|
|
322
|
+
diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0, shiftKey: true })),
|
|
323
|
+
);
|
|
324
|
+
expect(onShiftClickKeyframe).toHaveBeenCalledWith(
|
|
325
|
+
"clip-1",
|
|
326
|
+
expect.objectContaining({ percentage: 50 }),
|
|
327
|
+
);
|
|
328
|
+
expect(onClickKeyframe).not.toHaveBeenCalled();
|
|
329
|
+
act(() => root.unmount());
|
|
330
|
+
});
|
|
331
|
+
|
|
264
332
|
it("publishes retime previews after StrictMode effect replay", () => {
|
|
265
333
|
const { diamond, host, root } = renderRetimeLane(undefined, true);
|
|
266
334
|
const initialLeft = diamond.style.left;
|
|
@@ -1108,10 +1176,9 @@ describe("TimelineClipDiamonds", () => {
|
|
|
1108
1176
|
// Regression: onClickKeyframe's state updates can re-render the diamond
|
|
1109
1177
|
// button out from under the gesture before the browser auto-synthesizes the
|
|
1110
1178
|
// "click" event that follows a button's pointerdown+pointerup. That orphaned
|
|
1111
|
-
// click then bubbles to the ancestor clip's onClick
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
// own clip. suppressClickRef lets that ancestor ignore the stray click.
|
|
1179
|
+
// click then bubbles to the ancestor clip's onClick. That stray click can
|
|
1180
|
+
// replace keyframe focus or collapse a marquee selection, so suppressClickRef
|
|
1181
|
+
// lets the ancestor ignore it.
|
|
1115
1182
|
it("arms suppressClickRef synchronously on a keyframe click", () => {
|
|
1116
1183
|
const suppressClickRef = { current: false };
|
|
1117
1184
|
const host = createTimelineHost();
|
|
@@ -1150,6 +1217,7 @@ describe("TimelineClipDiamonds", () => {
|
|
|
1150
1217
|
const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
|
|
1151
1218
|
const host = createTimelineHost();
|
|
1152
1219
|
const root = createRoot(host);
|
|
1220
|
+
const onSelectSegment = vi.fn();
|
|
1153
1221
|
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
|
|
1154
1222
|
percentage,
|
|
1155
1223
|
tweenPercentage: percentage,
|
|
@@ -1182,13 +1250,15 @@ describe("TimelineClipDiamonds", () => {
|
|
|
1182
1250
|
isSelected
|
|
1183
1251
|
currentPercentage={0}
|
|
1184
1252
|
elementId="clip-1"
|
|
1253
|
+
clipStart={10}
|
|
1254
|
+
clipDuration={10}
|
|
1185
1255
|
selectedKeyframes={new Set()}
|
|
1186
|
-
onSelectSegment={
|
|
1256
|
+
onSelectSegment={onSelectSegment}
|
|
1187
1257
|
groupAware
|
|
1188
1258
|
/>,
|
|
1189
1259
|
);
|
|
1190
1260
|
});
|
|
1191
|
-
return { host, root };
|
|
1261
|
+
return { host, onSelectSegment, root };
|
|
1192
1262
|
};
|
|
1193
1263
|
|
|
1194
1264
|
it("shows the inline ease button on a colliding merged segment (bulk edit)", () => {
|
|
@@ -1201,8 +1271,23 @@ describe("TimelineClipDiamonds", () => {
|
|
|
1201
1271
|
});
|
|
1202
1272
|
|
|
1203
1273
|
it("shows the inline ease button on single-animation merged segments", () => {
|
|
1204
|
-
const { host, root } = renderSegmentLane(false);
|
|
1274
|
+
const { host, onSelectSegment, root } = renderSegmentLane(false);
|
|
1205
1275
|
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
|
1276
|
+
const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]")!;
|
|
1277
|
+
expect(ease.getAttribute("aria-label")).toBe("Edit none easing after 10s");
|
|
1278
|
+
expect(ease.classList.contains("opacity-0")).toBe(true);
|
|
1279
|
+
act(() => ease.click());
|
|
1280
|
+
expect(onSelectSegment).toHaveBeenCalledOnce();
|
|
1281
|
+
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
|
|
1282
|
+
act(() => root.unmount());
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
it("ends connectors at the diamond boundaries", () => {
|
|
1286
|
+
const { host, root } = renderSegmentLane(false);
|
|
1287
|
+
const connectors = host.querySelectorAll<HTMLElement>("[data-keyframe-connector]");
|
|
1288
|
+
|
|
1289
|
+
expect(Array.from(connectors, (connector) => connector.style.left)).toEqual(["11px", "111px"]);
|
|
1290
|
+
expect(Array.from(connectors, (connector) => connector.style.width)).toEqual(["78px", "78px"]);
|
|
1206
1291
|
act(() => root.unmount());
|
|
1207
1292
|
});
|
|
1208
1293
|
|