@hyperframes/studio 0.7.56 → 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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.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 +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  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 +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  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/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -0,0 +1,106 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { usePlayerStore } from "../../player/store/playerStore";
7
+ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
8
+ import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ let root: Root | null = null;
13
+
14
+ afterEach(() => {
15
+ if (root) {
16
+ act(() => root?.unmount());
17
+ root = null;
18
+ }
19
+ document.body.innerHTML = "";
20
+ usePlayerStore.getState().reset();
21
+ // reset() does not cover the out-of-loop seek request; clear it explicitly
22
+ // so a pending-seek test can't leak into the next one.
23
+ usePlayerStore.getState().clearSeekRequest();
24
+ useAssetPreviewStore.getState().clearPreviewAsset();
25
+ });
26
+
27
+ function mountOverlay(): void {
28
+ const host = document.createElement("div");
29
+ document.body.append(host);
30
+ root = createRoot(host);
31
+ act(() => {
32
+ root?.render(<AssetPreviewOverlay />);
33
+ });
34
+ }
35
+
36
+ describe("AssetPreviewOverlay playback dismissal", () => {
37
+ it("dismisses immediately when opened while playback is ALREADY running", () => {
38
+ // The RAF playback loop bypasses the store, so no post-open store change
39
+ // will arrive — the dismiss check must be level-triggered, not edge-triggered.
40
+ usePlayerStore.setState({ isPlaying: true });
41
+ mountOverlay();
42
+
43
+ act(() => {
44
+ useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
45
+ });
46
+
47
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
48
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
49
+ });
50
+
51
+ it("dismisses immediately when opened while a seek is ALREADY in flight", () => {
52
+ // A pending out-of-loop seek (requestedSeekTime set, not yet consumed) may
53
+ // produce no further store change before currentTime settles — the on-open
54
+ // check must evaluate the full shared dismiss predicate, not just isPlaying.
55
+ usePlayerStore.setState({ isPlaying: false, requestedSeekTime: 3.2 });
56
+ mountOverlay();
57
+
58
+ act(() => {
59
+ useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
60
+ });
61
+
62
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
63
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
64
+ });
65
+
66
+ it("stays open when the playhead is idle", () => {
67
+ mountOverlay();
68
+
69
+ act(() => {
70
+ useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
71
+ });
72
+
73
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
74
+ expect(document.querySelector('[role="dialog"]')).not.toBeNull();
75
+ });
76
+
77
+ it("still dismisses when playback starts AFTER the preview opened", () => {
78
+ mountOverlay();
79
+
80
+ act(() => {
81
+ useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
82
+ });
83
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
84
+
85
+ act(() => {
86
+ usePlayerStore.setState({ isPlaying: true });
87
+ });
88
+
89
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
90
+ });
91
+
92
+ it("still dismisses when the playhead is scrubbed after opening", () => {
93
+ usePlayerStore.setState({ currentTime: 2 });
94
+ mountOverlay();
95
+
96
+ act(() => {
97
+ useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
98
+ });
99
+
100
+ act(() => {
101
+ usePlayerStore.setState({ currentTime: 4.5 });
102
+ });
103
+
104
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
105
+ });
106
+ });
@@ -2,15 +2,20 @@
2
2
  * CapCut-style asset preview overlay rendered inside PreviewPane.
3
3
  *
4
4
  * Shown when the user clicks an asset card that has NOT yet been added to the
5
- * timeline. Displays the media (image / video / audio) without modifying the
6
- * compositionno undo entry, no file mutation.
5
+ * timeline. Displays the media (image / video / audio) as a compact floating
6
+ * card over the canvas the canvas stays visible behind a barely-tinted
7
+ * click-catcher — without modifying the composition (no undo entry, no file
8
+ * mutation).
7
9
  *
8
- * Dismiss: X button, Escape key, or click on the scrim.
10
+ * Dismiss: X button, Escape key, click outside the card, or any playhead
11
+ * activity (starting playback / seeking) — the canvas refocuses.
9
12
  * Switching to another not-added asset replaces the current preview.
10
13
  */
11
14
  import { useEffect, useCallback } from "react";
12
15
  import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
13
16
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
17
+ import { usePlayerStore } from "../../player/store/playerStore";
18
+ import { shouldDismissAssetPreview } from "../../utils/assetPreviewDismiss";
14
19
  import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
15
20
 
