@hyperframes/studio 0.7.57 → 0.7.59

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 (247) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  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 +27 -7
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +18029 -11563
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles/tailwind-preset.d.ts +5 -0
  14. package/dist/styles/tailwind-preset.js +8 -1
  15. package/dist/styles/tailwind-preset.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/App.tsx +6 -6
  18. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  19. package/src/components/StudioFeedbackBar.tsx +2 -2
  20. package/src/components/StudioRightPanel.tsx +29 -51
  21. package/src/components/editor/AnimationCard.test.tsx +134 -0
  22. package/src/components/editor/AnimationCard.tsx +19 -4
  23. package/src/components/editor/ArcPathControls.tsx +1 -0
  24. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  25. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  26. package/src/components/editor/DomEditOverlay.tsx +5 -2
  27. package/src/components/editor/GestureRecordControl.tsx +7 -1
  28. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  29. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  30. package/src/components/editor/LayersPanel.tsx +110 -99
  31. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  32. package/src/components/editor/PropertyPanel.tsx +144 -148
  33. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  34. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  35. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  36. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  37. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  38. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  39. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  40. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  41. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  42. package/src/components/editor/domEditingDom.ts +5 -6
  43. package/src/components/editor/domEditingElement.ts +15 -32
  44. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  45. package/src/components/editor/gsapLivePreview.ts +34 -0
  46. package/src/components/editor/layersPanelSort.ts +80 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  48. package/src/components/editor/manualEditingAvailability.ts +9 -0
  49. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  50. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  51. package/src/components/editor/propertyPanelColor.tsx +31 -1
  52. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  53. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  54. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  55. package/src/components/editor/propertyPanelFill.tsx +22 -5
  56. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  57. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  58. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  59. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  60. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  61. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  62. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  63. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  64. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  65. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  66. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  67. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  68. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  69. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  70. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  71. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  72. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  73. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  74. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  75. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  76. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  77. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  78. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  79. package/src/components/editor/propertyPanelFont.tsx +136 -95
  80. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  81. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  82. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  83. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  84. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  85. package/src/components/editor/propertyPanelSections.tsx +154 -90
  86. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  87. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  88. package/src/components/editor/propertyPanelTypes.ts +14 -1
  89. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  90. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  91. package/src/components/editor/useColorGradingController.test.ts +418 -0
  92. package/src/components/editor/useColorGradingController.ts +596 -0
  93. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  94. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  95. package/src/components/nle/PreviewOverlays.tsx +27 -2
  96. package/src/components/nle/TimelinePane.test.ts +3 -0
  97. package/src/components/nle/TimelinePane.tsx +11 -1
  98. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  99. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  100. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  101. package/src/components/nle/zLaneGesture.test.ts +176 -0
  102. package/src/components/nle/zLaneGesture.ts +77 -0
  103. package/src/components/panels/VariablesPanel.tsx +4 -0
  104. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  105. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  106. package/src/components/storyboard/FramePoster.tsx +24 -4
  107. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  108. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  109. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  110. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  111. package/src/components/storyboard/StoryboardView.tsx +9 -2
  112. package/src/components/storyboard/frameComments.test.ts +95 -0
  113. package/src/components/storyboard/frameComments.ts +115 -0
  114. package/src/components/storyboard/useFrameComments.ts +82 -0
  115. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  116. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  117. package/src/contexts/DomEditContext.tsx +4 -0
  118. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  119. package/src/contexts/VariablePromoteContext.tsx +10 -5
  120. package/src/hooks/domEditCommitRunner.ts +11 -0
  121. package/src/hooks/domEditCommitTypes.ts +17 -1
  122. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  123. package/src/hooks/fetchStubTestUtils.ts +16 -0
  124. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  125. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  126. package/src/hooks/timelineEditingHelpers.ts +32 -15
  127. package/src/hooks/timelineMoveAdapter.ts +2 -1
  128. package/src/hooks/timelineTimingSync.test.ts +703 -33
  129. package/src/hooks/timelineTimingSync.ts +282 -97
  130. package/src/hooks/timelineTrackVisibility.test.ts +55 -1
  131. package/src/hooks/timelineTrackVisibility.ts +31 -13
  132. package/src/hooks/useAppHotkeys.ts +9 -0
  133. package/src/hooks/useClipboard.ts +19 -17
  134. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  135. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  136. package/src/hooks/useDomEditCommits.ts +81 -162
  137. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  138. package/src/hooks/useDomEditSession.test.tsx +3 -1
  139. package/src/hooks/useDomEditSession.ts +18 -6
  140. package/src/hooks/useDomEditTextCommits.ts +14 -9
  141. package/src/hooks/useDomEditWiring.ts +10 -9
  142. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  143. package/src/hooks/useElementLifecycleOps.ts +182 -84
  144. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  145. package/src/hooks/useFileManager.ts +82 -60
  146. package/src/hooks/useGsapAnimationOps.ts +7 -6
  147. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  148. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  149. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  150. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  151. package/src/hooks/useGsapScriptCommits.ts +150 -50
  152. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  153. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  154. package/src/hooks/useInspectorSplitResize.ts +51 -0
  155. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  156. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  157. package/src/hooks/usePersistentEditHistory.ts +71 -31
  158. package/src/hooks/usePreviewPersistence.ts +2 -2
  159. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  160. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  161. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  162. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  163. package/src/hooks/useSdkSession.ts +155 -14
  164. package/src/hooks/useSlideshowPersist.ts +7 -1
  165. package/src/hooks/useStoryboard.ts +23 -4
  166. package/src/hooks/useTimelineEditing.test.tsx +306 -155
  167. package/src/hooks/useTimelineEditing.ts +111 -101
  168. package/src/hooks/useTimelineEditingTypes.ts +4 -1
  169. package/src/hooks/useTimelineGroupEditing.ts +51 -5
  170. package/src/hooks/useVariablesPersist.ts +9 -7
  171. package/src/icons/SystemIcons.tsx +2 -0
  172. package/src/player/components/Timeline.test.ts +31 -21
  173. package/src/player/components/Timeline.tsx +62 -91
  174. package/src/player/components/TimelineCanvas.tsx +36 -5
  175. package/src/player/components/TimelineLanes.tsx +32 -10
  176. package/src/player/components/TimelineOverlays.tsx +36 -0
  177. package/src/player/components/TimelineRuler.tsx +18 -5
  178. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  179. package/src/player/components/timelineCallbacks.ts +4 -1
  180. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  181. package/src/player/components/timelineClipDragCommit.ts +173 -71
  182. package/src/player/components/timelineGapCommit.test.ts +163 -0
  183. package/src/player/components/timelineGapCommit.ts +103 -0
  184. package/src/player/components/timelineGaps.test.ts +221 -0
  185. package/src/player/components/timelineGaps.ts +182 -0
  186. package/src/player/components/timelineLayout.test.ts +2 -1
  187. package/src/player/components/timelineLayout.ts +22 -6
  188. package/src/player/components/timelineMarquee.test.ts +20 -10
  189. package/src/player/components/timelineMarquee.ts +9 -2
  190. package/src/player/components/timelineStackingSync.test.ts +42 -1
  191. package/src/player/components/timelineStackingSync.ts +31 -16
  192. package/src/player/components/timelineZMirror.test.ts +667 -0
  193. package/src/player/components/timelineZMirror.ts +393 -0
  194. package/src/player/components/timelineZoom.test.ts +2 -2
  195. package/src/player/components/timelineZoom.ts +7 -3
  196. package/src/player/components/useTimelineClipDrag.ts +1 -1
  197. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  198. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  199. package/src/player/components/useTimelinePlayhead.ts +4 -3
  200. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  201. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  202. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  203. package/src/player/components/useTimelineStackingSync.ts +5 -3
  204. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  205. package/src/player/components/useTrackGapMenu.ts +155 -0
  206. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  207. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  208. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  209. package/src/player/lib/layerOrdering.test.ts +1 -15
  210. package/src/player/lib/layerOrdering.ts +5 -28
  211. package/src/player/lib/timelineDOM.test.ts +72 -0
  212. package/src/player/lib/timelineDOM.ts +3 -0
  213. package/src/player/lib/timelineElementHelpers.ts +35 -11
  214. package/src/player/store/playerStore.ts +12 -1
  215. package/src/styles/studio.css +22 -0
  216. package/src/styles/tailwind-preset.shared.js +7 -0
  217. package/src/telemetry/events.test.ts +10 -0
  218. package/src/telemetry/events.ts +1 -0
  219. package/src/utils/designInputTracking.test.ts +97 -0
  220. package/src/utils/designInputTracking.ts +80 -0
  221. package/src/utils/domEditSaveQueue.ts +4 -3
  222. package/src/utils/editHistory.test.ts +82 -0
  223. package/src/utils/editHistory.ts +14 -2
  224. package/src/utils/gsapSoftReload.test.ts +54 -115
  225. package/src/utils/gsapSoftReload.ts +42 -152
  226. package/src/utils/gsapUndoRestore.test.ts +269 -0
  227. package/src/utils/gsapUndoRestore.ts +269 -0
  228. package/src/utils/sdkCutover.gate.test.ts +7 -7
  229. package/src/utils/sdkCutover.test.ts +490 -57
  230. package/src/utils/sdkCutover.ts +181 -219
  231. package/src/utils/sdkEditTransaction.ts +292 -0
  232. package/src/utils/sdkResolverAttempts.ts +94 -0
  233. package/src/utils/sdkResolverShadow.test.ts +148 -0
  234. package/src/utils/sdkResolverShadow.ts +139 -106
  235. package/src/utils/setSlideshowManifest.test.ts +0 -9
  236. package/src/utils/setSlideshowManifest.ts +20 -27
  237. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  238. package/src/utils/studioFileHistory.test.ts +41 -0
  239. package/src/utils/studioFileHistory.ts +30 -27
  240. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  241. package/src/utils/studioHelpers.test.ts +45 -0
  242. package/src/utils/studioHelpers.ts +45 -14
  243. package/src/utils/studioSaveDiagnostics.ts +17 -0
  244. package/dist/assets/index-DfmYkU44.js +0 -423
  245. package/dist/assets/index-_pqzyxB1.css +0 -1
  246. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  247. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -0,0 +1,169 @@
