@hyperframes/studio 0.7.89 → 0.7.92

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 (86) hide show
  1. package/dist/assets/{hyperframes-player-DVTnBvSO.js → hyperframes-player-9qOa08Tv.js} +1 -1
  2. package/dist/assets/{index-Qvx7boWH.js → index-Bw4lNdtO.js} +1 -1
  3. package/dist/assets/{index-ulQEdWKo.js → index-CDTN2ZVa.js} +1 -1
  4. package/dist/assets/index-DIntkrQl.js +428 -0
  5. package/dist/index.d.ts +20 -16
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +6495 -5429
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioRightPanel.tsx +1 -1
  11. package/src/components/editor/GsapAnimationList.tsx +84 -0
  12. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  13. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  14. package/src/components/editor/PropertyPanel.tsx +1 -1
  15. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  16. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  17. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  18. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  19. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  20. package/src/components/editor/propertyPanelTypes.ts +1 -1
  21. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  22. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  23. package/src/components/renders/useRenderQueue.ts +16 -3
  24. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  25. package/src/contexts/DomEditContext.tsx +1 -1
  26. package/src/hooks/useDomEditSession.test.tsx +1 -1
  27. package/src/hooks/useDomEditSession.ts +1 -1
  28. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  29. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  30. package/src/player/components/BeatStrip.test.tsx +374 -0
  31. package/src/player/components/BeatStrip.tsx +295 -37
  32. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  33. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  34. package/src/player/components/Timeline.test.ts +2 -3
  35. package/src/player/components/Timeline.tsx +47 -48
  36. package/src/player/components/TimelineCanvas.tsx +14 -71
  37. package/src/player/components/TimelineClip.tsx +6 -3
  38. package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
  39. package/src/player/components/TimelineClipDiamonds.tsx +66 -211
  40. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  41. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  42. package/src/player/components/TimelineLanes.test.tsx +0 -1
  43. package/src/player/components/TimelineLanes.tsx +3 -2
  44. package/src/player/components/TimelineOverlays.test.ts +161 -0
  45. package/src/player/components/TimelineOverlays.tsx +104 -11
  46. package/src/player/components/timelineCallbacks.ts +1 -1
  47. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  48. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  49. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  50. package/src/player/components/timelineClipDragPreview.ts +18 -12
  51. package/src/player/components/timelineClipDragTypes.ts +12 -0
  52. package/src/player/components/timelineDiamondTypes.ts +5 -15
  53. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  54. package/src/player/components/timelineDragDrop.ts +152 -30
  55. package/src/player/components/timelineLaneProps.ts +0 -1
  56. package/src/player/components/timelineMarquee.test.ts +73 -25
  57. package/src/player/components/timelineMarquee.ts +44 -16
  58. package/src/player/components/timelineTestViewport.ts +13 -0
  59. package/src/player/components/timelineViewModel.ts +4 -4
  60. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  61. package/src/player/components/useTimelineClipDrag.ts +112 -70
  62. package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
  63. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  64. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  65. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  66. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  67. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  68. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  69. package/src/player/components/useTimelineRowVirtualization.ts +11 -4
  70. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  71. package/src/player/components/useTrackGapMenu.ts +32 -9
  72. package/src/player/store/keyframeSlice.ts +59 -18
  73. package/src/player/store/playerStore.test.ts +76 -0
  74. package/src/player/store/playerStore.ts +19 -11
  75. package/src/telemetry/canary.test.ts +325 -0
  76. package/src/telemetry/canary.ts +268 -0
  77. package/src/telemetry/client.test.ts +8 -3
  78. package/src/telemetry/client.ts +13 -43
  79. package/src/telemetry/config.ts +16 -2
  80. package/src/telemetry/distinctId.test.ts +35 -0
  81. package/src/telemetry/distinctId.ts +11 -2
  82. package/src/telemetry/policy.test.ts +81 -0
  83. package/src/telemetry/policy.ts +104 -0
  84. package/src/utils/studioTelemetry.test.ts +76 -0
  85. package/src/utils/studioTelemetry.ts +14 -6
  86. package/dist/assets/index-CddKwfQ1.js +0 -428
@@ -14,20 +14,44 @@ import {
14
14
  } from "./timelineOptimisticRevision";
15
15
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
16
16
  import type { StackingPatch } from "./timelineStackingSync";
