@hyperframes/studio 0.8.126 → 0.8.128

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 (126) hide show
  1. package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
  2. package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
  3. package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
  4. package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +2184 -1745
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
  20. package/src/components/editor/MotionPathOverlay.tsx +20 -26
  21. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  22. package/src/components/editor/domEditingLayers.test.ts +45 -18
  23. package/src/components/editor/domEditingLayers.ts +11 -9
  24. package/src/components/editor/domEditingTypes.ts +1 -0
  25. package/src/components/editor/keyframeRetime.ts +11 -4
  26. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  27. package/src/components/editor/motionPathCommit.ts +58 -0
  28. package/src/components/editor/motionPathGeometry.test.ts +13 -0
  29. package/src/components/editor/motionPathGeometry.ts +4 -2
  30. package/src/components/editor/probeSourceElement.ts +17 -1
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  32. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  33. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  34. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  35. package/src/hooks/gsapDragCommit.ts +41 -6
  36. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  37. package/src/hooks/gsapEditOutcome.ts +27 -2
  38. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  39. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  40. package/src/hooks/gsapMutationClient.ts +35 -7
  41. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  42. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  43. package/src/hooks/gsapParsedTween.ts +27 -7
  44. package/src/hooks/gsapPositionDetection.ts +5 -2
  45. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  46. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  47. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  48. package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
  49. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  50. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  51. package/src/hooks/gsapShared.test.ts +49 -35
  52. package/src/hooks/gsapShared.ts +35 -33
  53. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
  54. package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
  55. package/src/hooks/gsapValueAtPlayhead.ts +49 -10
  56. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  58. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  59. package/src/hooks/timelineEditingHelpers.ts +40 -14
  60. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  61. package/src/hooks/timelineMoveAdapter.ts +3 -5
  62. package/src/hooks/timelineTimingSync.test.ts +83 -125
  63. package/src/hooks/timelineTimingSync.ts +62 -42
  64. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  65. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  66. package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
  67. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  68. package/src/hooks/useDomSelection.test.ts +27 -0
  69. package/src/hooks/useDomSelection.ts +12 -13
  70. package/src/hooks/useDomSelectionTypes.ts +1 -0
  71. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  72. package/src/hooks/useEditHistoryActions.ts +9 -6
  73. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  74. package/src/hooks/useEnableKeyframes.ts +54 -71
  75. package/src/hooks/useGestureCommit.ts +2 -1
  76. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  77. package/src/hooks/useGsapTweenCache.ts +2 -17
  78. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  79. package/src/hooks/usePersistentEditHistory.ts +13 -2
  80. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  81. package/src/hooks/usePreviewPersistence.ts +26 -4
  82. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  83. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  84. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  85. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  86. package/src/hooks/useTimelineEditing.ts +21 -295
  87. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  88. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  89. package/src/player/components/Timeline.test.ts +12 -4
  90. package/src/player/components/TimelineClip.tsx +3 -0
  91. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  92. package/src/player/components/TimelineLanes.test.tsx +61 -5
  93. package/src/player/components/TimelineLanes.tsx +20 -28
  94. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  95. package/src/player/components/timelineCallbacks.ts +1 -4
  96. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  97. package/src/player/components/timelineClipDragCommit.ts +39 -28
  98. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  99. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  100. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  101. package/src/player/components/timelineEditing.ts +1 -3
  102. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  103. package/src/player/components/timelineLinkSelection.ts +1 -35
  104. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  105. package/src/player/components/trackHeaderLaneState.ts +9 -2
  106. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  107. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  108. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  109. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  110. package/src/player/components/useTimelineClipDrag.ts +0 -3
  111. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  112. package/src/player/store/batchElementUpdates.test.ts +43 -0
  113. package/src/player/store/batchElementUpdates.ts +9 -0
  114. package/src/player/store/elementPatchQueue.ts +35 -0
  115. package/src/player/store/playerStore.ts +5 -6
  116. package/src/utils/domEditHelpers.ts +5 -0
  117. package/src/utils/globalTimeCompiler.test.ts +0 -4
  118. package/src/utils/globalTimeCompiler.ts +6 -4
  119. package/src/utils/gsapKeyframeEases.ts +79 -0
  120. package/src/utils/sameData.ts +9 -0
  121. package/src/utils/studioFileHistory.ts +1 -0
  122. package/src/utils/studioPendingEdits.ts +15 -1
  123. package/dist/chunk-246VB2G3.js.map +0 -1
  124. package/dist/chunk-3F47EFCT.js.map +0 -1
  125. package/dist/chunk-5EKWJZLM.js.map +0 -1
  126. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -10,7 +10,11 @@ import { defaultTimelineTheme } from "./timelineTheme";
