@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
@@ -1,21 +1,20 @@
1
1
  import { memo, useEffect, useRef, useState } from "react";
2
2
  import { BEAT_BAND_H } from "./BeatStrip";
3
- import {
4
- KEYFRAME_DRAG_THRESHOLD_PX,
5
- previewClipPct,
6
- resolveKeyframeDrag,
7
- } from "../../components/editor/keyframeDrag";
8
3
  import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
9
- import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
10
4
  import { LANE_H } from "./timelineLayout";
11
5
  import { STUDIO_PREVIEW_FPS } from "../lib/time";
12
6
  import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
7
+ import {
8
+ beginTimelineKeyframeRetime,
9
+ readPendingTimelineKeyframeRetimes,
10
+ subscribeTimelineKeyframeRetimePreview,
11
+ type TimelineKeyframeRetimeHandle,
12
+ } from "./useTimelineKeyframeHandlers";
13
13
  import {
14
14
  DIAMOND_RATIO,
15
15
  KF_MAX_PCT,
16
16
  KF_MIN_PCT,
17
17
  keyframeTarget,
18
- type DragState,
19
18
  type TimelineClipDiamondsProps,
20
19
  type TimelineDiamondKeyframe,
21
20
  type TimelineDiamondLaneProps,
@@ -86,65 +85,29 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
86
85
  globalEase = "none",
87
86
  }: TimelineDiamondLaneProps) {
88
87
  // Hooks must run before the early return below.
89
- const dragRef = useRef<DragState | null>(null);
90
- // Pending retime destination (clip + tween %) per keyframe key, so a rapid
91
- // second drag composes from where the first move left the keyframe (whose
92
- // cache entry has not rebuilt yet) instead of the stale rendered value.
93
- const pendingRetimeRef = useRef<Map<string, { clipPct: number; tweenPct: number }> | null>(null);
94
- // Lazy: `useRef(new Map())` allocates a Map on every render and throws all but
95
- // the first away, once per mounted lane.
96
- pendingRetimeRef.current ??= new Map();
97
- const pendingRetimes = pendingRetimeRef.current;
98
- // The most recent retime dispatched from this lane, whichever diamond it came
99
- // from. Selection is lane-wide, so "is my revert still relevant" is a lane-wide
100
- // question, not a per-keyframe one.
101
- const latestRetimeRef = useRef<{ clipPct: number; tweenPct: number } | null>(null);
102
- useEffect(() => {
103
- // Clear a pending entry once the authoritative cache reflects THAT keyframe
104
- // at ~its destination. Match by tolerance, not equality: cache writers round
105
- // clip %s, so an exact check would leak an entry after every successful
106
- // retime. Match by identity too: a bare "some keyframe is near that %" test
107
- // cleared the entry whenever an unrelated sibling happened to sit there,
108
- // which is easy to hit on an evenly spaced row.
109
- const pendingEntries = pendingRetimeRef.current;
110
- if (!pendingEntries) return;
111
- for (const [key, pending] of pendingEntries) {
112
- const settled = keyframesData.keyframes.some(
113
- (k) =>
114
- timelineKeyframeSelectionKey(elementId, keyframeTarget(k)) === key &&
115
- Math.abs(k.percentage - pending.clipPct) < 0.2,
116
- );
117
- if (settled) pendingEntries.delete(key);
118
- }
119
- }, [keyframesData.keyframes, elementId]);
88
+ // The retime itself lives on the stable scroll viewport (beginTimelineKeyframeRetime),
89
+ // so a row unmounted by virtualization mid-drag does not drop the gesture.
90
+ // This lane only arms it and renders the preview it publishes.
91
+ const rootRef = useRef<HTMLDivElement>(null);
92
+ const retimeHandleRef = useRef<TimelineKeyframeRetimeHandle | null>(null);
93
+ // Retime destinations already dispatched but not yet in the keyframe cache, so
94
+ // a rapid second drag composes from where the first move left the keyframe
95
+ // instead of the stale rendered value.
96
+ const pendingRetimes = readPendingTimelineKeyframeRetimes(rootRef.current);
120
97
  // Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
121
98
  // (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
122
99
  // on drop re-keys the diamond from source.
123
100
  const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
124
- // One preview render per frame: a 120Hz trackpad fires pointermove far faster
125
- // than the lane can repaint, and every diamond in the row re-evaluates its
126
- // memo on each of those renders.
127
- const previewFrameRef = useRef<number | null>(null);
128
- const cancelPreviewFrame = () => {
129
- if (previewFrameRef.current === null) return;
130
- cancelAnimationFrame(previewFrameRef.current);
131
- previewFrameRef.current = null;
132
- };
133
- // Escape backs out of an in-flight retime, the way clip and element drags
134
- // already do. Nothing was written yet (the commit happens on pointerup), so
135
- // dropping the preview is the whole undo.
136
101
  useEffect(() => {
137
- const onKeyDown = (event: KeyboardEvent) => {
138
- if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return;
139
- dragRef.current.cancelled = true;
140
- cancelPreviewFrame();
141
- setPreview(null);
142
- };
143
- document.addEventListener("keydown", onKeyDown);
144
- return () => {
145
- document.removeEventListener("keydown", onKeyDown);
146
- cancelPreviewFrame();
147
- };
102
+ const source = rootRef.current;
103
+ if (!source) return;
104
+ return subscribeTimelineKeyframeRetimePreview(source, (nextPreview) => {
105
+ setPreview(
106
+ nextPreview === null
107
+ ? null
108
+ : { kfKey: nextPreview.keyframeKey, clipPct: nextPreview.clipPercentage },
109
+ );
110
+ });
148
111
  }, []);
149
112
  // The button element can re-render (reposition/unmount) synchronously from
150
113
  // the state updates onClickKeyframe/onMoveKeyframe trigger, before the
@@ -197,7 +160,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
197
160
  // O(keyframes squared) allocations on every playhead tick.
198
161
  const pendingClipPctOf = (keyframe: TimelineDiamondKeyframe) =>
199
162
  pendingRetimes.get(timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)))