17
- import { usePlayerStore, type TimelineElement } from "../store/playerStore";
17
+ import type { TimelineElement, usePlayerStore } from "../store/playerStore";
18
+
19
+ export type TimelineGestureKind = "drag" | "resize";
20
+ type TimelineGesturePhase = "active" | "committing" | "cancelled" | "complete";
21
+
22
+ export interface TimelineGestureLifecycle {
23
+ kind: TimelineGestureKind | null;
24
+ phase: TimelineGesturePhase;
25
+ pointerId: number | null;
26
+ sessionEpoch: number;
27
+ }
28
+
29
+ interface TimelineGestureCommit {
30
+ kind: TimelineGestureKind;
31
+ drag: DraggedClipState | null;
32
+ resize: ResizingClipState | null;
33
+ groupResize: TimelineGroupResizeSession | null;
34
+ }
18
35
 
19
36
  type UpdateElement = ReturnType<typeof usePlayerStore.getState>["updateElement"];
20
37
 
21
38
  interface TimelineClipDragGestureLifecycleInput {
39
+ lifecycleRef: RefObject<TimelineGestureLifecycle>;
40
+ sessionEpochRef: RefObject<number>;
41
+ cancelGestureRef: RefObject<
42
+ (options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
43
+ >;
44
+ scrollRef: RefObject<HTMLDivElement | null>;
22
45
  draggedClipRef: RefObject<DraggedClipState | null>;
23
46
  resizingClipRef: RefObject<ResizingClipState | null>;
24
47
  blockedClipRef: RefObject<BlockedClipState | null>;
25
48
  groupResizeRef: RefObject<TimelineGroupResizeSession | null>;
26
49
  suppressClickRef: RefObject<boolean>;
50
+ gestureSelectedKeysRef: RefObject<ReadonlySet<string>>;
27
51
  elementsRef: RefObject<TimelineElement[]>;
28
52
  trackOrderRef: RefObject<number[]>;
29
- setDraggedClip: Dispatch<SetStateAction<DraggedClipState | null>>;
30
- setResizingClip: Dispatch<SetStateAction<ResizingClipState | null>>;
53
+ setDraggedClipState: Dispatch<SetStateAction<DraggedClipState | null>>;
54
+ setResizingClipState: Dispatch<SetStateAction<ResizingClipState | null>>;
31
55
  setShowPopover: (show: boolean) => void;
32
56
  setRangeSelectionRef: RefObject<((selection: null) => void) | null>;
33
57
  applyResizePointerRef: RefObject<(resize: ResizingClipState, clientX: number) => void>;
@@ -36,7 +60,7 @@ interface TimelineClipDragGestureLifecycleInput {
36
60
  updateDraggedClipPreviewRef: RefObject<
37
61
  (drag: DraggedClipState, clientX: number, clientY: number) => DraggedClipState
38
62
  >;
39
- restoreGroupResizeMembers: (session: TimelineGroupResizeSession, all?: boolean) => void;
63
+ publishDraggedClip: (next: DraggedClipState | null) => void;
40
64
  updateElement: UpdateElement;
41
65
  onMoveElementRef: RefObject<TimelineEditCallbacks["onMoveElement"]>;
42
66
  onMoveElementsRef: RefObject<TimelineEditCallbacks["onMoveElements"]>;
@@ -55,22 +79,27 @@ interface TimelineClipDragGestureLifecycleInput {
55
79
  export function mountTimelineClipDragGestureLifecycle({
56
80
  // The explicit destructuring mirrors the single call site's dependency object by design.
57
81
  // fallow-ignore-next-line code-duplication
82
+ lifecycleRef,
83
+ sessionEpochRef,
84
+ cancelGestureRef,
85
+ scrollRef,
58
86
  draggedClipRef,
59
87
  resizingClipRef,
60
88
  blockedClipRef,
61
89
  groupResizeRef,
62
90
  suppressClickRef,
91
+ gestureSelectedKeysRef,
63
92
  elementsRef,
64
93
  trackOrderRef,
65
- setDraggedClip,
66
- setResizingClip,
94
+ setDraggedClipState,
95
+ setResizingClipState,
67
96
  setShowPopover,
68
97
  setRangeSelectionRef,
69
98
  applyResizePointerRef,
70
99
  syncClipDragAutoScrollRef,
71
100
  stopClipDragAutoScrollRef,
72
101
  updateDraggedClipPreviewRef,
73
- restoreGroupResizeMembers,
102
+ publishDraggedClip,
74
103
  updateElement,
75
104
  onMoveElementRef,
76
105
  onMoveElementsRef,
@@ -87,9 +116,65 @@ export function mountTimelineClipDragGestureLifecycle({
87
116
  });
88
117
  };
89
118
 
119
+ const pointerMatchesGesture = (event: PointerEvent): boolean => {
120
+ const pointerId = lifecycleRef.current.pointerId;
121
+ return pointerId === null || event.pointerId === pointerId;
122
+ };
123
+
124
+ const releasePointerCapture = (pointerId: number | null) => {
125
+ if (pointerId === null) return;
126
+ const scroll = scrollRef.current;
127
+ try {
128
+ if (scroll?.hasPointerCapture(pointerId)) scroll.releasePointerCapture(pointerId);
129
+ } catch {
130
+ // Window listeners are authoritative when native capture is unavailable.
131
+ }
132
+ };
133
+
134
+ const capturePointer = (pointerId: number | null) => {
135
+ if (pointerId === null) return;
136
+ try {
137
+ scrollRef.current?.setPointerCapture(pointerId);
138
+ } catch {
139
+ // Window listeners remain authoritative when native capture is unavailable.
140
+ }
141
+ };
142
+
143
+ const clearGestureProjection = (updateReact: boolean) => {
144
+ draggedClipRef.current = null;
145
+ resizingClipRef.current = null;
146
+ groupResizeRef.current = null;
147
+ if (updateReact) {
148
+ setDraggedClipState(null);
149
+ setResizingClipState(null);
150
+ }
151
+ };
152
+
153
+ const cancelGesture = ({
154
+ updateReact = true,
155
+ suppressClick = false,
156
+ }: {
157
+ updateReact?: boolean;
158
+ suppressClick?: boolean;
159
+ } = {}): boolean => {
160
+ const lifecycle = lifecycleRef.current;
161
+ if (lifecycle.phase !== "active") return false;
162
+ lifecycle.phase = "cancelled";
163
+ stopClipDragAutoScrollRef.current();
164
+ clearGestureProjection(updateReact);
165
+ releasePointerCapture(lifecycle.pointerId);
166
+ if (suppressClick) suppressClickRef.current = true;
167
+ lifecycle.kind = null;
168
+ lifecycle.pointerId = null;
169
+ lifecycle.phase = "complete";
170
+ return true;
171
+ };
172
+ cancelGestureRef.current = cancelGesture;
173
+
90
174
  const handleResizePointerMove = (event: PointerEvent, resize: ResizingClipState) => {
91
175
  const distance = Math.abs(event.clientX - resize.originClientX);
92
176
  if (!resize.started && distance < 2) return;
177
+ if (!resize.started) capturePointer(resize.pointerId);
93
178
  setShowPopover(false);
94
179
  setRangeSelectionRef.current?.(null);
95
180
  applyResizePointerRef.current(resize, event.clientX);
@@ -106,6 +191,7 @@ export function mountTimelineClipDragGestureLifecycle({
106
191
  if (!blocked.started) {
107
192
  blocked.started = true;
108
193
  blockedClipRef.current = blocked;
194
+ capturePointer(blocked.pointerId);
109
195
  suppressClickRef.current = true;
110
196
  setShowPopover(false);
111
197
  setRangeSelectionRef.current?.(null);
@@ -119,34 +205,33 @@ export function mountTimelineClipDragGestureLifecycle({
119
205
  event.clientY - drag.originClientY,
120
206
  );
121
207
  if (!drag.started && distance < 4) return;
208
+ if (!drag.started) capturePointer(drag.pointerId);
122
209
  setShowPopover(false);
123
210
  setRangeSelectionRef.current?.(null);
124
- setDraggedClip((previous) =>
125
- previous
126
- ? updateDraggedClipPreviewRef.current(previous, event.clientX, event.clientY)
127
- : previous,
128
- );
211
+ publishDraggedClip(updateDraggedClipPreviewRef.current(drag, event.clientX, event.clientY));
129
212
  syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
130
213
  };
131
214
 
132
215
  const handleWindowPointerMove = (event: PointerEvent) => {
133
216
  const resize = resizingClipRef.current;
134
- if (resize) return handleResizePointerMove(event, resize);
217
+ if (resize) {
218
+ if (!pointerMatchesGesture(event)) return;
219
+ return handleResizePointerMove(event, resize);
220
+ }
135
221
  const blocked = blockedClipRef.current;
136
- if (blocked) return handleBlockedPointerMove(event, blocked);
222
+ if (blocked) {
223
+ if (blocked.pointerId !== event.pointerId) return;
224
+ return handleBlockedPointerMove(event, blocked);
225
+ }
137
226
  const drag = draggedClipRef.current;
138
- if (drag) handleDragPointerMove(event, drag);
227
+ if (drag && pointerMatchesGesture(event)) handleDragPointerMove(event, drag);
139
228
  };
140
229
 
141
- const commitResizePointerUp = (resize: ResizingClipState) => {
142
- resizingClipRef.current = null;
143
- setResizingClip(null);
144
- const groupSession = groupResizeRef.current;
145
- groupResizeRef.current = null;
146
- if (!resize.started) {
147
- if (groupSession) restoreGroupResizeMembers(groupSession);
148
- return;
149
- }
230
+ const commitResizePointerUp = (
231
+ resize: ResizingClipState,
232
+ groupSession: TimelineGroupResizeSession | null,
233
+ ) => {
234
+ if (!resize.started) return;
150
235
  suppressClickRef.current = true;
151
236
  clearSuppressedClick();
152
237
  if (groupSession) {
@@ -193,8 +278,6 @@ export function mountTimelineClipDragGestureLifecycle({
193
278
  };
194
279
 
195
280
  const commitDragPointerUp = (drag: DraggedClipState) => {
196
- draggedClipRef.current = null;
197
- setDraggedClip(null);
198
281
  if (!drag.started) return;
199
282
  suppressClickRef.current = true;
200
283
  clearSuppressedClick();
@@ -204,25 +287,96 @@ export function mountTimelineClipDragGestureLifecycle({
204
287
  updateElement,
205
288
  onMoveElement: onMoveElementRef.current,
206
289
  onMoveElements: onMoveElementsRef.current,
207
- selectedKeys: usePlayerStore.getState().selectedElementIds,
290
+ selectedKeys: gestureSelectedKeysRef.current,
208
291
  readZIndex: readZIndexRef.current,
209
292
  onStackingPatches: onStackingPatchesRef.current,
210
293
  refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
211
294
  });
212
295
  };
213
296
 
214
- const handleWindowPointerUp = () => {
297
+ /** Group gestures commit atomically: one missing member cancels the whole resize. */
298
+ const gestureSourcesStillExist = (): boolean => {
299
+ const gestureElements = groupResizeRef.current?.members.map((member) => member.element) ?? [
300
+ resizingClipRef.current?.element ?? draggedClipRef.current?.element,
301
+ ];
302
+ const liveKeys = new Set(elementsRef.current.map((element) => element.key ?? element.id));
303
+ return gestureElements.every(
304
+ (element) => element === undefined || liveKeys.has(element.key ?? element.id),
305
+ );
306
+ };
307
+
308
+ const claimActiveGesture = (event: PointerEvent): TimelineGestureCommit | "ignored" | null => {
309
+ const lifecycle = lifecycleRef.current;
310
+ if (lifecycle.phase !== "active") return null;
311
+ if (!pointerMatchesGesture(event)) return "ignored";
312
+ if (lifecycle.sessionEpoch !== sessionEpochRef.current) {
313
+ cancelGesture();
314
+ return "ignored";
315
+ }
316
+ if (!gestureSourcesStillExist()) {
317
+ cancelGesture();
318
+ return "ignored";
319
+ }
320
+ lifecycle.phase = "committing";
321
+ const claimed = lifecycle.kind
322
+ ? {
323
+ kind: lifecycle.kind,
324
+ drag: draggedClipRef.current,
325
+ resize: resizingClipRef.current,
326
+ groupResize: groupResizeRef.current,
327
+ }
328
+ : "ignored";
215
329
  stopClipDragAutoScrollRef.current();
216
- const resize = resizingClipRef.current;
217
- if (resize) return commitResizePointerUp(resize);
218
- const blocked = blockedClipRef.current;
219
- if (blocked) return finishBlockedPointerUp(blocked);
220
- const drag = draggedClipRef.current;
221
- if (!drag) {
222
- if (suppressClickRef.current) clearSuppressedClick();
330
+ clearGestureProjection(true);
331
+ releasePointerCapture(lifecycle.pointerId);
332
+ if (claimed === "ignored") lifecycle.phase = "complete";
333
+ return claimed;
334
+ };
335
+
336
+ const commitClaimedGesture = (gesture: TimelineGestureCommit) => {
337
+ try {
338
+ if (gesture.kind === "resize" && gesture.resize) {
339
+ commitResizePointerUp(gesture.resize, gesture.groupResize);
340
+ } else if (gesture.kind === "drag" && gesture.drag) {
341
+ commitDragPointerUp(gesture.drag);
342
+ }
343
+ } finally {
344
+ const lifecycle = lifecycleRef.current;
345
+ lifecycle.kind = null;
346
+ lifecycle.pointerId = null;
347
+ lifecycle.phase = "complete";
348
+ }
349
+ };
350
+
351
+ const handleWindowPointerUp = (event: PointerEvent) => {
352
+ const claimed = claimActiveGesture(event);
353
+ if (claimed === "ignored") return;
354
+ if (claimed) {
355
+ commitClaimedGesture(claimed);
223
356
  return;
224
357
  }
225
- commitDragPointerUp(drag);
358
+ const blocked = blockedClipRef.current;
359
+ if (blocked) {
360
+ if (blocked.pointerId !== event.pointerId) return;
361
+ return finishBlockedPointerUp(blocked);
362
+ }
363
+ if (suppressClickRef.current) clearSuppressedClick();
364
+ };
365
+
366
+ const handleWindowPointerCancel = (event: PointerEvent) => {
367
+ if (lifecycleRef.current.phase === "active" && pointerMatchesGesture(event)) {
368
+ if (cancelGesture({ suppressClick: true })) clearSuppressedClick();
369
+ }
370
+ const blocked = blockedClipRef.current;
371
+ if (blocked && blocked.pointerId !== event.pointerId) return;
372
+ blockedClipRef.current = null;
373
+ if (suppressClickRef.current) clearSuppressedClick();
374
+ };
375
+
376
+ const handleLostPointerCapture = (event: PointerEvent) => {
377
+ if (lifecycleRef.current.phase === "active" && pointerMatchesGesture(event)) {
378
+ if (cancelGesture({ suppressClick: true })) clearSuppressedClick();
379
+ }
226
380
  };
227
381
 
228
382
  const handleWindowKeyDown = (event: KeyboardEvent) => {
@@ -234,28 +388,22 @@ export function mountTimelineClipDragGestureLifecycle({
234
388
  });
235
389
  if (!decision.cancel) return;
236
390
  event.preventDefault();
237
- event.stopPropagation();
238
- stopClipDragAutoScrollRef.current();
239
- draggedClipRef.current = null;
240
- setDraggedClip(null);
241
- resizingClipRef.current = null;
242
- setResizingClip(null);
243
- const groupSession = groupResizeRef.current;
244
- groupResizeRef.current = null;
245
- if (groupSession) restoreGroupResizeMembers(groupSession);
246
391
  blockedClipRef.current = null;
247
- if (decision.suppressClick) suppressClickRef.current = true;
392
+ cancelGesture({ suppressClick: decision.suppressClick });
248
393
  };
249
394
 
250
395
  window.addEventListener("pointermove", handleWindowPointerMove);
251
396
  window.addEventListener("pointerup", handleWindowPointerUp);
252
- window.addEventListener("pointercancel", handleWindowPointerUp);
253
- window.addEventListener("keydown", handleWindowKeyDown, true);
397
+ window.addEventListener("pointercancel", handleWindowPointerCancel);
398
+ window.addEventListener("lostpointercapture", handleLostPointerCapture);
399
+ window.addEventListener("keydown", handleWindowKeyDown);
254
400
  return () => {
255
- stopClipDragAutoScrollRef.current();
401
+ cancelGesture({ updateReact: false });
402
+ cancelGestureRef.current = () => false;
256
403
  window.removeEventListener("pointermove", handleWindowPointerMove);
257
404
  window.removeEventListener("pointerup", handleWindowPointerUp);
258
- window.removeEventListener("pointercancel", handleWindowPointerUp);
259
- window.removeEventListener("keydown", handleWindowKeyDown, true);
405
+ window.removeEventListener("pointercancel", handleWindowPointerCancel);
406
+ window.removeEventListener("lostpointercapture", handleLostPointerCapture);
407
+ window.removeEventListener("keydown", handleWindowKeyDown);
260
408
  };
261
409
  }
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
3
3
  import {
4
4
  computeDragPreview,
5
5
  computeResizePreview,
6
+ getTimelineDragOverlayPosition,
6
7
  type DragPreviewContext,
7
8
  } from "./timelineClipDragPreview";
8
9
  import type { DraggedClipState } from "./timelineClipDragTypes";
@@ -84,6 +85,7 @@ function horizontalDrag(
84
85
  const originClientX = 800;
85
86
  const originClientY = yForRow(grabRowFloat);
86
87
  const drag: DraggedClipState = {
88
+ pointerId: 0,
87
89
  element,
88
90
  originClientX,
89
91
  originClientY,
@@ -129,6 +131,7 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
129
131
  const originClientX = 800;
130
132
  const originClientY = yForRow(0.5);
131
133
  const drag: DraggedClipState = {
134
+ pointerId: 0,
132
135
  element: moodboard,
133
136
  originClientX,
134
137
  originClientY,
@@ -170,6 +173,7 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
170
173
  const occupied = [dragged, clip("block-0", 0, 0, 1, 2), clip("block-1", 1, 0, 1, 1)];
171
174
  const clientY = RULER_H + TRACKS_TOP_PAD + 30;
172
175
  const drag: DraggedClipState = {
176
+ pointerId: 0,
173
177
  element: dragged,
174
178
  originClientX: 0,
175
179
  originClientY: clientY,
@@ -221,3 +225,32 @@ describe("computeResizePreview — composition source continuity", () => {
221
225
  });
222
226
  });
223
227
  });
228
+
229
+ describe("getTimelineDragOverlayPosition", () => {
230
+ it("keeps the gesture actor under the pointer across two-axis autoscroll", () => {
231
+ const { drag } = horizontalDrag(moodboard, 0.5, 2);
232
+ const scroll = {
233
+ scrollLeft: 500,
234
+ scrollTop: 300,
235
+ getBoundingClientRect: () => ({ left: 20, top: 40 }),
236
+ } as Pick<HTMLDivElement, "scrollLeft" | "scrollTop" | "getBoundingClientRect">;
237
+ expect(
238
+ getTimelineDragOverlayPosition(
239
+ {
240
+ ...drag,
241
+ pointerClientX: 900,
242
+ pointerClientY: 700,
243
+ pointerOffsetX: 25,
244
+ pointerOffsetY: 10,
245
+ },
246
+ scroll,
247
+ ),
248
+ ).toEqual({ left: 1_355, top: 950 });
249
+ });
250
+
251
+ it("does not mount an actor before threshold or without the stable viewport", () => {
252
+ const { drag } = horizontalDrag(moodboard, 0.5, 2);
253
+ expect(getTimelineDragOverlayPosition({ ...drag, started: false }, fakeScroll())).toBeNull();
254
+ expect(getTimelineDragOverlayPosition(drag, null)).toBeNull();
255
+ });
256
+ });
@@ -45,6 +45,19 @@ export interface DragPreviewContext {
45
45
  audioTracks?: ReadonlySet<number>;
46
46
  }
47
47
 
48
+ /** Content-space position for the stable viewport drag actor. */
49
+ export function getTimelineDragOverlayPosition(
50
+ drag: DraggedClipState,
51
+ scroll: Pick<HTMLDivElement, "scrollLeft" | "scrollTop" | "getBoundingClientRect"> | null,
52
+ ): { left: number; top: number } | null {
53
+ if (!drag.started || !scroll) return null;
54
+ const rect = scroll.getBoundingClientRect();
55
+ return {
56
+ left: drag.pointerClientX - rect.left + scroll.scrollLeft - drag.pointerOffsetX,
57
+ top: drag.pointerClientY - rect.top + scroll.scrollTop - drag.pointerOffsetY,
58
+ };
59
+ }
60
+
48
61
  /**
49
62
  * Max start a drag may reach. Allow dragging past the current content into the
50
63
  * rendered timeline extent (the viewport-fill keeps that ≥ the viewport width).
@@ -328,29 +341,22 @@ export function computeResizePreview(
328
341
 
329
342
  /**
330
343
  * Apply a rigid group-resize preview: fold the grabbed clip's raw delta into the
331
- * session, preview every non-grabbed member through the store (`updateElement`),
332
- * and set the grabbed clip's preview state (it renders from resizingClip state, so
333
- * its store value stays pristine until commit — like the single-clip path).
344
+ * session and publish a coordinator-owned projection. Canonical elements stay
345
+ * pristine until the exactly-once commit.
334
346
  */
335
347
  export function previewGroupResize(
336
348
  session: TimelineGroupResizeSession,
337
349
  next: ResizePreviewResult,
338
- grabbedKey: string,
339
- updateElement: (
340
- key: string,
341
- patch: { start: number; duration: number; playbackStart?: number },
350
+ setResizeState: (
351
+ v: ResizePreviewResult & { groupPreview: TimelineGroupResizeSession["changes"] },
342
352
  ) => void,
343
- setResizeState: (v: ResizePreviewResult) => void,
344
353
  ): void {
345
354
  const grabbedChange = applyTimelineGroupResizePreview(session, next);
346
- for (const c of session.changes) {
347
- if (c.key === grabbedKey) continue;
348
- updateElement(c.key, { start: c.start, duration: c.duration, playbackStart: c.playbackStart });
349
- }
350
355
  setResizeState({
351
356
  originScrollLeft: next.originScrollLeft,
352
357
  previewStart: grabbedChange?.start ?? next.previewStart,
353
358
  previewDuration: grabbedChange?.duration ?? next.previewDuration,
354
359
  previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart,
360
+ groupPreview: session.changes,
355
361
  });
356
362
  }
@@ -4,6 +4,8 @@ import type { BlockedTimelineEditIntent } from "./timelineEditing";
4
4
 
5
5
  /* ── Shared clip-drag state types ───────────────────────────────── */
6
6
  export interface DraggedClipState {
7
+ /** Pointer captured by the stable viewport coordinator for this gesture. */
8
+ pointerId: number;
7
9
  element: TimelineElement;
8
10
  originClientX: number;
9
11
  originClientY: number;
@@ -39,6 +41,8 @@ export interface DraggedClipState {
39
41
  }
40
42
 
41
43
  export interface ResizingClipState {
44
+ /** Pointer captured by the stable viewport coordinator for this gesture. */
45
+ pointerId: number;
42
46
  element: TimelineElement;
43
47
  edge: "start" | "end";
44
48
  originClientX: number;
@@ -53,10 +57,18 @@ export interface ResizingClipState {
53
57
  previewStart: number;
54
58
  previewDuration: number;
55
59
  previewPlaybackStart?: number;
60
+ /** Coordinator-owned group projection; canonical elements change only on commit. */
61
+ groupPreview?: readonly {
62
+ key: string;
63
+ start: number;
64
+ duration: number;
65
+ playbackStart?: number;
66
+ }[];
56
67
  started: boolean;
57
68
  }
58
69
 
59
70
  export interface BlockedClipState {
71
+ pointerId: number;
60
72
  element: TimelineElement;
61
73
  intent: BlockedTimelineEditIntent;
62
74
  originClientX: number;
@@ -93,20 +93,6 @@ export const DIAMOND_RATIO = 0.8;
93
93
  export const KF_MIN_PCT = -5;
94
94
  export const KF_MAX_PCT = 105;
95
95
 
96
- export type DragState = {
97
- kfKey: string;
98
- startX: number;
99
- fromClipPct: number;
100
- moved: boolean;
101
- /** Latest pointer x, flushed to the preview once per frame. */
102
- lastX: number;
103
- /** Index in the sorted row, needed by the neighbour clamp off the render path. */
104
- index: number;
105
- /** Escape was pressed: the drag is dead, and the pointerup that follows is
106
- * swallowed rather than falling through to the click branch. */
107
- cancelled?: boolean;
108
- };
109
-
110
96
  /**
111
97
  * The full identity of a diamond, used by every callback and by the selection
112
98
  * key. Collapsed clip rows and expanded property lanes read the same cache, so
@@ -115,7 +101,11 @@ export type DragState = {
115
101
  * the other, and would strip the animation id the retime/delete mutations use to
116
102
  * pick between two animations that collide at one percentage.
117
103
  */
118
- export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyframeTarget {
104
+ export function keyframeTarget(
105
+ // The identity fields only, so callers holding a narrower keyframe row (the
106
+ // retime coordinator's) build the same key instead of re-listing the shape.
107
+ keyframe: Omit<TimelineDiamondKeyframe, "properties">,
108
+ ): TimelineKeyframeTarget {
119
109
  return {
120
110
  percentage: keyframe.percentage,
121
111
  tweenPercentage: keyframe.tweenPercentage,