@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
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Fragment, useId } from "react";
|
|
1
|
+
import { Fragment, useId, useMemo } from "react";
|
|
2
2
|
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
3
3
|
import { TimelineClip } from "./TimelineClip";
|
|
4
|
-
import {
|
|
4
|
+
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
|
|
5
5
|
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
6
6
|
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
|
7
7
|
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
|
@@ -27,6 +27,9 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
|
|
|
27
27
|
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
28
28
|
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
29
29
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
30
|
+
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
31
|
+
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
32
|
+
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
30
33
|
|
|
31
34
|
interface TimelineLanesProps extends TimelineLaneBaseProps {
|
|
32
35
|
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
|
|
@@ -49,6 +52,8 @@ export function TimelineLanes({
|
|
|
49
52
|
displayTrackOrder,
|
|
50
53
|
rowGeometry,
|
|
51
54
|
virtualRows,
|
|
55
|
+
logicalRows,
|
|
56
|
+
focusedTargetId,
|
|
52
57
|
rowsVirtualized,
|
|
53
58
|
clipIndex,
|
|
54
59
|
renderTimeRange,
|
|
@@ -99,14 +104,20 @@ export function TimelineLanes({
|
|
|
99
104
|
onRazorSplit,
|
|
100
105
|
onRazorSplitAll,
|
|
101
106
|
}: TimelineLanesProps) {
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// aria-controls resolve to whichever mounted first. React's useId embeds
|
|
105
|
-
// colons, which are legal in an id and in aria-controls but need escaping in
|
|
106
|
-
// a CSS `#id` selector, so they come out here and the prefix stays plain.
|
|
107
|
+
// ponytail: One per-instance namespace prevents aria-controls and aria-owns
|
|
108
|
+
// from resolving into a second timeline that renders the same logical rows.
|
|
107
109
|
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
|
|
108
110
|
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
109
111
|
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
|
112
|
+
const logicalRowsByTrack = useMemo(() => {
|
|
113
|
+
const byTrack = new Map<number, TimelineLogicalRow[]>();
|
|
114
|
+
for (const logicalRow of logicalRows) {
|
|
115
|
+
const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
|
|
116
|
+
trackRows.push(logicalRow);
|
|
117
|
+
byTrack.set(logicalRow.physicalTrackKey, trackRows);
|
|
118
|
+
}
|
|
119
|
+
return byTrack;
|
|
120
|
+
}, [logicalRows]);
|
|
110
121
|
const toggleClipExpandedTracked = (key: string) => {
|
|
111
122
|
const willExpand = !expandedClipIds.has(key);
|
|
112
123
|
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
|
@@ -128,10 +139,23 @@ export function TimelineLanes({
|
|
|
128
139
|
},
|
|
129
140
|
]
|
|
130
141
|
: [];
|
|
142
|
+
const keyboard = useTimelineKeyboardActor({
|
|
143
|
+
logicalRows,
|
|
144
|
+
focusedTargetId,
|
|
145
|
+
rowGeometry,
|
|
146
|
+
scrollRef,
|
|
147
|
+
onToggleRow: (row) => {
|
|
148
|
+
if (row.elementId) toggleClipExpandedTracked(row.elementId);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
131
151
|
return (
|
|
132
152
|
<div
|
|
133
|
-
role="
|
|
153
|
+
role="treegrid"
|
|
134
154
|
aria-label="Timeline tracks"
|
|
155
|
+
aria-rowcount={logicalRows.length}
|
|
156
|
+
aria-colcount={2}
|
|
157
|
+
onFocus={keyboard.onFocus}
|
|
158
|
+
onKeyDown={keyboard.onKeyDown}
|
|
135
159
|
className={rowsVirtualized ? "absolute inset-0" : undefined}
|
|
136
160
|
>
|
|
137
161
|
{
|
|
@@ -140,6 +164,9 @@ export function TimelineLanes({
|
|
|
140
164
|
const trackNum = displayTrackOrder[row];
|
|
141
165
|
if (trackNum === undefined) return null;
|
|
142
166
|
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
|
|
167
|
+
const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
|
|
168
|
+
const logicalRow = trackLogicalRows[0];
|
|
169
|
+
if (!logicalRow) return null;
|
|
143
170
|
const rowHeight = rowGeometry.getRowHeight(row);
|
|
144
171
|
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
|
145
172
|
const renderElements = rowsVirtualized
|
|
@@ -156,9 +183,7 @@ export function TimelineLanes({
|
|
|
156
183
|
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
157
184
|
// All lanes use the same uniform color — no alternating stripes.
|
|
158
185
|
const rowBackground = theme.rowBackground;
|
|
159
|
-
//
|
|
160
|
-
// or the music track when nothing is selected). When shown, keyframe
|
|
161
|
-
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
|
186
|
+
// Keep diamonds below the beat strip on the active/music track.
|
|
162
187
|
const beatStripOnTrack =
|
|
163
188
|
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
|
|
164
189
|
(selectedElementId
|
|
@@ -166,9 +191,7 @@ export function TimelineLanes({
|
|
|
166
191
|
: els.some(isMusicTrack));
|
|
167
192
|
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
|
|
168
193
|
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
|
|
169
|
-
//
|
|
170
|
-
// most lanes). A track can hold several elements; scoping to one keeps
|
|
171
|
-
// their keyframes from cramming into a single row.
|
|
194
|
+
// Only the selected/most-keyframed clip owns expanded lanes on a shared track.
|
|
172
195
|
const keyframeClip = resolveTrackKeyframeClip(
|
|
173
196
|
els,
|
|
174
197
|
laneCounts,
|
|
@@ -178,22 +201,22 @@ export function TimelineLanes({
|
|
|
178
201
|
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
|
|
179
202
|
const keyframeClipExpanded =
|
|
180
203
|
keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
|
|
181
|
-
//
|
|
182
|
-
// the disclosure: the caret in the sticky header and the diamond lanes
|
|
183
|
-
// on the canvas. Keyed by display row, not by `trackNum`, which is a
|
|
184
|
-
// fractional sort key and would mint ids like `...-0.16666666666666666`.
|
|
204
|
+
// Link the sticky caret to the canvas lanes with a stable display-row id.
|
|
185
205
|
const lanesId = `${lanesIdPrefix}-track-${row}`;
|
|
186
206
|
return (
|
|
187
207
|
<TimelineTrackRow
|
|
188
208
|
key={rowKey}
|
|
189
209
|
index={row}
|
|
190
210
|
rowKey={rowKey}
|
|
191
|
-
|
|
211
|
+
logicalRow={logicalRow}
|
|
212
|
+
propertyRows={trackLogicalRows.slice(1)}
|
|
213
|
+
lanesId={lanesId}
|
|
192
214
|
top={rowGeometry.getRowTop(row)}
|
|
193
215
|
height={rowHeight}
|
|
194
216
|
virtualized={rowsVirtualized}
|
|
195
217
|
background={rowBackground}
|
|
196
218
|
borderColor={theme.rowBorder}
|
|
219
|
+
rovingTargetId={keyboard.rovingTargetId}
|
|
197
220
|
>
|
|
198
221
|
<TimelineTrackHeader
|
|
199
222
|
trackNumber={trackNum}
|
|
@@ -224,8 +247,11 @@ export function TimelineLanes({
|
|
|
224
247
|
onToggleTrackHidden={onToggleTrackHidden}
|
|
225
248
|
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
226
249
|
onSeek={onSeek}
|
|
250
|
+
rovingTargetId={keyboard.rovingTargetId}
|
|
227
251
|
/>
|
|
228
252
|
<div
|
|
253
|
+
role="gridcell"
|
|
254
|
+
aria-colindex={2}
|
|
229
255
|
style={{
|
|
230
256
|
width: trackContentWidth,
|
|
231
257
|
marginLeft: contentGutter, // room for a 0% diamond left of t=0
|
|
@@ -297,9 +323,8 @@ export function TimelineLanes({
|
|
|
297
323
|
const isSelected =
|
|
298
324
|
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
|
299
325
|
const isComposition = !!el.compositionSrc;
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// at/after the change (DOM flash, drag interruption).
|
|
326
|
+
// The element identity is already unique per clip. Never fold in the map
|
|
327
|
+
// index, or a splice/reorder remounts every clip at/after the change.
|
|
303
328
|
const clipKey = elementKey;
|
|
304
329
|
const isDraggingClip =
|
|
305
330
|
draggedClip?.started === true &&
|
|
@@ -307,11 +332,8 @@ export function TimelineLanes({
|
|
|
307
332
|
getTimelineElementIdentity(draggedElement) === elementKey;
|
|
308
333
|
if (isDraggingClip) return null;
|
|
309
334
|
const previewElement = getPreviewElement(el);
|
|
310
|
-
// Passenger of a live multi-drag:
|
|
311
|
-
//
|
|
312
|
-
// compositor transform on a same-geometry wrapper (absolute
|
|
313
|
-
// inset-0 → identical offset parent, so the clip's own
|
|
314
|
-
// left/top are preserved), plus the ghost's elevated z/opacity.
|
|
335
|
+
// Passenger of a live multi-drag: preserve the formation without changing
|
|
336
|
+
// the passenger's timeline data until the owning drag commits.
|
|
315
337
|
const isPassenger =
|
|
316
338
|
multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
|
|
317
339
|
const passengerOffsetPx = isPassenger
|
|
@@ -336,6 +358,9 @@ export function TimelineLanes({
|
|
|
336
358
|
capabilities={capabilities}
|
|
337
359
|
theme={theme}
|
|
338
360
|
isComposition={isComposition}
|
|
361
|
+
tabIndex={
|
|
362
|
+
keyboard.rovingTargetId === timelineClipFocusId(elementKey) ? 0 : -1
|
|
363
|
+
}
|
|
339
364
|
onHoverStart={() => setHoveredClip(clipKey)}
|
|
340
365
|
onHoverEnd={() => setHoveredClip(null)}
|
|
341
366
|
onResizeStart={
|
|
@@ -473,41 +498,33 @@ export function TimelineLanes({
|
|
|
473
498
|
renderClipContent,
|
|
474
499
|
renderClipOverlay,
|
|
475
500
|
)}
|
|
476
|
-
{!showsLanes && keyframeCache?.get(elementKey) && (
|
|
477
|
-
<TimelineClipDiamonds
|
|
478
|
-
keyframesData={keyframeCache.get(elementKey)!}
|
|
479
|
-
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
|
480
|
-
clipHeightPx={rowHeight - 2 * CLIP_Y}
|
|
481
|
-
clipDuration={previewElement.duration}
|
|
482
|
-
beatsActive={beatStripOnTrack}
|
|
483
|
-
accentColor={clipStyle.accent}
|
|
484
|
-
isSelected={isSelected}
|
|
485
|
-
currentPercentage={
|
|
486
|
-
previewElement.duration > 0
|
|
487
|
-
? ((currentTime - previewElement.start) / previewElement.duration) *
|
|
488
|
-
100
|
|
489
|
-
: 0
|
|
490
|
-
}
|
|
491
|
-
elementId={elementKey}
|
|
492
|
-
selectedKeyframes={selectedKeyframes}
|
|
493
|
-
onClickKeyframe={(_elId, target) =>
|
|
494
|
-
onClickKeyframe?.(previewElement, target)
|
|
495
|
-
}
|
|
496
|
-
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
497
|
-
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
498
|
-
onMoveKeyframe={onMoveKeyframe}
|
|
499
|
-
onSelectSegment={onSelectSegment}
|
|
500
|
-
suppressClickRef={suppressClickRef}
|
|
501
|
-
/>
|
|
502
|
-
)}
|
|
503
501
|
</TimelineClip>
|
|
504
502
|
);
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
503
|
+
const compactKeyframes = keyframeCache?.get(elementKey);
|
|
504
|
+
const compactDiamonds = !showsLanes && compactKeyframes && (
|
|
505
|
+
<TimelineCompactDiamonds
|
|
506
|
+
key={`${clipKey}-diamonds`}
|
|
507
|
+
element={previewElement}
|
|
508
|
+
elementId={elementKey}
|
|
509
|
+
keyframesData={compactKeyframes}
|
|
510
|
+
pixelsPerSecond={pps}
|
|
511
|
+
rowHeight={rowHeight}
|
|
512
|
+
beatsActive={beatStripOnTrack}
|
|
513
|
+
accentColor={clipStyle.accent}
|
|
514
|
+
isSelected={isSelected}
|
|
515
|
+
currentTime={currentTime}
|
|
516
|
+
selectedKeyframes={selectedKeyframes}
|
|
517
|
+
rovingTargetId={keyboard.rovingTargetId}
|
|
518
|
+
onClickKeyframe={onClickKeyframe}
|
|
519
|
+
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
520
|
+
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
521
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
522
|
+
onSelectSegment={onSelectSegment}
|
|
523
|
+
suppressClickRef={suppressClickRef}
|
|
524
|
+
/>
|
|
525
|
+
);
|
|
526
|
+
// Keep this shell mounted while collapsed so aria-controls stays valid
|
|
527
|
+
// and multi-drag cannot remount the subtree mid-gesture.
|
|
511
528
|
const propertyLanes = isTrackKeyframeClip && (
|
|
512
529
|
<TimelinePropertyLanes
|
|
513
530
|
key={`${clipKey}-property-lanes`}
|
|
@@ -529,6 +546,7 @@ export function TimelineLanes({
|
|
|
529
546
|
}
|
|
530
547
|
elementId={elementKey}
|
|
531
548
|
selectedKeyframes={selectedKeyframes}
|
|
549
|
+
rovingTargetId={keyboard.rovingTargetId}
|
|
532
550
|
onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
|
|
533
551
|
onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
|
|
534
552
|
onShiftClickKeyframe={(target) =>
|
|
@@ -544,14 +562,12 @@ export function TimelineLanes({
|
|
|
544
562
|
suppressClickRef={suppressClickRef}
|
|
545
563
|
/>
|
|
546
564
|
);
|
|
547
|
-
//
|
|
548
|
-
// array here makes React reconcile the outer array by
|
|
549
|
-
// position, so a window shift remounts otherwise stable
|
|
550
|
-
// clip keys and can tear down focus mid-reveal.
|
|
565
|
+
// One keyed child prevents window shifts from remounting focused clips.
|
|
551
566
|
if (!isPassenger) {
|
|
552
567
|
return (
|
|
553
568
|
<Fragment key={clipKey}>
|
|
554
569
|
{clip}
|
|
570
|
+
{compactDiamonds}
|
|
555
571
|
{propertyLanes}
|
|
556
572
|
</Fragment>
|
|
557
573
|
);
|
|
@@ -568,6 +584,7 @@ export function TimelineLanes({
|
|
|
568
584
|
}}
|
|
569
585
|
>
|
|
570
586
|
{clip}
|
|
587
|
+
{compactDiamonds}
|
|
571
588
|
{propertyLanes}
|
|
572
589
|
</div>
|
|
573
590
|
);
|
|
@@ -70,6 +70,7 @@ function renderPropertyLanes(overrides: Partial<TimelinePropertyLanesProps> = {}
|
|
|
70
70
|
act(() => {
|
|
71
71
|
root.render(
|
|
72
72
|
<TimelinePropertyLanes
|
|
73
|
+
id="timeline-property-lanes-test"
|
|
73
74
|
animations={[]}
|
|
74
75
|
clipStart={0}
|
|
75
76
|
clipDuration={1}
|
|
@@ -367,6 +368,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
367
368
|
act(() => {
|
|
368
369
|
root.render(
|
|
369
370
|
<TimelinePropertyLanes
|
|
371
|
+
id="timeline-property-lanes-selected-test"
|
|
370
372
|
animations={animations}
|
|
371
373
|
clipStart={0}
|
|
372
374
|
clipDuration={1}
|
|
@@ -417,6 +419,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
417
419
|
act(() => {
|
|
418
420
|
root.render(
|
|
419
421
|
<TimelinePropertyLanes
|
|
422
|
+
id="timeline-property-lanes-unselected-test"
|
|
420
423
|
animations={animations}
|
|
421
424
|
clipStart={0}
|
|
422
425
|
clipDuration={1}
|
|
@@ -433,7 +436,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
433
436
|
});
|
|
434
437
|
const unselectedSegments = laneEaseSegments(host, "position");
|
|
435
438
|
expect(unselectedSegments).toHaveLength(2);
|
|
436
|
-
expect(
|
|
439
|
+
expect(laneEaseButtons(host, "position")).toHaveLength(2);
|
|
437
440
|
act(() => root.unmount());
|
|
438
441
|
});
|
|
439
442
|
|
|
@@ -461,13 +464,14 @@ describe("TimelinePropertyLanes", () => {
|
|
|
461
464
|
expect(new Set(paths).size).toBe(3);
|
|
462
465
|
// Uniqueness alone passes even when the curves are swapped between segments.
|
|
463
466
|
// Each segment is labelled with the ease it draws, so pin the ORDER: a
|
|
464
|
-
// segment carries the ease of the keyframe it arrives at.
|
|
467
|
+
// segment carries the ease of the keyframe it arrives at. The trailing time
|
|
468
|
+
// is what separates two segments that share an ease name in the same lane.
|
|
465
469
|
expect(
|
|
466
470
|
segments.map((segment) => revealEaseButton(segment)?.getAttribute("aria-label")),
|
|
467
471
|
).toEqual([
|
|
468
|
-
"Edit none easing",
|
|
469
|
-
"Edit power2.out easing",
|
|
470
|
-
"Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing",
|
|
472
|
+
"Edit none easing after 0s",
|
|
473
|
+
"Edit power2.out easing after 0.33s",
|
|
474
|
+
"Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing after 0.66s",
|
|
471
475
|
]);
|
|
472
476
|
act(() => root.unmount());
|
|
473
477
|
});
|
|
@@ -9,6 +9,7 @@ import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
|
|
|
9
9
|
import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
|
|
10
10
|
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
11
11
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
12
|
+
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
|
|
12
13
|
|
|
13
14
|
export interface TimelinePropertyLanesProps {
|
|
14
15
|
/**
|
|
@@ -16,7 +17,7 @@ export interface TimelinePropertyLanesProps {
|
|
|
16
17
|
* `aria-controls` at the lanes a sighted user sees it reveal. Minted by
|
|
17
18
|
* TimelineLanes, which owns both this subtree and the caret's.
|
|
18
19
|
*/
|
|
19
|
-
id
|
|
20
|
+
id: string;
|
|
20
21
|
animations: readonly GsapAnimation[];
|
|
21
22
|
clipStart: number;
|
|
22
23
|
clipDuration: number;
|
|
@@ -27,6 +28,7 @@ export interface TimelinePropertyLanesProps {
|
|
|
27
28
|
currentPercentage: number;
|
|
28
29
|
elementId: string;
|
|
29
30
|
selectedKeyframes: ReadonlySet<string>;
|
|
31
|
+
rovingTargetId?: string | null;
|
|
30
32
|
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
|
31
33
|
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
|
32
34
|
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
|
@@ -187,6 +189,7 @@ export function TimelinePropertyLanes({
|
|
|
187
189
|
currentPercentage,
|
|
188
190
|
elementId,
|
|
189
191
|
selectedKeyframes,
|
|
192
|
+
rovingTargetId = null,
|
|
190
193
|
onSelectSegment,
|
|
191
194
|
onClickKeyframe,
|
|
192
195
|
onShiftClickKeyframe,
|
|
@@ -223,9 +226,11 @@ export function TimelinePropertyLanes({
|
|
|
223
226
|
{laneData.map(({ group, keyframesData }, laneIndex) => (
|
|
224
227
|
<div
|
|
225
228
|
key={group}
|
|
229
|
+
id={timelineLogicalRowCellId(id, timelinePropertyRowId(elementId, group), "content")}
|
|
226
230
|
role="group"
|
|
227
231
|
aria-label={`${group} keyframes`}
|
|
228
232
|
data-property-group={group}
|
|
233
|
+
data-timeline-element-id={elementId}
|
|
229
234
|
data-timeline-property-lane=""
|
|
230
235
|
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
|
231
236
|
className="absolute"
|
|
@@ -240,12 +245,14 @@ export function TimelinePropertyLanes({
|
|
|
240
245
|
keyframesData={keyframesData}
|
|
241
246
|
clipWidthPx={clipWidthPx}
|
|
242
247
|
clipHeightPx={LANE_H}
|
|
243
|
-
clipDuration={clipDuration}
|
|
244
248
|
accentColor={accentColor}
|
|
245
249
|
isSelected={isSelected}
|
|
246
250
|
currentPercentage={currentPercentage}
|
|
247
251
|
elementId={elementId}
|
|
252
|
+
clipStart={clipStart}
|
|
253
|
+
clipDuration={clipDuration}
|
|
248
254
|
selectedKeyframes={selectedKeyframes}
|
|
255
|
+
rovingTargetId={rovingTargetId}
|
|
249
256
|
onSelectSegment={onSelectSegment}
|
|
250
257
|
onClickKeyframe={onClickKeyframe}
|
|
251
258
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "./trackHeaderLaneState";
|
|
17
17
|
import { valueReadout } from "./trackHeaderLaneValues";
|
|
18
18
|
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
19
|
+
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
|
|
19
20
|
|
|
20
21
|
interface TimelineTrackHeaderProps {
|
|
21
22
|
/** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
|
|
@@ -41,6 +42,7 @@ interface TimelineTrackHeaderProps {
|
|
|
41
42
|
currentTime: number;
|
|
42
43
|
isTrackHidden: boolean;
|
|
43
44
|
isAudioTrack: boolean;
|
|
45
|
+
rovingTargetId?: string | null;
|
|
44
46
|
theme: TimelineTheme;
|
|
45
47
|
onToggleClipExpanded: () => void;
|
|
46
48
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
@@ -191,6 +193,7 @@ function PropertyGroupNavigation({
|
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
function PropertyGroupHeaderRow({
|
|
196
|
+
lanesId,
|
|
194
197
|
lane,
|
|
195
198
|
laneIndex,
|
|
196
199
|
isLastLane,
|
|
@@ -201,7 +204,9 @@ function PropertyGroupHeaderRow({
|
|
|
201
204
|
columnWidth,
|
|
202
205
|
onTogglePropertyGroupKeyframe,
|
|
203
206
|
onSeek,
|
|
207
|
+
rovingTargetId = null,
|
|
204
208
|
}: {
|
|
209
|
+
lanesId: string;
|
|
205
210
|
lane: TimelinePropertyLane;
|
|
206
211
|
laneIndex: number;
|
|
207
212
|
isLastLane: boolean;
|
|
@@ -212,7 +217,9 @@ function PropertyGroupHeaderRow({
|
|
|
212
217
|
columnWidth: number;
|
|
213
218
|
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
214
219
|
onSeek?: (time: number) => void;
|
|
220
|
+
rovingTargetId: string | null;
|
|
215
221
|
}) {
|
|
222
|
+
const elementId = expandedElement.key ?? expandedElement.id;
|
|
216
223
|
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
|
|
217
224
|
lane,
|
|
218
225
|
currentTime,
|
|
@@ -221,6 +228,10 @@ function PropertyGroupHeaderRow({
|
|
|
221
228
|
|
|
222
229
|
return (
|
|
223
230
|
<div
|
|
231
|
+
id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
|
|
232
|
+
data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
|
|
233
|
+
data-timeline-element-id={elementId}
|
|
234
|
+
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
|
|
224
235
|
data-property-group={lane.group}
|
|
225
236
|
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
|
226
237
|
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
|
|
@@ -298,6 +309,7 @@ export function TimelineTrackHeader({
|
|
|
298
309
|
onToggleTrackHidden,
|
|
299
310
|
onTogglePropertyGroupKeyframe,
|
|
300
311
|
onSeek,
|
|
312
|
+
rovingTargetId = null,
|
|
301
313
|
}: TimelineTrackHeaderProps) {
|
|
302
314
|
const clipPercentage = keyframeClip
|
|
303
315
|
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
|
|
@@ -314,6 +326,8 @@ export function TimelineTrackHeader({
|
|
|
314
326
|
|
|
315
327
|
return (
|
|
316
328
|
<div
|
|
329
|
+
role="rowheader"
|
|
330
|
+
aria-colindex={1}
|
|
317
331
|
className={`sticky left-0 z-[12] shrink-0 ${
|
|
318
332
|
!isKeyframeLayer
|
|
319
333
|
? showTrackLabel
|
|
@@ -374,6 +388,7 @@ export function TimelineTrackHeader({
|
|
|
374
388
|
lanes.map((lane, laneIndex) => (
|
|
375
389
|
<PropertyGroupHeaderRow
|
|
376
390
|
key={lane.group}
|
|
391
|
+
lanesId={lanesId}
|
|
377
392
|
lane={lane}
|
|
378
393
|
laneIndex={laneIndex}
|
|
379
394
|
isLastLane={laneIndex === lanes.length - 1}
|
|
@@ -384,6 +399,7 @@ export function TimelineTrackHeader({
|
|
|
384
399
|
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
|
385
400
|
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
386
401
|
onSeek={onSeek}
|
|
402
|
+
rovingTargetId={rovingTargetId}
|
|
387
403
|
/>
|
|
388
404
|
))}
|
|
389
405
|
</>
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { timelineLogicalRowCellId } from "./timelineNavigationIdentity";
|
|
3
|
+
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
2
4
|
|
|
3
5
|
interface TimelineTrackRowProps {
|
|
4
6
|
index: number;
|
|
5
7
|
rowKey: number;
|
|
6
|
-
|
|
8
|
+
logicalRow: TimelineLogicalRow;
|
|
9
|
+
propertyRows: readonly TimelineLogicalRow[];
|
|
10
|
+
lanesId: string;
|
|
7
11
|
top: number;
|
|
8
12
|
height: number;
|
|
9
13
|
virtualized: boolean;
|
|
10
14
|
background: string;
|
|
11
15
|
borderColor: string;
|
|
16
|
+
rovingTargetId?: string | null;
|
|
12
17
|
children: ReactNode;
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -16,23 +21,24 @@ interface TimelineTrackRowProps {
|
|
|
16
21
|
export function TimelineTrackRow({
|
|
17
22
|
index,
|
|
18
23
|
rowKey,
|
|
19
|
-
|
|
24
|
+
logicalRow,
|
|
25
|
+
propertyRows,
|
|
26
|
+
lanesId,
|
|
20
27
|
top,
|
|
21
28
|
height,
|
|
22
29
|
virtualized,
|
|
23
30
|
background,
|
|
24
31
|
borderColor,
|
|
32
|
+
rovingTargetId = null,
|
|
25
33
|
children,
|
|
26
34
|
}: TimelineTrackRowProps) {
|
|
27
35
|
return (
|
|
28
36
|
<div
|
|
29
|
-
role="
|
|
30
|
-
aria-posinset={index + 1}
|
|
31
|
-
aria-setsize={rowCount}
|
|
37
|
+
role="rowgroup"
|
|
32
38
|
data-index={index}
|
|
33
39
|
data-timeline-row={index}
|
|
34
40
|
data-timeline-row-key={rowKey}
|
|
35
|
-
className={
|
|
41
|
+
className={virtualized ? "absolute left-0 right-0" : "relative"}
|
|
36
42
|
style={{
|
|
37
43
|
top: virtualized ? top : undefined,
|
|
38
44
|
height,
|
|
@@ -40,7 +46,52 @@ export function TimelineTrackRow({
|
|
|
40
46
|
borderBottom: `1px solid ${borderColor}`,
|
|
41
47
|
}}
|
|
42
48
|
>
|
|
43
|
-
|
|
49
|
+
<div
|
|
50
|
+
role="row"
|
|
51
|
+
aria-rowindex={logicalRow.logicalIndex + 1}
|
|
52
|
+
aria-level={logicalRow.level}
|
|
53
|
+
aria-expanded={logicalRow.expandable ? logicalRow.expanded : undefined}
|
|
54
|
+
data-timeline-logical-row-id={logicalRow.id}
|
|
55
|
+
data-timeline-focus-id={logicalRow.id}
|
|
56
|
+
tabIndex={rovingTargetId === logicalRow.id ? 0 : -1}
|
|
57
|
+
className="flex"
|
|
58
|
+
style={{ height }}
|
|
59
|
+
>
|
|
60
|
+
{children}
|
|
61
|
+
</div>
|
|
62
|
+
{propertyRows.map((row) => {
|
|
63
|
+
const group = row.propertyGroup;
|
|
64
|
+
const keyframeCount = row.items.filter((item) => item.kind === "keyframe").length;
|
|
65
|
+
const easeCount = row.items.filter((item) => item.kind === "ease").length;
|
|
66
|
+
return (
|
|
67
|
+
// ponytail: aria-owns maps this hidden logical row onto the two visible
|
|
68
|
+
// property-lane cells without duplicating interactive controls.
|
|
69
|
+
<div
|
|
70
|
+
key={row.id}
|
|
71
|
+
role="row"
|
|
72
|
+
aria-rowindex={row.logicalIndex + 1}
|
|
73
|
+
aria-level={row.level}
|
|
74
|
+
data-property-group={group}
|
|
75
|
+
data-timeline-logical-row-id={row.id}
|
|
76
|
+
className="sr-only"
|
|
77
|
+
>
|
|
78
|
+
<div
|
|
79
|
+
role="rowheader"
|
|
80
|
+
aria-colindex={1}
|
|
81
|
+
aria-owns={timelineLogicalRowCellId(lanesId, row.id, "header")}
|
|
82
|
+
>
|
|
83
|
+
{group}
|
|
84
|
+
</div>
|
|
85
|
+
<div
|
|
86
|
+
role="gridcell"
|
|
87
|
+
aria-colindex={2}
|
|
88
|
+
aria-owns={timelineLogicalRowCellId(lanesId, row.id, "content")}
|
|
89
|
+
>
|
|
90
|
+
{keyframeCount} keyframes, {easeCount} ease controls
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
44
95
|
</div>
|
|
45
96
|
);
|
|
46
97
|
}
|
|
@@ -29,7 +29,7 @@ export interface TimelineClipDiamondsProps {
|
|
|
29
29
|
keyframesData: KeyframeCacheEntry;
|
|
30
30
|
clipWidthPx: number;
|
|
31
31
|
clipHeightPx: number;
|
|
32
|
-
/** Needed to compare the playhead to keyframes
|
|
32
|
+
/** Needed to compare the playhead to keyframes and voice their absolute time. */
|
|
33
33
|
clipDuration: number;
|
|
34
34
|
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
|
|
35
35
|
* the bottom half so they clear the strip at the top. */
|
|
@@ -38,7 +38,11 @@ export interface TimelineClipDiamondsProps {
|
|
|
38
38
|
isSelected: boolean;
|
|
39
39
|
currentPercentage: number;
|
|
40
40
|
elementId: string;
|
|
41
|
+
/** Absolute clip start, used only to voice a keyframe's time in its label. */
|
|
42
|
+
clipStart?: number;
|
|
41
43
|
selectedKeyframes: ReadonlySet<string>;
|
|
44
|
+
/** Focus id of the one timeline control currently in the tab order. */
|
|
45
|
+
rovingTargetId?: string | null;
|
|
42
46
|
onClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
43
47
|
onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
44
48
|
onContextMenuKeyframe?: (
|
|
@@ -86,12 +90,15 @@ export interface TimelineDiamondLaneProps extends Omit<
|
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
export const DIAMOND_RATIO = 0.8;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
|
|
94
|
+
/** Absolute time of a keyframe, for the screen-reader label. */
|
|
95
|
+
export function keyframeTimeLabel(
|
|
96
|
+
clipStart: number,
|
|
97
|
+
clipDuration: number,
|
|
98
|
+
percentage: number,
|
|
99
|
+
): string {
|
|
100
|
+
return `${Number((clipStart + (clipDuration * percentage) / 100).toFixed(2))}s`;
|
|
101
|
+
}
|
|
95
102
|
|
|
96
103
|
/**
|
|
97
104
|
* The full identity of a diamond, used by every callback and by the selection
|