@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
@@ -4,7 +4,7 @@ import React, { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { TimelineElement } from "../store/playerStore";
6
6
  import { usePlayerStore } from "../store/playerStore";
7
- import type { ResizingClipState } from "./useTimelineClipDrag";
7
+ import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
8
8
  import { useTimelineClipDrag } from "./useTimelineClipDrag";
9
9
  import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
10
10
 
@@ -38,38 +38,77 @@ function renderResizeHarness(
38
38
  usePlayerStore.getState().setSelectedElementIds(new Set(selected));
39
39
 
40
40
  const scroll = document.createElement("div");
41
+ const setPointerCapture = vi.fn();
42
+ scroll.setPointerCapture = setPointerCapture;
41
43
  document.body.append(scroll);
42
44
  const onResizeElement = vi.fn();
45
+ const onMoveElement = vi.fn();
46
+ const onBlockedEditAttempt = vi.fn();
43
47
  const onResizeElements = vi.fn().mockResolvedValue(undefined);
44
48
  let setResizingClip: ((s: ResizingClipState | null) => void) | null = null;
49
+ let setDraggedClip: ((s: DraggedClipState | null) => void) | null = null;
50
+ let resizingClip: ResizingClipState | null = null;
51
+ let blockedClipRef: React.RefObject<BlockedClipState | null> | null = null;
52
+ let epoch = 0;
45
53
 
46
- function Harness() {
54
+ function Harness({ sessionEpoch }: { sessionEpoch: number }) {
47
55
  const hook = useTimelineClipDrag({
48
56
  scrollRef: { current: scroll },
49
57
  ppsRef: { current: 100 },
50
58
  durationRef: { current: 100 },
51
59
  trackOrderRef: { current: [0, 1] },
52
60
  onResizeElement,
61
+ onMoveElement,
62
+ onBlockedEditAttempt,
53
63
  onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements,
54
64
  setShowPopover: vi.fn(),
55
65
  setRangeSelectionRef: { current: vi.fn() },
66
+ sessionEpoch,
56
67
  });
57
68
  setResizingClip = hook.setResizingClip;
69
+ setDraggedClip = hook.setDraggedClip;
70
+ resizingClip = hook.resizingClip;
71
+ blockedClipRef = hook.blockedClipRef;
58
72
  return null;
59
73
  }
60
74
 
61
- const root = mountReactHarness(<Harness />);
75
+ const root = mountReactHarness(<Harness sessionEpoch={epoch} />);
62
76
  const apply = setResizingClip!;
77
+ const dispatchPointer = (type: string, clientX: number, pointerId = 0) => {
78
+ const event = new MouseEvent(type, { bubbles: true, clientX, clientY: 0 });
79
+ Object.defineProperty(event, "pointerId", { value: pointerId });
80
+ window.dispatchEvent(event);
81
+ };
63
82
 
64
83
  return {
65
84
  onResizeElement,
85
+ onMoveElement,
86
+ onBlockedEditAttempt,
66
87
  onResizeElements,
88
+ setPointerCapture,
67
89
  storeById(id: string) {
68
90
  return usePlayerStore.getState().elements.find((e) => e.id === id)!;
69
91
  },
70
- startResize(element: TimelineElement, edge: "start" | "end") {
92
+ getResizeProjection() {
93
+ return resizingClip?.groupPreview ?? [];
94
+ },
95
+ getBlockedClip() {
96
+ return blockedClipRef?.current ?? null;
97
+ },
98
+ startBlocked(element: TimelineElement, pointerId = 0) {
99
+ blockedClipRef!.current = {
100
+ pointerId,
101
+ element,
102
+ intent: "move",
103
+ originClientX: 0,
104
+ originClientY: 0,
105
+ started: false,
106
+ };
107
+ },
108
+ startResize(element: TimelineElement, edge: "start" | "end", pointerId = 0) {
71
109
  act(() => {
72
110
  apply({
111
+ pointerId,
73
112
  element,
74
113
  edge,
75
114
  originClientX: 0,
@@ -80,19 +119,57 @@ function renderResizeHarness(
80
119
  });
81
120
  });
82
121
  },
83
- movePointer(clientX: number) {
122
+ startDrag(element: TimelineElement, pointerId = 0) {
123
+ act(() =>
124
+ setDraggedClip?.({
125
+ pointerId,
126
+ element,
127
+ originClientX: 0,
128
+ originClientY: 0,
129
+ originScrollLeft: 0,
130
+ originScrollTop: 0,
131
+ pointerClientX: 0,
132
+ pointerClientY: 0,
133
+ pointerOffsetX: 0,
134
+ pointerOffsetY: 0,
135
+ previewStart: element.start,
136
+ previewTrack: element.track,
137
+ insertRow: null,
138
+ snapTime: null,
139
+ snapType: null,
140
+ started: false,
141
+ }),
142
+ );
143
+ },
144
+ movePointer(clientX: number, pointerId = 0) {
84
145
  act(() => {
85
- window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX, clientY: 0 }));
146
+ dispatchPointer("pointermove", clientX, pointerId);
147
+ });
148
+ },
149
+ async dropPointer(pointerId = 0) {
150
+ await act(async () => {
151
+ dispatchPointer("pointerup", 0, pointerId);
86
152
  });
87
153
  },
88
- async dropPointer() {
154
+ async moveAndDropPointer(clientX: number, pointerId = 0) {
89
155
  await act(async () => {
90
- window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
156
+ dispatchPointer("pointermove", clientX, pointerId);
157
+ dispatchPointer("pointerup", clientX, pointerId);
91
158
  });
92
159
  },
160
+ cancelPointer(pointerId = 0) {
161
+ act(() => dispatchPointer("pointercancel", 0, pointerId));
162
+ },
163
+ losePointerCapture(pointerId = 0) {
164
+ act(() => dispatchPointer("lostpointercapture", 0, pointerId));
165
+ },
166
+ setSessionEpoch(next: number) {
167
+ epoch = next;
168
+ act(() => root.render(<Harness sessionEpoch={epoch} />));
169
+ },
93
170
  pressEscape() {
94
171
  act(() => {
95
- window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
172
+ scroll.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
96
173
  });
97
174
  },
98
175
  unmount() {
@@ -161,14 +238,131 @@ describe("useTimelineClipDrag — single-clip resize (unchanged path)", () => {
161
238
  });
162
239
  });
163
240
 
241
+ describe("useTimelineClipDrag — gesture lifecycle", () => {
242
+ it("does not capture a stationary click before the drag threshold", () => {
243
+ const a = el("a", { duration: 2 });
244
+ const h = renderResizeHarness([a], []);
245
+ h.startDrag(a, 7);
246
+ expect(h.setPointerCapture).not.toHaveBeenCalled();
247
+
248
+ h.movePointer(3, 7);
249
+ expect(h.setPointerCapture).not.toHaveBeenCalled();
250
+
251
+ h.movePointer(5, 7);
252
+ expect(h.setPointerCapture).toHaveBeenCalledOnce();
253
+ expect(h.setPointerCapture).toHaveBeenCalledWith(7);
254
+ h.unmount();
255
+ });
256
+
257
+ it("commits the final drag position exactly once", async () => {
258
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
259
+ const a = el("a", { duration: 2 });
260
+ const h = renderResizeHarness([a], []);
261
+ h.startDrag(a, 7);
262
+ await h.moveAndDropPointer(50, 7);
263
+ await h.dropPointer(7);
264
+ expect(h.onMoveElement).toHaveBeenCalledTimes(1);
265
+ expect(h.onMoveElement).toHaveBeenCalledWith(a, expect.objectContaining({ start: 0.5 }));
266
+ expect(warnSpy).toHaveBeenCalledWith(
267
+ expect.stringContaining("only single-clip onMoveElement wired"),
268
+ );
269
+ warnSpy.mockRestore();
270
+ h.unmount();
271
+ });
272
+
273
+ it("ignores a foreign pointer cancellation during a blocked gesture", async () => {
274
+ const a = el("a", { duration: 2 });
275
+ const h = renderResizeHarness([a], []);
276
+ h.startBlocked(a, 7);
277
+ h.movePointer(5, 7);
278
+ expect(h.onBlockedEditAttempt).toHaveBeenCalledOnce();
279
+
280
+ h.cancelPointer(8);
281
+ expect(h.getBlockedClip()?.pointerId).toBe(7);
282
+
283
+ await h.dropPointer(7);
284
+ expect(h.getBlockedClip()).toBeNull();
285
+ h.unmount();
286
+ });
287
+
288
+ it("commits the final same-turn pointer move exactly once", async () => {
289
+ const a = el("a", { duration: 2 });
290
+ const h = renderResizeHarness([a], []);
291
+ h.startResize(a, "end", 7);
292
+ await h.moveAndDropPointer(75, 7);
293
+ await h.dropPointer(7);
294
+ expect(h.onResizeElement).toHaveBeenCalledTimes(1);
295
+ expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.75 }));
296
+ h.unmount();
297
+ });
298
+
299
+ it("ignores another pointer and cancels without committing", async () => {
300
+ const a = el("a", { duration: 2 });
301
+ const h = renderResizeHarness([a], []);
302
+ h.startResize(a, "end", 7);
303
+ h.movePointer(50, 8);
304
+ await h.dropPointer(8);
305
+ expect(h.onResizeElement).not.toHaveBeenCalled();
306
+ h.cancelPointer(7);
307
+ await h.dropPointer(7);
308
+ expect(h.onResizeElement).not.toHaveBeenCalled();
309
+ expect(h.storeById("a").duration).toBe(2);
310
+ h.unmount();
311
+ });
312
+
313
+ it("cancels an active projection when the project epoch changes", () => {
314
+ const a = el("a", { duration: 2 });
315
+ const h = renderResizeHarness([a], []);
316
+ h.startResize(a, "end", 7);
317
+ h.movePointer(50, 7);
318
+ h.setSessionEpoch(1);
319
+ expect(h.getResizeProjection()).toHaveLength(0);
320
+ expect(h.onResizeElement).not.toHaveBeenCalled();
321
+ expect(h.storeById("a").duration).toBe(2);
322
+ h.unmount();
323
+ });
324
+
325
+ it("does not commit a stale clip deleted during the gesture", async () => {
326
+ const a = el("a", { duration: 2 });
327
+ const h = renderResizeHarness([a], []);
328
+ h.startResize(a, "end", 7);
329
+ h.movePointer(50, 7);
330
+ act(() => usePlayerStore.getState().setElements([]));
331
+ await h.dropPointer(7);
332
+ expect(h.onResizeElement).not.toHaveBeenCalled();
333
+ expect(usePlayerStore.getState().elements).toEqual([]);
334
+ h.unmount();
335
+ });
336
+
337
+ it("treats lost capture and unmount as cancellation", async () => {
338
+ const a = el("a", { duration: 2 });
339
+ const lost = renderResizeHarness([a], []);
340
+ lost.startResize(a, "end", 7);
341
+ lost.movePointer(50, 7);
342
+ lost.losePointerCapture(7);
343
+ await lost.dropPointer(7);
344
+ expect(lost.onResizeElement).not.toHaveBeenCalled();
345
+ lost.unmount();
346
+
347
+ const unmounted = renderResizeHarness([a], []);
348
+ unmounted.startResize(a, "end", 9);
349
+ unmounted.movePointer(50, 9);
350
+ unmounted.unmount();
351
+ expect(unmounted.onResizeElement).not.toHaveBeenCalled();
352
+ expect(usePlayerStore.getState().elements[0]?.duration).toBe(2);
353
+ });
354
+ });
355
+
164
356
  describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
165
- it("previews the non-grabbed member through the store, grabbed stays out until commit", () => {
357
+ it("previews every member without mutating canonical elements", () => {
166
358
  const { h } = startGroupResize(); // grabbed asks +0.5
167
359
 
168
- // Non-grabbed member is previewed in the store; the grabbed clip renders from
169
- // resizingClip state, so its store value is still the original until commit.
170
- expect(h.storeById("b").duration).toBe(3.5);
360
+ expect(h.getResizeProjection()).toEqual([
361
+ expect.objectContaining({ key: "a", duration: 2.5 }),
362
+ expect.objectContaining({ key: "b", duration: 3.5 }),
363
+ ]);
171
364
  expect(h.storeById("a").duration).toBe(2);
365
+ expect(h.storeById("b").duration).toBe(3);
172
366
  h.unmount();
173
367
  });
174
368
 
@@ -186,6 +380,7 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
186
380
  { coalesceKey: expect.stringMatching(/^clip-group-resize:/) },
187
381
  );
188
382
  expect(h.storeById("a").duration).toBe(2.5);
383
+ expect(h.getResizeProjection()).toHaveLength(0);
189
384
  expect(h.storeById("b").duration).toBe(3.5);
190
385
  h.unmount();
191
386
  });