10
10
  import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
11
11
  import { createTimelineClipIndex } from "../lib/timelineClipIndex";
12
12
  import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
13
- import { usePlayerStore, type TimelineElement } from "../store/playerStore";
13
+ import {
14
+ usePlayerStore,
15
+ type KeyframeCacheEntry,
16
+ type TimelineElement,
17
+ } from "../store/playerStore";
14
18
  import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
15
19
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
16
20
  import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
@@ -73,6 +77,7 @@ function positionTween(id: string): GsapAnimation {
73
77
 
74
78
  interface RenderLanesOptions {
75
79
  elements?: TimelineElement[];
80
+ keyframeCache?: Map<string, KeyframeCacheEntry>;
76
81
  animations?: Map<string, GsapAnimation[]>;
77
82
  expandedClipIds?: string[];
78
83
  selectedElementIds?: Set<string>;
@@ -172,6 +177,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
172
177
  getPreviewElement={(el) => el}
173
178
  getTrackStyle={getTrackStyle}
174
179
  gsapAnimations={gsapAnimations}
180
+ keyframeCache={next.keyframeCache}
175
181
  selectedKeyframes={new Set()}
176
182
  currentTime={0}
177
183
  onContextMenuLane={next.onContextMenuLane}
@@ -389,9 +395,54 @@ describe("TimelineLanes disclosure target", () => {
389
395
  act(() => second.root.unmount());
390
396
  });
391
397
 
392
- // The passenger branch wraps [clip, lanes] in a transformed div that re-renders
393
- // on every pointer move. An unstable key there remounts the lanes and drops the
394
- // in-flight drag.
398
+ it("does not remount a clip when it becomes, then stops being, a drag passenger", () => {
399
+ const elements = [element("clip-a", TRACK_A), element("clip-b", TRACK_A)];
400
+ const selectedElementIds = new Set(["clip-a", "clip-b"]);
401
+ const dragging: MultiDragPreviewInput = {
402
+ dragStarted: true,
403
+ draggedKey: "clip-b",
404
+ draggedOriginStart: 0,
405
+ draggedPreviewStart: 0.5,
406
+ selectedKeys: selectedElementIds,
407
+ };
408
+ const keyframeCache = new Map<string, KeyframeCacheEntry>([
409
+ [
410
+ "clip-a",
411
+ {
412
+ format: "percentage",
413
+ keyframes: [
414
+ { percentage: 0, properties: { x: 0 } },
415
+ { percentage: 100, properties: { x: 50 } },
416
+ ],
417
+ },
418
+ ],
419
+ ]);
420
+ const view = renderLanes({ elements, selectedElementIds, keyframeCache });
421
+ const clipA = view.host.querySelector<HTMLElement>('[data-el-id="clip-a"]');
422
+ const diamonds = () =>
423
+ view.host
424
+ .querySelector('[data-el-id="clip-a"] ~ div button[aria-label*="keyframe at"]')
425
+ ?.closest<HTMLElement>("div.pointer-events-none");
426
+ expect(clipA).not.toBeNull();
427
+ expect(clipA?.parentElement).toBe(
428
+ view.host.querySelector('[data-el-id="clip-b"]')?.parentElement,
429
+ );
430
+
431
+ expect(diamonds()).not.toBeNull();
432
+ view.rerender({ elements, selectedElementIds, keyframeCache, multiDragPreview: dragging });
433
+ expect(view.host.querySelector('[data-el-id="clip-a"]')).toBe(clipA);
434
+ expect(clipA?.style.transform).toMatch(/^translateX\([1-9]/);
435
+ // Its keyframe diamonds ride with it.
436
+ expect(diamonds()?.style.transform).toBe(clipA?.style.transform);
437
+
438
+ view.rerender({ elements, selectedElementIds, keyframeCache });
439
+ expect(view.host.querySelector('[data-el-id="clip-a"]')).toBe(clipA);
440
+ expect(clipA?.style.transform).toBe("");
441
+ act(() => view.root.unmount());
442
+ });
443
+
444
+ // A passenger re-renders on every pointer move. An unstable key there remounts
445
+ // the lanes and drops the in-flight drag.
395
446
  it("does not remount the lanes while a multi-clip drag slides the formation", () => {
396
447
  const elements = [element("clip-a", TRACK_A), element("clip-b", TRACK_A)];
397
448
  const selectedElementIds = new Set(["clip-a", "clip-b"]);
@@ -411,9 +462,11 @@ describe("TimelineLanes disclosure target", () => {
411
462
  });
412
463
 
413
464
  const before = ariaControlsTarget(view.host);
414
- const beforeLane = before?.querySelector("[data-timeline-property-lane]");
465
+ const beforeLane = before?.querySelector<HTMLElement>("[data-timeline-property-lane]");
415
466
  expect(before).not.toBeNull();
416
467
  expect(beforeLane).not.toBeNull();
468
+ const startOffset = beforeLane?.style.transform;
469
+ expect(startOffset).toMatch(/^translateX\([1-9]/);
417
470
 
418
471
  view.rerender({
419
472
  elements,
@@ -426,6 +479,9 @@ describe("TimelineLanes disclosure target", () => {
426
479
  // Node identity, not just presence: a remount replaces these nodes.
427
480
  expect(ariaControlsTarget(view.host)).toBe(before);
428
481
  expect(before?.querySelector("[data-timeline-property-lane]")).toBe(beforeLane);
482
+ // The lanes ride with the formation, not just the clip.
483
+ expect(beforeLane?.style.transform).toMatch(/^translateX\([1-9]/);
484
+ expect(beforeLane?.style.transform).not.toBe(startOffset);
429
485
  act(() => view.root.unmount());
430
486
  });
431
487
  });
@@ -1,4 +1,4 @@
1
- import { Fragment, useId, useMemo } from "react";
1
+ import { Fragment, useId, useMemo, type CSSProperties } from "react";
2
2
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
3
  import { TimelineClip } from "./TimelineClip";
4
4
  import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
@@ -35,6 +35,15 @@ import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
35
35
  import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
36
36
  import { deriveTimelineTransitionSeamsByTrack } from "./timelineTransitionSeams";
37
37
 
38
+ function passengerStyleAt(offsetPx: number): CSSProperties {
39
+ return {
40
+ transform: `translateX(${offsetPx}px)`,
41
+ opacity: 0.85,
42
+ zIndex: 20,
43
+ pointerEvents: "none",
44
+ };
45
+ }
46
+
38
47
  export function TimelineLanes({
39
48
  pps,
40
49
  contentOrigin,
@@ -390,9 +399,9 @@ export function TimelineLanes({
390
399
  // the passenger's timeline data until the owning drag commits.
391
400
  const isPassenger =
392
401
  multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
393
- const passengerOffsetPx = isPassenger
394
- ? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview)
395
- : 0;
402
+ const passengerStyle = isPassenger
403
+ ? passengerStyleAt(multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview))
404
+ : undefined;
396
405
  const clipGestures = createClipGestureHandlers(
397
406
  el,
398
407
  elementKey,
@@ -424,6 +433,7 @@ export function TimelineLanes({
424
433
  }}
425
434
  el={previewElement}
426
435
  pps={pps}
436
+ passengerStyle={passengerStyle}
427
437
  clipY={CLIP_Y}
428
438
  clipHeight={clipBarHeight}
429
439
  isSelected={isSelected}
@@ -469,6 +479,7 @@ export function TimelineLanes({
469
479
  beatsActive={beatStripOnTrack}
470
480
  accentColor={clipStyle.accent}
471
481
  isSelected={isSelected}
482
+ passengerStyle={passengerStyle}
472
483
  currentTime={currentTime}
473
484
  selectedKeyframes={selectedKeyframes}
474
485
  rovingTargetId={keyboard.rovingTargetId}
@@ -494,6 +505,7 @@ export function TimelineLanes({
494
505
  clipDuration={previewElement.duration}
495
506
  clipLeftPx={previewElement.start * pps}
496
507
  clipWidthPx={Math.max(previewElement.duration * pps, 4)}
508
+ passengerStyle={passengerStyle}
497
509
  accentColor={clipStyle.accent}
498
510
  isSelected={isSelected}
499
511
  currentPercentage={
@@ -520,34 +532,14 @@ export function TimelineLanes({
520
532
  />
521
533
  );
522
534
 
523
- // Keep one keyed top-level child per element. Returning an
524
- // array here makes React reconcile the outer array by
525
- // position, so a window shift remounts otherwise stable
526
- // clip keys and can tear down focus mid-reveal.
527
- if (!isPassenger) {
528
- return (
529
- <Fragment key={clipKey}>
530
- {clip}
531
- {compactDiamonds}
532
- {propertyLanes}
533
- </Fragment>
534
- );
535
- }
535
+ // No wrapper node per clip, and the same Fragment whether or not it rides a
536
+ // drag, so joining or leaving one restyles it, never remounts it.
536
537
  return (
537
- <div
538
- key={clipKey}
539
- className="absolute inset-0"
540
- style={{
541
- transform: `translateX(${passengerOffsetPx}px)`,
542
- opacity: 0.85,
543
- zIndex: 20,
544
- pointerEvents: "none",
545
- }}
546
- >
538
+ <Fragment key={clipKey}>
547
539
  {clip}
548
540
  {compactDiamonds}
549
541
  {propertyLanes}
550
- </div>
542
+ </Fragment>
551
543
  );
552
544
  })
553
545
  }
@@ -1,4 +1,9 @@
1
- import { useMemo, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
1
+ import {
2
+ useMemo,
3
+ type CSSProperties,
4
+ type MouseEvent as ReactMouseEvent,
5
+ type RefObject,
6
+ } from "react";
2
7
  import {
3
8
  classifyPropertyGroup,
4
9
  type GsapAnimation,
@@ -23,6 +28,7 @@ export interface TimelinePropertyLanesProps {
23
28
  clipDuration: number;
24
29
  clipLeftPx: number;
25
30
  clipWidthPx: number;
31
+ passengerStyle?: CSSProperties;
26
32
  accentColor: string;
27
33
  isSelected: boolean;
28
34
  currentPercentage: number;
@@ -173,6 +179,7 @@ export function getTimelinePropertyLanes(
173
179
  return Array.from(sourceGroups(animations), ([group, groupAnimations]) => ({
174
180
  group,
175
181
  animations: groupAnimations,
182
+ elementAnimations: animations,
176
183
  keyframes: groupKeyframes(groupAnimations, group, clipStart, clipDuration),
177
184
  })).filter((lane) => lane.keyframes.length > 0);
178
185
  }
@@ -184,6 +191,7 @@ export function TimelinePropertyLanes({
184
191
  clipDuration,
185
192
  clipLeftPx,
186
193
  clipWidthPx,
194
+ passengerStyle,
187
195
  accentColor,
188
196
  isSelected,
189
197
  currentPercentage,
@@ -239,6 +247,7 @@ export function TimelinePropertyLanes({
239
247
  top: getTimelineLaneTop(laneIndex),
240
248
  width: clipWidthPx,
241
249
  height: LANE_H,
250
+ ...passengerStyle,
242
251
  }}
243
252
  >
244
253
  <TimelineDiamondLane
@@ -56,14 +56,11 @@ export interface TimelineEditCallbacks {
56
56
  ) => Promise<void> | void;
57
57
  /** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
58
58
  * `coalesceKey` (drag-commit gesture id) merges the move history entry with a
59
- * lane change's follow-up z-reorder entry into one undo step; `coalesceMs`
60
- * widens that entry's fold window when a server round-trip separates the
61
- * gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
59
+ * lane change's follow-up z-reorder entry into one undo step. */
62
60
  onMoveElements?: (
63
61
  edits: Array<{ element: TimelineElement; updates: TimelineAtomicMoveUpdates }>,
64
62
  coalesceKey?: string,
65
63
  operation?: TimelineMoveOperation,
66
- coalesceMs?: number,
67
64
  ) => Promise<void> | void;
68
65
  onResizeElement?: (
69
66
  element: TimelineElement,
@@ -11,6 +11,7 @@ import {
11
11
  import { normalizeToZones } from "./timelineZones";
12
12
  import { resolveZMirrorLaneMove } from "./timelineZMirror";
13
13
  import type { StackingPatch } from "./timelineStackingSync";
14
+ import { isStudioEditSaving, revertNewestStudioPendingEdit } from "../../utils/studioPendingEdits";
14
15
 
15
16
  function el(
16
17
  id: string,
@@ -1534,6 +1535,36 @@ describe("persistMoveEdits convergence", () => {
1534
1535
  },
1535
1536
  );
1536
1537
 
1538
+ it("registers a revert for the save in flight, and a reverted move is not reasserted", async () => {
1539
+ let current = el("clip", 0, 1, 2);
1540
+ let releaseSave!: () => void;
1541
+ const pendingSave = new Promise<void>((resolve) => {
1542
+ releaseSave = resolve;
1543
+ });
1544
+ const updateElement = (_key: string, updates: Partial<TimelineElement>) => {
1545
+ current = { ...current, ...updates };
1546
+ };
1547
+ const persisted = persistMoveEdits([{ element: current, updates: { start: 4, track: 0 } }], {
1548
+ elements: [current],
1549
+ trackOrder: [0],
1550
+ updateElement,
1551
+ onMoveElements: () => pendingSave,
1552
+ });
1553
+ expect(current.start).toBe(4);
1554
+ expect(isStudioEditSaving()).toBe(true);
1555
+
1556
+ const reapply = revertNewestStudioPendingEdit();
1557
+ expect(reapply).not.toBeNull();
1558
+ expect(current.start).toBe(1);
1559
+
1560
+ releaseSave();
1561
+ await expect(persisted).resolves.toBe(true);
1562
+ expect(current.start).toBe(1);
1563
+ expect(isStudioEditSaving()).toBe(false);
1564
+ reapply?.();
1565
+ expect(current.start).toBe(4);
1566
+ });
1567
+
1537
1568
  it("reasserts a saved lane after a stale runtime sync", async () => {
1538
1569
  const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
1539
1570
  let releaseSave: (() => void) | undefined;
@@ -25,6 +25,8 @@ import { runLaneZGesture } from "../../components/nle/zLaneGesture";
25
25
  import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
26
26
  import { authoredTrackForLane } from "./timelineAuthoredTrack";
27
27
  import { resolveGroupMovers } from "./timelineMultiDragPreview";
28
+ import { beginStudioPendingEdit } from "../../utils/studioPendingEdits";
29
+ import { batchElementUpdates } from "../store/batchElementUpdates";
28
30
 
29
31
  type StartTrack = TimelineAtomicMoveUpdates;
30
32
  export interface TimelineMoveEdit extends TimelineAtomicMoveEdit {
@@ -47,14 +49,11 @@ export interface DragCommitDeps {
47
49
  /** Atomic multi-clip persist (single undo) for lane changes + track inserts.
48
50
  * `coalesceKey`, when supplied, tags the resulting "Move timeline clips"
49
51
  * history entry so it merges with the lane change's z-reorder entry (see the
50
- * lane-change branch below). `coalesceMs` widens that entry's fold window
51
- * (per-gesture-unique keys make an unbounded window safe) — required when a
52
- * server round-trip sits between the gesture's two records. */
52
+ * lane-change branch below). */
53
53
  onMoveElements?: (
54
54
  edits: TimelineMoveEdit[],
55
55
  coalesceKey?: string,
56
56
  operation?: TimelineMoveOperation,
57
- coalesceMs?: number,
58
57
  ) => Promise<void> | void;
59
58
  /**
60
59
  * The current multi-selection (store.selectedElementIds). When the dragged
@@ -121,7 +120,6 @@ export function persistMoveEdits(
121
120
  deps: DragCommitDeps,
122
121
  coalesceKey?: string,
123
122
  operation: TimelineMoveOperation = "timing",
124
- coalesceMs?: number,
125
123
  ): Promise<boolean> {
126
124
  if (edits.length === 0) return Promise.resolve(true);
127
125
  edits = edits.map((e) => {
@@ -175,7 +173,9 @@ export function persistMoveEdits(
175
173
  }
176
174
  if (Object.keys(updates).length) updateElement(key, updates);
177
175
  };
178
- for (const e of edits) applyEdit(e);
176
+ const applyEdits = (reassert = false) =>
177
+ batchElementUpdates(() => edits.forEach((e) => applyEdit(e, reassert)));
178
+ applyEdits();
179
179
  // The store above gets DISPLAY lanes; the file below gets the authored-space
180
180
  // track when one was resolved (see TimelineMoveEdit.persistTrack).
181
181
  const persistEdits = edits.map((e) =>
@@ -183,19 +183,8 @@ export function persistMoveEdits(
183
183
  ? e
184
184
  : { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
185
185
  );
186
- const persisted = onMoveElements
187
- ? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
188
- : Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
189
- return Promise.resolve(persisted).then(
190
- () => {
191
- // Runtime timeline messages can arrive while the save is in flight and
192
- // restore the preview manifest's pre-gesture lane. Reassert the durable
193
- // result after persistence, but only while this remains the latest
194
- // optimistic gesture so an older save can never clobber a newer drag.
195
- for (const e of edits) applyEdit(e, true);
196
- return true;
197
- },
198
- (error) => {
186
+ const restorePrev = () =>
187
+ batchElementUpdates(() => {
199
188
  for (const p of prev) {
200
189
  const { audioGroup, ...timing } = p.updates;
201
190
  const updates: Partial<TimelineElement> = {};
@@ -208,10 +197,38 @@ export function persistMoveEdits(
208
197
  updates.audioGroup = audioGroup;
209
198
  if (Object.keys(updates).length) updateElement(p.key, updates);
210
199
  }
200
+ });
201
+ // Cmd+Z while the save is in flight shows the old lanes at once; the returned fn re-applies the move.
202
+ const saving = onMoveElements
203
+ ? beginStudioPendingEdit(() => {
204
+ restorePrev();
205
+ return () => applyEdits(true);
206
+ })
207
+ : null;
208
+ const start = () =>
209
+ onMoveElements
210
+ ? onMoveElements(persistEdits, coalesceKey, operation)
211
+ : Promise.all(
212
+ persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))),
213
+ );
214
+ const persisted = saving ? saving.adopt(start) : start();
215
+ const done = Promise.resolve(persisted).then(
216
+ () => {
217
+ // Runtime timeline messages can arrive while the save is in flight and
218
+ // restore the preview manifest's pre-gesture lane. Reassert the durable
219
+ // result after persistence, but only while this remains the latest
220
+ // optimistic gesture so an older save can never clobber a newer drag.
221
+ if (!saving?.reverted()) applyEdits(true);
222
+ return true;
223
+ },
224
+ (error) => {
225
+ if (!saving?.reverted()) restorePrev();
211
226
  console.error("[Timeline] Failed to persist clip edits", error);
212
227
  return false;
213
228
  },
214
229
  );
230
+ saving?.settle(done);
231
+ return done;
215
232
  }
216
233
 
217
234
  /**
@@ -481,12 +498,7 @@ function commitTrackInsert(
481
498
  * useCanvasZOrderTimelineMirror).
482
499
  *
483
500
  * `coalesceKey` MUST be the z persist's key (`z-reorder:<action>:<ids>:g<seq>`)
484
- * so editHistory folds the z write and this track write into ONE undo entry, and
485
- * `coalesceMs` MUST widen this record's fold window: the mirror only runs after
486
- * the z persist's server round-trip resolved, so under real network latency the
487
- * gap between the two records exceeds the reducer's 300ms default and the fold
488
- * would never happen live. The key is unique per gesture, so an unbounded
489
- * window can never merge distinct gestures.
501
+ * so editHistory folds the z write and this track write into ONE undo entry.
490
502
  *
491
503
  * Resolves `true` once the move persisted, `false` on rollback / refused insert.
492
504
  */
@@ -495,7 +507,6 @@ export function commitZMirrorLaneMove(
495
507
  move: NonNullable<ZMirrorLaneMove>,
496
508
  deps: DragCommitDeps,
497
509
  coalesceKey: string,
498
- coalesceMs?: number,
499
510
  ): Promise<boolean> {
500
511
  if (move.kind === "move") {
501
512
  const edit: TimelineMoveEdit = {
@@ -504,14 +515,14 @@ export function commitZMirrorLaneMove(
504
515
  persistTrack: move.persistTrack,
505
516
  };
506
517
  return refreshAfterDurableLaneMove(
507
- persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs),
518
+ persistMoveEdits([edit], deps, coalesceKey, "lane-reorder"),
508
519
  deps,
509
520
  );
510
521
  }
511
522
  const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
512
523
  if (!built || built.edits.length === 0) return Promise.resolve(false);
513
524
  return refreshAfterDurableLaneMove(
514
- persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs),
525
+ persistMoveEdits(built.edits, deps, coalesceKey, "track-insert"),
515
526
  deps,
516
527
  );
517
528
  }
@@ -195,7 +195,7 @@ export function mountTimelineClipDragGestureLifecycle({
195
195
  event.clientX - blocked.originClientX,
196
196
  event.clientY - blocked.originClientY,
197
197
  );
198
- const threshold = blocked.intent === "move" || blocked.intent === "edit-many" ? 4 : 2;
198
+ const threshold = blocked.intent === "move" ? 4 : 2;
199
199
  if (!blocked.started && distance < threshold) return;
200
200
  if (!blocked.started) {
201
201
  blocked.started = true;
@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import type { PointerEvent as ReactPointerEvent } from "react";
5
5
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
6
6
  import { createClipGestureHandlers, type ClipGestureDeps } from "./timelineClipGestureHandlers";
7
- import { MAX_HAND_EDIT_CLIPS } from "./timelineEditing";
8
7
 
9
8
  afterEach(() => usePlayerStore.getState().reset());
10
9
 
@@ -18,11 +17,9 @@ const clips: TimelineElement[] = Array.from({ length: 6 }, (_, i) => ({
18
17
  }));
19
18
  const capabilities = { canMove: true, canTrimStart: true, canTrimEnd: true, readOnly: false };
20
19
 
21
- function grabFirstClip(count: number, gesture: "move" | "resize", locked: number[] = []) {
20
+ function grabFirstClip(count: number, gesture: "move" | "resize") {
22
21
  const store = usePlayerStore.getState();
23
- store.setElements(
24
- clips.map((clip, i) => (locked.includes(i) ? { ...clip, timelineLocked: true } : clip)),
25
- );
22
+ store.setElements(clips);
26
23
  store.setSelectedElementIds(new Set(clips.slice(0, count).map((c) => c.id)));
27
24
  const setDraggedClip = vi.fn();
28
25
  const setResizingClip = vi.fn();
@@ -55,40 +52,15 @@ function grabFirstClip(count: number, gesture: "move" | "resize", locked: number
55
52
  }
56
53
 
57
54
  describe("hand-editing a multi-selection", () => {
58
- it("moves up to the limit by hand", () => {
59
- const { setDraggedClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS, "move");
55
+ it.each([1, 3, 4, 6])("moves %i selected clips by hand", (count) => {
56
+ const { setDraggedClip, blockedClipRef } = grabFirstClip(count, "move");
60
57
  expect(setDraggedClip).toHaveBeenCalledOnce();
61
58
  expect(blockedClipRef.current).toBeNull();
62
59
  });
63
60
 
64
- it("refuses to start a drag above the limit and asks for the toast", () => {
65
- const { setDraggedClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "move");
66
- expect(setDraggedClip).not.toHaveBeenCalled();
67
- expect(blockedClipRef.current?.intent).toBe("edit-many");
68
- });
69
-
70
- it("resizes up to the limit by hand", () => {
71
- const { setResizingClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS, "resize");
61
+ it.each([1, 3, 4, 6])("resizes %i selected clips by hand", (count) => {
62
+ const { setResizingClip, blockedClipRef } = grabFirstClip(count, "resize");
72
63
  expect(setResizingClip).toHaveBeenCalledOnce();
73
64
  expect(blockedClipRef.current).toBeNull();
74
65
  });
75
-
76
- it("refuses to start a resize above the limit and asks for the toast", () => {
77
- const { setResizingClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "resize");
78
- expect(setResizingClip).not.toHaveBeenCalled();
79
- expect(blockedClipRef.current?.intent).toBe("edit-many");
80
- });
81
-
82
- it("counts the clips a move would change: locked ones in the selection stay put", () => {
83
- const allowed = grabFirstClip(MAX_HAND_EDIT_CLIPS + 2, "move", [3, 4]);
84
- expect(allowed.setDraggedClip).toHaveBeenCalledOnce();
85
- const refused = grabFirstClip(MAX_HAND_EDIT_CLIPS + 2, "move", [4]);
86
- expect(refused.setDraggedClip).not.toHaveBeenCalled();
87
- });
88
-
89
- it("counts the clips a trim would change: a locked member leaves a trim of the grabbed clip alone", () => {
90
- const allowed = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "resize", [3]);
91
- expect(allowed.setResizingClip).toHaveBeenCalledOnce();
92
- expect(allowed.blockedClipRef.current).toBeNull();
93
- });
94
66
  });
@@ -8,11 +8,7 @@ import {
8
8
  import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
9
9
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
10
10
  import { CLIP_HANDLE_W } from "./timelineLayout";
11
- import {
12
- exceedsHandEditLimit,
13
- selectClipWithLinks,
14
- toggleClipWithLinks,
15
- } from "./timelineLinkSelection";
11
+ import { selectClipWithLinks, toggleClipWithLinks } from "./timelineLinkSelection";
16
12
  import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
17
13
 
18
14
  export interface ClipGestureDeps {
@@ -77,7 +73,6 @@ function isIntentBlocked(
77
73
  */
78
74
  function resolvePointerDownAction(
79
75
  e: ReactPointerEvent,
80
- grabbed: TimelineElement,
81
76
  capabilities: TimelineEditCapabilities,
82
77
  onResizeElement: ClipGestureDeps["onResizeElement"],
83
78
  onMoveElement: ClipGestureDeps["onMoveElement"],
@@ -98,8 +93,6 @@ function resolvePointerDownAction(
98
93
  }
99
94
 
100
95
  if (!onMoveElement || !capabilities.canMove) return { kind: "ignore" };
101
- if (exceedsHandEditLimit(grabbed, e.altKey, null))
102
- return { kind: "block", intent: "edit-many", rect };
103
96
  return { kind: "move", rect };
104
97
  }
105
98
 
@@ -136,17 +129,6 @@ export function createClipGestureHandlers(
136
129
  const onResizeStart = (edge: "start" | "end", e: ReactPointerEvent): void => {
137
130
  if (!canStartResize(edge, e, capabilities, onResizeElement)) return;
138
131
  e.stopPropagation();
139
- if (exceedsHandEditLimit(el, e.altKey, edge)) {
140
- blockedClipRef.current = {
141
- pointerId: e.pointerId,
142
- element: el,
143
- intent: "edit-many",
144
- originClientX: e.clientX,
145
- originClientY: e.clientY,
146
- started: false,
147
- };
148
- return;
149
- }
150
132
  blockedClipRef.current = null;
151
133
  setShowPopover(false);
152
134
  setRangeSelection(null);
@@ -165,7 +147,7 @@ export function createClipGestureHandlers(
165
147
  };
166
148
 
167
149
  const onPointerDown = (e: ReactPointerEvent): void => {
168
- const action = resolvePointerDownAction(e, el, capabilities, onResizeElement, onMoveElement);
150
+ const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
169
151
  if (action.kind === "ignore") return;
170
152
 
171
153
  if (action.kind === "block") {
@@ -248,9 +248,7 @@ export interface TimelinePromptElement {
248
248
  track: number;
249
249
  }
250
250
 
251
- export type BlockedTimelineEditIntent = "move" | "edit-many" | "resize-start" | "resize-end";
252
-
253
- export const MAX_HAND_EDIT_CLIPS = 3;
251
+ export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
254
252
 
255
253
  export interface TimelineRangeSelection {
256
254
  start: number;
@@ -1,6 +1,7 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
3
3
  import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
4
+ import { batchElementUpdates } from "../store/batchElementUpdates";
4
5
  import {
5
6
  beginTimelineOptimisticGesture,
6
7
  rollbackLatestTimelineOptimisticGesture,
@@ -17,13 +18,15 @@ export function commitTimelineGroupResize(
17
18
  updateElement,
18
19
  changes.map((change) => change.key),
19
20
  );
20
- for (const change of changes) {
21
- updateElement(change.key, {
22
- start: change.start,
23
- duration: change.duration,
24
- playbackStart: change.playbackStart,
25
- });
26
- }
21
+ batchElementUpdates(() => {
22
+ for (const change of changes) {
23
+ updateElement(change.key, {
24
+ start: change.start,
25
+ duration: change.duration,
26
+ playbackStart: change.playbackStart,
27
+ });
28
+ }
29
+ });
27
30
  if (!persist) {
28
31
  rollbackLatestTimelineOptimisticGesture(
29
32
  updateElement,
@@ -32,7 +35,6 @@ export function commitTimelineGroupResize(
32
35
  );
33
36
  return;
34
37
  }
35
- const coalesceKey = `clip-group-resize:${changes.map((change) => change.key).join(":")}`;
36
38
  Promise.resolve(
37
39
  persist(
38
40
  changes.map((change) => ({
@@ -41,7 +43,6 @@ export function commitTimelineGroupResize(
41
43
  duration: change.duration,
42
44
  playbackStart: change.playbackStart,
43
45
  })),
44
- { coalesceKey },
45
46
  ),
46
47
  ).catch((error) => {
47
48
  rollbackLatestTimelineOptimisticGesture(