200
- ?.clipPct ?? keyframe.percentage;
163
+ ?.clipPercentage ?? keyframe.percentage;
201
164
  const siblingRows = new Map<
202
165
  string | undefined,
203
166
  { keyframes: TimelineDiamondKeyframe[]; clipPcts: number[] }
@@ -247,6 +210,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
247
210
 
248
211
  return (
249
212
  <div
213
+ ref={rootRef}
250
214
  className="absolute inset-0"
251
215
  style={{
252
216
  // Above the clip's trim-handle strips (TimelineClip.tsx, z-index 4) so
@@ -294,150 +258,43 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
294
258
  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
295
259
  if (e.button !== 0) return;
296
260
  e.stopPropagation();
297
- if (canDrag) {
298
- e.currentTarget.setPointerCapture?.(e.pointerId);
299
- dragRef.current = {
300
- kfKey,
301
- startX: e.clientX,
302
- lastX: e.clientX,
303
- index: siblingIndex,
304
- fromClipPct: pendingRetimes.get(kfKey)?.clipPct ?? kf.percentage,
305
- moved: false,
306
- };
307
- }
308
- };
309
- const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
310
- const d = dragRef.current;
311
- if (!d || d.kfKey !== kfKey || d.cancelled) return;
312
- d.lastX = e.clientX;
313
- if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
314
- d.moved = true;
315
- }
316
- if (!d.moved || previewFrameRef.current !== null) return;
317
- previewFrameRef.current = requestAnimationFrame(() => {
318
- previewFrameRef.current = null;
319
- const live = dragRef.current;
320
- if (!live || live.kfKey !== kfKey || live.cancelled) return;
321
- setPreview({
322
- kfKey,
323
- clipPct: previewClipPct({
324
- pointerDownX: live.startX,
325
- pointerMoveX: live.lastX,
326
- clipWidthPx,
327
- draggedClipPct: live.fromClipPct,
328
- draggedIndex: live.index,
329
- sortedClipPcts: siblingClipPcts,
330
- }),
331
- });
261
+ if (!canDrag) return;
262
+ retimeHandleRef.current = beginTimelineKeyframeRetime({
263
+ event: e,
264
+ elementId,
265
+ keyframeKey: kfKey,
266
+ target,
267
+ keyframes: keyframesData.keyframes,
268
+ clipWidthPx,
269
+ // Clamp against this keyframe's own tween, not the whole merged row:
270
+ // a merged row interleaves several animations, and two colliding at
271
+ // one percentage would otherwise pin each other's diamonds in place.
272
+ draggedIndex: siblingIndex,
273
+ sortedClipPercentages: siblingClipPcts,
274
+ keyframeKeyOf: (keyframe) =>
275
+ timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)),
276
+ onMove: (fromTarget, toClipPercentage) =>
277
+ onMoveKeyframe?.(fromTarget, toClipPercentage) ?? Promise.resolve(false),
278
+ onSelect: (nextTarget, additive) => {
279
+ if (additive) onShiftClickKeyframe?.(nextTarget);
280
+ else onClickKeyframe?.(nextTarget);
281
+ },
282
+ suppressNextClick,
332
283
  });
333
284
  };
