@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.56",
3
+ "version": "0.7.58",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/player": "0.7.56",
50
- "@hyperframes/studio-server": "0.7.56",
51
- "@hyperframes/sdk": "0.7.56",
52
- "@hyperframes/core": "0.7.56",
53
- "@hyperframes/parsers": "0.7.56"
49
+ "@hyperframes/core": "0.7.58",
50
+ "@hyperframes/parsers": "0.7.58",
51
+ "@hyperframes/player": "0.7.58",
52
+ "@hyperframes/sdk": "0.7.58",
53
+ "@hyperframes/studio-server": "0.7.58"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.56"
68
+ "@hyperframes/producer": "0.7.58"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -189,10 +189,10 @@ export function StudioApp() {
189
189
  }>,
190
190
  coalesceKey?: string,
191
191
  operation: TimelineMoveOperation = "timing",
192
+ coalesceMs?: number,
192
193
  ) => {
193
- await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, {
194
- handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove,
195
- });
194
+ const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
195
+ await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
196
196
  },
197
197
  [timelineEditing.handleTimelineGroupMove],
198
198
  );
@@ -180,9 +180,9 @@ export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
180
180
  </>
181
181
  ) : (
182
182
  <>
183
- <span className="text-neutral-500 flex-shrink-0">How's the Studio experience?</span>
183
+ <span className="text-neutral-500 flex-shrink-0">Recommend HyperFrames?</span>
184
184
  <div className="flex items-center gap-0.5">
185
- {[1, 2, 3, 4, 5].map((n) => (
185
+ {Array.from({ length: 11 }, (_, n) => n).map((n) => (
186
186
  <button
187
187
  key={n}
188
188
  onClick={() => handleRating(n)}
@@ -3,7 +3,12 @@ import React, { act } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
6
+ import { resolveZIndexEntries } from "../nle/PreviewOverlays";
7
+ import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
8
+ import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
9
+ import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
6
10
  import { CanvasContextMenu } from "./CanvasContextMenu";
11
+ import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
7
12
  import type { DomEditSelection } from "./domEditing";
8
13
 
9
14
  installReactActEnvironment();
@@ -24,7 +29,8 @@ afterEach(() => {
24
29
 
25
30
  function renderMenu(props: {
26
31
  selection: DomEditSelection;
27
- onApplyZIndex?: () => void;
32
+ onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
33
+ onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
28
34
  onDelete?: (selection: DomEditSelection) => void;
29
35
  }) {
30
36
  root = createRoot(host);
@@ -36,6 +42,7 @@ function renderMenu(props: {
36
42
  selection: props.selection,
37
43
  onClose: () => {},
38
44
  onApplyZIndex: props.onApplyZIndex,
45
+ onZOrderCrossed: props.onZOrderCrossed,
39
46
  onDelete: props.onDelete,
40
47
  }),
41
48
  );
@@ -96,9 +103,26 @@ describe("CanvasContextMenu — handler gating", () => {
96
103
 
97
104
  renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });
98
105
 
99
- expect(zOrderButtons()).toHaveLength(4);
106
+ const buttons = zOrderButtons();
107
+ expect(buttons).toHaveLength(4);
100
108
  expect(hasDeleteItem()).toBe(false);
101
109
  expect(document.body.querySelector(".border-t")).toBeNull();
110
+
111
+ // Labels stay the exact industry-standard names (the icons add no text)...
112
+ expect(buttons.map((b) => b.textContent)).toEqual([
113
+ "Bring to front",
114
+ "Bring forward",
115
+ "Send backward",
116
+ "Send to back",
117
+ ]);
118
+ // ...and each item leads with a stroke icon that inherits the item's text
119
+ // color (currentColor), so the disabled muted tone applies to it too.
120
+ for (const button of buttons) {
121
+ const svg = button.firstElementChild;
122
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
123
+ expect(svg?.getAttribute("stroke")).toBe("currentColor");
124
+ expect(svg?.getAttribute("aria-hidden")).toBe("true");
125
+ }
102
126
  });
103
127
 
104
128
  it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
@@ -113,3 +137,143 @@ describe("CanvasContextMenu — handler gating", () => {
113
137
  expect(document.body.querySelector(".border-t")).toBeNull();
114
138
  });
115
139
  });
140
+
141
+ // ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ──
142
+
143
+ function pressMenuItem(label: string) {
144
+ const button = zOrderButtons().find((b) => b.textContent === label);
145
+ expect(button).toBeDefined();
146
+ act(() => {
147
+ button!.dispatchEvent(
148
+ new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
149
+ );
150
+ });
151
+ }
152
+
153
+ /** Target (static, earlier in DOM) below an equal-z sibling — z action must renumber. */
154
+ function makeStaticFamily() {
155
+ const parent = document.createElement("div");
156
+ const target = document.createElement("div");
157
+ target.id = "target";
158
+ // In happy-dom an unset computed position is "" (not "static"), which would
159
+ // skip the commit hook's static-position injection; declare it explicitly so
160
+ // the test exercises the browser default.
161
+ target.style.position = "static";
162
+ const other = document.createElement("div");
163
+ other.id = "other";
164
+ parent.append(target, other);
165
+ document.body.append(parent);
166
+ return { parent, target, other };
167
+ }
168
+
169
+ interface CapturedBatchCall {
170
+ batches: DomEditPatchBatch[];
171
+ options: { label: string; coalesceKey: string };
172
+ }
173
+
174
+ /** Mount the REAL commit hook (persist layer mocked at commitDomEditPatchBatches). */
175
+ function renderCommitHook(captured: CapturedBatchCall[]) {
176
+ type Commit = ReturnType<typeof useElementLifecycleOps>["handleDomZIndexReorderCommit"];
177
+ let commit: Commit | undefined;
178
+ function Harness() {
179
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps(
180
+ makeLifecycleOpsParams({
181
+ commitDomEditPatchBatches: async (batches, options) => {
182
+ captured.push({ batches, options });
183
+ return { durable: true, allMatched: true, changed: true };
184
+ },
185
+ }),
186
+ ));
187
+ return null;
188
+ }
189
+ const hookHost = document.createElement("div");
190
+ document.body.append(hookHost);
191
+ const hookRoot = createRoot(hookHost);
192
+ act(() => hookRoot.render(<Harness />));
193
+ return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
194
+ }
195
+
196
+ describe("CanvasContextMenu — z-action commit path", () => {
197
+ it("never mutates live styles itself and persists the position patch for a static element", async () => {
198
+ const { target } = makeStaticFamily();
199
+ const selection = makeSelection("Target", target);
200
+ const captured: CapturedBatchCall[] = [];
201
+ const { commit, cleanup } = renderCommitHook(captured);
202
+
203
+ // Wire onApplyZIndex the way the app does (PreviewOverlays → the commit
204
+ // hook), asserting the menu has NOT touched the DOM when it fires — the
205
+ // hook must capture true pre-change styles for its rollback.
206
+ const stylesAtApply: Array<{ zIndex: string; position: string }> = [];
207
+ renderMenu({
208
+ selection,
209
+ onApplyZIndex: (patches, action) => {
210
+ stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
211
+ const { entries } = resolveZIndexEntries(selection, patches);
212
+ void commit(entries, undefined, action);
213
+ },
214
+ });
215
+
216
+ await act(async () => pressMenuItem("Bring forward"));
217
+
218
+ // The menu left the element pristine; only the commit hook wrote styles.
219
+ expect(stylesAtApply).toEqual([{ zIndex: "", position: "static" }]);
220
+ expect(target.style.zIndex).toBe("1");
221
+ expect(target.style.position).toBe("relative");
222
+
223
+ // The persisted payload carries BOTH the z-index and the injected position,
224
+ // so the reorder survives the post-commit reloadPreview().
225
+ expect(captured).toHaveLength(1);
226
+ const targetPatch = captured[0]?.batches
227
+ .flatMap((batch) => batch.patches)
228
+ .find((patch) => patch.target.id === "target");
229
+ expect(targetPatch?.operations).toEqual(
230
+ expect.arrayContaining([
231
+ { type: "inline-style", property: "z-index", value: "1" },
232
+ { type: "inline-style", property: "position", value: "relative" },
233
+ ]),
234
+ );
235
+ // F7: the action kind is part of the default undo coalesce key, so two
236
+ // different menu actions never merge into one undo step.
237
+ expect(captured[0]?.options.coalesceKey).toContain("bring-forward");
238
+
239
+ cleanup();
240
+ });
241
+
242
+ it("reports the crossed sibling to onZOrderCrossed for a forward step (resolved pre-mutation)", async () => {
243
+ // target (earlier in DOM) and other are tied — bring-forward steps over
244
+ // `other`, and the flash callback must receive exactly that element, after
245
+ // onApplyZIndex ran (call order lets the host measure post-commit rects).
246
+ const { target, other } = makeStaticFamily();
247
+ const selection = makeSelection("Target", target);
248
+ const calls: Array<{ kind: string; crossed?: HTMLElement }> = [];
249
+
250
+ renderMenu({
251
+ selection,
252
+ onApplyZIndex: () => calls.push({ kind: "apply" }),
253
+ onZOrderCrossed: (crossed, action) => {
254
+ expect(action).toBe("bring-forward");
255
+ calls.push({ kind: "crossed", crossed });
256
+ },
257
+ });
258
+
259
+ await act(async () => pressMenuItem("Bring forward"));
260
+
261
+ expect(calls.map((c) => c.kind)).toEqual(["apply", "crossed"]);
262
+ expect(calls[1]?.crossed).toBe(other);
263
+ });
264
+
265
+ it("does not call onZOrderCrossed for bring-to-front", async () => {
266
+ const { target } = makeStaticFamily();
267
+ const onZOrderCrossed = vi.fn();
268
+
269
+ renderMenu({
270
+ selection: makeSelection("Target", target),
271
+ onApplyZIndex: vi.fn(),
272
+ onZOrderCrossed,
273
+ });
274
+
275
+ await act(async () => pressMenuItem("Bring to front"));
276
+
277
+ expect(onZOrderCrossed).not.toHaveBeenCalled();
278
+ });
279
+ });
@@ -7,10 +7,13 @@
7
7
  * useContextMenuDismiss.
8
8
  *
9
9
  * ── Wiring (z-order persistence) ─────────────────────────────────────────────
10
- * Z-index changes are applied optimistically to the live iframe element(s) via
10
+ * Z-index changes are resolved against the live iframe DOM via
11
11
  * `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
12
12
  * moving a target past an equal-z sibling can require renumbering the affected
13
- * set). The patches are surfaced through the `onApplyZIndex` prop.
13
+ * set). The patches are surfaced through the `onApplyZIndex` prop; the menu
14
+ * itself never mutates element styles — handleDomZIndexReorderCommit applies
15
+ * the live z-index (and injects position when needed) in the same synchronous
16
+ * flow, and captures the TRUE prior styles for its failure rollback.
14
17
  *
15
18
  * The prop MUST be wired at the call site to route through the full persist
16
19
  * path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
@@ -26,7 +29,9 @@ import type { DomEditSelection } from "./domEditing";
26
29
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
27
30
  import {
28
31
  isZOrderActionEnabled,
32
+ resolveCrossedNeighbor,
29
33
  resolveZOrderChange,
34
+ type ZOrderAction,
30
35
  type ZOrderPatch,
31
36
  } from "./canvasContextMenuZOrder";
32
37
 
@@ -38,12 +43,31 @@ interface CanvasContextMenuProps {
38
43
  selection: DomEditSelection;
39
44
  onClose: () => void;
40
45
  /**
41
- * Called with the resolved z-order patch list after an optimistic DOM update.
42
- * Each patch is an { element, zIndex } pair (the target and, when a renumber
43
- * is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
44
- * module-level wiring comment).
46
+ * Called with the resolved z-order patch list and the menu action that
47
+ * produced it (the action feeds the undo coalesce key, so two DIFFERENT
48
+ * actions never merge into one undo step). Each patch is an
49
+ * { element, zIndex } pair (the target and, when a renumber is needed,
50
+ * affected siblings). The menu does NOT touch the live DOM — wire to
51
+ * handleDomZIndexReorderCommit, which applies the live styles itself
52
+ * (see module-level wiring comment).
53
+ *
54
+ * `crossed` is the sibling a forward/backward step moved past, resolved from
55
+ * the SAME pre-mutation render order as the patches (null for front/back or
56
+ * when there is no neighbor). The host uses it to mirror the z action into a
57
+ * timeline lane move (resolveZMirrorLaneMove's crossedKey).
45
58
  */
46
- onApplyZIndex?: (patches: ZOrderPatch[]) => void;
59
+ onApplyZIndex?: (
60
+ patches: ZOrderPatch[],
61
+ action: ZOrderAction,
62
+ crossed: HTMLElement | null,
63
+ ) => void;
64
+ /**
65
+ * Called after a successful bring-forward / send-backward with the sibling
66
+ * the target stepped over (resolved from the SAME pre-mutation state as the
67
+ * patches), so the host can flash a highlight on it in the studio overlay.
68
+ * Never called for front/back or no-op actions.
69
+ */
70
+ onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
47
71
  /**
48
72
  * Delete the selected element. Wire to handleDomEditElementDelete from
49
73
  * useDomEditActionsContext — same path as the Delete/Backspace hotkey.
@@ -55,10 +79,61 @@ interface CanvasContextMenuProps {
55
79
 
56
80
  type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
57
81
 
82
+ // Stacked-layer + arrow glyphs, one per z action (16px, stroke, currentColor —
83
+ // matches the studio's inline-SVG conventions: fill="none", 1.2 stroke, round
84
+ // caps/joins). Single actions show ONE layer diamond with the arrow stepping
85
+ // one way; front/back show a TWO-diamond stack with the arrow piercing through
86
+ // and beyond it. `paths` are the d attributes, drawn in order.
87
+ const Z_ACTION_ICONS: Record<ZAction, string[]> = {
88
+ "bring-forward": [
89
+ "M3 11 L8 8.5 L13 11 L8 13.5 Z", // layer diamond (bottom)
90
+ "M8 8.5 L8 2", // arrow shaft up
91
+ "M5.5 4.5 L8 2 L10.5 4.5", // arrow head
92
+ ],
93
+ "send-backward": [
94
+ "M3 5 L8 2.5 L13 5 L8 7.5 Z", // layer diamond (top)
95
+ "M8 7.5 L8 14", // arrow shaft down
96
+ "M5.5 11.5 L8 14 L10.5 11.5", // arrow head
97
+ ],
98
+ "bring-to-front": [
99
+ "M3 9.5 L8 7 L13 9.5 L8 12 Z", // upper layer of the stack
100
+ "M3 12.5 L8 10 L13 12.5 L8 15 Z", // lower layer of the stack
101
+ "M8 12.5 L8 2", // arrow piercing up through/above the stack
102
+ "M5.5 4.5 L8 2 L10.5 4.5", // arrow head
103
+ ],
104
+ "send-to-back": [
105
+ "M3 4 L8 1.5 L13 4 L8 6.5 Z", // upper layer of the stack
106
+ "M3 7 L8 4.5 L13 7 L8 9.5 Z", // lower layer of the stack
107
+ "M8 3.5 L8 14", // arrow piercing down through/below the stack
108
+ "M5.5 11.5 L8 14 L10.5 11.5", // arrow head
109
+ ],
110
+ };
111
+
112
+ function ZActionIcon({ action }: { action: ZAction }) {
113
+ return (
114
+ <svg
115
+ width="16"
116
+ height="16"
117
+ viewBox="0 0 16 16"
118
+ fill="none"
119
+ stroke="currentColor"
120
+ strokeWidth="1.2"
121
+ strokeLinecap="round"
122
+ strokeLinejoin="round"
123
+ className="mr-2 shrink-0"
124
+ aria-hidden="true"
125
+ >
126
+ {Z_ACTION_ICONS[action].map((d) => (
127
+ <path key={d} d={d} />
128
+ ))}
129
+ </svg>
130
+ );
131
+ }
132
+
58
133
  const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
134
+ { action: "bring-to-front", label: "Bring to front" },
59
135
  { action: "bring-forward", label: "Bring forward" },
60
136
  { action: "send-backward", label: "Send backward" },
61
- { action: "bring-to-front", label: "Bring to front" },
62
137
  { action: "send-to-back", label: "Send to back" },
63
138
  ];
64
139
 
@@ -68,6 +143,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
68
143
  selection,
69
144
  onClose,
70
145
  onApplyZIndex,
146
+ onZOrderCrossed,
71
147
  onDelete,
72
148
  }: CanvasContextMenuProps) {
73
149
  const menuRef = useContextMenuDismiss(onClose);
@@ -93,20 +169,21 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
93
169
  const el = selection.element;
94
170
 
95
171
  function handleZAction(action: ZAction) {
96
- // No persist handler → do NOT touch the live iframe DOM. An optimistic
97
- // write with nothing to persist just reverts on the next reload.
98
172
  if (!onApplyZIndex) return;
99
173
  const patches = resolveZOrderChange(el, action);
100
174
  if (patches === null) return;
101
- // Optimistic update visible immediately even before persist completes.
102
- for (const patch of patches) {
103
- patch.element.style.zIndex = String(patch.zIndex);
104
- const view = patch.element.ownerDocument?.defaultView;
105
- if (view && view.getComputedStyle(patch.element).position === "static") {
106
- patch.element.style.position = "relative";
107
- }
108
- }
109
- onApplyZIndex(patches);
175
+ // Resolve the crossed neighbor BEFORE the commit path mutates live styles —
176
+ // both resolvers must read the same pre-change render order. Always resolved
177
+ // (not only for the flash): onApplyZIndex forwards it so the host can mirror
178
+ // the z step into a timeline lane move.
179
+ const crossed = resolveCrossedNeighbor(el, action);
180
+ // Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
181
+ // live z-index (and injects position:relative for static elements) in the
182
+ // same synchronous flow, so feedback is still instant — and it must read
183
+ // the PRE-change styles itself, both to capture true rollback values and
184
+ // to detect a static position that needs persisting.
185
+ onApplyZIndex(patches, action, crossed);
186
+ if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action);
110
187
  onClose();
111
188
  }
112
189
 
@@ -170,7 +247,10 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
170
247
  if (enabled) handleZAction(action);
171
248
  }}
172
249
  >
173
- {label}
250
+ {/* Icon inherits the item's text color via currentColor, so the
251
+ disabled muted tone applies to both icon and label. */}
252
+ <ZActionIcon action={action} />
253
+ <span>{label}</span>
174
254
  </button>
175
255
  );
176
256
  })}
@@ -1,9 +1,11 @@
1
- import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
1
+ import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
2
  import { type DomEditSelection } from "./domEditing";
3
3
  import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
4
4
  import { useMarqueeGestures } from "./marqueeCommit";
5
5
  import { MarqueeOverlay } from "./MarqueeOverlay";
6
6
  import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
7
+ import { useZOrderCrossedFlash, ZOrderCrossedFlash } from "./useZOrderCrossedFlash";
8
+ import { useCanvasContextMenuState } from "./useCanvasContextMenuState";
7
9
  import {
8
10
  type BlockedMoveState,
9
11
  type DomEditGroupPathOffsetCommit,
@@ -27,7 +29,7 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
27
29
  import { useMountEffect } from "../../hooks/useMountEffect";
28
30
  import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
29
31
  import { CanvasContextMenu } from "./CanvasContextMenu";
30
- import type { ZOrderPatch } from "./canvasContextMenuZOrder";
32
+ import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
31
33
  import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
32
34
 
33
35
  // Re-exports for external consumers — preserving existing import paths.
@@ -91,12 +93,20 @@ interface DomEditOverlayProps {
91
93
  */
92
94
  onDeleteSelection?: (selection: DomEditSelection) => void;
93
95
  /**
94
- * Called with the resolved z-order patch list after an optimistic DOM update.
95
- * The patch list is tie-aware and may include sibling elements (see
96
- * canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from
96
+ * Called with the resolved z-order patch list and the menu action that
97
+ * produced it (feeds the undo coalesce key). The patch list is tie-aware and
98
+ * may include sibling elements (see canvasContextMenuZOrder); the live DOM is
99
+ * NOT yet mutated. Wire to handleDomZIndexReorderCommit from
97
100
  * useDomEditActionsContext. See CanvasContextMenu.tsx module comment.
98
101
  */
99
- onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void;
102
+ onApplyZIndex?: (
103
+ selection: DomEditSelection,
104
+ patches: ZOrderPatch[],
105
+ action: ZOrderAction,
106
+ /** Sibling a forward/backward step moved past (pre-mutation render order);
107
+ * null for front/back. Feeds the timeline z-mirror's crossedKey. */
108
+ crossed: HTMLElement | null,
109
+ ) => void;
100
110
  }
101
111
 
102
112
  // fallow-ignore-next-line complexity
@@ -139,30 +149,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
139
149
  const snapGuidesRef = useRef<SnapGuidesState | null>(null);
140
150
  const rafPausedRef = useRef(false);
141
151
 
142
- // Context menu state: position of the right-click that opened it.
143
- // contextMenuSelection is the element the menu targets — captured at right-click
144
- // time so the menu can open even before the React selection state settles.
145
- const [contextMenu, setContextMenu] = useState<{
146
- x: number;
147
- y: number;
148
- sel: DomEditSelection;
149
- } | null>(null);
150
-
151
152
  const selectionRef = useRef(selection);
152
153
  selectionRef.current = selection;
153
154
 
154
- // Close the context menu whenever the selection moves off the element the menu
155
- // targets (a click that reselects elsewhere, a deselect, or a preview reload
156
- // that rebuilds the selection). Without this the menu can linger — orphaned —
157
- // over a stale target after the underlying element is gone. A right-click that
158
- // OPENS the menu also selects its target, so the common open path keeps the
159
- // menu (same element) rather than immediately dismissing it.
160
- useEffect(() => {
161
- if (!contextMenu) return;
162
- if (!selection || selection.element !== contextMenu.sel.element) {
163
- setContextMenu(null);
164
- }
165
- }, [selection, contextMenu]);
155
+ // Brief highlight on the sibling a forward/backward z step crossed drawn
156
+ // in this studio overlay, never in the iframe DOM (see useZOrderCrossedFlash).
157
+ const { zOrderFlashRect, handleZOrderCrossed } = useZOrderCrossedFlash({ overlayRef, iframeRef });
166
158
 
167
159
  const activeCompositionPathRef = useRef(activeCompositionPath);
168
160
  activeCompositionPathRef.current = activeCompositionPath;
@@ -428,37 +420,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
428
420
  e.stopPropagation();
429
421
  };
430
422
 
431
- // Right-click: select element first (if not already selected), then open menu.
432
- const handleContextMenu = useCallback(
433
- async (event: React.MouseEvent<HTMLDivElement>) => {
434
- event.preventDefault();
435
-
436
- // If no element is selected yet, resolve it from the pointer position first.
437
- const currentSel = selectionRef.current;
438
- let activeSel: DomEditSelection | null = currentSel;
439
- if (!currentSel) {
440
- const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
441
- const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
442
- if (!resolved) return; // Nothing under the cursor — skip menu.
443
- onSelectionChangeRef.current(resolved, { revealPanel: true });
444
- // Use `resolved` directly: React state (and therefore selectionRef) won't
445
- // update synchronously after onSelectionChange — we'd be reading stale null.
446
- activeSel = resolved;
447
- } else {
448
- // Check if the user right-clicked on an unselected element (hover target).
449
- const hover = hoverSelectionRef.current;
450
- if (hover && hover.element !== currentSel.element) {
451
- onSelectionChangeRef.current(hover, { revealPanel: true });
452
- activeSel = hover;
453
- }
454
- }
455
-
456
- if (!activeSel) return;
457
- setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
458
- },
459
- // eslint-disable-next-line react-hooks/exhaustive-deps
460
- [],
461
- );
423
+ // Right-click state + handler: select the element under the pointer (if
424
+ // needed), then open the menu; closes when the selection moves off-target.
425
+ const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({
426
+ selection,
427
+ selectionRef,
428
+ hoverSelectionRef,
429
+ onCanvasPointerMoveRef,
430
+ onSelectionChangeRef,
431
+ });
462
432
 
463
433
  return (
464
434
  <div
@@ -551,24 +521,26 @@ export const DomEditOverlay = memo(function DomEditOverlay({
551
521
  x={contextMenu.x}
552
522
  y={contextMenu.y}
553
523
  selection={contextMenu.sel}
554
- onClose={() => setContextMenu(null)}
524
+ onClose={closeContextMenu}
555
525
  onDelete={
556
526
  onDeleteSelection
557
527
  ? (sel) => {
558
- setContextMenu(null);
528
+ closeContextMenu();
559
529
  onDeleteSelection(sel);
560
530
  }
561
531
  : undefined
562
532
  }
563
533
  onApplyZIndex={
564
534
  onApplyZIndex
565
- ? (patches) => {
566
- onApplyZIndex(contextMenu.sel, patches);
535
+ ? (patches, action, crossed) => {
536
+ onApplyZIndex(contextMenu.sel, patches, action, crossed);
567
537
  }
568
538
  : undefined
569
539
  }
540
+ onZOrderCrossed={handleZOrderCrossed}
570
541
  />
571
542
  )}
543
+ <ZOrderCrossedFlash rect={zOrderFlashRect} />
572
544
  <GridOverlay
573
545
  visible={gridVisible}
574
546
  spacing={gridSpacing}