@hyperframes/studio 0.7.57 → 0.7.58

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 (112) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
  2. package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
  3. package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5954 -4474
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  17. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  18. package/src/components/editor/DomEditOverlay.tsx +5 -2
  19. package/src/components/editor/LayersPanel.tsx +110 -99
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  21. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  26. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  27. package/src/components/nle/PreviewOverlays.tsx +27 -2
  28. package/src/components/nle/TimelinePane.test.ts +3 -0
  29. package/src/components/nle/TimelinePane.tsx +11 -1
  30. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  32. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  33. package/src/components/nle/zLaneGesture.test.ts +176 -0
  34. package/src/components/nle/zLaneGesture.ts +77 -0
  35. package/src/hooks/domEditCommitTypes.ts +17 -1
  36. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  37. package/src/hooks/fetchStubTestUtils.ts +16 -0
  38. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  39. package/src/hooks/timelineEditingHelpers.ts +32 -15
  40. package/src/hooks/timelineMoveAdapter.ts +2 -1
  41. package/src/hooks/timelineTimingSync.test.ts +703 -33
  42. package/src/hooks/timelineTimingSync.ts +282 -97
  43. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  44. package/src/hooks/timelineTrackVisibility.ts +4 -3
  45. package/src/hooks/useClipboard.ts +19 -17
  46. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  47. package/src/hooks/useDomEditCommits.ts +143 -78
  48. package/src/hooks/useDomEditSession.test.tsx +3 -1
  49. package/src/hooks/useDomEditSession.ts +1 -1
  50. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  51. package/src/hooks/useElementLifecycleOps.ts +174 -81
  52. package/src/hooks/usePreviewPersistence.ts +2 -2
  53. package/src/hooks/useTimelineEditing.test.tsx +302 -154
  54. package/src/hooks/useTimelineEditing.ts +97 -93
  55. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  56. package/src/hooks/useTimelineGroupEditing.ts +35 -3
  57. package/src/player/components/Timeline.test.ts +31 -21
  58. package/src/player/components/Timeline.tsx +62 -91
  59. package/src/player/components/TimelineCanvas.tsx +36 -5
  60. package/src/player/components/TimelineLanes.tsx +32 -10
  61. package/src/player/components/TimelineOverlays.tsx +36 -0
  62. package/src/player/components/TimelineRuler.tsx +18 -5
  63. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  64. package/src/player/components/timelineCallbacks.ts +4 -1
  65. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  66. package/src/player/components/timelineClipDragCommit.ts +173 -71
  67. package/src/player/components/timelineGapCommit.test.ts +163 -0
  68. package/src/player/components/timelineGapCommit.ts +103 -0
  69. package/src/player/components/timelineGaps.test.ts +221 -0
  70. package/src/player/components/timelineGaps.ts +182 -0
  71. package/src/player/components/timelineLayout.test.ts +2 -1
  72. package/src/player/components/timelineLayout.ts +22 -6
  73. package/src/player/components/timelineMarquee.test.ts +20 -10
  74. package/src/player/components/timelineMarquee.ts +9 -2
  75. package/src/player/components/timelineStackingSync.test.ts +42 -1
  76. package/src/player/components/timelineStackingSync.ts +31 -16
  77. package/src/player/components/timelineZMirror.test.ts +667 -0
  78. package/src/player/components/timelineZMirror.ts +393 -0
  79. package/src/player/components/timelineZoom.test.ts +2 -2
  80. package/src/player/components/timelineZoom.ts +7 -3
  81. package/src/player/components/useTimelineClipDrag.ts +1 -1
  82. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  83. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  84. package/src/player/components/useTimelinePlayhead.ts +4 -3
  85. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  86. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  87. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  88. package/src/player/components/useTimelineStackingSync.ts +5 -3
  89. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  90. package/src/player/components/useTrackGapMenu.ts +155 -0
  91. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  92. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  93. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  94. package/src/player/lib/layerOrdering.test.ts +1 -15
  95. package/src/player/lib/layerOrdering.ts +5 -28
  96. package/src/player/lib/timelineDOM.test.ts +72 -0
  97. package/src/player/lib/timelineDOM.ts +3 -0
  98. package/src/player/lib/timelineElementHelpers.ts +35 -11
  99. package/src/player/store/playerStore.ts +12 -1
  100. package/src/telemetry/events.test.ts +10 -0
  101. package/src/telemetry/events.ts +1 -0
  102. package/src/utils/domEditSaveQueue.ts +4 -3
  103. package/src/utils/editHistory.test.ts +82 -0
  104. package/src/utils/editHistory.ts +14 -2
  105. package/src/utils/gsapSoftReload.test.ts +54 -115
  106. package/src/utils/gsapSoftReload.ts +42 -152
  107. package/src/utils/gsapUndoRestore.test.ts +269 -0
  108. package/src/utils/gsapUndoRestore.ts +269 -0
  109. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  110. package/dist/assets/index-_pqzyxB1.css +0 -1
  111. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  112. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -0,0 +1,116 @@
