@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,13 +1,475 @@
1
- import { useCallback, type MouseEvent as ReactMouseEvent } from "react";
1
+ import {
2
+ useCallback,
3
+ type MouseEvent as ReactMouseEvent,
4
+ type PointerEvent as ReactPointerEvent,
5
+ } from "react";
6
+ import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
7
+ import {
8
+ KEYFRAME_DRAG_THRESHOLD_PX,
9
+ previewClipPct,
10
+ resolveKeyframeDrag,
11
+ } from "../../components/editor/keyframeDrag";
2
12
  import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
13
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
3
14
  import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
4
15
  import { usePlayerStore } from "../store/playerStore";
5
16
  import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
17
+ import {
18
+ applyTimelineHorizontalAutoScrollStep,
19
+ resolveTimelineAutoScrollLoopAction,
20
+ } from "./timelineEditing";
6
21
  import {
7
22
  timelineKeyframeSelectionKey,
8
23
  type TimelineKeyframeTarget,
9
24
  } from "./timelineKeyframeIdentity";
10
25
 
26
+ interface TimelineRetimeKeyframe {
27
+ percentage: number;
28
+ tweenPercentage?: number;
29
+ propertyGroup?: string;
30
+ animationId?: string;
31
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
32
+ }
33
+
34
+ interface TimelineKeyframeRetimeInput {
35
+ event: ReactPointerEvent<HTMLElement>;
36
+ elementId: string;
37
+ keyframeKey: string;
38
+ target: TimelineKeyframeTarget;
39
+ keyframes: readonly TimelineRetimeKeyframe[];
40
+ clipWidthPx: number;
41
+ draggedIndex: number;
42
+ sortedClipPercentages: readonly number[];
43
+ onMove: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
44
+ onSelect: (target: TimelineKeyframeTarget, additive: boolean) => void;
45
+ suppressNextClick: () => void;
46
+ /**
47
+ * Selection key of a cache keyframe, used to retire a pending entry once the
48
+ * authoritative cache reflects THAT keyframe at its destination. Without it a
49
+ * bare "some keyframe is near that %" test retires the entry whenever an
50
+ * unrelated sibling happens to sit there, which is easy to hit on an evenly
51
+ * spaced row.
52
+ */
53
+ keyframeKeyOf: (keyframe: TimelineRetimeKeyframe) => string;
54
+ }
55
+
56
+ interface PendingTimelineKeyframeRetime {
57
+ elementId: string;
58
+ clipPercentage: number;
59
+ tweenPercentage: number;
60
+ destinationKeyframeKey: string;
61
+ sessionEpoch: number;
62
+ }
63
+
64
+ interface TimelineKeyframeRetimePreview {
65
+ keyframeKey: string;
66
+ clipPercentage: number;
67
+ }
68
+
69
+ interface TimelineKeyframeRetimeActor extends TimelineKeyframeRetimeInput {
70
+ phase: "active" | "committing" | "cancelled" | "complete";
71
+ pointerId: number | null;
72
+ pointerDownX: number;
73
+ lastClientX: number;
74
+ lastClientY: number;
75
+ originScrollLeft: number;
76
+ fromClipPercentage: number;
77
+ moved: boolean;
78
+ sessionEpoch: number;
79
+ sourceWasPresent: boolean;
80
+ scrollRaf: number;
81
+ unsubscribeStore: (() => void) | null;
82
+ teardownListeners: (() => void) | null;
83
+ }
84
+
85
+ interface TimelineKeyframeRetimeCoordinator {
86
+ actor: TimelineKeyframeRetimeActor | null;
87
+ pending: Map<string, PendingTimelineKeyframeRetime>;
88
+ preview: TimelineKeyframeRetimePreview | null;
89
+ previewListeners: Set<(preview: TimelineKeyframeRetimePreview | null) => void>;
90
+ /**
91
+ * The most recent retime dispatched through this viewport, whichever diamond
92
+ * it came from. Selection is viewport-wide, so "is my revert still relevant"
93
+ * is a viewport-wide question, not a per-keyframe one.
94
+ */
95
+ latest: PendingTimelineKeyframeRetime | null;
96
+ }
97
+
98
+ type TimelineRetimePointerEvent = Pick<
99
+ PointerEvent,
100
+ "clientX" | "clientY" | "pointerId" | "shiftKey"
101
+ >;
102
+
103
+ const keyframeRetimeCoordinators = new WeakMap<EventTarget, TimelineKeyframeRetimeCoordinator>();
104
+
105
+ function getRetimeOwner(target: HTMLElement): EventTarget {
106
+ return target.closest<HTMLElement>("[data-timeline-scroll-viewport]") ?? target.ownerDocument;
107
+ }
108
+
109
+ function getRetimeCoordinator(owner: EventTarget): TimelineKeyframeRetimeCoordinator {
110
+ const existing = keyframeRetimeCoordinators.get(owner);
111
+ if (existing) return existing;
112
+ const coordinator: TimelineKeyframeRetimeCoordinator = {
113
+ actor: null,
114
+ pending: new Map(),
115
+ preview: null,
116
+ previewListeners: new Set(),
117
+ latest: null,
118
+ };
119
+ keyframeRetimeCoordinators.set(owner, coordinator);
120
+ return coordinator;
121
+ }
122
+
123
+ function stablePointerId(pointerId: number): number | null {
124
+ return Number.isFinite(pointerId) ? pointerId : null;
125
+ }
126
+
127
+ /**
128
+ * The retime destinations already dispatched from `source`'s viewport but not
129
+ * yet reflected in the keyframe cache. A renderer clamping a drag against its
130
+ * neighbours has to compose these in, or a second drag can cross a neighbour
131
+ * that already moved past it.
132
+ */
133
+ export function readPendingTimelineKeyframeRetimes(
134
+ source: HTMLElement | null | undefined,
135
+ ): ReadonlyMap<string, { clipPercentage: number; tweenPercentage: number }> {
136
+ if (!source) return EMPTY_PENDING_RETIMES;
137
+ return keyframeRetimeCoordinators.get(getRetimeOwner(source))?.pending ?? EMPTY_PENDING_RETIMES;
138
+ }
139
+
140
+ const EMPTY_PENDING_RETIMES: ReadonlyMap<
141
+ string,
142
+ { clipPercentage: number; tweenPercentage: number }
143
+ > = new Map();
144
+
145
+ function publishRetimePreview(
146
+ coordinator: TimelineKeyframeRetimeCoordinator,
147
+ preview: TimelineKeyframeRetimePreview | null,
148
+ ): void {
149
+ coordinator.preview = preview;
150
+ for (const listener of coordinator.previewListeners) listener(preview);
151
+ }
152
+
153
+ export function subscribeTimelineKeyframeRetimePreview(
154
+ source: HTMLElement,
155
+ listener: (preview: TimelineKeyframeRetimePreview | null) => void,
156
+ ): () => void {
157
+ const coordinator = getRetimeCoordinator(getRetimeOwner(source));
158
+ coordinator.previewListeners.add(listener);
159
+ listener(coordinator.preview);
160
+ return () => coordinator.previewListeners.delete(listener);
161
+ }
162
+
163
+ function resolveRetimeTweenPercentage(
164
+ actor: TimelineKeyframeRetimeActor,
165
+ toClipPercentage: number,
166
+ ): number {
167
+ const animationKeyframes =
168
+ actor.target.animationId === undefined
169
+ ? actor.keyframes
170
+ : actor.keyframes.filter((keyframe) => keyframe.animationId === actor.target.animationId);
171
+ const tweenPercentages = animationKeyframes
172
+ .map((keyframe) => keyframe.tweenPercentage)
173
+ .filter((value): value is number => typeof value === "number");
174
+ const mapped = clipToTweenPercentage(animationKeyframes, toClipPercentage);
175
+ if (tweenPercentages.length === 0) return mapped;
176
+ return Math.max(Math.min(...tweenPercentages), Math.min(Math.max(...tweenPercentages), mapped));
177
+ }
178
+
179
+ export interface TimelineKeyframeRetimeHandle {
180
+ update: (event: ReactPointerEvent<HTMLElement>) => void;
181
+ commit: (event: ReactPointerEvent<HTMLElement>) => void;
182
+ cancel: (event: ReactPointerEvent<HTMLElement>) => void;
183
+ }
184
+
185
+ /**
186
+ * Starts a keyframe retime on the stable timeline viewport. The row/button is
187
+ * only an entry point: window listeners own the gesture through virtualization.
188
+ */
189
+ export function beginTimelineKeyframeRetime(
190
+ input: TimelineKeyframeRetimeInput,
191
+ ): TimelineKeyframeRetimeHandle {
192
+ const source = input.event.currentTarget;
193
+ const owner = getRetimeOwner(source);
194
+ const viewport = owner instanceof HTMLElement ? owner : null;
195
+ const coordinator = getRetimeCoordinator(owner);
196
+ const sessionEpoch = usePlayerStore.getState().timelineSessionEpoch;
197
+
198
+ const cancel = (actor: TimelineKeyframeRetimeActor) => {
199
+ if (actor.phase !== "active") return;
200
+ actor.phase = "cancelled";
201
+ publishRetimePreview(coordinator, null);
202
+ if (actor.scrollRaf) cancelAnimationFrame(actor.scrollRaf);
203
+ actor.unsubscribeStore?.();
204
+ actor.teardownListeners?.();
205
+ if (viewport && actor.pointerId !== null) {
206
+ try {
207
+ viewport.releasePointerCapture(actor.pointerId);
208
+ } catch {
209
+ // Window listeners remain the native fallback when capture is unavailable.
210
+ }
211
+ }
212
+ if (coordinator.actor === actor) {
213
+ coordinator.actor = null;
214
+ }
215
+ actor.phase = "complete";
216
+ };
217
+
218
+ if (coordinator.actor) cancel(coordinator.actor);
219
+
220
+ for (const [key, pending] of coordinator.pending) {
221
+ if (pending.sessionEpoch !== sessionEpoch) coordinator.pending.delete(key);
222
+ }
223
+
224
+ for (const [key, pending] of coordinator.pending) {
225
+ // Tolerance, not equality: cache writers round clip %s, so an exact check
226
+ // would leak an entry after every successful retime.
227
+ if (
228
+ pending.elementId === input.elementId &&
229
+ input.keyframes.some(
230
+ (keyframe) =>
231
+ input.keyframeKeyOf(keyframe) === pending.destinationKeyframeKey &&
232
+ Math.abs(keyframe.percentage - pending.clipPercentage) < 0.2,
233
+ )
234
+ ) {
235
+ coordinator.pending.delete(key);
236
+ }
237
+ }
238
+
239
+ const pending = coordinator.pending.get(input.keyframeKey);
240
+ const actor: TimelineKeyframeRetimeActor = {
241
+ ...input,
242
+ phase: "active",
243
+ pointerId: stablePointerId(input.event.pointerId),
244
+ pointerDownX: input.event.clientX,
245
+ lastClientX: input.event.clientX,
246
+ lastClientY: input.event.clientY,
247
+ originScrollLeft: viewport?.scrollLeft ?? 0,
248
+ fromClipPercentage: pending?.clipPercentage ?? input.target.percentage,
249
+ moved: false,
250
+ sessionEpoch,
251
+ sourceWasPresent: usePlayerStore
252
+ .getState()
253
+ .elements.some((element) => (element.key ?? element.id) === input.elementId),
254
+ scrollRaf: 0,
255
+ unsubscribeStore: null,
256
+ teardownListeners: null,
257
+ };
258
+ coordinator.actor = actor;
259
+
260
+ const matchesPointer = (event: TimelineRetimePointerEvent) =>
261
+ actor.pointerId === null || event.pointerId === actor.pointerId;
262
+ const pointerXWithScroll = () =>
263
+ actor.lastClientX + (viewport?.scrollLeft ?? 0) - actor.originScrollLeft;
264
+ const publishPreview = () => {
265
+ publishRetimePreview(coordinator, {
266
+ keyframeKey: actor.keyframeKey,
267
+ clipPercentage: previewClipPct({
268
+ pointerDownX: actor.pointerDownX,
269
+ pointerMoveX: pointerXWithScroll(),
270
+ clipWidthPx: actor.clipWidthPx,
271
+ draggedClipPct: actor.fromClipPercentage,
272
+ draggedIndex: actor.draggedIndex,
273
+ sortedClipPcts: actor.sortedClipPercentages,
274
+ }),
275
+ });
276
+ };
277
+ const stopAutoScroll = () => {
278
+ if (actor.scrollRaf) cancelAnimationFrame(actor.scrollRaf);
279
+ actor.scrollRaf = 0;
280
+ };
281
+ const stepAutoScroll = () => {
282
+ actor.scrollRaf = 0;
283
+ if (
284
+ actor.phase !== "active" ||
285
+ !viewport ||
286
+ !applyTimelineHorizontalAutoScrollStep(viewport, actor.lastClientX)
287
+ ) {
288
+ return;
289
+ }
290
+ publishPreview();
291
+ actor.scrollRaf = requestAnimationFrame(stepAutoScroll);
292
+ };
293
+ const syncAutoScroll = () => {
294
+ if (!viewport || !actor.moved) return;
295
+ const action = resolveTimelineAutoScrollLoopAction(
296
+ viewport,
297
+ actor.lastClientX,
298
+ actor.lastClientY,
299
+ actor.scrollRaf !== 0,
300
+ );
301
+ if (action === "stop") stopAutoScroll();
302
+ else if (action === "start") actor.scrollRaf = requestAnimationFrame(stepAutoScroll);
303
+ };
304
+ const teardown = () => {
305
+ stopAutoScroll();
306
+ actor.unsubscribeStore?.();
307
+ actor.unsubscribeStore = null;
308
+ actor.teardownListeners?.();
309
+ actor.teardownListeners = null;
310
+ };
311
+ const releaseCapture = () => {
312
+ if (!viewport || actor.pointerId === null) return;
313
+ try {
314
+ viewport.releasePointerCapture(actor.pointerId);
315
+ } catch {
316
+ // Capture may already have been released by the browser.
317
+ }
318
+ };
319
+
320
+ const claimActorForCommit = (event: TimelineRetimePointerEvent): boolean => {
321
+ if (actor.phase !== "active" || !matchesPointer(event)) return false;
322
+ if (actor.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch) {
323
+ cancel(actor);
324
+ return false;
325
+ }
326
+ actor.phase = "committing";
327
+ actor.lastClientX = event.clientX;
328
+ actor.lastClientY = event.clientY;
329
+ teardown();
330
+ releaseCapture();
331
+ publishRetimePreview(coordinator, null);
332
+ if (coordinator.actor === actor) {
333
+ coordinator.actor = null;
334
+ }
335
+ actor.suppressNextClick();
336
+ return true;
337
+ };
338
+
339
+ const commitMove = (toClipPercentage: number) => {
340
+ const newTweenPercentage = resolveRetimeTweenPercentage(actor, toClipPercentage);
341
+ const pendingBefore = coordinator.pending.get(actor.keyframeKey);
342
+ const fromTarget = pendingBefore
343
+ ? {
344
+ ...actor.target,
345
+ percentage: pendingBefore.clipPercentage,
346
+ tweenPercentage: pendingBefore.tweenPercentage,
347
+ }
348
+ : actor.target;
349
+ const nextPending = {
350
+ elementId: actor.elementId,
351
+ clipPercentage: toClipPercentage,
352
+ tweenPercentage: newTweenPercentage,
353
+ destinationKeyframeKey: timelineKeyframeSelectionKey(actor.elementId, {
354
+ ...actor.target,
355
+ percentage: toClipPercentage,
356
+ tweenPercentage: newTweenPercentage,
357
+ }),
358
+ sessionEpoch: actor.sessionEpoch,
359
+ };
360
+ coordinator.pending.set(actor.keyframeKey, nextPending);
361
+ coordinator.latest = nextPending;
362
+ const clearPending = () => {
363
+ if (coordinator.pending.get(actor.keyframeKey) === nextPending) {
364
+ coordinator.pending.delete(actor.keyframeKey);
365
+ }
366
+ };
367
+ // A rejected drop (the destination time is already occupied) snaps the
368
+ // diamond back to its source position, so the pending entry AND the
369
+ // selection have to revert with it: parking on the ghost drop position
370
+ // strands the playhead + selection on a keyframe that is not there.
371
+ const revertRetime = () => {
372
+ // Only the newest gesture owns the selection. A rejected first drag
373
+ // whose commit settles after a second one started would otherwise park
374
+ // the selection back on ITS source keyframe, undoing a retime the user
375
+ // has already made and moving the playhead with it.
376
+ const isLatest = coordinator.latest === nextPending;
377
+ clearPending();
378
+ if (isLatest) actor.onSelect(fromTarget, false);
379
+ };
380
+ void actor.onMove(fromTarget, toClipPercentage).then((committed) => {
381
+ if (!committed) revertRetime();
382
+ }, revertRetime);
383
+ actor.onSelect(
384
+ {
385
+ ...actor.target,
386
+ percentage: toClipPercentage,
387
+ tweenPercentage: newTweenPercentage,
388
+ },
389
+ false,
390
+ );
391
+ };
392
+
393
+ const finishCommit = (event: TimelineRetimePointerEvent) => {
394
+ if (!claimActorForCommit(event)) return;
395
+ const result = resolveKeyframeDrag({
396
+ pointerDownX: actor.pointerDownX,
397
+ pointerUpX: pointerXWithScroll(),
398
+ clipWidthPx: actor.clipWidthPx,
399
+ draggedClipPct: actor.fromClipPercentage,
400
+ draggedIndex: actor.draggedIndex,
401
+ sortedClipPcts: actor.sortedClipPercentages,
402
+ });
403
+ if (result.kind === "move" && result.toClipPct !== undefined) {
404
+ commitMove(result.toClipPct);
405
+ } else {
406
+ actor.onSelect(actor.target, event.shiftKey);
407
+ }
408
+ actor.phase = "complete";
409
+ };
410
+ const onPointerMove = (event: TimelineRetimePointerEvent) => {
411
+ if (actor.phase !== "active" || !matchesPointer(event)) return;
412
+ actor.lastClientX = event.clientX;
413
+ actor.lastClientY = event.clientY;
414
+ if (
415
+ !actor.moved &&
416
+ Math.abs(pointerXWithScroll() - actor.pointerDownX) >= KEYFRAME_DRAG_THRESHOLD_PX
417
+ ) {
418
+ actor.moved = true;
419
+ }
420
+ if (actor.moved) publishPreview();
421
+ syncAutoScroll();
422
+ };
423
+ const onPointerUp = (event: TimelineRetimePointerEvent) => finishCommit(event);
424
+ const onPointerCancel = (event: TimelineRetimePointerEvent) => {
425
+ if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
426
+ };
427
+ const onLostPointerCapture = (event: PointerEvent) => {
428
+ if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
429
+ };
430
+ const onKeyDown = (event: KeyboardEvent) => {
431
+ if (event.key === "Escape") cancel(actor);
432
+ };
433
+
434
+ window.addEventListener("pointermove", onPointerMove, true);
435
+ window.addEventListener("pointerup", onPointerUp, true);
436
+ window.addEventListener("pointercancel", onPointerCancel, true);
437
+ window.addEventListener("keydown", onKeyDown);
438
+ viewport?.addEventListener("lostpointercapture", onLostPointerCapture);
439
+ actor.teardownListeners = () => {
440
+ window.removeEventListener("pointermove", onPointerMove, true);
441
+ window.removeEventListener("pointerup", onPointerUp, true);
442
+ window.removeEventListener("pointercancel", onPointerCancel, true);
443
+ window.removeEventListener("keydown", onKeyDown);
444
+ viewport?.removeEventListener("lostpointercapture", onLostPointerCapture);
445
+ };
446
+ actor.unsubscribeStore = usePlayerStore.subscribe((state) => {
447
+ const sourceStillPresent = state.elements.some(
448
+ (element) => (element.key ?? element.id) === actor.elementId,
449
+ );
450
+ if (state.timelineSessionEpoch !== actor.sessionEpoch) {
451
+ coordinator.pending.clear();
452
+ coordinator.latest = null;
453
+ cancel(actor);
454
+ } else if (actor.sourceWasPresent && !sourceStillPresent) {
455
+ for (const [key, pending] of coordinator.pending) {
456
+ if (pending.elementId === actor.elementId) coordinator.pending.delete(key);
457
+ }
458
+ if (coordinator.latest?.elementId === actor.elementId) coordinator.latest = null;
459
+ cancel(actor);
460
+ }
461
+ });
462
+
463
+ if (viewport && actor.pointerId !== null) {
464
+ try {
465
+ viewport.setPointerCapture(actor.pointerId);
466
+ } catch {
467
+ // Window listeners are the native fallback when capture is unavailable.
468
+ }
469
+ }
470
+ return { update: onPointerMove, commit: onPointerUp, cancel: onPointerCancel };
471
+ }
472
+
11
473
  interface UseTimelineKeyframeHandlersInput {
12
474
  expandedElements: TimelineElement[];
13
475
  keyframeCache: Map<string, KeyframeCacheEntry>;
@@ -88,12 +550,13 @@ export function useTimelineKeyframeHandlers({
88
550
  setKfContextMenu({
89
551
  x: e.clientX + 4,
90
552
  y: e.clientY + 2,
553
+ sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
554
+ element: el,
91
555
  elementId: elId,
92
556
  percentage: target.percentage,
93
557
  tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage,
94
558
  propertyGroup: target.propertyGroup,
95
559
  animationId: target.animationId,
96
- element: el,
97
560
  currentEase: kf?.ease ?? kfData?.ease,
98
561
  });
99
562
  },
@@ -0,0 +1,7 @@
1
+ import { useCallback } from "react";
2
+ import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
3
+
4
+ export function useTimelineLaneMoveRefresh(): () => void {
5
+ const setRefreshKey = useStudioPlaybackContextOptional()?.setRefreshKey;
6
+ return useCallback(() => setRefreshKey?.((key) => key + 1), [setRefreshKey]);
7
+ }
@@ -0,0 +1,80 @@
1
+ import type { RefObject } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { TimelineElement } from "../store/playerStore";
4
+ import type { TimelineRowGeometry } from "./timelineLayout";
5
+ import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
6
+ import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
9
+ import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
10
+
11
+ interface TimelineLogicalFocusInput {
12
+ scrollRef: RefObject<HTMLDivElement | null>;
13
+ tracks: readonly (readonly [number, readonly TimelineElement[]])[];
14
+ layout: { displayTrackOrder: readonly number[]; rowGeometry: TimelineRowGeometry };
15
+ laneCounts: ReadonlyMap<string, number>;
16
+ selectedElementId: string | null;
17
+ selectedElementIds: ReadonlySet<string>;
18
+ gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
19
+ elements: readonly TimelineElement[];
20
+ pixelsPerSecond: number;
21
+ contentOrigin: number;
22
+ allowHorizontal: boolean;
23
+ viewport: TimelineScrollViewportSnapshot;
24
+ sessionEpoch: number;
25
+ draggedRowKey?: number;
26
+ resizingElementIds?: readonly string[];
27
+ clipContextMenuRowKey?: number;
28
+ keyframeContextMenuRowKey?: number;
29
+ lastScrollLeftRef: RefObject<number>;
30
+ syncScrollViewport: (element: HTMLDivElement) => void;
31
+ }
32
+
33
+ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
34
+ const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
35
+ const projectId = usePlayerStore((state) => state.timelineProjectId);
36
+ const logicalRows = useTimelineLogicalRows({
37
+ tracks: input.tracks,
38
+ displayTrackOrder: input.layout.displayTrackOrder,
39
+ laneCounts: input.laneCounts,
40
+ selectedElementId: input.selectedElementId,
41
+ selectedElementIds: input.selectedElementIds,
42
+ expandedClipIds,
43
+ gsapAnimations: input.gsapAnimations,
44
+ });
45
+ const focus = useTimelineFocusCoordinator({
46
+ scrollRef: input.scrollRef,
47
+ logicalRows,
48
+ elements: input.elements,
49
+ rowGeometry: input.layout.rowGeometry,
50
+ pixelsPerSecond: input.pixelsPerSecond,
51
+ contentOrigin: input.contentOrigin,
52
+ allowHorizontal: input.allowHorizontal,
53
+ viewportVersion: input.viewport,
54
+ projectId,
55
+ sessionEpoch: input.sessionEpoch,
56
+ syncScrollViewport: input.syncScrollViewport,
57
+ });
58
+ const rows = useTimelineRowVirtualization({
59
+ scrollRef: input.scrollRef,
60
+ viewport: input.viewport,
61
+ rowGeometry: input.layout.rowGeometry,
62
+ sessionEpoch: input.sessionEpoch,
63
+ elements: input.elements,
64
+ selectedElementId: input.selectedElementId,
65
+ focusedRowKey: focus.focusedRowKey,
66
+ draggedRowKey: input.draggedRowKey,
67
+ resizingElementIds: input.resizingElementIds,
68
+ clipContextMenuRowKey: input.clipContextMenuRowKey,
69
+ keyframeContextMenuRowKey: input.keyframeContextMenuRowKey,
70
+ lastScrollLeftRef: input.lastScrollLeftRef,
71
+ syncScrollViewport: input.syncScrollViewport,
72
+ });
73
+ return {
74
+ logicalRows,
75
+ ...focus,
76
+ rowVirtualizationActive: rows.enabled,
77
+ virtualRows: rows.virtualRows,
78
+ timelineFocusProps: rows.timelineFocusProps,
79
+ };
80
+ }
@@ -0,0 +1,57 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
9
+ import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
10
+
11
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
12
+
13
+ const tracks = Array.from(
14
+ { length: 1_000 },
15
+ (_, track) =>
16
+ [
17
+ track,
18
+ [{ id: `clip-${track}`, tag: "div", track, start: track, duration: 1 }],
19
+ ] as const satisfies readonly [number, readonly TimelineElement[]],
20
+ );
21
+ const displayTrackOrder = tracks.map(([track]) => track);
22
+ const laneCounts = new Map<string, number>();
23
+ const selectedElementIds = new Set<string>();
24
+ const expandedClipIds = new Set<string>();
25
+ const gsapAnimations = new Map();
26
+
27
+ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]> }) {
28
+ usePlayerStore((state) => state.requestedSeekTime);
29
+ const logicalRows = useTimelineLogicalRows({
30
+ tracks,
31
+ displayTrackOrder,
32
+ laneCounts,
33
+ selectedElementId: null,
34
+ selectedElementIds,
35
+ expandedClipIds,
36
+ gsapAnimations,
37
+ });
38
+ snapshots.push(logicalRows);
39
+ return null;
40
+ }
41
+
42
+ afterEach(() => usePlayerStore.getState().reset());
43
+
44
+ describe("useTimelineLogicalRows", () => {
45
+ it("preserves the dense logical model across an unrelated store update", () => {
46
+ const host = document.createElement("div");
47
+ const root = createRoot(host);
48
+ const snapshots: Array<readonly TimelineLogicalRow[]> = [];
49
+ act(() => root.render(<Harness snapshots={snapshots} />));
50
+ const first = snapshots.at(-1);
51
+
52
+ act(() => usePlayerStore.setState({ requestedSeekTime: 1 }));
53
+
54
+ expect(snapshots.at(-1)).toBe(first);
55
+ act(() => root.unmount());
56
+ });
57
+ });
@@ -0,0 +1,47 @@
1
+ import { useMemo } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { TimelineElement } from "../store/playerStore";
4
+ import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
5
+
6
+ interface TimelineLogicalRowsInput {
7
+ tracks: readonly (readonly [number, readonly TimelineElement[]])[];
8
+ displayTrackOrder: readonly number[];
9
+ laneCounts: ReadonlyMap<string, number>;
10
+ selectedElementId: string | null;
11
+ selectedElementIds: ReadonlySet<string>;
12
+ expandedClipIds: ReadonlySet<string>;
13
+ gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
14
+ }
15
+
16
+ /** Shared by rendering and focus coordination; stable input refs preserve memo identity. */
17
+ export function useTimelineLogicalRows({
18
+ tracks,
19
+ displayTrackOrder,
20
+ laneCounts,
21
+ selectedElementId,
22
+ selectedElementIds,
23
+ expandedClipIds,
24
+ gsapAnimations,
25
+ }: TimelineLogicalRowsInput) {
26
+ return useMemo(
27
+ () =>
28
+ buildTimelineLogicalRows({
29
+ tracks,
30
+ displayTrackOrder,
31
+ laneCounts,
32
+ selectedElementId,
33
+ selectedElementIds,
34
+ expandedClipIds,
35
+ gsapAnimations,
36
+ }),
37
+ [
38
+ displayTrackOrder,
39
+ expandedClipIds,
40
+ gsapAnimations,
41
+ laneCounts,
42
+ selectedElementId,
43
+ selectedElementIds,
44
+ tracks,
45
+ ],
46
+ );
47
+ }