@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
@@ -3,9 +3,22 @@
3
3
  import React, { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { usePlayerStore } from "../player";
6
- import type { DomEditPatchBatch } from "./domEditCommitTypes";
6
+ import {
7
+ LAYER_REVEAL_PRIOR_POSITION_ATTR,
8
+ LAYER_REVEAL_PRIOR_Z_ATTR,
9
+ } from "../player/lib/timelineElementHelpers";
10
+ import {
11
+ LAYER_REVEAL_LIFT_Z,
12
+ LAYER_REVEAL_PENDING_COMMIT_ATTR,
13
+ liftElementToTop,
14
+ restoreLiftedElement,
15
+ useLayerRevealOverride,
16
+ } from "../components/editor/useLayerRevealOverride";
17
+ import type { DomEditPatchBatch, DomEditPatchBatchesResult } from "./domEditCommitTypes";
7
18
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
19
+ import { makeLifecycleOpsParams } from "./elementLifecycleOpsTestUtils";
8
20
  import { mountReactHarness } from "./domSelectionTestHarness";
21
+ import { runZLaneGesture } from "../components/nle/zLaneGesture";
9
22
 
10
23
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
24
 
@@ -13,11 +26,14 @@ afterEach(() => {
13
26
  document.body.innerHTML = "";
14
27
  usePlayerStore.getState().setElements([]);
15
28
  vi.unstubAllGlobals();
29
+ vi.useRealTimers();
16
30
  });
17
31
 
18
32
  interface BatchOptions {
19
33
  label: string;
20
34
  coalesceKey: string;
35
+ coalesceMs?: number;
36
+ skipReload?: boolean;
21
37
  }
22
38
 
23
39
  interface CapturedBatchCall {
@@ -36,26 +52,22 @@ type ReorderCommit = (
36
52
  key?: string;
37
53
  }>,
38
54
  coalesceKeyOverride?: string,
39
- ) => Promise<void>;
55
+ actionKind?: string,
56
+ ) => Promise<DomEditPatchBatchesResult | undefined | void>;
40
57
 
41
58
  function renderReorderHook(
42
59
  capturedCalls: CapturedBatchCall[],
43
60
  onReady: (commit: ReorderCommit) => void,
44
61
  ) {
45
62
  function Harness() {
46
- const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
47
- activeCompPath: "index.html",
48
- showToast: vi.fn(),
49
- writeProjectFile: vi.fn(async () => {}),
50
- domEditSaveTimestampRef: { current: 0 },
51
- editHistory: { recordEdit: vi.fn(async () => {}) },
52
- projectIdRef: { current: null },
53
- reloadPreview: vi.fn(),
54
- clearDomSelection: vi.fn(),
55
- commitDomEditPatchBatches: async (batches, options) => {
56
- capturedCalls.push({ batches, options });
57
- },
58
- });
63
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
64
+ makeLifecycleOpsParams({
65
+ commitDomEditPatchBatches: async (batches, options) => {
66
+ capturedCalls.push({ batches, options });
67
+ return { durable: true, allMatched: true, changed: true };
68
+ },
69
+ }),
70
+ );
59
71
  onReady(handleDomZIndexReorderCommit);
60
72
  return null;
61
73
  }
@@ -109,6 +121,25 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
109
121
  act(() => root.unmount());
110
122
  });
111
123
 
124
+ it("requests skipReload on every z-reorder persist (live DOM already final)", async () => {
125
+ // The commit applies the z-index (and any injected position) to the live
126
+ // iframe DOM and the store synchronously, so the persisted style-only patch
127
+ // adds nothing the preview doesn't already show — the batch commit is asked
128
+ // to skip the iframe remount. commitDomEditPatchBatches still falls back to
129
+ // reloading when the server can't confirm every patch target matched.
130
+ const el = document.createElement("div");
131
+ el.id = "clip-z";
132
+
133
+ const { captured, root } = await runReorderCommit(el, [
134
+ { element: el, zIndex: 4, id: "clip-z", sourceFile: "index.html" },
135
+ ]);
136
+
137
+ expect(captured).toHaveLength(1);
138
+ expect(captured[0]?.options.skipReload).toBe(true);
139
+
140
+ act(() => root.unmount());
141
+ });
142
+
112
143
  it("preserves a real id when the element has one", async () => {
113
144
  const el = document.createElement("video");
114
145
  el.id = "v-hero";
@@ -176,6 +207,509 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
176
207
  act(() => root.unmount());
177
208
  });