1
+ import { memo } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
+
5
+ interface TrackGapContextMenuProps {
6
+ x: number;
7
+ y: number;
8
+ /** Width (seconds) of the gap under the pointer, or null when no clip exists to the right. */
9
+ gapWidth: number | null;
10
+ /** "Close gap" actionable: a gap exists AND every clip that must shift is movable. */
11
+ canCloseGap: boolean;
12
+ /** "Close all gaps" actionable: the lane has gaps AND every shifting clip is movable. */
13
+ canCloseAllGaps: boolean;
14
+ /** The lane has at least one gap (distinguishes the two disabled reasons). */
15
+ hasAnyGaps: boolean;
16
+ onClose: () => void;
17
+ onCloseGap: () => void;
18
+ onCloseAllGaps: () => void;
19
+ /** Hover state for the gap-strip highlight overlay (null = nothing hovered).
20
+ * Only reported for ACTIONABLE rows — a disabled row closes nothing, so
21
+ * highlighting from it would promise an action that can't happen. */
22
+ onHoverAction: (action: "close-gap" | "close-all" | null) => void;
23
+ }
24
+
25
+ /**
26
+ * Context menu for right-clicking EMPTY space on a timeline lane
27
+ * (CapCut/Premiere-style). Offers "Close gap" (collapse the clicked gap by
28
+ * shifting the following clips on that lane left) and "Close all gaps"
29
+ * (compact the whole lane contiguous from 0). Both rows are ALWAYS present —
30
+ * an inapplicable action dims with a tooltip explaining why, rather than
31
+ * vanishing into a one-item menu. Hovering an actionable row highlights the
32
+ * gap strip(s) it would close (via onHoverAction → TimelineCanvas overlay).
33
+ * Styling mirrors ClipContextMenu.
34
+ */
35
+ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
36
+ x,
37
+ y,
38
+ gapWidth,
39
+ canCloseGap,
40
+ canCloseAllGaps,
41
+ hasAnyGaps,
42
+ onClose,
43
+ onCloseGap,
44
+ onCloseAllGaps,
45
+ onHoverAction,
46
+ }: TrackGapContextMenuProps) {
47
+ const menuRef = useContextMenuDismiss(onClose);
48
+
49
+ const menuWidth = 200;
50
+ const menuHeight = 68;
51
+ const overflowY = y + menuHeight - window.innerHeight;
52
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
53
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
54
+
55
+ const itemClass = (enabled: boolean) =>
56
+ `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
57
+ enabled
58
+ ? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
59
+ : "text-neutral-600 cursor-not-allowed"
60
+ }`;
61
+
62
+ // Disabled reasons: no gap under the pointer beats the lock reason — a
63
+ // pointer not on a gap has nothing to close regardless of movability.
64
+ const closeGapTitle = canCloseGap
65
+ ? undefined
66
+ : gapWidth == null
67
+ ? "No gap here"
68
+ : "A clip on this track can't be moved";
69
+ const closeAllTitle = canCloseAllGaps
70
+ ? undefined
71
+ : hasAnyGaps
72
+ ? "A clip on this track can't be moved"
73
+ : "No gaps on this track";
74
+
75
+ return createPortal(
76
+ <div
77
+ ref={menuRef}
78
+ className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
79
+ style={{ left: adjustedX, top: adjustedY }}
80
+ onPointerLeave={() => onHoverAction(null)}
81
+ >
82
+ <button
83
+ type="button"
84
+ className={itemClass(canCloseGap)}
85
+ disabled={!canCloseGap}
86
+ title={closeGapTitle}
87
+ onPointerEnter={() => onHoverAction(canCloseGap ? "close-gap" : null)}
88
+ onClick={() => {
89
+ if (!canCloseGap) return;
90
+ onCloseGap();
91
+ onClose();
92
+ }}
93
+ >
94
+ <span>Close gap</span>
95
+ {gapWidth != null && (
96
+ <span className="text-neutral-500 text-[10px] ml-3">{gapWidth.toFixed(2)}s</span>
97
+ )}
98
+ </button>
99
+ <button
100
+ type="button"
101
+ className={itemClass(canCloseAllGaps)}
102
+ disabled={!canCloseAllGaps}
103
+ title={closeAllTitle}
104
+ onPointerEnter={() => onHoverAction(canCloseAllGaps ? "close-all" : null)}
105
+ onClick={() => {
106
+ if (!canCloseAllGaps) return;
107
+ onCloseAllGaps();
108
+ onClose();
109
+ }}
110
+ >
111
+ <span>Close all gaps</span>
112
+ </button>
113
+ </div>,
114
+ document.body,
115
+ );
116
+ });
@@ -31,11 +31,14 @@ export interface TimelineEditCallbacks {
31
31
  ) => Promise<void> | void;