334
285
  const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
335
- const d = dragRef.current;
336
- if (d?.kfKey === kfKey && d.cancelled) {
337
- // Escape already ended this drag; the release is not a click.
338
- dragRef.current = null;
339
- e.currentTarget.releasePointerCapture?.(e.pointerId);
340
- suppressNextClick();
341
- return;
342
- }
343
- // No drag armed (canDrag false / non-primary press) → treat as a click.
344
- if (!d || d.kfKey !== kfKey) {
345
- if (e.button !== 0) return;
346
- suppressNextClick();
347
- if (e.shiftKey) onShiftClickKeyframe?.(target);
348
- else onClickKeyframe?.(target);
286
+ // The viewport coordinator owns an armed retime; this local path is
287
+ // only for diamonds that cannot be dragged.
288
+ if (canDrag) {
289
+ retimeHandleRef.current?.commit(e);
290
+ retimeHandleRef.current = null;
291
+ e.stopPropagation();
349
292
  return;
350
293
  }
351
- e.stopPropagation();
352
- dragRef.current = null;
353
- cancelPreviewFrame();
354
- setPreview(null);
355
- e.currentTarget.releasePointerCapture?.(e.pointerId);
294
+ if (e.button !== 0) return;
356
295
  suppressNextClick();
357
- // Single-diamond retime by design: a multi-select drag would have to
358
- // move every selected keyframe as one mutation, which the script ops
359
- // do not express yet. Selecting several and dragging one moves only
360
- // the dragged one.
361
- const res = resolveKeyframeDrag({
362
- pointerDownX: d.startX,
363
- pointerUpX: e.clientX,
364
- clipWidthPx,
365
- draggedClipPct: d.fromClipPct,
366
- draggedIndex: siblingIndex,
367
- sortedClipPcts: siblingClipPcts,
368
- });
369
- if (res.kind === "click" || res.kind === "noop") {
370
- // "noop" is a press with enough pointer jitter to arm a drag (canDrag
371
- // is on for every diamond once the clip is selected) that resolved
372
- // back onto ~the same position — no real retime, so treat it as the
373
- // click it was. Otherwise a normal click with a few px of mouse/
374
- // trackpad drift silently does nothing: no selection, no move.
375
- if (e.shiftKey) onShiftClickKeyframe?.(target);
376
- else onClickKeyframe?.(target);
377
- } else if (res.kind === "move" && res.toClipPct != null) {
378
- const animKfs =
379
- target.animationId === undefined
380
- ? keyframesData.keyframes
381
- : keyframesData.keyframes.filter((k) => k.animationId === target.animationId);
382
- // Clamp to the mapped tween range: clipToTweenPercentage extrapolates
383
- // linearly, so a boundary drag past the range would otherwise reselect
384
- // an out-of-range tween % (e.g. 150%) even though the mutation clamps
385
- // the moved endpoint back to the boundary.
386
- const tweenPcts = animKfs
387
- .map((k) => k.tweenPercentage)
388
- .filter((v): v is number => typeof v === "number");
389
- const clampTween = (v: number) =>
390
- tweenPcts.length
391
- ? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
392
- : v;
393
- const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));
394
- // For a rapid second retime the diamond still renders the stale cache
395
- // position, so identify the FROM keyframe by the pending (already-moved)
396
- // position; the mutation locates the source keyframe by this identity.
397
- const pendingBefore = pendingRetimes.get(kfKey);
398
- const fromTarget = pendingBefore
399
- ? {
400
- ...target,
401
- percentage: pendingBefore.clipPct,
402
- tweenPercentage: pendingBefore.tweenPct,
403
- }
404
- : target;
405
- const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
406
- pendingRetimes.set(kfKey, pending);
407
- latestRetimeRef.current = pending;
408
- const clearPending = () => {
409
- if (pendingRetimes.get(kfKey) === pending) {
410
- pendingRetimes.delete(kfKey);
411
- }
412
- };
413
- // A rejected drop (the destination time is already occupied) snaps
414
- // the diamond back to its source position, so the pending entry AND
415
- // the selection have to revert with it — parking on the ghost drop
416
- // position strands the playhead + selection on a keyframe that does
417
- // not exist there.
418
- const revertRetime = () => {
419
- // Only the newest gesture owns the selection. A rejected first drag
420
- // whose commit settles after a second one started would otherwise
421
- // park the selection back on ITS source keyframe, undoing a retime
422
- // the user has already made and moving the playhead with it.
423
- const isLatest = latestRetimeRef.current === pending;
424
- clearPending();
425
- if (isLatest) onClickKeyframe?.(fromTarget);
426
- };
427
- void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
428
- if (!committed) revertRetime();
429
- }, revertRetime);
430
- // A retime still targeted this exact diamond — park/select it at its
431
- // new position, same as a plain click, or a drag that actually moved
432
- // something looks identical to one that silently did nothing. Done
433
- // optimistically so the gesture stays responsive; revertRetime puts
434
- // it back if the move is rejected.
435
- onClickKeyframe?.({
436
- ...target,
437
- percentage: res.toClipPct,
438
- tweenPercentage: newTweenPct,
439
- });
440
- }
296
+ if (e.shiftKey) onShiftClickKeyframe?.(target);
297
+ else onClickKeyframe?.(target);
441
298
  };