16
21
  function basename(path: string): string {
@@ -37,11 +42,7 @@ function AssetPreviewMedia({
37
42
  }) {
38
43
  if (kind === "image") {
39
44
  return (
40
- <img
41
- src={serveUrl}
42
- alt={name}
43
- className="max-w-full max-h-[70vh] rounded-md object-contain shadow-2xl"
44
- />
45
+ <img src={serveUrl} alt={name} className="max-w-full max-h-[40vh] rounded object-contain" />
45
46
  );
46
47
  }
47
48
  if (kind === "video") {
@@ -52,12 +53,12 @@ function AssetPreviewMedia({
52
53
  autoPlay
53
54
  muted
54
55
  playsInline
55
- className="max-w-full max-h-[70vh] rounded-md shadow-2xl"
56
+ className="max-w-full max-h-[40vh] rounded"
56
57
  />
57
58
  );
58
59
  }
59
60
  return (
60
- <div className="flex flex-col items-center gap-4 px-6 py-8 rounded-xl bg-neutral-900 border border-neutral-800">
61
+ <div className="flex flex-col items-center gap-3 px-6 py-4">
61
62
  <svg
62
63
  width="40"
63
64
  height="40"
@@ -94,6 +95,30 @@ export function AssetPreviewOverlay() {
94
95
  return () => window.removeEventListener("keydown", handleKeyDown);
95
96
  }, [previewAsset, handleKeyDown]);
96
97
 
98
+ // The canvas refocuses on any playhead activity: starting playback or a
99
+ // seek/scrub away from where the playhead sat when the preview opened
100
+ // dismisses it. openedTime is captured per preview open (previewAsset dep),
101
+ // so a stale render can never dismiss against the wrong reference time.
102
+ useEffect(() => {
103
+ if (!previewAsset) return;
104
+ const opened = usePlayerStore.getState();
105
+ const openedTime = opened.currentTime;
106
+ // Level-triggered, not edge-triggered: a preview opened while playback is
107
+ // ALREADY running (the RAF loop bypasses the store) or while a seek is
108
+ // already in flight gets no store change to react to, so evaluate the
109
+ // current state once, through the same shared predicate the subscription
110
+ // uses. openedTime is this snapshot's own currentTime, so the
111
+ // time-diverged branch can't false-positive at open — only the
112
+ // isPlaying / requestedSeekTime branches can fire here.
113
+ if (shouldDismissAssetPreview(openedTime, opened)) {
114
+ clearPreviewAsset();
115
+ return;
116
+ }
117
+ return usePlayerStore.subscribe((state) => {
118
+ if (shouldDismissAssetPreview(openedTime, state)) clearPreviewAsset();
119
+ });
120
+ }, [previewAsset, clearPreviewAsset]);
121
+
97
122
  if (!previewAsset || !previewProjectId) return null;
98
123
 
99
124
  const serveUrl = resolveMediaPreviewUrl(previewAsset, previewProjectId);
@@ -101,40 +126,39 @@ export function AssetPreviewOverlay() {
101
126
 
102
127
  return (
103
128
  <div
104
- className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80"
129
+ className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/20"
105
130
  onClick={clearPreviewAsset}
106
131
  role="dialog"
107
132
  aria-label={`Preview: ${name}`}
108
- aria-modal="true"
109
133
  >
110
- {/* Close button */}
111
- <button
112
- className="absolute top-3 right-3 w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
113
- onClick={(e) => {
114
- e.stopPropagation();
115
- clearPreviewAsset();
116
- }}
117
- aria-label="Close preview"
118
- >
119
- <svg
120
- width="12"
121
- height="12"
122
- viewBox="0 0 24 24"
123
- stroke="currentColor"
124
- strokeWidth="2.5"
125
- fill="none"
126
- strokeLinecap="round"
127
- >
128
- <line x1="18" y1="6" x2="6" y2="18" />
129
- <line x1="6" y1="6" x2="18" y2="18" />
130
- </svg>
131
- </button>
132
-
133
- {/* Media */}
134
+ {/* Floating preview card — compact, canvas stays visible around it */}
134
135
  <div
135
- className="flex flex-col items-center gap-3 max-w-[90%] max-h-[85%]"
136
+ className="relative flex flex-col items-center gap-2 max-w-[58%] rounded-lg border border-neutral-800 bg-neutral-950/95 p-2 pt-8 shadow-2xl"
136
137
  onClick={(e) => e.stopPropagation()}
137
138
  >
139
+ {/* Close button */}
140
+ <button
141
+ className="absolute top-2 right-2 w-6 h-6 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
142
+ onClick={(e) => {
143
+ e.stopPropagation();
144
+ clearPreviewAsset();
145
+ }}
146
+ aria-label="Close preview"
147
+ >
148
+ <svg
149
+ width="12"
150
+ height="12"
151
+ viewBox="0 0 24 24"
152
+ stroke="currentColor"
153
+ strokeWidth="2.5"
154
+ fill="none"
155
+ strokeLinecap="round"
156
+ >
157
+ <line x1="18" y1="6" x2="6" y2="18" />
158
+ <line x1="6" y1="6" x2="18" y2="18" />
159
+ </svg>
160
+ </button>
161
+
138
162
  <AssetPreviewMedia kind={resolveAssetKind(previewAsset)} serveUrl={serveUrl} name={name} />
139
163
 
140
164
  {/* Filename label */}
@@ -18,6 +18,10 @@ import {
18
18
  import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
19
19
  import { readHfId, type DomEditSelection } from "../editor/domEditing";
20
20
  import { buildStableSelector } from "../editor/domEditingDom";
21
+ import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
22
+ import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
23
+ import { useCanvasZOrderTimelineMirror } from "./useCanvasZOrderTimelineMirror";
24
+ import { runZLaneGesture } from "./zLaneGesture";
21
25
  import type { BlockPreviewInfo } from "../sidebar/BlocksTab";
22
26
  import type { GestureRecordingState } from "../editor/GestureRecordControl";
23
27
  import type { ReactNode } from "react";
@@ -38,6 +42,8 @@ type ZIndexReorderEntry = {
38
42
  selector?: string;
39
43
  selectorIndex?: number;
40
44
  sourceFile: string;
45
+ /** Timeline store key — lets the commit update the store zIndex synchronously. */
46
+ key?: string;
41
47
  };
42
48
 
43
49
  /** Can this element be robustly re-targeted for a persisted z change? */
@@ -58,6 +64,12 @@ function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReord
58
64
  selector: sel.selector,
59
65
  selectorIndex: sel.selectorIndex,
60
66
  sourceFile: sel.sourceFile,
67
+ key: deriveTimelineStoreKey({
68
+ domId: sel.id ?? undefined,
69
+ selector: sel.selector,
70
+ selectorIndex: sel.selectorIndex,
71
+ sourceFile: sel.sourceFile,
72
+ }),
61
73
  };
62
74
  }
63
75
 
@@ -75,7 +87,15 @@ function siblingZIndexEntry(
75
87
  const id = element.id || undefined;
76
88
  const selector = buildStableSelector(element);
77
89
  if (!canTargetZIndexElement(element, id, selector)) return null;
78
- return { element, zIndex, id, selector, selectorIndex: undefined, sourceFile };
90
+ return {
91
+ element,
92
+ zIndex,
93
+ id,
94
+ selector,
95
+ selectorIndex: undefined,
96
+ sourceFile,
97
+ key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
98
+ };
79
99
  }
80
100
 
81
101
  /** Short human-readable label for a dropped sibling, for the console warning below. */
@@ -88,14 +108,16 @@ function describeZIndexElement(element: HTMLElement): string {
88
108
  }
89
109
 
90
110
  // Resolve z-index patches into commit entries; a sibling with no stable
91
- // id/selector can't be written to source, so it is collected as a label for the
92
- // revert-on-reload warning instead.
93
- function resolveZIndexEntries(
111
+ // id/selector can't be written to source, so it is returned as `dropped` for
112
+ // the revert-on-reload warning (and a live-only style write, so the resolved
113
+ // stacking order still renders coherently). Exported so tests can drive the
114
+ // menu → commit path through the same wiring the app uses.
115
+ export function resolveZIndexEntries(
94
116
  sel: DomEditSelection,
95
117
  patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
96
- ): { entries: ZIndexReorderEntry[]; droppedLabels: string[] } {
118
+ ): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
97
119
  const entries: ZIndexReorderEntry[] = [];
98
- const droppedLabels: string[] = [];
120
+ const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
99
121
  for (const patch of patches) {
100
122
  if (patch.element === sel.element) {
101
123
  entries.push(selectedZIndexEntry(sel, patch.zIndex));
@@ -103,9 +125,9 @@ function resolveZIndexEntries(
103
125
  }
104
126
  const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
105
127
  if (entry) entries.push(entry);
106
- else droppedLabels.push(describeZIndexElement(patch.element));
128
+ else dropped.push(patch);
107
129
  }
108
- return { entries, droppedLabels };
130
+ return { entries, dropped };
109
131
  }
110
132
 
111
133
  // fallow-ignore-next-line complexity
@@ -139,6 +161,7 @@ export function PreviewOverlays({
139
161
  handleDomEditElementDelete,
140
162
  handleDomZIndexReorderCommit,
141
163
  } = useDomEditActionsContext();
164
+ const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
142
165
 
143
166
  // fallow-ignore-next-line complexity
144
167
  const [snapPrefs, setSnapPrefs] = useState(() => {
@@ -205,19 +228,41 @@ export function PreviewOverlays({
205
228
  onRotationCommit={handleDomRotationCommit}
206
229
  onStyleCommit={handleDomStyleCommit}
207
230
  onDeleteSelection={handleDomEditElementDelete}
208
- onApplyZIndex={(sel, patches) => {
209
- const { entries, droppedLabels } = resolveZIndexEntries(sel, patches);
210
- if (droppedLabels.length > 0) {
211
- // The optimistic z-index has already been applied live at this point —
212
- // these siblings just won't be written to source, so they'll revert to
213
- // their prior stacking order on the next reload with no other signal.
231
+ onApplyZIndex={(sel, patches, action, crossed) => {
232
+ const { entries, dropped } = resolveZIndexEntries(sel, patches);
233
+ if (dropped.length > 0) {
234
+ // These siblings can't be written to source. Apply their live z
235
+ // anyway so the resolved stacking order renders coherently it
236
+ // just reverts to the prior order on the next reload.
237
+ for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
214
238
  console.warn(
215
239
  "[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
216
240
  "(will revert on reload):",
217
- droppedLabels.join(", "),
241
+ dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
218
242
  );
219
243
  }
220
- if (entries.length > 0) handleDomZIndexReorderCommit(entries);
244
+ if (entries.length === 0) return;
245
+ // Shared undo coalesce key: passed to BOTH the z persist and the
246
+ // timeline lane mirror below so editHistory folds the two records
247
+ // into one undo entry (same value handleDomZIndexReorderCommit would
248
+ // default to — passed explicitly so the mirror shares it by
249
+ // construction, not by formula duplication).
250
+ const coalesceKey = zReorderCoalesceKey(entries, action);
251
+ // One serialized z→lane transaction: the mirror runs only AFTER the
252
+ // z commit resolved AND reported durable targets, and a second rapid
253
+ // gesture cannot interleave between the two phases — see
254
+ // runZLaneGesture. A failed z commit already toasted + rolled back.
255
+ runZLaneGesture({
256
+ commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
257
+ mirror: () =>
258
+ mirrorZOrderToTimeline({
259
+ selectionKey: entries.find((e) => e.element === sel.element)?.key,
260
+ action,
261
+ crossed,
262
+ sourceFile: sel.sourceFile,
263
+ coalesceKey,
264
+ }),
265
+ }).catch(() => undefined);
221
266
  }}
222
267
  gridVisible={snapPrefs.gridVisible}
223
268
  gridSpacing={snapPrefs.gridSpacing}
@@ -22,6 +22,7 @@ describe("TimelinePane move wrapper", () => {
22
22
  "clip-lane-move:7",
23
23
  "track-insert",
24
24
  onMoveElements,
25
+ Number.POSITIVE_INFINITY,
25
26
  );
26
27
  expect(onMoveElements).toHaveBeenCalledWith(
27
28
  [
@@ -32,6 +33,8 @@ describe("TimelinePane move wrapper", () => {
32
33
  ],
33
34
  "clip-lane-move:7",
34
35
  "track-insert",
36
+ // The per-gesture coalesce window rides along with the shared key.
37
+ Number.POSITIVE_INFINITY,
35
38
  );
36
39
  });
37
40
 
@@ -21,7 +21,9 @@ export function forwardRebasedTimelineMoveElements(
21
21
  edits: TimelineMoveEdit[],
22
22
  coalesceKey?: string,
23
23
  operation?: TimelineMoveOperation,
24
+ coalesceMs?: number,
24
25
  ) => Promise<void> | void,
26
+ coalesceMs?: number,
25
27
  ) {
26
28
  return onMoveElements(
27
29
  edits.map(({ element, updates }) => {
@@ -34,6 +36,7 @@ export function forwardRebasedTimelineMoveElements(
34
36
  }),
35
37
  coalesceKey,
36
38
  operation,
39
+ coalesceMs,
37
40
  );
38
41
  }
39
42
 
@@ -160,6 +163,7 @@ export function TimelinePane({
160
163
  edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
161
164
  coalesceKey?: string,
162
165
  operation?: TimelineMoveOperation,
166
+ coalesceMs?: number,
163
167
  ) => {
164
168
  // Match the sibling handlers: report the telemetry when the batch touches at
165
169
  // least one expanded sub-comp child (the clips being rebased to local coords).
@@ -167,7 +171,13 @@ export function TimelinePane({
167
171
  trackStudioExpandedClipEdit({ action: "move" });
168
172
  }
169
173
  if (!onMoveElements) return;
170
- return forwardRebasedTimelineMoveElements(edits, coalesceKey, operation, onMoveElements);
174
+ return forwardRebasedTimelineMoveElements(
175
+ edits,
176
+ coalesceKey,
177
+ operation,
178
+ onMoveElements,
179
+ coalesceMs,
180
+ );
171
181
  },
172
182
  [onMoveElements],
173
183
  );