1
+ // @vitest-environment jsdom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ LAYER_REVEAL_LIFT_Z,
7
+ LAYER_REVEAL_PENDING_COMMIT_ATTR,
8
+ beginLayerRevealCommit,
9
+ completeLayerRevealCommit,
10
+ liftElementToTop,
11
+ restoreLiftedElement,
12
+ useLayerRevealOverride,
13
+ } from "./useLayerRevealOverride";
14
+ import { readEffectiveZIndex } from "./canvasContextMenuZOrder";
15
+ import { getElementZIndex } from "../../player/lib/layerOrdering";
16
+ import {
17
+ LAYER_REVEAL_PRIOR_Z_ATTR,
18
+ readTimelineElementZIndex,
19
+ } from "../../player/lib/timelineElementHelpers";
20
+ import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
21
+
22
+ installReactActEnvironment();
23
+
24
+ function makeEl(zIndex?: string, position?: string): HTMLElement {
25
+ const el = document.createElement("div");
26
+ if (zIndex != null) el.style.zIndex = zIndex;
27
+ if (position != null) el.style.position = position;
28
+ document.body.appendChild(el);
29
+ return el;
30
+ }
31
+
32
+ describe("liftElementToTop / restoreLiftedElement", () => {
33
+ it("paints on top but every z reader keeps reporting the TRUE z", () => {
34
+ const el = makeEl("6", "absolute");
35
+ const lift = liftElementToTop(el);
36
+ expect(lift).not.toBeNull();
37
+ // The renderer sees the lifted value…
38
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
39
+ // …every studio reader sees the true z.
40
+ expect(readEffectiveZIndex(el)).toBe(6);
41
+ expect(getElementZIndex(el)).toBe(6);
42
+ expect(readTimelineElementZIndex(el)).toBe(6);
43
+
44
+ restoreLiftedElement(el, lift!);
45
+ expect(el.style.zIndex).toBe("6");
46
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
47
+ });
48
+
49
+ it("gives a static element a temporary position:relative and restores it", () => {
50
+ const el = makeEl();
51
+ const lift = liftElementToTop(el)!;
52
+ expect(el.style.position).toBe("relative");
53
+ expect(lift.positionLifted).toBe(true);
54
+ restoreLiftedElement(el, lift);
55
+ expect(el.style.position).toBe("");
56
+ expect(el.style.zIndex).toBe("");
57
+ });
58
+
59
+ it("a z-reorder commit consumes the lift: restore becomes a no-op", () => {
60
+ const el = makeEl("3", "absolute");
61
+ const lift = liftElementToTop(el)!;
62
+ // Simulate handleDomZIndexReorderCommit: real z written, attrs removed.
63
+ el.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
64
+ el.style.zIndex = "8";
65
+ restoreLiftedElement(el, lift);
66
+ expect(el.style.zIndex).toBe("8"); // the commit's value survives
67
+ expect(readEffectiveZIndex(el)).toBe(8);
68
+ });
69
+
70
+ it("durable z persistence consumes its pending reveal ownership", () => {
71
+ const el = makeEl("3", "absolute");
72
+ const lift = liftElementToTop(el)!;
73
+ const ownership = beginLayerRevealCommit(el)!;
74
+ el.style.zIndex = "8";
75
+
76
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(true);
77
+ completeLayerRevealCommit(el, ownership);
78
+ restoreLiftedElement(el, lift);
79
+
80
+ expect(el.style.zIndex).toBe("8");
81
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
82
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
83
+ });
84
+
85
+ it("does not clobber a z someone else wrote while lifted", () => {
86
+ const el = makeEl("3", "absolute");
87
+ const lift = liftElementToTop(el)!;
88
+ el.style.zIndex = "42"; // e.g. a GSAP seek or manual edit
89
+ restoreLiftedElement(el, lift);
90
+ expect(el.style.zIndex).toBe("42");
91
+ });
92
+ });
93
+
94
+ describe("useLayerRevealOverride — delayed reveal ownership", () => {
95
+ beforeEach(() => {
96
+ vi.useFakeTimers();
97
+ });
98
+
99
+ afterEach(() => {
100
+ vi.useRealTimers();
101
+ document.body.innerHTML = "";
102
+ });
103
+
104
+ it("cancels a pending reveal when selection changes or playback begins", () => {
105
+ const host = document.createElement("div");
106
+ const selected = makeEl("2", "absolute");
107
+ const other = makeEl("3", "absolute");
108
+ document.body.appendChild(host);
109
+ const root = createRoot(host);
110
+ let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
111
+
112
+ function Harness({ element, isPlaying }: { element: HTMLElement; isPlaying: boolean }) {
113
+ ({ scheduleReveal } = useLayerRevealOverride({
114
+ isPlaying,
115
+ selectedElement: element,
116
+ }));
117
+ return null;
118
+ }
119
+
120
+ act(() => {
121
+ root.render(React.createElement(Harness, { element: selected, isPlaying: false }));
122
+ });
123
+ act(() => scheduleReveal!(selected, 150));
124
+ expect(vi.getTimerCount()).toBe(1);
125
+
126
+ act(() => {
127
+ root.render(React.createElement(Harness, { element: other, isPlaying: false }));
128
+ });
129
+ expect(vi.getTimerCount()).toBe(0);
130
+
131
+ act(() => scheduleReveal!(other, 150));
132
+ expect(vi.getTimerCount()).toBe(1);
133
+ act(() => {
134
+ root.render(React.createElement(Harness, { element: other, isPlaying: true }));
135
+ });
136
+ expect(vi.getTimerCount()).toBe(0);
137
+
138
+ act(() => vi.advanceTimersByTime(150));
139
+ expect(selected.style.zIndex).toBe("2");
140
+ expect(other.style.zIndex).toBe("3");
141
+ act(() => root.unmount());
142
+ });
143
+
144
+ it("revalidates the current selection before a delayed reveal runs", () => {
145
+ const host = document.createElement("div");
146
+ const selected = makeEl("2", "absolute");
147
+ const stale = makeEl("3", "absolute");
148
+ document.body.appendChild(host);
149
+ const root = createRoot(host);
150
+ let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
151
+
152
+ function Harness() {
153
+ ({ scheduleReveal } = useLayerRevealOverride({
154
+ isPlaying: false,
155
+ selectedElement: selected,
156
+ }));
157
+ return null;
158
+ }
159
+
160
+ act(() => root.render(React.createElement(Harness)));
161
+ act(() => scheduleReveal!(stale, 150));
162
+ expect(vi.getTimerCount()).toBe(1);
163
+ act(() => vi.advanceTimersByTime(150));
164
+
165
+ expect(stale.style.zIndex).toBe("3");
166
+ expect(stale.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
167
+ act(() => root.unmount());
168
+ });
169
+ });
@@ -0,0 +1,389 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import {
3
+ LAYER_REVEAL_PRIOR_POSITION_ATTR,
4
+ LAYER_REVEAL_PRIOR_Z_ATTR,
5
+ } from "../../player/lib/timelineElementHelpers";
6
+ import { readEffectiveZIndex } from "./canvasContextMenuZOrder";
7
+
8
+ /** The lifted paint order — far above any authored z. Only the RENDERER sees
9
+ * it: every studio z reader is reveal-transparent (readLayerRevealPriorZ). */
10
+ export const LAYER_REVEAL_LIFT_Z = "2147483000";
11
+ export const LAYER_REVEAL_PENDING_COMMIT_ATTR = "data-hf-studio-reveal-pending-commit";
12
+
13
+ interface RevealedNode {
14
+ element: HTMLElement;
15
+ priors: { display: string; visibility: string; opacity: string };
16
+ /** Values THIS override wrote — restore only while they are still in place. */
17
+ applied: { display?: string; visibility?: string; opacity?: string };
18
+ }
19
+
20
+ interface RevealLift {
21
+ priors: { zIndex: string; position: string };
22
+ positionLifted: boolean;
23
+ }
24
+
25
+ type PendingRevealCommit = {
26
+ token: string;
27
+ releasedPriors?: RevealLift["priors"];
28
+ };
29
+
30
+ export type LayerRevealCommitOwnership = {
31
+ token: string;
32
+ priorZ: string;
33
+ priorPosition: string | null;
34
+ activeLiftStyles: RevealLift["priors"];
35
+ };
36
+
37
+ let revealCommitSequence = 0;
38
+ const zPersistCounts = new WeakMap<HTMLElement, number>();
39
+ const REVEAL_RETRY_MS = 16;
40
+
41
+ /** Prevent delayed reveals from capturing an optimistic z as authored state. */
42
+ export function beginLayerZPersist(element: HTMLElement): () => void {
43
+ zPersistCounts.set(element, (zPersistCounts.get(element) ?? 0) + 1);
44
+ let released = false;
45
+ return () => {
46
+ if (released) return;
47
+ released = true;
48
+ const remaining = (zPersistCounts.get(element) ?? 1) - 1;
49
+ if (remaining > 0) zPersistCounts.set(element, remaining);
50
+ else zPersistCounts.delete(element);
51
+ };
52
+ }
53
+
54
+ interface RevealState {
55
+ /** The layer element the reveal was applied for (deselect detection). */
56
+ base: HTMLElement;
57
+ nodes: RevealedNode[];
58
+ lift: RevealLift | null;
59
+ }
60
+
61
+ function restoreInline(el: HTMLElement, property: string, prior: string): void {
62
+ if (prior) el.style.setProperty(property, prior);
63
+ else el.style.removeProperty(property);
64
+ }
65
+
66
+ // This decoder owns the complete serialized reveal-commit schema. Splitting
67
+ // field checks across helpers would create multiple authorities for validity.
68
+ // fallow-ignore-next-line complexity
69
+ function readPendingRevealCommit(element: HTMLElement): PendingRevealCommit | null {
70
+ const raw = element.getAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR);
71
+ if (!raw) return null;
72
+ try {
73
+ const value: unknown = JSON.parse(raw);
74
+ if (typeof value !== "object" || value === null || !("token" in value)) return null;
75
+ const token = value.token;
76
+ if (typeof token !== "string") return null;
77
+ if (!("releasedPriors" in value) || value.releasedPriors === undefined) return { token };
78
+ const releasedPriors = value.releasedPriors;
79
+ if (
80
+ typeof releasedPriors !== "object" ||
81
+ releasedPriors === null ||
82
+ !("zIndex" in releasedPriors) ||
83
+ !("position" in releasedPriors)
84
+ ) {
85
+ return null;
86
+ }
87
+ const { zIndex, position } = releasedPriors;
88
+ return typeof zIndex === "string" && typeof position === "string"
89
+ ? { token, releasedPriors: { zIndex, position } }
90
+ : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function writePendingRevealCommit(element: HTMLElement, state: PendingRevealCommit): void {
97
+ element.setAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR, JSON.stringify(state));
98
+ }
99
+
100
+ /** Hand an active reveal lift to one pending durable z commit. */
101
+ export function beginLayerRevealCommit(element: HTMLElement): LayerRevealCommitOwnership | null {
102
+ const priorZ = element.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
103
+ if (priorZ == null) return null;
104
+ const ownership = {
105
+ token: `reveal-z-${revealCommitSequence++}`,
106
+ priorZ,
107
+ priorPosition: element.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR),
108
+ activeLiftStyles: { zIndex: element.style.zIndex, position: element.style.position },
109
+ };
110
+ writePendingRevealCommit(element, { token: ownership.token });
111
+ element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
112
+ element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
113
+ return ownership;
114
+ }
115
+
116
+ /** Durable success consumes the reveal regardless of whether selection released it meanwhile. */
117
+ export function completeLayerRevealCommit(
118
+ element: HTMLElement,
119
+ ownership: LayerRevealCommitOwnership,
120
+ ): void {
121
+ if (readPendingRevealCommit(element)?.token === ownership.token) {
122
+ element.removeAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Roll back only the reveal transaction this commit owns. An active reveal gets
128
+ * its temporary lift back; a reveal released while persistence was pending gets
129
+ * its exact authored inline styles back, with no resurrected metadata.
130
+ */
131
+ export function rollbackLayerRevealCommit(
132
+ element: HTMLElement,
133
+ ownership: LayerRevealCommitOwnership,
134
+ ): void {
135
+ const pending = readPendingRevealCommit(element);
136
+ if (pending?.token !== ownership.token) return;
137
+ element.removeAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR);
138
+ element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
139
+ element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
140
+ if (pending.releasedPriors) {
141
+ restoreInline(element, "z-index", pending.releasedPriors.zIndex);
142
+ restoreInline(element, "position", pending.releasedPriors.position);
143
+ return;
144
+ }
145
+ restoreInline(element, "z-index", ownership.activeLiftStyles.zIndex);
146
+ restoreInline(element, "position", ownership.activeLiftStyles.position);
147
+ element.setAttribute(LAYER_REVEAL_PRIOR_Z_ATTR, ownership.priorZ);
148
+ if (ownership.priorPosition != null) {
149
+ element.setAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR, ownership.priorPosition);
150
+ }
151
+ }
152
+
153
+ /** Restore a property ONLY when its current inline value is still the one this
154
+ * override wrote — a later real edit (commit, animation seek) is the new
155
+ * truth and must not be clobbered. */
156
+ function restoreIfOurs(
157
+ el: HTMLElement,
158
+ property: "display" | "visibility" | "opacity",
159
+ applied: string | undefined,
160
+ prior: string,
161
+ ): void {
162
+ if (applied == null) return;
163
+ if (el.style.getPropertyValue(property) !== applied) return;
164
+ restoreInline(el, property, prior);
165
+ }
166
+
167
+ /** What hides this node at the current frame, per computed style. */
168
+ function readHideSignals(el: HTMLElement, win: Window) {
169
+ const computed = win.getComputedStyle(el);
170
+ const opacity = Number.parseFloat(computed.opacity);
171
+ return {
172
+ display: computed.display === "none",
173
+ visibility: computed.visibility === "hidden" || computed.visibility === "collapse",
174
+ opacity: Number.isFinite(opacity) && opacity <= 0.01,
175
+ };
176
+ }
177
+
178
+ /** Force one hidden node visible with inline styles; returns priors + applied. */
179
+ function revealNode(
180
+ el: HTMLElement,
181
+ win: Window,
182
+ needs: ReturnType<typeof readHideSignals>,
183
+ ): RevealedNode {
184
+ const priors = {
185
+ display: el.style.display,
186
+ visibility: el.style.visibility,
187
+ opacity: el.style.opacity,
188
+ };
189
+ const applied: RevealedNode["applied"] = {};
190
+ if (needs.display) {
191
+ // Prefer whatever the stylesheet says once the inline hide is lifted;
192
+ // only force block when the sheet itself hides it.
193
+ el.style.removeProperty("display");
194
+ if (win.getComputedStyle(el).display === "none") el.style.display = "block";
195
+ applied.display = el.style.display;
196
+ }
197
+ if (needs.visibility) {
198
+ el.style.visibility = "visible";
199
+ applied.visibility = "visible";
200
+ }
201
+ if (needs.opacity) {
202
+ el.style.opacity = "1";
203
+ applied.opacity = "1";
204
+ }
205
+ return { element: el, priors, applied };
206
+ }
207
+
208
+ /** Walk `element` → body, force-revealing every hiding node; returns the touched nodes. */
209
+ function revealHiddenChain(element: HTMLElement): RevealedNode[] {
210
+ const doc = element.ownerDocument;
211
+ const win = doc.defaultView;
212
+ if (!win) return [];
213
+ const nodes: RevealedNode[] = [];
214
+ let el: HTMLElement | null = element;
215
+ while (el && el !== doc.body && el !== doc.documentElement) {
216
+ let needs: ReturnType<typeof readHideSignals>;
217
+ try {
218
+ needs = readHideSignals(el, win);
219
+ } catch {
220
+ break; // detached / cross-realm — leave the rest alone
221
+ }
222
+ if (needs.display || needs.visibility || needs.opacity) nodes.push(revealNode(el, win, needs));
223
+ el = el.parentElement;
224
+ }
225
+ return nodes;
226
+ }
227
+
228
+ /**
229
+ * Lift the selected element to the TOP of the paint order while selected —
230
+ * regardless of its authored z or panel position. The true z is parked in
231
+ * LAYER_REVEAL_PRIOR_Z_ATTR so every studio z reader keeps reporting it (the
232
+ * lift is invisible to menus, badges, the lane mirror, and the panel sort);
233
+ * only the renderer sees the lifted inline value. A static element gets a
234
+ * temporary position:relative (layout-preserving) so the z applies, with the
235
+ * prior position parked in LAYER_REVEAL_PRIOR_POSITION_ATTR for the z-commit's
236
+ * static check. Exported for direct unit testing.
237
+ */
238
+ export function liftElementToTop(element: HTMLElement): RevealLift | null {
239
+ const win = element.ownerDocument.defaultView;
240
+ if (!win) return null;
241
+ const priors = { zIndex: element.style.zIndex, position: element.style.position };
242
+ let positionLifted = false;
243
+ try {
244
+ element.setAttribute(LAYER_REVEAL_PRIOR_Z_ATTR, String(readEffectiveZIndex(element)));
245
+ if (win.getComputedStyle(element).position === "static") {
246
+ element.setAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR, "static");
247
+ element.style.position = "relative";
248
+ positionLifted = true;
249
+ }
250
+ } catch {
251
+ element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
252
+ return null; // detached / cross-realm — no lift
253
+ }
254
+ element.style.zIndex = LAYER_REVEAL_LIFT_Z;
255
+ return { priors, positionLifted };
256
+ }
257
+
258
+ /**
259
+ * Undo an active lift. Skipped entirely when the prior-z attribute is gone —
260
+ * a z-reorder commit consumed the lift (handleDomZIndexReorderCommit removes
261
+ * the attributes and writes the new real z), and that commit is the truth.
262
+ * Exported for direct unit testing.
263
+ */
264
+ export function restoreLiftedElement(element: HTMLElement, lift: RevealLift): void {
265
+ if (!element.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)) {
266
+ const pending = readPendingRevealCommit(element);
267
+ if (pending && !pending.releasedPriors) {
268
+ // Persistence temporarily owns the live z. Hand it the exact authored
269
+ // priors so a later rejection can restore them after this hook lets go.
270
+ writePendingRevealCommit(element, { ...pending, releasedPriors: lift.priors });
271
+ }
272
+ return;
273
+ }
274
+ element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
275
+ element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
276
+ if (element.style.zIndex === LAYER_REVEAL_LIFT_Z) {
277
+ restoreInline(element, "z-index", lift.priors.zIndex);
278
+ }
279
+ if (lift.positionLifted && element.style.position === "relative") {
280
+ restoreInline(element, "position", lift.priors.position);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Temporary "show me this element" override for the Layers panel
286
+ * (Webflow-navigator style): clicking a layer forces it (and every hiding
287
+ * ancestor up to the body) visible with LIVE inline styles, and paints it on
288
+ * TOP of the stack while selected (see liftElementToTop).
289
+ *
290
+ * Strictly ephemeral by construction:
291
+ * - Exact prior inline values are recorded per touched node and restored on
292
+ * every exit path — reveal of a different layer, deselect, playback start,
293
+ * unmount. Nothing is ever sent to a persist path, and each property is
294
+ * restored only while it still holds the value this override wrote.
295
+ * - A post-edit iframe reload replaces the DOM; detached nodes are skipped on
296
+ * restore (the fresh document never had the override).
297
+ * - Scrubbing/playing lets the runtime and GSAP rewrite these same inline
298
+ * styles — that is the animation showing reality, and the override is
299
+ * dropped on play for exactly that reason.
300
+ */
301
+ export function useLayerRevealOverride({
302
+ isPlaying,
303
+ selectedElement,
304
+ }: {
305
+ isPlaying: boolean;
306
+ selectedElement: HTMLElement | null;
307
+ }): {
308
+ scheduleReveal: (element: HTMLElement, delayMs: number) => void;
309
+ } {
310
+ const stateRef = useRef<RevealState | null>(null);
311
+ const pendingRevealRef = useRef<{ timer: number; element: HTMLElement } | null>(null);
312
+ const currentRef = useRef({ isPlaying, selectedElement });
313
+ currentRef.current = { isPlaying, selectedElement };
314
+
315
+ const restoreReveal = useCallback(() => {
316
+ const state = stateRef.current;
317
+ stateRef.current = null;
318
+ if (!state) return;
319
+ for (const { element, priors, applied } of state.nodes) {
320
+ if (!element.isConnected) continue;
321
+ restoreIfOurs(element, "display", applied.display, priors.display);
322
+ restoreIfOurs(element, "visibility", applied.visibility, priors.visibility);
323
+ restoreIfOurs(element, "opacity", applied.opacity, priors.opacity);
324
+ }
325
+ if (state.lift && state.base.isConnected) restoreLiftedElement(state.base, state.lift);
326
+ }, []);
327
+
328
+ const reveal = useCallback(
329
+ (element: HTMLElement) => {
330
+ restoreReveal();
331
+ const nodes = revealHiddenChain(element);
332
+ const lift = liftElementToTop(element);
333
+ if (nodes.length > 0 || lift) stateRef.current = { base: element, nodes, lift };
334
+ },
335
+ [restoreReveal],
336
+ );
337
+
338
+ const cancelScheduledReveal = useCallback(() => {
339
+ const pending = pendingRevealRef.current;
340
+ pendingRevealRef.current = null;
341
+ if (pending) window.clearTimeout(pending.timer);
342
+ }, []);
343
+
344
+ const scheduleReveal = useCallback(
345
+ (element: HTMLElement, delayMs: number) => {
346
+ cancelScheduledReveal();
347
+ const current = currentRef.current;
348
+ if (current.isPlaying) return;
349
+ const attemptReveal = () => {
350
+ const latest = currentRef.current;
351
+ if (latest.isPlaying || latest.selectedElement !== element || !element.isConnected) {
352
+ pendingRevealRef.current = null;
353
+ return;
354
+ }
355
+ if (zPersistCounts.has(element)) {
356
+ const timer = window.setTimeout(attemptReveal, REVEAL_RETRY_MS);
357
+ pendingRevealRef.current = { timer, element };
358
+ return;
359
+ }
360
+ pendingRevealRef.current = null;
361
+ reveal(element);
362
+ };
363
+ const timer = window.setTimeout(attemptReveal, delayMs);
364
+ pendingRevealRef.current = { timer, element };
365
+ },
366
+ [cancelScheduledReveal, reveal],
367
+ );
368
+
369
+ // Selection and playback jointly own eligibility for both pending and active
370
+ // reveals. Cancel first; playback or a different selection then restores the
371
+ // animation/authored styles.
372
+ useEffect(() => {
373
+ const pending = pendingRevealRef.current;
374
+ if (isPlaying || (pending && pending.element !== selectedElement)) cancelScheduledReveal();
375
+ const base = stateRef.current?.base;
376
+ if (isPlaying || (base && selectedElement !== base)) restoreReveal();
377
+ }, [cancelScheduledReveal, isPlaying, restoreReveal, selectedElement]);
378
+
379
+ // Unmount: never leave a timer or override behind.
380
+ useEffect(
381
+ () => () => {
382
+ cancelScheduledReveal();
383
+ restoreReveal();
384
+ },
385
+ [cancelScheduledReveal, restoreReveal],
386
+ );
387
+
388
+ return { scheduleReveal };
389
+ }
@@ -19,6 +19,9 @@ import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
19
19
  import { readHfId, type DomEditSelection } from "../editor/domEditing";
20
20
  import { buildStableSelector } from "../editor/domEditingDom";
21
21
  import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
22
+ import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
23
+ import { useCanvasZOrderTimelineMirror } from "./useCanvasZOrderTimelineMirror";
24
+ import { runZLaneGesture } from "./zLaneGesture";
22
25
  import type { BlockPreviewInfo } from "../sidebar/BlocksTab";
23
26
  import type { GestureRecordingState } from "../editor/GestureRecordControl";
24
27
  import type { ReactNode } from "react";
@@ -158,6 +161,7 @@ export function PreviewOverlays({
158
161
  handleDomEditElementDelete,
159
162
  handleDomZIndexReorderCommit,
160
163
  } = useDomEditActionsContext();
164
+ const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
161
165
 
162
166
  // fallow-ignore-next-line complexity
163
167
  const [snapPrefs, setSnapPrefs] = useState(() => {
@@ -224,7 +228,7 @@ export function PreviewOverlays({
224
228
  onRotationCommit={handleDomRotationCommit}
225
229
  onStyleCommit={handleDomStyleCommit}
226
230
  onDeleteSelection={handleDomEditElementDelete}
227
- onApplyZIndex={(sel, patches, action) => {
231
+ onApplyZIndex={(sel, patches, action, crossed) => {
228
232
  const { entries, dropped } = resolveZIndexEntries(sel, patches);
229
233
  if (dropped.length > 0) {
230
234
  // These siblings can't be written to source. Apply their live z
@@ -237,7 +241,28 @@ export function PreviewOverlays({
237
241
  dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
238
242
  );
239
243
  }
240
- if (entries.length > 0) handleDomZIndexReorderCommit(entries, undefined, action);
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);
241
266
  }}
242
267
  gridVisible={snapPrefs.gridVisible}
243
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
  );