32
32
  /** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
33
33
  * `coalesceKey` (drag-commit gesture id) merges the move history entry with a
34
- * lane change's follow-up z-reorder entry into one undo step. */
34
+ * lane change's follow-up z-reorder entry into one undo step; `coalesceMs`
35
+ * widens that entry's fold window when a server round-trip separates the
36
+ * gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
35
37
  onMoveElements?: (
36
38
  edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
37
39
  coalesceKey?: string,
38
40
  operation?: TimelineMoveOperation,
41
+ coalesceMs?: number,
39
42
  ) => Promise<void> | void;
40
43
  onResizeElement?: (
41
44
  element: TimelineElement,
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
3
3
  import type { DraggedClipState } from "./useTimelineClipDrag";
4
4
  import {
5
5
  commitDraggedClipMove,
6
+ commitZMirrorLaneMove,
6
7
  type DragCommitDeps,
7
8
  type TimelineMoveEdit,
8
9
  } from "./timelineClipDragCommit";
@@ -12,6 +13,7 @@ import {
12
13
  pushEditHistoryEntry,
13
14
  } from "../../utils/editHistory";
14
15
  import { normalizeToZones } from "./timelineZones";
16
+ import { resolveZMirrorLaneMove } from "./timelineZMirror";
15
17
  import type { StackingPatch } from "./timelineStackingSync";
16
18
 
17
19
  function el(
@@ -29,9 +31,7 @@ function el(
29
31
  /** Flush the microtask chain: the z-sync now fires only after the move persist
30
32
  * promise resolves (serialized), so tests asserting on it must await. */
31
33
  async function flushMicrotasks(): Promise<void> {
32
- await Promise.resolve();
33
- await Promise.resolve();
34
- await Promise.resolve();
34
+ for (let step = 0; step < 8; step += 1) await Promise.resolve();
35
35
  }
36
36
 
37
37
  function drag(
@@ -370,6 +370,73 @@ describe("commitDraggedClipMove", () => {
370
370
  expect(map.b.track).toBe(2); // at/below the insert → +1 shift
371
371
  });
372
372
 
373
+ it("a selected audio passenger moves in time during a visual insert without being renumbered", () => {
374
+ const a = { ...el("a", 0, 0, 5), sourceFile: "scene.html" };
375
+ const b = { ...el("b", 1, 10, 5), sourceFile: "scene.html" };
376
+ const t = { ...el("t", 1, 0, 5), sourceFile: "scene.html" };
377
+ const audio = {
378
+ ...el("audio", 2, 4, 20, "audio"),
379
+ sourceFile: "scene.html",
380
+ authoredTrack: 7,
381
+ };
382
+
383
+ const { onMoveElements } = runClipMove(
384
+ drag(t, { previewStart: 5, previewTrack: 1, insertRow: 1 }),
385
+ {
386
+ elements: [a, b, t, audio],
387
+ trackOrder: [0, 1, 2],
388
+ selectedKeys: new Set(["t", "audio"]),
389
+ },
390
+ );
391
+
392
+ const map = editMap(onMoveElements.mock.calls[0][0]);
393
+ expect(map.audio).toEqual({ start: 9, track: 7 });
394
+ });
395
+
396
+ it("an audio insert persists zone-local tracks without rewriting visual lanes", () => {
397
+ const v0 = el("v0", 0, 0, 5);
398
+ const v1 = el("v1", 1, 0, 5);
399
+ const a = { ...el("a", 2, 0, 5, "audio"), authoredTrack: 0 };
400
+ const b = { ...el("b", 3, 10, 5, "audio"), authoredTrack: 1 };
401
+ const t = { ...el("t", 3, 0, 5, "audio"), authoredTrack: 1 };
402
+
403
+ const { onMoveElements } = runClipMove(
404
+ drag(t, { previewStart: 0, previewTrack: 3, insertRow: 3 }),
405
+ { elements: [v0, v1, a, b, t], trackOrder: [0, 1, 2, 3] },
406
+ );
407
+
408
+ expect(editMap(onMoveElements.mock.calls[0][0])).toEqual({
409
+ a: { start: 0, track: 0 },
410
+ t: { start: 0, track: 1 },
411
+ b: { start: 10, track: 2 },
412
+ });
413
+ });
414
+
415
+ it("refuses an audio insert when it would renumber a locked authored row", () => {
416
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
417
+ const visual = el("visual", 0, 0, 5);
418
+ const locked = {
419
+ ...el("locked", 1, 0, 5, "audio"),
420
+ authoredTrack: 0,
421
+ timelineLocked: true,
422
+ };
423
+ const target = { ...el("target", 2, 0, 5, "audio"), authoredTrack: 1 };
424
+
425
+ try {
426
+ const { onMoveElements } = runClipMove(
427
+ drag(target, { previewStart: 0, previewTrack: 2, insertRow: 1 }),
428
+ { elements: [visual, locked, target], trackOrder: [0, 1, 2] },
429
+ );
430
+
431
+ expect(onMoveElements).not.toHaveBeenCalled();
432
+ expect(warnSpy).toHaveBeenCalledWith(
433
+ expect.stringContaining("locked clip locked would need renumbering"),
434
+ );
435
+ } finally {
436
+ warnSpy.mockRestore();
437
+ }
438
+ });
439
+
373
440
  describe("lane ↔ stacking sync", () => {
374
441
  it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", async () => {
375
442
  // a & b overlap in time. Elements carry their authored z (as real discovery
@@ -1024,3 +1091,212 @@ describe("commitDraggedClipMove", () => {
1024
1091
  });
1025
1092
  });
1026
1093
  });
1094
+
1095
+ describe("commitZMirrorLaneMove", () => {
1096
+ const mirrorDeps = (elements: TimelineElement[], trackOrder: number[]) => {
1097
+ const updateElement = vi.fn();
1098
+ const onMoveElements = vi.fn();
1099
+ return {
1100
+ updateElement,
1101
+ onMoveElements,
1102
+ deps: { elements, trackOrder, updateElement, onMoveElements } as DragCommitDeps,
1103
+ };
1104
+ };
1105
+
1106
+ it("kind:move persists start + display lane with the persistTrack override (same shape as a lane drag)", async () => {
1107
+ // t sits at lane 2 in a sparse file (authored 7); the mirror lands it on
1108
+ // lane 0 whose authored track is 3.
1109
+ const t = { ...el("t", 2, 0, 10), authoredTrack: 7 };
1110
+ const elements = [{ ...el("a", 0, 20, 5), authoredTrack: 3 }, el("b", 1, 0, 10), t];
1111
+ const { updateElement, onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2]);
1112
+ const moved = await commitZMirrorLaneMove(
1113
+ t,
1114
+ { kind: "move", displayTrack: 0, persistTrack: 3 },
1115
+ deps,
1116
+ "z-reorder:bring-forward:t",
1117
+ );
1118
+ expect(moved).toBe(true);
1119
+ // Optimistic store update: DISPLAY lane + the written authoredTrack mirror.
1120
+ expect(updateElement).toHaveBeenCalledWith("t", {
1121
+ start: 0,
1122
+ track: 0,
1123
+ authoredTrack: 3,
1124
+ });
1125
+ // Persist: authored-space track, the z persist's coalesce key, lane-reorder op.
1126
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
1127
+ const [persistEdits, coalesceKey, operation] = onMoveElements.mock.calls[0];
1128
+ expect(editMap(persistEdits)).toEqual({ t: { start: 0, track: 3 } });
1129
+ expect(coalesceKey).toBe("z-reorder:bring-forward:t");
1130
+ expect(operation).toBe("lane-reorder");
1131
+ });
1132
+
1133
+ it("kind:insert reuses the track-insert renumber core (+1 shift below the new lane)", async () => {
1134
+ // a,b,c mutually overlapping on lanes 0/1/2. Mirror-insert c at row 1: c
1135
+ // lands on the new lane, b (at/below) shifts down — identical to the drag
1136
+ // insert test above, proving the shared core (no duplicated renumber logic).
1137
+ const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)];
1138
+ const { onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2]);
1139
+ const moved = await commitZMirrorLaneMove(
1140
+ elements[2],
1141
+ { kind: "insert", insertRow: 1 },
1142
+ deps,
1143
+ "z-reorder:bring-forward:c",
1144
+ );
1145
+ expect(moved).toBe(true);
1146
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
1147
+ expect(onMoveElements.mock.calls[0][1]).toBe("z-reorder:bring-forward:c");
1148
+ expect(onMoveElements.mock.calls[0][2]).toBe("track-insert");
1149
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1150
+ expect(map.a.track).toBe(0);
1151
+ expect(map.c.track).toBe(1);
1152
+ expect(map.b.track).toBe(2);
1153
+ });
1154
+
1155
+ it("visual mirror inserts never persist or renumber same-file audio", async () => {
1156
+ // b and t share a visual lane because they do not overlap. Inserting t between
1157
+ // a and b creates one extra visual lane, so whole-timeline normalization moves
1158
+ // audio from display lane 2 to 3. That display-only shift must not be written.
1159
+ const a = { ...el("a", 0, 0, 5), sourceFile: "scene.html" };
1160
+ const b = { ...el("b", 1, 10, 5), sourceFile: "scene.html" };
1161
+ const t = { ...el("t", 1, 0, 5), sourceFile: "scene.html" };
1162
+ const audio = { ...el("audio", 2, 0, 20, "audio"), sourceFile: "scene.html" };
1163
+ const { onMoveElements, deps } = mirrorDeps([a, b, t, audio], [0, 1, 2]);
1164
+
1165
+ const moved = await commitZMirrorLaneMove(
1166
+ t,
1167
+ { kind: "insert", insertRow: 1 },
1168
+ deps,
1169
+ "z-reorder:bring-forward:t",
1170
+ );
1171
+
1172
+ expect(moved).toBe(true);
1173
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1174
+ expect(map.audio).toBeUndefined();
1175
+ expect(map).toEqual({
1176
+ a: { start: 0, track: 0 },
1177
+ b: { start: 10, track: 2 },
1178
+ t: { start: 0, track: 1 },
1179
+ });
1180
+ });
1181
+
1182
+ it("foreign expanded rows never distort the root-file insert topology", async () => {
1183
+ const host = { ...el("host", 0, 0, 5), sourceFile: "index.html" };
1184
+ const child1 = {
1185
+ ...el("child-1", 0.25, 0, 5),
1186
+ sourceFile: "scene.html",
1187
+ expandedParentStart: 0,
1188
+ };
1189
+ const child2 = {
1190
+ ...el("child-2", 0.5, 0, 5),
1191
+ sourceFile: "scene.html",
1192
+ expandedParentStart: 0,
1193
+ };
1194
+ const b = { ...el("b", 1, 0, 5), sourceFile: "index.html" };
1195
+ const t = { ...el("t", 2, 0, 5), sourceFile: "index.html" };
1196
+ const elements = [host, child1, child2, b, t];
1197
+ const { onMoveElements, deps } = mirrorDeps(elements, [0, 0.25, 0.5, 1, 2]);
1198
+
1199
+ const moved = await commitZMirrorLaneMove(
1200
+ t,
1201
+ { kind: "insert", insertRow: 1 },
1202
+ deps,
1203
+ "z-reorder:bring-forward:t",
1204
+ );
1205
+
1206
+ expect(moved).toBe(true);
1207
+ expect(editMap(onMoveElements.mock.calls[0][0])).toEqual({
1208
+ host: { start: 0, track: 0 },
1209
+ t: { start: 0, track: 1 },
1210
+ b: { start: 0, track: 2 },
1211
+ });
1212
+ });
1213
+
1214
+ it("never triggers the lane→z stacking sync (it would fight the just-set z values)", async () => {
1215
+ // Even with BOTH z-sync deps supplied (the drag paths would engage them for
1216
+ // a vertical move like this), the mirror commit must not emit stacking
1217
+ // patches — the z values were just written by the user's menu action.
1218
+ const t = el("t", 2, 0, 10);
1219
+ const elements = [el("a", 0, 20, 5), el("b", 1, 0, 10), t];
1220
+ const { deps } = mirrorDeps(elements, [0, 1, 2]);
1221
+ const onStackingPatches = vi.fn();
1222
+ const moved = await commitZMirrorLaneMove(
1223
+ t,
1224
+ { kind: "move", displayTrack: 0, persistTrack: 0 },
1225
+ { ...deps, readZIndex: () => 0, onStackingPatches },
1226
+ "z-reorder:bring-forward:t",
1227
+ );
1228
+ await flushMicrotasks();
1229
+ expect(moved).toBe(true);
1230
+ expect(onStackingPatches).not.toHaveBeenCalled();
1231
+ });
1232
+
1233
+ it("resolves false and rolls the store back when the persist rejects", async () => {
1234
+ const t = el("t", 1, 0, 10);
1235
+ const elements = [el("a", 0, 20, 5), t];
1236
+ const updateElement = vi.fn();
1237
+ const onMoveElements = vi.fn().mockRejectedValue(new Error("boom"));
1238
+ const moved = await commitZMirrorLaneMove(
1239
+ t,
1240
+ { kind: "move", displayTrack: 0, persistTrack: 0 },
1241
+ { elements, trackOrder: [0, 1], updateElement, onMoveElements },
1242
+ "z-reorder:send-backward:t",
1243
+ );
1244
+ expect(moved).toBe(false);
1245
+ // Optimistic write then rollback to the original lane.
1246
+ expect(updateElement).toHaveBeenLastCalledWith("t", {
1247
+ start: 0,
1248
+ track: 1,
1249
+ authoredTrack: undefined,
1250
+ });
1251
+ });
1252
+
1253
+ it("END-TO-END one-element step: resolver insertRow renumbers the clip strictly between the two neighbors", async () => {
1254
+ // 3 stacked back-to-back clips + a free lane beyond the far one. Send t
1255
+ // (top) backward past b: the resolver must bound at c and produce the
1256
+ // insert row IMMEDIATELY below b, and commitZMirrorLaneMove's renumber must
1257
+ // land t strictly between b and c — never on the farther free lane 3.
1258
+ const t = el("t", 0, 0, 10);
1259
+ const b = el("b", 1, 0, 10);
1260
+ const c = el("c", 2, 0, 10);
1261
+ const far = el("far", 3, 20, 5); // free over t's span, beyond c
1262
+ const elements = [t, b, c, far];
1263
+ const move = resolveZMirrorLaneMove({
1264
+ action: "send-backward",
1265
+ element: t,
1266
+ elements,
1267
+ crossedKey: "b",
1268
+ });
1269
+ expect(move).toEqual({ kind: "insert", insertRow: 2 });
1270
+ const { onMoveElements, deps } = mirrorDeps(elements, [0, 1, 2, 3]);
1271
+ const moved = await commitZMirrorLaneMove(t, move!, deps, "z-reorder:send-backward:t");
1272
+ expect(moved).toBe(true);
1273
+ const map = editMap(onMoveElements.mock.calls[0][0]);
1274
+ // The renumber compacts t's vacated top lane, so the whole set shifts up by
1275
+ // one while t lands on the b/c boundary — strictly between the two.
1276
+ expect(map).toEqual({
1277
+ b: { start: 0, track: 0 },
1278
+ t: { start: 0, track: 1 },
1279
+ c: { start: 0, track: 2 },
1280
+ far: { start: 20, track: 3 },
1281
+ });
1282
+ expect(map.b.track).toBeLessThan(map.t.track);
1283
+ expect(map.t.track).toBeLessThan(map.c.track);
1284
+ });
1285
+
1286
+ it("resolves false for a refused insert (locked clip would need renumbering)", async () => {
1287
+ // b is locked and sits at/below the insert row, so the whole-set renumber
1288
+ // is refused — no persist call.
1289
+ const a = el("a", 0, 0, 5);
1290
+ const b: TimelineElement = { ...el("b", 1, 0, 5), timelineLocked: true };
1291
+ const c = el("c", 2, 0, 5);
1292
+ const { onMoveElements, deps } = mirrorDeps([a, b, c], [0, 1, 2]);
1293
+ const moved = await commitZMirrorLaneMove(
1294
+ c,
1295
+ { kind: "insert", insertRow: 1 },
1296
+ deps,
1297
+ "z-reorder:bring-forward:c",
1298
+ );
1299
+ expect(moved).toBe(false);
1300
+ expect(onMoveElements).not.toHaveBeenCalled();
1301
+ });
1302
+ });