@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.
Files changed (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. 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
- KF_MAX_PCT,
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
- const dragRef = useRef<DragState | null>(null);
90
- // Pending retime destination (clip + tween %) per keyframe key, so a rapid
91
- // second drag composes from where the first move left the keyframe (whose
92
- // cache entry has not rebuilt yet) instead of the stale rendered value.
93
- const pendingRetimeRef = useRef<Map<string, { clipPct: number; tweenPct: number }> | null>(null);
94
- // Lazy: `useRef(new Map())` allocates a Map on every render and throws all but
95
- // the first away, once per mounted lane.
96
- pendingRetimeRef.current ??= new Map();
97
- const pendingRetimes = pendingRetimeRef.current;
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 onKeyDown = (event: KeyboardEvent) => {
138
- if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return;
139
- dragRef.current.cancelled = true;
140
- cancelPreviewFrame();
141
- setPreview(null);
142
- };
143
- document.addEventListener("keydown", onKeyDown);
144
- return () => {
145
- document.removeEventListener("keydown", onKeyDown);
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
- const sorted = keyframesData.keyframes
181
- .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
182
- .sort((a, b) => a.percentage - b.percentage);
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
- ?.clipPct ?? keyframe.percentage;
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 = (percentage: number) =>
216
- Math.max(0, Math.min(clipWidthPx, (percentage / 100) * clipWidthPx));
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.percentage);
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.percentage) : Infinity;
224
- const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity;
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 / 100) * clipWidthPx - marker.hitWidth / 2;
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
- e.currentTarget.setPointerCapture?.(e.pointerId);
299
- dragRef.current = {
300
- kfKey,
301
- startX: e.clientX,
302
- lastX: e.clientX,
303
- index: siblingIndex,
304
- fromClipPct: pendingRetimes.get(kfKey)?.clipPct ?? kf.percentage,
305
- moved: false,
306
- };
307
- }
308
- };
309
- const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
310
- const d = dragRef.current;
311
- if (!d || d.kfKey !== kfKey || d.cancelled) return;
312
- d.lastX = e.clientX;
313
- if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
314
- d.moved = true;
315
- }
316
- if (!d.moved || previewFrameRef.current !== null) return;
317
- previewFrameRef.current = requestAnimationFrame(() => {
318
- previewFrameRef.current = null;
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
- const d = dragRef.current;
336
- if (d?.kfKey === kfKey && d.cancelled) {
337
- // Escape already ended this drag; the release is not a click.
338
- dragRef.current = null;
339
- e.currentTarget.releasePointerCapture?.(e.pointerId);
340
- suppressNextClick();
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.stopPropagation();
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
- // Single-diamond retime by design: a multi-select drag would have to
358
- // move every selected keyframe as one mutation, which the script ops
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={onPointerMove}
367
+ onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
480
368
  onPointerUp={onPointerUp}
481
- onPointerCancel={(e) => {
482
- // Browser/OS cancellation (or lost capture) ends the drag without a
483
- // pointerup, so clear the armed drag and preview or a ghost diamond
484
- // stays stuck at the last previewed position.
485
- if (dragRef.current?.kfKey !== kfKey) return;
486
- dragRef.current = null;
487
- cancelPreviewFrame();
488
- setPreview(null);
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 { TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
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={keyframeTarget(kf)}
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
- aria-label={`Edit ${ease} easing`}
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