178
209
 
210
+ it("keeps distinct actions in distinct default coalesce keys", async () => {
211
+ const el = document.createElement("div");
212
+ el.id = "clip-a";
213
+ document.body.appendChild(el);
214
+ const captured: CapturedBatchCall[] = [];
215
+ let commit: ReorderCommit | undefined;
216
+ const root = renderReorderHook(captured, (fn) => (commit = fn));
217
+
218
+ await act(async () => {
219
+ await commit!(
220
+ [{ element: el, zIndex: 1, id: "clip-a", sourceFile: "index.html" }],
221
+ undefined,
222
+ "bring-forward",
223
+ );
224
+ await commit!(
225
+ [{ element: el, zIndex: 0, id: "clip-a", sourceFile: "index.html" }],
226
+ undefined,
227
+ "send-backward",
228
+ );
229
+ });
230
+
231
+ // Same element set, different actions — the keys must differ so the two
232
+ // edits never coalesce into one undo step. Each key also carries a fresh
233
+ // gesture sequence, which is what makes the commit's unbounded per-gesture
234
+ // coalesce window safe (see zReorderCoalesceKey).
235
+ expect(captured).toHaveLength(2);
236
+ expect(captured[0]?.options.coalesceKey).toMatch(/^z-reorder:bring-forward:clip-a:g\d+$/);
237
+ expect(captured[1]?.options.coalesceKey).toMatch(/^z-reorder:send-backward:clip-a:g\d+$/);
238
+ expect(captured[0]?.options.coalesceKey).not.toBe(captured[1]?.options.coalesceKey);
239
+ // The two-phase gesture fold rides an unbounded window on both records.
240
+ expect(captured[0]?.options.coalesceMs).toBe(Number.POSITIVE_INFINITY);
241
+ act(() => root.unmount());
242
+ });
243
+
244
+ it("updates the store zIndex synchronously for entries that carry a store key", async () => {
245
+ const el = document.createElement("div");
246
+ el.id = "clip-a";
247
+ document.body.appendChild(el);
248
+ usePlayerStore.getState().setElements([
249
+ {
250
+ id: "clip-a",
251
+ key: "index.html#clip-a",
252
+ tag: "div",
253
+ start: 0,
254
+ duration: 1,
255
+ track: 0,
256
+ zIndex: 0,
257
+ hasExplicitZIndex: false,
258
+ },
259
+ ]);
260
+
261
+ let commit: ReorderCommit | undefined;
262
+ let resolveBatch: (() => void) | undefined;
263
+ const batchResult = { durable: true, allMatched: true, changed: true };
264
+ function Harness() {
265
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
266
+ makeLifecycleOpsParams({
267
+ // Persist stays pending so the assertion below can only be satisfied
268
+ // by the SYNCHRONOUS store update (the lane-sync path's requirement).
269
+ commitDomEditPatchBatches: () =>
270
+ new Promise((resolve) => (resolveBatch = () => resolve(batchResult))),
271
+ }),
272
+ );
273
+ commit = handleDomZIndexReorderCommit;
274
+ return null;
275
+ }
276
+ const root = mountReactHarness(<Harness />);
277
+
278
+ let pending: Promise<unknown> | undefined;
279
+ act(() => {
280
+ pending = commit!([
281
+ {
282
+ element: el,
283
+ zIndex: 5,
284
+ id: "clip-a",
285
+ sourceFile: "index.html",
286
+ key: "index.html#clip-a",
287
+ },
288
+ ]);
289
+ });
290
+
291
+ expect(usePlayerStore.getState().elements[0]).toMatchObject({
292
+ zIndex: 5,
293
+ hasExplicitZIndex: true,
294
+ });
295
+
296
+ resolveBatch?.();
297
+ await act(async () => pending);
298
+ act(() => root.unmount());
299
+ });
300
+
301
+ // The canvas context-menu path: the menu no longer pre-applies styles, so the
302
+ // hook sees the PRISTINE element — prior styles are captured before any
303
+ // mutation and a failed persist restores them exactly (previously the menu's
304
+ // optimistic write made the "rollback" restore the already-mutated values,
305
+ // and the never-persisted position patch silently reverted on reload).
306
+ it("rolls back a static, inline-style-free element to pristine styles on failure", async () => {
307
+ const el = document.createElement("div");
308
+ el.id = "clip-a";
309
+ el.style.position = "static"; // happy-dom computes "" for unset position
310
+ document.body.appendChild(el);
311
+ const failure = new Error("persist failed");
312
+
313
+ let commit: ReorderCommit | undefined;
314
+ function Harness() {
315
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
316
+ makeLifecycleOpsParams({
317
+ commitDomEditPatchBatches: vi.fn(async () => {
318
+ // The live styles were applied by the hook before persist ran.
319
+ expect(el.style.zIndex).toBe("2");
320
+ expect(el.style.position).toBe("relative");
321
+ throw failure;
322
+ }),
323
+ }),
324
+ );
325
+ commit = handleDomZIndexReorderCommit;
326
+ return null;
327
+ }
328
+ const root = mountReactHarness(<Harness />);
329
+
330
+ let rejection: unknown;
331
+ await act(async () => {
332
+ try {
333
+ await commit!(
334
+ [{ element: el, zIndex: 2, id: "clip-a", sourceFile: "index.html" }],
335
+ undefined,
336
+ "bring-forward",
337
+ );
338
+ } catch (error) {
339
+ rejection = error;
340
+ }
341
+ });
342
+
343
+ expect(rejection).toBe(failure);
344
+ expect(el.style.zIndex).toBe("");
345
+ expect(el.style.position).toBe("static");
346
+ act(() => root.unmount());
347
+ });
348
+
349
+ it("returns an active reveal lift and its original styles to their owner on failure", async () => {
350
+ const el = document.createElement("div");
351
+ el.id = "clip-a";
352
+ el.style.zIndex = "4";
353
+ el.style.position = "static";
354
+ document.body.appendChild(el);
355
+ const lift = liftElementToTop(el)!;
356
+ const failure = new Error("persist failed");
357
+
358
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
359
+ expect(el.style.position).toBe("relative");
360
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe("4");
361
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR)).toBe("static");
362
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
363
+
364
+ let commit: ReorderCommit | undefined;
365
+ function Harness() {
366
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
367
+ makeLifecycleOpsParams({
368
+ commitDomEditPatchBatches: vi.fn(async () => {
369
+ expect(el.style.zIndex).toBe("8");
370
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
371
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR)).toBe(false);
372
+ throw failure;
373
+ }),
374
+ }),
375
+ );
376
+ commit = handleDomZIndexReorderCommit;
377
+ return null;
378
+ }
379
+ const root = mountReactHarness(<Harness />);
380
+
381
+ let rejection: unknown;
382
+ await act(async () => {
383
+ try {
384
+ await commit!([{ element: el, zIndex: 8, id: "clip-a", sourceFile: "index.html" }]);
385
+ } catch (error) {
386
+ rejection = error;
387
+ }
388
+ });
389
+ expect(rejection).toBe(failure);
390
+
391
+ // The failed optimistic commit must put the still-active lift back exactly
392
+ // as it found it, including the metadata that lets reveal cleanup restore
393
+ // the authored z/position rather than abandoning the temporary lift.
394
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
395
+ expect(el.style.position).toBe("relative");
396
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe("4");
397
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR)).toBe("static");
398
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
399
+
400
+ restoreLiftedElement(el, lift);
401
+ expect(el.style.zIndex).toBe("4");
402
+ expect(el.style.position).toBe("static");
403
+ act(() => root.unmount());
404
+ });
405
+
406
+ it("reconciles live, store, and reveal state when no patch target matched", async () => {
407
+ const el = document.createElement("div");
408
+ el.id = "clip-a";
409
+ el.style.zIndex = "4";
410
+ el.style.position = "static";
411
+ document.body.appendChild(el);
412
+ usePlayerStore.getState().setElements([
413
+ {
414
+ id: "clip-a",
415
+ tag: "div",
416
+ start: 0,
417
+ duration: 1,
418
+ track: 0,
419
+ zIndex: 4,
420
+ hasExplicitZIndex: true,
421
+ },
422
+ ]);
423
+ const lift = liftElementToTop(el)!;
424
+ const unmatched = { durable: false, allMatched: false, changed: false };
425
+ let commit: ReorderCommit | undefined;
426
+
427
+ function Harness() {
428
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
429
+ makeLifecycleOpsParams({
430
+ commitDomEditPatchBatches: vi.fn(async () => unmatched),
431
+ }),
432
+ ));
433
+ return null;
434
+ }
435
+ const root = mountReactHarness(<Harness />);
436
+
437
+ let result: unknown;
438
+ await act(async () => {
439
+ result = await commit!([
440
+ {
441
+ element: el,
442
+ zIndex: 8,
443
+ id: "clip-a",
444
+ sourceFile: "index.html",
445
+ key: "clip-a",
446
+ },
447
+ ]);
448
+ });
449
+
450
+ expect(result).toEqual(unmatched);
451
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
452
+ expect(el.style.position).toBe("relative");
453
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe("4");
454
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR)).toBe("static");
455
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
456
+ expect(usePlayerStore.getState().elements[0]).toMatchObject({
457
+ zIndex: 4,
458
+ hasExplicitZIndex: true,
459
+ });
460
+
461
+ restoreLiftedElement(el, lift);
462
+ expect(el.style.zIndex).toBe("4");
463
+ expect(el.style.position).toBe("static");
464
+ act(() => root.unmount());
465
+ });
466
+
467
+ it.each(["rejection", "unmatched"] as const)(
468
+ "defers a delayed reveal until a pending z commit finishes with %s",
469
+ async (outcome) => {
470
+ vi.useFakeTimers();
471
+ const el = document.createElement("div");
472
+ el.id = "clip-a";
473
+ el.style.zIndex = "4";
474
+ el.style.position = "absolute";
475
+ document.body.appendChild(el);
476
+ usePlayerStore.getState().setElements([
477
+ {
478
+ id: "clip-a",
479
+ tag: "div",
480
+ start: 0,
481
+ duration: 1,
482
+ track: 0,
483
+ zIndex: 4,
484
+ hasExplicitZIndex: true,
485
+ },
486
+ ]);
487
+ let resolvePersist: ((result: DomEditPatchBatchesResult) => void) | undefined;
488
+ let rejectPersist: ((error: Error) => void) | undefined;
489
+ const persist = vi.fn(
490
+ () =>
491
+ new Promise<DomEditPatchBatchesResult>((resolve, reject) => {
492
+ resolvePersist = resolve;
493
+ rejectPersist = reject;
494
+ }),
495
+ );
496
+ let commit: ReorderCommit | undefined;
497
+ let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
498
+
499
+ function Harness() {
500
+ ({ scheduleReveal } = useLayerRevealOverride({
501
+ isPlaying: false,
502
+ selectedElement: el,
503
+ }));
504
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
505
+ makeLifecycleOpsParams({ commitDomEditPatchBatches: persist }),
506
+ ));
507
+ return null;
508
+ }
509
+ const root = mountReactHarness(<Harness />);
510
+
511
+ let pending: Promise<unknown> | undefined;
512
+ act(() => {
513
+ scheduleReveal!(el, 10);
514
+ pending = commit!([
515
+ { element: el, zIndex: 8, id: "clip-a", sourceFile: "index.html", key: "clip-a" },
516
+ ]);
517
+ vi.advanceTimersByTime(10);
518
+ });
519
+
520
+ // The timer elapsed, but it must not capture optimistic z=8 as authored.
521
+ expect(el.style.zIndex).toBe("8");
522
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
523
+
524
+ let rejection: unknown;
525
+ await act(async () => {
526
+ if (outcome === "rejection") rejectPersist!(new Error("save failed"));
527
+ else resolvePersist!({ durable: false, allMatched: false, changed: false });
528
+ try {
529
+ await pending;
530
+ } catch (error) {
531
+ rejection = error;
532
+ }
533
+ });
534
+ if (outcome === "rejection") expect(rejection).toBeInstanceOf(Error);
535
+
536
+ act(() => vi.advanceTimersByTime(16));
537
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
538
+ expect(el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe("4");
539
+ expect(usePlayerStore.getState().elements[0]?.zIndex).toBe(4);
540
+ act(() => root.unmount());
541
+ },
542
+ );
543
+
544
+ it("serializes overlapping commits through the shared gesture owner", async () => {
545
+ const el = document.createElement("div");
546
+ el.id = "clip-a";
547
+ el.style.zIndex = "1";
548
+ document.body.appendChild(el);
549
+ usePlayerStore.getState().setElements([
550
+ {
551
+ id: "clip-a",
552
+ tag: "div",
553
+ start: 0,
554
+ duration: 1,
555
+ track: 0,
556
+ zIndex: 1,
557
+ hasExplicitZIndex: true,
558
+ },
559
+ ]);
560
+ const firstFailure = new Error("first persist failed");
561
+ let rejectFirst: ((error: Error) => void) | undefined;
562
+ let resolveSecond: (() => void) | undefined;
563
+ const persist = vi
564
+ .fn()
565
+ .mockImplementationOnce(
566
+ () =>
567
+ new Promise<never>((_resolve, reject) => {
568
+ rejectFirst = reject;
569
+ }),
570
+ )
571
+ .mockImplementationOnce(
572
+ () =>
573
+ new Promise((resolve) => {
574
+ resolveSecond = () => resolve({ durable: true, allMatched: true, changed: true });
575
+ }),
576
+ );
577
+ let commit: ReorderCommit | undefined;
578
+
579
+ function Harness() {
580
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
581
+ makeLifecycleOpsParams({ commitDomEditPatchBatches: persist }),
582
+ ));
583
+ return null;
584
+ }
585
+ const root = mountReactHarness(<Harness />);
586
+
587
+ let firstPending: Promise<unknown> | undefined;
588
+ let secondPending: Promise<unknown> | undefined;
589
+ act(() => {
590
+ firstPending = runZLaneGesture({
591
+ commitZ: () =>
592
+ commit!([
593
+ { element: el, zIndex: 2, id: "clip-a", sourceFile: "index.html", key: "clip-a" },
594
+ ]),
595
+ mirror: async () => true,
596
+ });
597
+ secondPending = runZLaneGesture({
598
+ commitZ: () =>
599
+ commit!([
600
+ { element: el, zIndex: 3, id: "clip-a", sourceFile: "index.html", key: "clip-a" },
601
+ ]),
602
+ mirror: async () => true,
603
+ });
604
+ });
605
+
606
+ expect(persist).toHaveBeenCalledTimes(1);
607
+ expect(el.style.zIndex).toBe("2");
608
+ expect(usePlayerStore.getState().elements[0]?.zIndex).toBe(2);
609
+
610
+ let rejection: unknown;
611
+ await act(async () => {
612
+ rejectFirst!(firstFailure);
613
+ try {
614
+ await firstPending;
615
+ } catch (error) {
616
+ rejection = error;
617
+ }
618
+ await Promise.resolve();
619
+ });
620
+
621
+ expect(rejection).toBe(firstFailure);
622
+ expect(persist).toHaveBeenCalledTimes(2);
623
+ expect(el.style.zIndex).toBe("3");
624
+ expect(usePlayerStore.getState().elements[0]?.zIndex).toBe(3);
625
+
626
+ await act(async () => {
627
+ resolveSecond!();
628
+ await secondPending;
629
+ });
630
+ expect(el.style.zIndex).toBe("3");
631
+ expect(usePlayerStore.getState().elements[0]?.zIndex).toBe(3);
632
+ act(() => root.unmount());
633
+ });
634
+
635
+ it.each(["deselection", "playback"] as const)(
636
+ "does not resurrect a reveal released by %s while persistence is pending",
637
+ async (releaseBy) => {
638
+ vi.useFakeTimers();
639
+ const el = document.createElement("div");
640
+ el.id = "clip-a";
641
+ el.style.zIndex = "4";
642
+ el.style.position = "static";
643
+ const other = document.createElement("div");
644
+ document.body.append(el, other);
645
+ const failure = new Error("persist failed");
646
+ let rejectPersist: ((error: Error) => void) | undefined;
647
+ const persist = vi.fn(
648
+ () =>
649
+ new Promise<never>((_resolve, reject) => {
650
+ rejectPersist = reject;
651
+ }),
652
+ );
653
+ let commit: ReorderCommit | undefined;
654
+ let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
655
+
656
+ function Harness({
657
+ selectedElement,
658
+ isPlaying,
659
+ }: {
660
+ selectedElement: HTMLElement | null;
661
+ isPlaying: boolean;
662
+ }) {
663
+ ({ scheduleReveal } = useLayerRevealOverride({ isPlaying, selectedElement }));
664
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
665
+ makeLifecycleOpsParams({ commitDomEditPatchBatches: persist }),
666
+ ));
667
+ return null;
668
+ }
669
+
670
+ const root = mountReactHarness(<Harness selectedElement={el} isPlaying={false} />);
671
+ act(() => {
672
+ scheduleReveal!(el, 0);
673
+ vi.advanceTimersByTime(0);
674
+ });
675
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
676
+
677
+ let pending: Promise<unknown> | undefined;
678
+ act(() => {
679
+ pending = commit!([{ element: el, zIndex: 8, id: el.id, sourceFile: "index.html" }]);
680
+ });
681
+ expect(el.style.zIndex).toBe("8");
682
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(true);
683
+
684
+ act(() => {
685
+ root.render(
686
+ <Harness
687
+ selectedElement={releaseBy === "deselection" ? other : el}
688
+ isPlaying={releaseBy === "playback"}
689
+ />,
690
+ );
691
+ });
692
+
693
+ let rejection: unknown;
694
+ await act(async () => {
695
+ rejectPersist!(failure);
696
+ try {
697
+ await pending;
698
+ } catch (error) {
699
+ rejection = error;
700
+ }
701
+ });
702
+
703
+ expect(rejection).toBe(failure);
704
+ expect(el.style.zIndex).toBe("4");
705
+ expect(el.style.position).toBe("static");
706
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
707
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR)).toBe(false);
708
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
709
+ act(() => root.unmount());
710
+ },
711
+ );
712
+
179
713
  it("rolls back only live and store state after an atomic reorder failure", async () => {
180
714
  const writeProjectFile = vi.fn(async () => {});
181
715
  const recordEdit = vi.fn(async () => {});
@@ -202,20 +736,17 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
202
736
 
203
737
  let commit: ReorderCommit | undefined;
204
738
  function Harness() {
205
- const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
206
- activeCompPath: "index.html",
207
- showToast: vi.fn(),
208
- writeProjectFile,
209
- domEditSaveTimestampRef: { current: 0 },
210
- editHistory: { recordEdit },
211
- projectIdRef: { current: "demo" },
212
- reloadPreview: vi.fn(),
213
- clearDomSelection: vi.fn(),
214
- forceReloadSdkSession,
215
- commitDomEditPatchBatches: vi.fn(async () => {
216
- throw originalError;
739
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
740
+ makeLifecycleOpsParams({
741
+ writeProjectFile,
742
+ editHistory: { recordEdit },
743
+ projectIdRef: { current: "demo" },
744
+ forceReloadSdkSession,
745
+ commitDomEditPatchBatches: vi.fn(async () => {
746
+ throw originalError;
747
+ }),
217
748
  }),
218
- });
749
+ );
219
750
  commit = handleDomZIndexReorderCommit;
220
751
  return null;
221
752
  }