@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
|
@@ -10,10 +10,10 @@ import {
|
|
|
10
10
|
subscribeTimelineKeyframeRetimePreview,
|
|
11
11
|
type TimelineKeyframeRetimeHandle,
|
|
12
12
|
} from "./useTimelineKeyframeHandlers";
|
|
13
|
+
import { timelineKeyframeFocusId } from "./timelineNavigationIdentity";
|
|
13
14
|
import {
|
|
14
15
|
DIAMOND_RATIO,
|
|
15
|
-
|
|
16
|
-
KF_MIN_PCT,
|
|
16
|
+
keyframeTimeLabel,
|
|
17
17
|
keyframeTarget,
|
|
18
18
|
type TimelineClipDiamondsProps,
|
|
19
19
|
type TimelineDiamondKeyframe,
|
|
@@ -68,13 +68,15 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
68
68
|
keyframesData,
|
|
69
69
|
clipWidthPx,
|
|
70
70
|
clipHeightPx,
|
|
71
|
-
clipDuration,
|
|
72
71
|
beatsActive,
|
|
73
72
|
accentColor,
|
|
74
73
|
isSelected,
|
|
75
74
|
currentPercentage,
|
|
76
75
|
elementId,
|
|
76
|
+
clipStart = 0,
|
|
77
|
+
clipDuration = 0,
|
|
77
78
|
selectedKeyframes,
|
|
79
|
+
rovingTargetId = null,
|
|
78
80
|
onClickKeyframe,
|
|
79
81
|
onShiftClickKeyframe,
|
|
80
82
|
onContextMenuKeyframe,
|
|
@@ -140,9 +142,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
140
142
|
// (2.5.8) minimum and still fits the 28px lane. Beat-strip lanes keep the
|
|
141
143
|
// shrunken box: 24px there would reach up into the beat strip.
|
|
142
144
|
const hitHeight = beatsActive ? diamondSize : 24;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
// Keyframes authored outside the element's visible clip window are parked at
|
|
146
|
+
// the boundary rather than hidden: dropping them made the lane's count
|
|
147
|
+
// disagree with its diamonds and left users unable to inspect or remove state
|
|
148
|
+
// that still affects the clip once it appears.
|
|
149
|
+
const sorted = [...keyframesData.keyframes].sort((a, b) => a.percentage - b.percentage);
|
|
150
|
+
const beforeClip = sorted.filter((keyframe) => keyframe.percentage < 0);
|
|
151
|
+
const afterClip = sorted.filter((keyframe) => keyframe.percentage > 100);
|
|
152
|
+
const boundaryStep = Math.max(6, Math.round(diamondSize * 0.55));
|
|
146
153
|
// The neighbour clamp bounds a dragged diamond between its immediate siblings
|
|
147
154
|
// so a retime can't reorder the tween. Siblings means "keyframes of the SAME
|
|
148
155
|
// tween": a merged row interleaves several animations, and two of them
|
|
@@ -175,16 +182,30 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
175
182
|
clipPcts: row.map((s) => s.clipPct),
|
|
176
183
|
});
|
|
177
184
|
}
|
|
178
|
-
const centerXOf = (
|
|
179
|
-
|
|
185
|
+
const centerXOf = (keyframe: TimelineDiamondKeyframe, percentage = keyframe.percentage) => {
|
|
186
|
+
if (percentage < 0) {
|
|
187
|
+
const rank = beforeClip.indexOf(keyframe);
|
|
188
|
+
return -(beforeClip.length - Math.max(0, rank)) * boundaryStep;
|
|
189
|
+
}
|
|
190
|
+
if (percentage > 100) {
|
|
191
|
+
const rank = afterClip.indexOf(keyframe);
|
|
192
|
+
return clipWidthPx + (Math.max(0, rank) + 1) * boundaryStep;
|
|
193
|
+
}
|
|
194
|
+
return (percentage / 100) * clipWidthPx;
|
|
195
|
+
};
|
|
180
196
|
// One record per diamond, carrying its own geometry, so the connector and
|
|
181
197
|
// button passes below read neighbours as values instead of index lookups.
|
|
182
198
|
const markers = sorted.map((keyframe, index) => {
|
|
183
|
-
const centerX = centerXOf(keyframe
|
|
199
|
+
const centerX = centerXOf(keyframe);
|
|
200
|
+
// Parked diamonds sit on their own boundary spacing, so the in-clip
|
|
201
|
+
// neighbour-gap shrink would only make them unreadable.
|
|
202
|
+
if (keyframe.percentage < 0 || keyframe.percentage > 100) {
|
|
203
|
+
return { keyframe, centerX, hitWidth: diamondSize, visualSize: diamondSize };
|
|
204
|
+
}
|
|
184
205
|
const previous = sorted[index - 1];
|
|
185
206
|
const next = sorted[index + 1];
|
|
186
|
-
const previousGap = previous ? centerX - centerXOf(previous
|
|
187
|
-
const nextGap = next ? centerXOf(next
|
|
207
|
+
const previousGap = previous ? centerX - centerXOf(previous) : Infinity;
|
|
208
|
+
const nextGap = next ? centerXOf(next) - centerX : Infinity;
|
|
188
209
|
const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
|
|
189
210
|
const hitWidth = Math.min(diamondSize, nearestGap);
|
|
190
211
|
return {
|
|
@@ -226,6 +247,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
226
247
|
<TimelineDiamondConnectors
|
|
227
248
|
markers={markers}
|
|
228
249
|
centerY={centerY}
|
|
250
|
+
elementId={elementId}
|
|
251
|
+
clipStart={clipStart}
|
|
252
|
+
clipDuration={clipDuration}
|
|
253
|
+
rovingTargetId={rovingTargetId}
|
|
229
254
|
baseColor={baseColor}
|
|
230
255
|
baseOpacity={baseOpacity}
|
|
231
256
|
groupAware={groupAware}
|
|
@@ -237,7 +262,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
237
262
|
{markers.map((marker, i) => {
|
|
238
263
|
const kf = marker.keyframe;
|
|
239
264
|
const target = keyframeTarget(kf);
|
|
265
|
+
const focusId = timelineKeyframeFocusId(elementId, target);
|
|
240
266
|
const kfKey = timelineKeyframeSelectionKey(elementId, target);
|
|
267
|
+
const boundary = kf.percentage < 0 ? "before" : kf.percentage > 100 ? "after" : null;
|
|
241
268
|
// Clamp against this keyframe's own tween, not the whole merged row.
|
|
242
269
|
const siblingRow = siblingRows.get(kf.animationId);
|
|
243
270
|
const siblingClipPcts = siblingRow?.clipPcts ?? [];
|
|
@@ -249,7 +276,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
249
276
|
// The 0% diamond's left half lands in the reserved left gutter (the
|
|
250
277
|
// content origin is inset past the label column, Figma-style) so it stays
|
|
251
278
|
// fully visible instead of being clipped by the sticky label column.
|
|
252
|
-
const leftPx = (renderPct
|
|
279
|
+
const leftPx = centerXOf(kf, renderPct) - marker.hitWidth / 2;
|
|
253
280
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
254
281
|
const atPlayhead = kf === playheadKeyframe;
|
|
255
282
|
const isHighlighted = isKfSelected || atPlayhead;
|
|
@@ -306,6 +333,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
306
333
|
key={`${kf.animationId ?? i}:${kf.propertyGroup ?? ""}:${kf.tweenPercentage ?? kf.percentage}`}
|
|
307
334
|
type="button"
|
|
308
335
|
className="absolute"
|
|
336
|
+
data-timeline-focus-id={focusId}
|
|
309
337
|
data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
|
|
310
338
|
data-keyframe-percentage={
|
|
311
339
|
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
|
|
@@ -313,6 +341,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
313
341
|
data-keyframe-at-playhead={String(atPlayhead)}
|
|
314
342
|
data-keyframe-selected={String(isKfSelected)}
|
|
315
343
|
aria-current={atPlayhead ? "time" : undefined}
|
|
344
|
+
data-keyframe-outside-clip={boundary ?? undefined}
|
|
345
|
+
tabIndex={focusId === rovingTargetId ? 0 : -1}
|
|
346
|
+
aria-label={`${kf.propertyGroup ?? "Motion"} keyframe at ${keyframeTimeLabel(clipStart, clipDuration, kf.percentage)}${boundary ? ` (${boundary} clip)` : ""}`}
|
|
316
347
|
aria-pressed={isKfSelected}
|
|
317
348
|
style={{
|
|
318
349
|
left: leftPx,
|
|
@@ -335,6 +366,15 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
335
366
|
onPointerDown={onPointerDown}
|
|
336
367
|
onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
|
|
337
368
|
onPointerUp={onPointerUp}
|
|
369
|
+
// Keyboard activation only (detail 0): pointer presses already
|
|
370
|
+
// resolve through the pointerup path above.
|
|
371
|
+
onClick={(e) => {
|
|
372
|
+
if (e.detail !== 0) return;
|
|
373
|
+
e.stopPropagation();
|
|
374
|
+
suppressNextClick();
|
|
375
|
+
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
|
376
|
+
else onClickKeyframe?.(target);
|
|
377
|
+
}}
|
|
338
378
|
onPointerCancel={
|
|
339
379
|
canDrag
|
|
340
380
|
? (e) => {
|
|
@@ -348,7 +388,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
348
388
|
e.stopPropagation();
|
|
349
389
|
onContextMenuKeyframe?.(e, target);
|
|
350
390
|
}}
|
|
351
|
-
title={`${roundPct(kf.percentage)}
|
|
391
|
+
title={`${roundPct(kf.percentage)}%${boundary ? ` · ${boundary} clip` : ""}`}
|
|
352
392
|
>
|
|
353
393
|
<svg
|
|
354
394
|
width={marker.visualSize}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { CLIP_Y } from "./timelineLayout";
|
|
3
|
+
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
4
|
+
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
5
|
+
|
|
6
|
+
interface TimelineCompactDiamondsProps extends Pick<
|
|
7
|
+
TimelineLaneBaseProps,
|
|
8
|
+
| "currentTime"
|
|
9
|
+
| "selectedKeyframes"
|
|
10
|
+
| "onClickKeyframe"
|
|
11
|
+
| "onShiftClickKeyframe"
|
|
12
|
+
| "onContextMenuKeyframe"
|
|
13
|
+
| "onMoveKeyframe"
|
|
14
|
+
| "onSelectSegment"
|
|
15
|
+
| "suppressClickRef"
|
|
16
|
+
> {
|
|
17
|
+
element: TimelineElement;
|
|
18
|
+
elementId: string;
|
|
19
|
+
keyframesData: KeyframeCacheEntry;
|
|
20
|
+
pixelsPerSecond: number;
|
|
21
|
+
rowHeight: number;
|
|
22
|
+
beatsActive: boolean;
|
|
23
|
+
accentColor: string;
|
|
24
|
+
isSelected: boolean;
|
|
25
|
+
rovingTargetId: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Inline diamonds shown while the clip's property lanes are collapsed. */
|
|
29
|
+
export function TimelineCompactDiamonds({
|
|
30
|
+
element,
|
|
31
|
+
elementId,
|
|
32
|
+
keyframesData,
|
|
33
|
+
pixelsPerSecond,
|
|
34
|
+
rowHeight,
|
|
35
|
+
beatsActive,
|
|
36
|
+
accentColor,
|
|
37
|
+
isSelected,
|
|
38
|
+
currentTime,
|
|
39
|
+
selectedKeyframes,
|
|
40
|
+
rovingTargetId,
|
|
41
|
+
onClickKeyframe,
|
|
42
|
+
onShiftClickKeyframe,
|
|
43
|
+
onContextMenuKeyframe,
|
|
44
|
+
onMoveKeyframe,
|
|
45
|
+
onSelectSegment,
|
|
46
|
+
suppressClickRef,
|
|
47
|
+
}: TimelineCompactDiamondsProps) {
|
|
48
|
+
const width = Math.max(element.duration * pixelsPerSecond, 4);
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className="absolute pointer-events-none"
|
|
52
|
+
style={{
|
|
53
|
+
left: element.start * pixelsPerSecond,
|
|
54
|
+
top: CLIP_Y,
|
|
55
|
+
width,
|
|
56
|
+
height: rowHeight - 2 * CLIP_Y,
|
|
57
|
+
zIndex: isSelected ? 11 : 6,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<TimelineClipDiamonds
|
|
61
|
+
keyframesData={keyframesData}
|
|
62
|
+
clipWidthPx={width}
|
|
63
|
+
clipHeightPx={rowHeight - 2 * CLIP_Y}
|
|
64
|
+
beatsActive={beatsActive}
|
|
65
|
+
accentColor={accentColor}
|
|
66
|
+
isSelected={isSelected}
|
|
67
|
+
currentPercentage={
|
|
68
|
+
element.duration > 0 ? ((currentTime - element.start) / element.duration) * 100 : 0
|
|
69
|
+
}
|
|
70
|
+
elementId={elementId}
|
|
71
|
+
clipStart={element.start}
|
|
72
|
+
clipDuration={element.duration}
|
|
73
|
+
selectedKeyframes={selectedKeyframes}
|
|
74
|
+
rovingTargetId={rovingTargetId}
|
|
75
|
+
onClickKeyframe={(_id, target) => onClickKeyframe?.(element, target)}
|
|
76
|
+
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
77
|
+
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
78
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
79
|
+
onSelectSegment={onSelectSegment}
|
|
80
|
+
suppressClickRef={suppressClickRef}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -2,7 +2,8 @@ import { Fragment, useRef } from "react";
|
|
|
2
2
|
import { KEYFRAME_DRAG_THRESHOLD_PX } from "../../components/editor/keyframeDrag";
|
|
3
3
|
import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
|
|
4
4
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
5
|
-
import type
|
|
5
|
+
import { keyframeTimeLabel, type TimelineDiamondKeyframe } from "./timelineDiamondTypes";
|
|
6
|
+
import { timelineEaseFocusId } from "./timelineNavigationIdentity";
|
|
6
7
|
|
|
7
8
|
/** One diamond's geometry within its row, as computed by the lane. */
|
|
8
9
|
export interface TimelineDiamondMarker {
|
|
@@ -21,6 +22,10 @@ export interface TimelineDiamondMarker {
|
|
|
21
22
|
export function TimelineDiamondConnectors({
|
|
22
23
|
markers,
|
|
23
24
|
centerY,
|
|
25
|
+
elementId,
|
|
26
|
+
clipStart,
|
|
27
|
+
clipDuration,
|
|
28
|
+
rovingTargetId,
|
|
24
29
|
baseColor,
|
|
25
30
|
baseOpacity,
|
|
26
31
|
groupAware,
|
|
@@ -30,6 +35,11 @@ export function TimelineDiamondConnectors({
|
|
|
30
35
|
}: {
|
|
31
36
|
markers: readonly TimelineDiamondMarker[];
|
|
32
37
|
centerY: number;
|
|
38
|
+
elementId: string;
|
|
39
|
+
clipStart: number;
|
|
40
|
+
clipDuration: number;
|
|
41
|
+
/** Focus id of the one timeline control currently in the tab order. */
|
|
42
|
+
rovingTargetId: string | null;
|
|
33
43
|
baseColor: string;
|
|
34
44
|
baseOpacity: number;
|
|
35
45
|
groupAware: boolean;
|
|
@@ -48,6 +58,7 @@ export function TimelineDiamondConnectors({
|
|
|
48
58
|
if (x2 - x1 < 1) return null;
|
|
49
59
|
const connectorLeft = x1 + previous.visualSize / 2;
|
|
50
60
|
const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
|
|
61
|
+
const target = keyframeTarget(kf);
|
|
51
62
|
return (
|
|
52
63
|
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
|
|
53
64
|
<div
|
|
@@ -70,7 +81,14 @@ export function TimelineDiamondConnectors({
|
|
|
70
81
|
width={x2 - x1}
|
|
71
82
|
centerY={centerY}
|
|
72
83
|
ease={kf.ease ?? globalEase}
|
|
73
|
-
target={
|
|
84
|
+
target={target}
|
|
85
|
+
focusId={timelineEaseFocusId(elementId, target)}
|
|
86
|
+
rovingTargetId={rovingTargetId}
|
|
87
|
+
afterLabel={keyframeTimeLabel(
|
|
88
|
+
clipStart,
|
|
89
|
+
clipDuration,
|
|
90
|
+
previous.keyframe.percentage,
|
|
91
|
+
)}
|
|
74
92
|
// connectorWidth is the clear span between the two diamonds'
|
|
75
93
|
// edges, so a 24x24 target centred in it overhangs a diamond as
|
|
76
94
|
// soon as the span is narrower than 24. The segment wrapper sits
|
|
@@ -111,6 +129,9 @@ function SegmentEaseControl({
|
|
|
111
129
|
centerY,
|
|
112
130
|
ease,
|
|
113
131
|
target,
|
|
132
|
+
focusId,
|
|
133
|
+
rovingTargetId,
|
|
134
|
+
afterLabel,
|
|
114
135
|
roomForFullTarget,
|
|
115
136
|
onSelectSegment,
|
|
116
137
|
}: {
|
|
@@ -119,6 +140,11 @@ function SegmentEaseControl({
|
|
|
119
140
|
centerY: number;
|
|
120
141
|
ease: string;
|
|
121
142
|
target: TimelineKeyframeTarget;
|
|
143
|
+
focusId: string;
|
|
144
|
+
/** Focus id of the one timeline control currently in the tab order. */
|
|
145
|
+
rovingTargetId: string | null;
|
|
146
|
+
/** Time label of the keyframe this segment starts at, for the accessible name. */
|
|
147
|
+
afterLabel: string;
|
|
122
148
|
roomForFullTarget: boolean;
|
|
123
149
|
onSelectSegment: (target: TimelineKeyframeTarget) => void;
|
|
124
150
|
}) {
|
|
@@ -151,7 +177,9 @@ function SegmentEaseControl({
|
|
|
151
177
|
<button
|
|
152
178
|
type="button"
|
|
153
179
|
data-keyframe-ease-button=""
|
|
154
|
-
|
|
180
|
+
data-timeline-focus-id={focusId}
|
|
181
|
+
tabIndex={focusId === rovingTargetId ? 0 : -1}
|
|
182
|
+
aria-label={`Edit ${ease} easing after ${afterLabel}`}
|
|
155
183
|
title={`Edit ${ease} easing`}
|
|
156
184
|
// A visible 24x24 badge would collide with the diamonds either side, so
|
|
157
185
|
// the WCAG 2.2 (2.5.8) target is met with a centered transparent
|
|
@@ -9,6 +9,7 @@ import { getTrackStyle } from "./timelineIcons";
|
|
|
9
9
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
10
10
|
import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
|
|
11
11
|
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
12
|
+
import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
|
|
12
13
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
13
14
|
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
14
15
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
@@ -26,6 +27,17 @@ afterEach(() => {
|
|
|
26
27
|
const TRACK_A = 1 / 6;
|
|
27
28
|
const TRACK_B = 0.5;
|
|
28
29
|
|
|
30
|
+
/** Every string a screen reader or a sighted user actually reads. */
|
|
31
|
+
function visibleText(host: HTMLElement): string {
|
|
32
|
+
return host.textContent ?? "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ariaLabels(host: HTMLElement): string {
|
|
36
|
+
return Array.from(host.querySelectorAll("[aria-label]"))
|
|
37
|
+
.map((el) => el.getAttribute("aria-label") ?? "")
|
|
38
|
+
.join(" ");
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
function element(id: string, track: number): TimelineElement {
|
|
30
42
|
return { id, label: id, tag: "div", start: 0, duration: 2, track };
|
|
31
43
|
}
|
|
@@ -94,6 +106,16 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
94
106
|
rowGeometry={getTimelineRowGeometry(rowHeights)}
|
|
95
107
|
virtualRows={displayTrackOrder.map((_, index) => ({ index, rowKey: index }))}
|
|
96
108
|
rowsVirtualized={false}
|
|
109
|
+
focusedTargetId={null}
|
|
110
|
+
logicalRows={buildTimelineLogicalRows({
|
|
111
|
+
tracks,
|
|
112
|
+
displayTrackOrder,
|
|
113
|
+
laneCounts,
|
|
114
|
+
selectedElementId: null,
|
|
115
|
+
selectedElementIds: next.selectedElementIds ?? new Set(),
|
|
116
|
+
expandedClipIds: new Set(next.expandedClipIds ?? []),
|
|
117
|
+
gsapAnimations,
|
|
118
|
+
})}
|
|
97
119
|
clipIndex={createTimelineClipIndex(tracks)}
|
|
98
120
|
renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
|
|
99
121
|
pinnedClipIdentities={new Set()}
|
|
@@ -152,7 +174,11 @@ describe("TimelineLanes track numbering", () => {
|
|
|
152
174
|
|
|
153
175
|
expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
|
|
154
176
|
expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2);
|
|
155
|
-
|
|
177
|
+
// Only what a user reads. The fractional key still identifies the row in
|
|
178
|
+
// `id` / `data-` attributes, which is exactly where an opaque sort key
|
|
179
|
+
// belongs.
|
|
180
|
+
expect(visibleText(view.host)).not.toContain("0.16666666666666666");
|
|
181
|
+
expect(ariaLabels(view.host)).not.toContain("0.16666666666666666");
|
|
156
182
|
act(() => view.root.unmount());
|
|
157
183
|
});
|
|
158
184
|
|
|
@@ -179,11 +205,12 @@ describe("TimelineLanes track numbering", () => {
|
|
|
179
205
|
onContextMenuLane,
|
|
180
206
|
});
|
|
181
207
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
208
|
+
// The lane's own content cell: the track row's second child, after the
|
|
209
|
+
// sticky header column.
|
|
210
|
+
const secondTrackContent = view.host
|
|
211
|
+
.querySelectorAll("[data-timeline-row]")[1]
|
|
212
|
+
?.querySelector('[role="row"]')
|
|
213
|
+
?.children.item(1);
|
|
187
214
|
act(() => {
|
|
188
215
|
secondTrackContent?.dispatchEvent(
|
|
189
216
|
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100 }),
|
|
@@ -238,9 +265,40 @@ describe("TimelineLanes disclosure target", () => {
|
|
|
238
265
|
);
|
|
239
266
|
const firstIds = idsFor(first.host);
|
|
240
267
|
const secondIds = idsFor(second.host);
|
|
268
|
+
const cellIdsFor = (host: HTMLElement) =>
|
|
269
|
+
new Set(
|
|
270
|
+
Array.from(host.querySelectorAll<HTMLElement>("[data-property-group][id]"), (cell) =>
|
|
271
|
+
cell.getAttribute("id"),
|
|
272
|
+
).filter((id): id is string => id !== null),
|
|
273
|
+
);
|
|
274
|
+
const ownedIdsFor = (host: HTMLElement) =>
|
|
275
|
+
Array.from(host.querySelectorAll("[aria-owns]"), (owner) =>
|
|
276
|
+
owner.getAttribute("aria-owns"),
|
|
277
|
+
).filter((id): id is string => id !== null);
|
|
278
|
+
const firstCellIds = cellIdsFor(first.host);
|
|
279
|
+
const secondCellIds = cellIdsFor(second.host);
|
|
241
280
|
|
|
281
|
+
for (const { host } of [first, second]) {
|
|
282
|
+
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
|
|
283
|
+
expect(treegrid?.getAttribute("aria-colcount")).toBe("2");
|
|
284
|
+
expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
|
|
285
|
+
expect(
|
|
286
|
+
[...host.querySelectorAll('[role="rowheader"]')].every(
|
|
287
|
+
(cell) => cell.getAttribute("aria-colindex") === "1",
|
|
288
|
+
),
|
|
289
|
+
).toBe(true);
|
|
290
|
+
expect(
|
|
291
|
+
[...host.querySelectorAll('[role="gridcell"]')].every(
|
|
292
|
+
(cell) => cell.getAttribute("aria-colindex") === "2",
|
|
293
|
+
),
|
|
294
|
+
).toBe(true);
|
|
295
|
+
}
|
|
242
296
|
expect(firstIds.length).toBeGreaterThan(0);
|
|
243
297
|
expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
|
|
298
|
+
expect(firstCellIds.size).toBeGreaterThan(0);
|
|
299
|
+
expect([...firstCellIds].some((id) => secondCellIds.has(id))).toBe(false);
|
|
300
|
+
expect(ownedIdsFor(first.host).every((id) => firstCellIds.has(id))).toBe(true);
|
|
301
|
+
expect(ownedIdsFor(second.host).every((id) => secondCellIds.has(id))).toBe(true);
|
|
244
302
|
// Still a legal CSS id selector: the aria-controls lookups above use `#id`.
|
|
245
303
|
for (const id of [...firstIds, ...secondIds]) {
|
|
246
304
|
expect(id).toMatch(/^[A-Za-z][\w-]*$/);
|