@@ -213,7 +408,10 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
213
408
  });
214
409
  h.startResize(a, "end");
215
410
  h.movePointer(50);
216
- expect(h.storeById("b").duration).toBe(3.5);
411
+ expect(h.getResizeProjection()).toEqual(
412
+ expect.arrayContaining([expect.objectContaining({ key: "b", duration: 3.5 })]),
413
+ );
414
+ expect(h.storeById("b").duration).toBe(3);
217
415
 
218
416
  await h.dropPointer();
219
417
  expect(h.storeById("a").duration).toBe(2);
@@ -229,12 +427,16 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
229
427
  h.unmount();
230
428
  });
231
429
 
232
- it("Escape rolls back the previewed non-grabbed member and persists nothing", () => {
430
+ it("Escape discards the projection and persists nothing", () => {
233
431
  const { h } = startGroupResize();
234
- expect(h.storeById("b").duration).toBe(3.5); // previewed
432
+ expect(h.getResizeProjection()).toHaveLength(2);
433
+ const sawEscape = vi.fn();
434
+ document.addEventListener("keydown", sawEscape, { once: true });
235
435
 
236
436
  h.pressEscape();
237
- expect(h.storeById("b").duration).toBe(3); // restored
437
+ expect(sawEscape).toHaveBeenCalledOnce();
438
+ expect(h.getResizeProjection()).toHaveLength(0);
439
+ expect(h.storeById("b").duration).toBe(3);
238
440
  expect(h.onResizeElement).not.toHaveBeenCalled();
239
441
  h.unmount();
240
442
  });
@@ -1,4 +1,4 @@
1
- import { useRef, useState, useCallback, useMemo } from "react";
1
+ import { useRef, useState, useCallback, useMemo, useEffect } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import {
4
4
  applyTimelineAutoScrollStep,
@@ -25,9 +25,13 @@ import type {
25
25
  ResizingClipState,
26
26
  BlockedClipState,
27
27
  } from "./timelineClipDragTypes";
28
- import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
29
28
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
30
29
  import type { TimelineRowGeometry } from "./timelineLayout";
30
+ import {
31
+ mountTimelineClipDragGestureLifecycle,
32
+ type TimelineGestureKind,
33
+ type TimelineGestureLifecycle,
34
+ } from "./timelineClipDragGestureLifecycle";
31
35
 
32
36
  export type {
33
37
  DraggedClipState,
@@ -74,6 +78,7 @@ interface UseTimelineClipDragInput {
74
78
  readZIndex?: (element: TimelineElement) => number;
75
79
  onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
76
80
  refreshAfterLaneMove?: () => void;
81
+ sessionEpoch?: number;
77
82
  }
78
83
 
79
84
  export function useTimelineClipDrag({
@@ -92,6 +97,7 @@ export function useTimelineClipDrag({
92
97
  readZIndex,
93
98
  onStackingPatches,
94
99
  refreshAfterLaneMove,
100
+ sessionEpoch = 0,
95
101
  }: UseTimelineClipDragInput) {
96
102
  const updateElement = usePlayerStore((s) => s.updateElement);
97
103
  const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
@@ -162,41 +168,72 @@ export function useTimelineClipDrag({
162
168
  [],
163
169
  );
164
170
 
165
- const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
171
+ const [draggedClip, setDraggedClipState] = useState<DraggedClipState | null>(null);
166
172
  const draggedClipRef = useRef<DraggedClipState | null>(null);
167
- draggedClipRef.current = draggedClip;
173
+ const publishDraggedClip = useCallback((next: DraggedClipState | null) => {
174
+ draggedClipRef.current = next;
175
+ setDraggedClipState(next);
176
+ }, []);
168
177
 
169
- const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
178
+ const [resizingClip, setResizingClipState] = useState<ResizingClipState | null>(null);
170
179
  const resizingClipRef = useRef<ResizingClipState | null>(null);
171
- resizingClipRef.current = resizingClip;
180
+ const publishResizingClip = useCallback((next: ResizingClipState | null) => {
181
+ resizingClipRef.current = next;
182
+ setResizingClipState(next);
183
+ }, []);
184
+
185
+ const lifecycleRef = useRef<TimelineGestureLifecycle>({
186
+ kind: null,
187
+ phase: "complete",
188
+ pointerId: null,
189
+ sessionEpoch,
190
+ });
191
+ const sessionEpochRef = useRef(sessionEpoch);
192
+ sessionEpochRef.current = sessionEpoch;
193
+ const gestureSelectedKeysRef = useRef<ReadonlySet<string>>(new Set());
194
+ const cancelGestureRef = useRef<
195
+ (options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
196
+ >(() => false);
197
+ const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number) => {
198
+ if (lifecycleRef.current.phase === "active") cancelGestureRef.current();
199
+ lifecycleRef.current = {
200
+ kind,
201
+ phase: "active",
202
+ pointerId,
203
+ sessionEpoch: sessionEpochRef.current,
204
+ };
205
+ gestureSelectedKeysRef.current = new Set(usePlayerStore.getState().selectedElementIds);
206
+ }, []);
207
+ const setDraggedClip = useCallback(
208
+ (next: DraggedClipState | null) => {
209
+ if (!next) {
210
+ cancelGestureRef.current();
211
+ return;
212
+ }
213
+ beginGesture("drag", next.pointerId);
214
+ publishDraggedClip(next);
215
+ },
216
+ [beginGesture, publishDraggedClip],
217
+ );
218
+ const setResizingClip = useCallback(
219
+ (next: ResizingClipState | null) => {
220
+ if (!next) {
221
+ cancelGestureRef.current();
222
+ return;
223
+ }
224
+ beginGesture("resize", next.pointerId);
225
+ publishResizingClip(next);
226
+ },
227
+ [beginGesture, publishResizingClip],
228
+ );
172
229
 
173
230
  const blockedClipRef = useRef<BlockedClipState | null>(null);
174
231
  const suppressClickRef = useRef(false);
175
232
 
176
- // Active multi-select group-resize session (restored from main 36413da7f): set
177
- // lazily on the first resize pointermove when the grabbed clip is part of a
178
- // capability-clean multi-selection (null ⇒ single-clip resize). Holds the
179
- // pre-gesture snapshot so the non-grabbed members (previewed through the store)
180
- // roll back on escape / cancel / failed persist.
233
+ // Active multi-select group-resize session, created lazily on first movement.
234
+ // It owns a projection only; canonical store timing changes at commit.
181
235
  const groupResizeRef = useRef<TimelineGroupResizeSession | null>(null);
182
236
 
183
- // Restore the non-grabbed group members to their pre-gesture timing (the
184
- // grabbed clip renders from resizingClip state, so it is never written during
185
- // preview). `all` also restores the grabbed clip after a committed persist fails.
186
- const restoreGroupResizeMembers = useCallback(
187
- (session: TimelineGroupResizeSession, all = false) => {
188
- for (const m of session.members) {
189
- if (!all && m.key === session.grabbedKey) continue;
190
- updateElement(m.key, {
191
- start: m.start,
192
- duration: m.duration,
193
- playbackStart: m.playbackStart,
194
- });
195
- }
196
- },
197
- [updateElement],
198
- );
199
-
200
237
  const onMoveElementRef = useRef(onMoveElement);
201
238
  onMoveElementRef.current = onMoveElement;
202
239
  const onMoveElementsRef = useRef(onMoveElements);
@@ -237,7 +274,7 @@ export function useTimelineClipDrag({
237
274
  trackOrder: trackOrderRef.current,
238
275
  rowHeights: rowGeometryRef?.current.rowHeights,
239
276
  elements: elementsRef.current,
240
- selectedKeys: usePlayerStore.getState().selectedElementIds,
277
+ selectedKeys: gestureSelectedKeysRef.current,
241
278
  buildSnapTargets,
242
279
  audioTracks: dragAudioTracksRef.current,
243
280
  });
@@ -256,18 +293,19 @@ export function useTimelineClipDrag({
256
293
  buildSnapTargets,
257
294
  });
258
295
  const setResizeState = (v: ResizePreviewResult) =>
259
- setResizingClip((prev) => (prev ? { ...prev, started: true, ...v } : prev));
296
+ publishResizingClip(
297
+ resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
298
+ );
260
299
 
261
300
  // Group resize: a capability-clean multi-selection resizes rigidly by one
262
301
  // shared, member-clamped delta (legacy main 36413da7f). The grabbed clip
263
- // drives the raw delta and renders from resizingClip state; non-grabbed
264
- // members preview through the store (their store value stays pristine).
302
+ // drives the raw delta; every member renders from the coordinator projection.
265
303
  const grabbedKey = resize.element.key ?? resize.element.id;
266
304
  let session = groupResizeRef.current;
267
305
  if (!session || session.grabbedKey !== grabbedKey || session.edge !== resize.edge) {
268
306
  const members = buildTimelineGroupResizeMembers(
269
307
  elementsRef.current,
270
- usePlayerStore.getState().selectedElementIds,
308
+ gestureSelectedKeysRef.current,
271
309
  grabbedKey,
272
310
  resize.edge,
273
311
  );
@@ -287,9 +325,9 @@ export function useTimelineClipDrag({
287
325
  setResizeState(next);
288
326
  return;
289
327
  }
290
- previewGroupResize(session, next, grabbedKey, updateElement, setResizeState);
328
+ previewGroupResize(session, next, setResizeState);
291
329
  },
292
- [scrollRef, ppsRef, buildSnapTargets, updateElement],
330
+ [scrollRef, ppsRef, buildSnapTargets, publishResizingClip],
293
331
  );
294
332
  const applyResizePointerRef = useRef(applyResizePointer);
295
333
  applyResizePointerRef.current = applyResizePointer;
@@ -300,9 +338,7 @@ export function useTimelineClipDrag({
300
338
  cancelAnimationFrame(clipDragScrollRaf.current);
301
339
  clipDragScrollRaf.current = 0;
302
340
  }
303
- // Gesture teardown: drop the frozen-per-gesture perf caches so the next drag
304
- // rebuilds them against fresh store state (see snapTargetsCacheRef). Does NOT
305
- // touch groupResizeRef — commit reads it after this runs.
341
+ // Gesture teardown: drop frozen caches so the next gesture reads fresh state.
306
342
  snapTargetsCacheRef.current.clear();
307
343
  dragAudioTracksRef.current = null;
308
344
  }, []);
@@ -317,16 +353,14 @@ export function useTimelineClipDrag({
317
353
  if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
318
354
 
319
355
  if (drag) {
320
- setDraggedClip((prev) =>
321
- prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
322
- );
356
+ publishDraggedClip(updateDraggedClipPreview(drag, pointer.clientX, pointer.clientY));
323
357
  } else if (resize) {
324
358
  // Re-run the trim preview so the edge keeps tracking while the content
325
359
  // scrolls under the stationary pointer (scroll-compensated pointer x).
326
360
  applyResizePointerRef.current(resize, pointer.clientX);
327
361
  }
328
362
  clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
329
- }, [scrollRef, updateDraggedClipPreview]);
363
+ }, [publishDraggedClip, scrollRef, updateDraggedClipPreview]);
330
364
 
331
365
  const syncClipDragAutoScroll = useCallback(
332
366
  (clientX: number, clientY: number) => {
@@ -354,35 +388,44 @@ export function useTimelineClipDrag({
354
388
  const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
355
389
  stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
356
390
 
357
- useMountEffect(() => {
358
- return mountTimelineClipDragGestureLifecycle({
359
- draggedClipRef,
360
- resizingClipRef,
361
- blockedClipRef,
362
- groupResizeRef,
363
- suppressClickRef,
364
- elementsRef,
365
- trackOrderRef,
366
- setDraggedClip,
367
- setResizingClip,
368
- setShowPopover,
369
- setRangeSelectionRef,
370
- applyResizePointerRef,
371
- syncClipDragAutoScrollRef,
372
- stopClipDragAutoScrollRef,
373
- updateDraggedClipPreviewRef,
374
- restoreGroupResizeMembers,
375
- updateElement,
376
- onMoveElementRef,
377
- onMoveElementsRef,
378
- onResizeElementRef,
379
- onResizeElementsRef,
380
- onBlockedEditAttemptRef,
381
- readZIndexRef,
391
+ useMountEffect(() =>
392
+ mountTimelineClipDragGestureLifecycle({
382
393
  onStackingPatchesRef,
383
394
  refreshAfterLaneMoveRef,
384
- });
385
- });
395
+ readZIndexRef,
396
+ onBlockedEditAttemptRef,
397
+ onResizeElementsRef,
398
+ onResizeElementRef,
399
+ onMoveElementsRef,
400
+ onMoveElementRef,
401
+ updateElement,
402
+ publishDraggedClip,
403
+ updateDraggedClipPreviewRef,
404
+ stopClipDragAutoScrollRef,
405
+ syncClipDragAutoScrollRef,
406
+ applyResizePointerRef,
407
+ setRangeSelectionRef,
408
+ setShowPopover,
409
+ setResizingClipState,
410
+ setDraggedClipState,
411
+ trackOrderRef,
412
+ elementsRef,
413
+ gestureSelectedKeysRef,
414
+ suppressClickRef,
415
+ groupResizeRef,
416
+ blockedClipRef,
417
+ resizingClipRef,
418
+ draggedClipRef,
419
+ scrollRef,
420
+ cancelGestureRef,
421
+ sessionEpochRef,
422
+ lifecycleRef,
423
+ }),
424
+ );
425
+
426
+ useEffect(() => {
427
+ cancelGestureRef.current();
428
+ }, [sessionEpoch]);
386
429
 
387
430
  return {
388
431
  draggedClip,
@@ -391,7 +434,6 @@ export function useTimelineClipDrag({
391
434
  setResizingClip,
392
435
  blockedClipRef,
393
436
  suppressClickRef,
394
- syncClipDragAutoScroll,
395
437
  stopClipDragAutoScroll,
396
438
  };
397
439
  }