@hyperframes/studio 0.7.90 → 0.7.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.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-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- 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/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- 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 +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- 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/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- 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/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- 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/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- 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/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- 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/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -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/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -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/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
import { memo, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { BEAT_BAND_H } from "./BeatStrip";
|
|
3
|
-
import {
|
|
4
|
-
KEYFRAME_DRAG_THRESHOLD_PX,
|
|
5
|
-
previewClipPct,
|
|
6
|
-
resolveKeyframeDrag,
|
|
7
|
-
} from "../../components/editor/keyframeDrag";
|
|
8
3
|
import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
|
|
9
|
-
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
|
10
4
|
import { LANE_H } from "./timelineLayout";
|
|
11
5
|
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
12
6
|
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
|
7
|
+
import {
|
|
8
|
+
beginTimelineKeyframeRetime,
|
|
9
|
+
readPendingTimelineKeyframeRetimes,
|
|
10
|
+
subscribeTimelineKeyframeRetimePreview,
|
|
11
|
+
type TimelineKeyframeRetimeHandle,
|
|
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
|
-
type DragState,
|
|
19
18
|
type TimelineClipDiamondsProps,
|
|
20
19
|
type TimelineDiamondKeyframe,
|
|
21
20
|
type TimelineDiamondLaneProps,
|
|
@@ -69,13 +68,15 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
69
68
|
keyframesData,
|
|
70
69
|
clipWidthPx,
|
|
71
70
|
clipHeightPx,
|
|
72
|
-
clipDuration,
|
|
73
71
|
beatsActive,
|
|
74
72
|
accentColor,
|
|
75
73
|
isSelected,
|
|
76
74
|
currentPercentage,
|
|
77
75
|
elementId,
|
|
76
|
+
clipStart = 0,
|
|
77
|
+
clipDuration = 0,
|
|
78
78
|
selectedKeyframes,
|
|
79
|
+
rovingTargetId = null,
|
|
79
80
|
onClickKeyframe,
|
|
80
81
|
onShiftClickKeyframe,
|
|
81
82
|
onContextMenuKeyframe,
|
|
@@ -86,65 +87,29 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
86
87
|
globalEase = "none",
|
|
87
88
|
}: TimelineDiamondLaneProps) {
|
|
88
89
|
// Hooks must run before the early return below.
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
//
|
|
95
|
-
// the first
|
|
96
|
-
|
|
97
|
-
const pendingRetimes =
|
|
98
|
-
// The most recent retime dispatched from this lane, whichever diamond it came
|
|
99
|
-
// from. Selection is lane-wide, so "is my revert still relevant" is a lane-wide
|
|
100
|
-
// question, not a per-keyframe one.
|
|
101
|
-
const latestRetimeRef = useRef<{ clipPct: number; tweenPct: number } | null>(null);
|
|
102
|
-
useEffect(() => {
|
|
103
|
-
// Clear a pending entry once the authoritative cache reflects THAT keyframe
|
|
104
|
-
// at ~its destination. Match by tolerance, not equality: cache writers round
|
|
105
|
-
// clip %s, so an exact check would leak an entry after every successful
|
|
106
|
-
// retime. Match by identity too: a bare "some keyframe is near that %" test
|
|
107
|
-
// cleared the entry whenever an unrelated sibling happened to sit there,
|
|
108
|
-
// which is easy to hit on an evenly spaced row.
|
|
109
|
-
const pendingEntries = pendingRetimeRef.current;
|
|
110
|
-
if (!pendingEntries) return;
|
|
111
|
-
for (const [key, pending] of pendingEntries) {
|
|
112
|
-
const settled = keyframesData.keyframes.some(
|
|
113
|
-
(k) =>
|
|
114
|
-
timelineKeyframeSelectionKey(elementId, keyframeTarget(k)) === key &&
|
|
115
|
-
Math.abs(k.percentage - pending.clipPct) < 0.2,
|
|
116
|
-
);
|
|
117
|
-
if (settled) pendingEntries.delete(key);
|
|
118
|
-
}
|
|
119
|
-
}, [keyframesData.keyframes, elementId]);
|
|
90
|
+
// The retime itself lives on the stable scroll viewport (beginTimelineKeyframeRetime),
|
|
91
|
+
// so a row unmounted by virtualization mid-drag does not drop the gesture.
|
|
92
|
+
// This lane only arms it and renders the preview it publishes.
|
|
93
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
94
|
+
const retimeHandleRef = useRef<TimelineKeyframeRetimeHandle | null>(null);
|
|
95
|
+
// Retime destinations already dispatched but not yet in the keyframe cache, so
|
|
96
|
+
// a rapid second drag composes from where the first move left the keyframe
|
|
97
|
+
// instead of the stale rendered value.
|
|
98
|
+
const pendingRetimes = readPendingTimelineKeyframeRetimes(rootRef.current);
|
|
120
99
|
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
|
|
121
100
|
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
|
122
101
|
// on drop re-keys the diamond from source.
|
|
123
102
|
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
|
124
|
-
// One preview render per frame: a 120Hz trackpad fires pointermove far faster
|
|
125
|
-
// than the lane can repaint, and every diamond in the row re-evaluates its
|
|
126
|
-
// memo on each of those renders.
|
|
127
|
-
const previewFrameRef = useRef<number | null>(null);
|
|
128
|
-
const cancelPreviewFrame = () => {
|
|
129
|
-
if (previewFrameRef.current === null) return;
|
|
130
|
-
cancelAnimationFrame(previewFrameRef.current);
|
|
131
|
-
previewFrameRef.current = null;
|
|
132
|
-
};
|
|
133
|
-
// Escape backs out of an in-flight retime, the way clip and element drags
|
|
134
|
-
// already do. Nothing was written yet (the commit happens on pointerup), so
|
|
135
|
-
// dropping the preview is the whole undo.
|
|
136
103
|
useEffect(() => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
cancelPreviewFrame();
|
|
147
|
-
};
|
|
104
|
+
const source = rootRef.current;
|
|
105
|
+
if (!source) return;
|
|
106
|
+
return subscribeTimelineKeyframeRetimePreview(source, (nextPreview) => {
|
|
107
|
+
setPreview(
|
|
108
|
+
nextPreview === null
|
|
109
|
+
? null
|
|
110
|
+
: { kfKey: nextPreview.keyframeKey, clipPct: nextPreview.clipPercentage },
|
|
111
|
+
);
|
|
112
|
+
});
|
|
148
113
|
}, []);
|
|
149
114
|
// The button element can re-render (reposition/unmount) synchronously from
|
|
150
115
|
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
|
@@ -177,9 +142,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
177
142
|
// (2.5.8) minimum and still fits the 28px lane. Beat-strip lanes keep the
|
|
178
143
|
// shrunken box: 24px there would reach up into the beat strip.
|
|
179
144
|
const hitHeight = beatsActive ? diamondSize : 24;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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));
|
|
183
153
|
// The neighbour clamp bounds a dragged diamond between its immediate siblings
|
|
184
154
|
// so a retime can't reorder the tween. Siblings means "keyframes of the SAME
|
|
185
155
|
// tween": a merged row interleaves several animations, and two of them
|
|
@@ -197,7 +167,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
197
167
|
// O(keyframes squared) allocations on every playhead tick.
|
|
198
168
|
const pendingClipPctOf = (keyframe: TimelineDiamondKeyframe) =>
|
|
199
169
|
pendingRetimes.get(timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)))
|
|
200
|
-
?.
|
|
170
|
+
?.clipPercentage ?? keyframe.percentage;
|
|
201
171
|
const siblingRows = new Map<
|
|
202
172
|
string | undefined,
|
|
203
173
|
{ keyframes: TimelineDiamondKeyframe[]; clipPcts: number[] }
|
|
@@ -212,16 +182,30 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
212
182
|
clipPcts: row.map((s) => s.clipPct),
|
|
213
183
|
});
|
|
214
184
|
}
|
|
215
|
-
const centerXOf = (
|
|
216
|
-
|
|
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
|
+
};
|
|
217
196
|
// One record per diamond, carrying its own geometry, so the connector and
|
|
218
197
|
// button passes below read neighbours as values instead of index lookups.
|
|
219
198
|
const markers = sorted.map((keyframe, index) => {
|
|
220
|
-
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
|
+
}
|
|
221
205
|
const previous = sorted[index - 1];
|
|
222
206
|
const next = sorted[index + 1];
|
|
223
|
-
const previousGap = previous ? centerX - centerXOf(previous
|
|
224
|
-
const nextGap = next ? centerXOf(next
|
|
207
|
+
const previousGap = previous ? centerX - centerXOf(previous) : Infinity;
|
|
208
|
+
const nextGap = next ? centerXOf(next) - centerX : Infinity;
|
|
225
209
|
const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
|
|
226
210
|
const hitWidth = Math.min(diamondSize, nearestGap);
|
|
227
211
|
return {
|
|
@@ -247,6 +231,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
247
231
|
|
|
248
232
|
return (
|
|
249
233
|
<div
|
|
234
|
+
ref={rootRef}
|
|
250
235
|
className="absolute inset-0"
|
|
251
236
|
style={{
|
|
252
237
|
// Above the clip's trim-handle strips (TimelineClip.tsx, z-index 4) so
|
|
@@ -262,6 +247,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
262
247
|
<TimelineDiamondConnectors
|
|
263
248
|
markers={markers}
|
|
264
249
|
centerY={centerY}
|
|
250
|
+
elementId={elementId}
|
|
251
|
+
clipStart={clipStart}
|
|
252
|
+
clipDuration={clipDuration}
|
|
253
|
+
rovingTargetId={rovingTargetId}
|
|
265
254
|
baseColor={baseColor}
|
|
266
255
|
baseOpacity={baseOpacity}
|
|
267
256
|
groupAware={groupAware}
|
|
@@ -273,7 +262,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
273
262
|
{markers.map((marker, i) => {
|
|
274
263
|
const kf = marker.keyframe;
|
|
275
264
|
const target = keyframeTarget(kf);
|
|
265
|
+
const focusId = timelineKeyframeFocusId(elementId, target);
|
|
276
266
|
const kfKey = timelineKeyframeSelectionKey(elementId, target);
|
|
267
|
+
const boundary = kf.percentage < 0 ? "before" : kf.percentage > 100 ? "after" : null;
|
|
277
268
|
// Clamp against this keyframe's own tween, not the whole merged row.
|
|
278
269
|
const siblingRow = siblingRows.get(kf.animationId);
|
|
279
270
|
const siblingClipPcts = siblingRow?.clipPcts ?? [];
|
|
@@ -285,7 +276,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
285
276
|
// The 0% diamond's left half lands in the reserved left gutter (the
|
|
286
277
|
// content origin is inset past the label column, Figma-style) so it stays
|
|
287
278
|
// fully visible instead of being clipped by the sticky label column.
|
|
288
|
-
const leftPx = (renderPct
|
|
279
|
+
const leftPx = centerXOf(kf, renderPct) - marker.hitWidth / 2;
|
|
289
280
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
290
281
|
const atPlayhead = kf === playheadKeyframe;
|
|
291
282
|
const isHighlighted = isKfSelected || atPlayhead;
|
|
@@ -294,150 +285,43 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
294
285
|
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
295
286
|
if (e.button !== 0) return;
|
|
296
287
|
e.stopPropagation();
|
|
297
|
-
if (canDrag)
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const live = dragRef.current;
|
|
320
|
-
if (!live || live.kfKey !== kfKey || live.cancelled) return;
|
|
321
|
-
setPreview({
|
|
322
|
-
kfKey,
|
|
323
|
-
clipPct: previewClipPct({
|
|
324
|
-
pointerDownX: live.startX,
|
|
325
|
-
pointerMoveX: live.lastX,
|
|
326
|
-
clipWidthPx,
|
|
327
|
-
draggedClipPct: live.fromClipPct,
|
|
328
|
-
draggedIndex: live.index,
|
|
329
|
-
sortedClipPcts: siblingClipPcts,
|
|
330
|
-
}),
|
|
331
|
-
});
|
|
288
|
+
if (!canDrag) return;
|
|
289
|
+
retimeHandleRef.current = beginTimelineKeyframeRetime({
|
|
290
|
+
event: e,
|
|
291
|
+
elementId,
|
|
292
|
+
keyframeKey: kfKey,
|
|
293
|
+
target,
|
|
294
|
+
keyframes: keyframesData.keyframes,
|
|
295
|
+
clipWidthPx,
|
|
296
|
+
// Clamp against this keyframe's own tween, not the whole merged row:
|
|
297
|
+
// a merged row interleaves several animations, and two colliding at
|
|
298
|
+
// one percentage would otherwise pin each other's diamonds in place.
|
|
299
|
+
draggedIndex: siblingIndex,
|
|
300
|
+
sortedClipPercentages: siblingClipPcts,
|
|
301
|
+
keyframeKeyOf: (keyframe) =>
|
|
302
|
+
timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)),
|
|
303
|
+
onMove: (fromTarget, toClipPercentage) =>
|
|
304
|
+
onMoveKeyframe?.(fromTarget, toClipPercentage) ?? Promise.resolve(false),
|
|
305
|
+
onSelect: (nextTarget, additive) => {
|
|
306
|
+
if (additive) onShiftClickKeyframe?.(nextTarget);
|
|
307
|
+
else onClickKeyframe?.(nextTarget);
|
|
308
|
+
},
|
|
309
|
+
suppressNextClick,
|
|
332
310
|
});
|
|
333
311
|
};
|
|
334
312
|
const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
// No drag armed (canDrag false / non-primary press) → treat as a click.
|
|
344
|
-
if (!d || d.kfKey !== kfKey) {
|
|
345
|
-
if (e.button !== 0) return;
|
|
346
|
-
suppressNextClick();
|
|
347
|
-
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
|
348
|
-
else onClickKeyframe?.(target);
|
|
313
|
+
// The viewport coordinator owns an armed retime; this local path is
|
|
314
|
+
// only for diamonds that cannot be dragged.
|
|
315
|
+
if (canDrag) {
|
|
316
|
+
retimeHandleRef.current?.commit(e);
|
|
317
|
+
retimeHandleRef.current = null;
|
|
318
|
+
e.stopPropagation();
|
|
349
319
|
return;
|
|
350
320
|
}
|
|
351
|
-
e.
|
|
352
|
-
dragRef.current = null;
|
|
353
|
-
cancelPreviewFrame();
|
|
354
|
-
setPreview(null);
|
|
355
|
-
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
321
|
+
if (e.button !== 0) return;
|
|
356
322
|
suppressNextClick();
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
// do not express yet. Selecting several and dragging one moves only
|
|
360
|
-
// the dragged one.
|
|
361
|
-
const res = resolveKeyframeDrag({
|
|
362
|
-
pointerDownX: d.startX,
|
|
363
|
-
pointerUpX: e.clientX,
|
|
364
|
-
clipWidthPx,
|
|
365
|
-
draggedClipPct: d.fromClipPct,
|
|
366
|
-
draggedIndex: siblingIndex,
|
|
367
|
-
sortedClipPcts: siblingClipPcts,
|
|
368
|
-
});
|
|
369
|
-
if (res.kind === "click" || res.kind === "noop") {
|
|
370
|
-
// "noop" is a press with enough pointer jitter to arm a drag (canDrag
|
|
371
|
-
// is on for every diamond once the clip is selected) that resolved
|
|
372
|
-
// back onto ~the same position — no real retime, so treat it as the
|
|
373
|
-
// click it was. Otherwise a normal click with a few px of mouse/
|
|
374
|
-
// trackpad drift silently does nothing: no selection, no move.
|
|
375
|
-
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
|
376
|
-
else onClickKeyframe?.(target);
|
|
377
|
-
} else if (res.kind === "move" && res.toClipPct != null) {
|
|
378
|
-
const animKfs =
|
|
379
|
-
target.animationId === undefined
|
|
380
|
-
? keyframesData.keyframes
|
|
381
|
-
: keyframesData.keyframes.filter((k) => k.animationId === target.animationId);
|
|
382
|
-
// Clamp to the mapped tween range: clipToTweenPercentage extrapolates
|
|
383
|
-
// linearly, so a boundary drag past the range would otherwise reselect
|
|
384
|
-
// an out-of-range tween % (e.g. 150%) even though the mutation clamps
|
|
385
|
-
// the moved endpoint back to the boundary.
|
|
386
|
-
const tweenPcts = animKfs
|
|
387
|
-
.map((k) => k.tweenPercentage)
|
|
388
|
-
.filter((v): v is number => typeof v === "number");
|
|
389
|
-
const clampTween = (v: number) =>
|
|
390
|
-
tweenPcts.length
|
|
391
|
-
? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
|
|
392
|
-
: v;
|
|
393
|
-
const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));
|
|
394
|
-
// For a rapid second retime the diamond still renders the stale cache
|
|
395
|
-
// position, so identify the FROM keyframe by the pending (already-moved)
|
|
396
|
-
// position; the mutation locates the source keyframe by this identity.
|
|
397
|
-
const pendingBefore = pendingRetimes.get(kfKey);
|
|
398
|
-
const fromTarget = pendingBefore
|
|
399
|
-
? {
|
|
400
|
-
...target,
|
|
401
|
-
percentage: pendingBefore.clipPct,
|
|
402
|
-
tweenPercentage: pendingBefore.tweenPct,
|
|
403
|
-
}
|
|
404
|
-
: target;
|
|
405
|
-
const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
|
|
406
|
-
pendingRetimes.set(kfKey, pending);
|
|
407
|
-
latestRetimeRef.current = pending;
|
|
408
|
-
const clearPending = () => {
|
|
409
|
-
if (pendingRetimes.get(kfKey) === pending) {
|
|
410
|
-
pendingRetimes.delete(kfKey);
|
|
411
|
-
}
|
|
412
|
-
};
|
|
413
|
-
// A rejected drop (the destination time is already occupied) snaps
|
|
414
|
-
// the diamond back to its source position, so the pending entry AND
|
|
415
|
-
// the selection have to revert with it — parking on the ghost drop
|
|
416
|
-
// position strands the playhead + selection on a keyframe that does
|
|
417
|
-
// not exist there.
|
|
418
|
-
const revertRetime = () => {
|
|
419
|
-
// Only the newest gesture owns the selection. A rejected first drag
|
|
420
|
-
// whose commit settles after a second one started would otherwise
|
|
421
|
-
// park the selection back on ITS source keyframe, undoing a retime
|
|
422
|
-
// the user has already made and moving the playhead with it.
|
|
423
|
-
const isLatest = latestRetimeRef.current === pending;
|
|
424
|
-
clearPending();
|
|
425
|
-
if (isLatest) onClickKeyframe?.(fromTarget);
|
|
426
|
-
};
|
|
427
|
-
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
|
|
428
|
-
if (!committed) revertRetime();
|
|
429
|
-
}, revertRetime);
|
|
430
|
-
// A retime still targeted this exact diamond — park/select it at its
|
|
431
|
-
// new position, same as a plain click, or a drag that actually moved
|
|
432
|
-
// something looks identical to one that silently did nothing. Done
|
|
433
|
-
// optimistically so the gesture stays responsive; revertRetime puts
|
|
434
|
-
// it back if the move is rejected.
|
|
435
|
-
onClickKeyframe?.({
|
|
436
|
-
...target,
|
|
437
|
-
percentage: res.toClipPct,
|
|
438
|
-
tweenPercentage: newTweenPct,
|
|
439
|
-
});
|
|
440
|
-
}
|
|
323
|
+
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
|
324
|
+
else onClickKeyframe?.(target);
|
|
441
325
|
};
|
|
442
326
|
|
|
443
327
|
return (
|
|
@@ -449,6 +333,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
449
333
|
key={`${kf.animationId ?? i}:${kf.propertyGroup ?? ""}:${kf.tweenPercentage ?? kf.percentage}`}
|
|
450
334
|
type="button"
|
|
451
335
|
className="absolute"
|
|
336
|
+
data-timeline-focus-id={focusId}
|
|
452
337
|
data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
|
|
453
338
|
data-keyframe-percentage={
|
|
454
339
|
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
|
|
@@ -456,6 +341,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
456
341
|
data-keyframe-at-playhead={String(atPlayhead)}
|
|
457
342
|
data-keyframe-selected={String(isKfSelected)}
|
|
458
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)` : ""}`}
|
|
459
347
|
aria-pressed={isKfSelected}
|
|
460
348
|
style={{
|
|
461
349
|
left: leftPx,
|
|
@@ -476,24 +364,31 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
476
364
|
overflow: "visible",
|
|
477
365
|
}}
|
|
478
366
|
onPointerDown={onPointerDown}
|
|
479
|
-
onPointerMove={
|
|
367
|
+
onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
|
|
480
368
|
onPointerUp={onPointerUp}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
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);
|
|
490
377
|
}}
|
|
378
|
+
onPointerCancel={
|
|
379
|
+
canDrag
|
|
380
|
+
? (e) => {
|
|
381
|
+
retimeHandleRef.current?.cancel(e);
|
|
382
|
+
retimeHandleRef.current = null;
|
|
383
|
+
}
|
|
384
|
+
: undefined
|
|
385
|
+
}
|
|
491
386
|
onContextMenu={(e) => {
|
|
492
387
|
e.preventDefault();
|
|
493
388
|
e.stopPropagation();
|
|
494
389
|
onContextMenuKeyframe?.(e, target);
|
|
495
390
|
}}
|
|
496
|
-
title={`${roundPct(kf.percentage)}
|
|
391
|
+
title={`${roundPct(kf.percentage)}%${boundary ? ` · ${boundary} clip` : ""}`}
|
|
497
392
|
>
|
|
498
393
|
<svg
|
|
499
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
|