442
299
 
443
300
  return (
@@ -476,18 +333,16 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
476
333
  overflow: "visible",
477
334
  }}
478
335
  onPointerDown={onPointerDown}
479
- onPointerMove={onPointerMove}
336
+ onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
480
337
  onPointerUp={onPointerUp}
481
- onPointerCancel={(e) => {
482
- // Browser/OS cancellation (or lost capture) ends the drag without a
483
- // pointerup, so clear the armed drag and preview or a ghost diamond
484
- // stays stuck at the last previewed position.
485
- if (dragRef.current?.kfKey !== kfKey) return;
486
- dragRef.current = null;
487
- cancelPreviewFrame();
488
- setPreview(null);
489
- e.currentTarget.releasePointerCapture?.(e.pointerId);
490
- }}
338
+ onPointerCancel={
339
+ canDrag
340
+ ? (e) => {
341
+ retimeHandleRef.current?.cancel(e);
342
+ retimeHandleRef.current = null;
343
+ }
344
+ : undefined
345
+ }
491
346
  onContextMenu={(e) => {
492
347
  e.preventDefault();
493
348
  e.stopPropagation();
@@ -0,0 +1,90 @@
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 { defaultTimelineTheme } from "./timelineTheme";
7
+ import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
8
+ import type { DraggedClipState } from "./timelineClipDragTypes";
9
+ import { getTrackStyle } from "./useTimelineTrackLayout";
10
+
11
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
12
+
13
+ const drag: DraggedClipState = {
14
+ pointerId: 0,
15
+ element: { id: "hero", tag: "div", start: 2, duration: 3, track: 1 },
16
+ originClientX: 100,
17
+ originClientY: 100,
18
+ originScrollLeft: 0,
19
+ originScrollTop: 0,
20
+ pointerClientX: 350,
21
+ pointerClientY: 240,
22
+ pointerOffsetX: 20,
23
+ pointerOffsetY: 10,
24
+ previewStart: 4,
25
+ previewTrack: 2,
26
+ insertRow: null,
27
+ snapTime: null,
28
+ snapType: null,
29
+ started: true,
30
+ };
31
+
32
+ afterEach(() => document.body.replaceChildren());
33
+
34
+ describe("TimelineGestureOverlay", () => {
35
+ it("keeps the drag actor mounted without a source-row node", () => {
36
+ const host = document.createElement("div");
37
+ document.body.append(host);
38
+ const root = createRoot(host);
39
+ const scroll = {
40
+ scrollLeft: 500,
41
+ scrollTop: 300,
42
+ getBoundingClientRect: () => ({ left: 50, top: 40 }),
43
+ } as HTMLDivElement;
44
+ act(() => {
45
+ root.render(
46
+ <TimelineGestureOverlay
47
+ drag={drag}
48
+ scrollRef={{ current: scroll }}
49
+ pixelsPerSecond={100}
50
+ rowHeight={42}
51
+ selectedElementId="hero"
52
+ currentTime={4}
53
+ theme={defaultTimelineTheme}
54
+ getTrackStyle={getTrackStyle}
55
+ />,
56
+ );
57
+ });
58
+ const actor = host.querySelector<HTMLElement>('[data-timeline-gesture-actor="hero"]');
59
+ expect(actor?.style.left).toBe("780px");
60
+ expect(actor?.style.top).toBe("490px");
61
+ expect(actor?.querySelector(".timeline-clip")).not.toBeNull();
62
+ expect(actor?.querySelector("[data-el-id]")).toBeNull();
63
+ expect(actor?.querySelector("[data-clip]")).toBeNull();
64
+ expect(host.querySelector("[data-source-row]")).toBeNull();
65
+ act(() => root.unmount());
66
+ });
67
+
68
+ it("keeps the stable overlay host after terminal cleanup", () => {
69
+ const host = document.createElement("div");
70
+ document.body.append(host);
71
+ const root = createRoot(host);
72
+ act(() => {
73
+ root.render(
74
+ <TimelineGestureOverlay
75
+ drag={null}
76
+ scrollRef={{ current: null }}
77
+ pixelsPerSecond={100}
78
+ rowHeight={42}
79
+ selectedElementId={null}
80
+ currentTime={0}
81
+ theme={defaultTimelineTheme}
82
+ getTrackStyle={getTrackStyle}
83
+ />,
84
+ );
85
+ });
86
+ expect(host.querySelector("[data-timeline-gesture-overlay]")).not.toBeNull();
87
+ expect(host.querySelector("[data-timeline-gesture-actor]")).toBeNull();
88
+ act(() => root.unmount());
89
+ });
90
+ });
@@ -0,0 +1,96 @@
1
+ import { memo, type ReactNode } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import type { TimelineTheme } from "./timelineTheme";
4
+ import { getRenderedTimelineElement } from "./timelineTheme";
5
+ import { TimelineClip } from "./TimelineClip";
6
+ import { getTimelineEditCapabilities } from "./timelineEditing";
7
+ import { renderClipChildren } from "./timelineClipChildren";
8
+ import { getTimelineDragOverlayPosition } from "./timelineClipDragPreview";
9
+ import type { DraggedClipState } from "./timelineClipDragTypes";
10
+ import type { TrackVisualStyle } from "./timelineIcons";
11
+ import { isTimelineClipActive } from "./useTimelineActiveClips";
12
+
13
+ interface TimelineGestureOverlayProps {
14
+ drag: DraggedClipState | null;
15
+ scrollRef: React.RefObject<HTMLDivElement | null>;
16
+ pixelsPerSecond: number;
17
+ rowHeight: number;
18
+ selectedElementId: string | null;
19
+ currentTime: number;
20
+ theme: TimelineTheme;
21
+ getTrackStyle: (tag: string) => TrackVisualStyle;
22
+ renderClipContent?: (
23
+ element: TimelineElement,
24
+ style: { clip: string; label: string },
25
+ ) => ReactNode;
26
+ renderClipOverlay?: (element: TimelineElement) => ReactNode;
27
+ }
28
+
29
+ /** Stable canvas child that owns the live drag actor independently of source rows. */
30
+ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
31
+ drag,
32
+ scrollRef,
33
+ pixelsPerSecond,
34
+ rowHeight,
35
+ selectedElementId,
36
+ currentTime,
37
+ theme,
38
+ getTrackStyle,
39
+ renderClipContent,
40
+ renderClipOverlay,
41
+ }: TimelineGestureOverlayProps) {
42
+ const element =
43
+ drag?.started === true
44
+ ? getRenderedTimelineElement({
45
+ element: drag.element,
46
+ draggedElementId: drag.element.key ?? drag.element.id,
47
+ previewStart: drag.previewStart,
48
+ previewTrack: drag.previewTrack,
49
+ })
50
+ : null;
51
+ const position = drag ? getTimelineDragOverlayPosition(drag, scrollRef.current) : null;
52
+ return (
53
+ <div data-timeline-gesture-overlay className="absolute inset-0 pointer-events-none">
54
+ {element && position && (
55
+ <div
56
+ data-timeline-gesture-actor={element.key ?? element.id}
57
+ className="absolute"
58
+ style={{
59
+ top: position.top,
60
+ left: position.left,
61
+ width: Math.max(element.duration * pixelsPerSecond, 4),
62
+ height: rowHeight,
63
+ zIndex: 40,
64
+ }}
65
+ >
66
+ <TimelineClip
67
+ el={{ ...element, start: 0 }}
68
+ pps={pixelsPerSecond}
69
+ clipY={0}
70
+ isSelected={selectedElementId === (element.key ?? element.id)}
71
+ isHovered={false}
72
+ isDragging
73
+ isGestureActor
74
+ isActive={isTimelineClipActive(element, currentTime)}
75
+ hasCustomContent={!!renderClipContent}
76
+ capabilities={getTimelineEditCapabilities(element)}
77
+ theme={theme}
78
+ isComposition={!!element.compositionSrc}
79
+ onHoverStart={() => {}}
80
+ onHoverEnd={() => {}}
81
+ onResizeStart={() => {}}
82
+ onClick={() => {}}
83
+ onDoubleClick={() => {}}
84
+ >
85
+ {renderClipChildren(
86
+ element,
87
+ getTrackStyle(element.tag),
88
+ renderClipContent,
89
+ renderClipOverlay,
90
+ )}
91
+ </TimelineClip>
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ });
@@ -116,7 +116,6 @@ function renderLanes(options: RenderLanesOptions = {}): {
116
116
  setResizingClip={vi.fn()}
117
117
  setDraggedClip={vi.fn()}
118
118
  setSelectedElementId={vi.fn()}
119
- syncClipDragAutoScroll={vi.fn()}
120
119
  shiftClickClipRef={createRef()}
121
120
  getPreviewElement={(el) => el}
122
121
  getTrackStyle={getTrackStyle}
@@ -76,7 +76,6 @@ export function TimelineLanes({
76
76
  setResizingClip,
77
77
  setDraggedClip,
78
78
  setSelectedElementId,
79
- syncClipDragAutoScroll,
80
79
  shiftClickClipRef,
81
80
  getPreviewElement,
82
81
  getTrackStyle,
@@ -350,6 +349,7 @@ export function TimelineLanes({
350
349
  setShowPopover(false);
351
350
  setRangeSelection(null);
352
351
  setResizingClip({
352
+ pointerId: e.pointerId,
353
353
  element: el,
354
354
  edge,
355
355
  originClientX: e.clientX,
@@ -388,6 +388,7 @@ export function TimelineLanes({
388
388
  (blockedIntent !== "move" && onResizeElement))
389
389
  ) {
390
390
  blockedClipRef.current = {
391
+ pointerId: e.pointerId,
391
392
  element: el,
392
393
  intent: blockedIntent,
393
394
  originClientX: e.clientX,
@@ -401,6 +402,7 @@ export function TimelineLanes({
401
402
  setShowPopover(false);
402
403
  setRangeSelection(null);
403
404
  setDraggedClip({
405
+ pointerId: e.pointerId,
404
406
  element: el,
405
407
  originClientX: e.clientX,
406
408
  originClientY: e.clientY,
@@ -418,7 +420,6 @@ export function TimelineLanes({
418
420
  snapType: null,
419
421
  started: false,
420
422
  });
421
- syncClipDragAutoScroll(e.clientX, e.clientY);
422
423
  }
423
424
  }
424
425
  onClick={