@hyperframes/studio 0.7.56 → 0.7.57

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 (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.56",
3
+ "version": "0.7.57",
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.57",
50
+ "@hyperframes/parsers": "0.7.57",
51
+ "@hyperframes/sdk": "0.7.57",
52
+ "@hyperframes/player": "0.7.57",
53
+ "@hyperframes/studio-server": "0.7.57"
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.57"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -3,7 +3,11 @@ 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 type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
6
9
  import { CanvasContextMenu } from "./CanvasContextMenu";
10
+ import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
7
11
  import type { DomEditSelection } from "./domEditing";
8
12
 
9
13
  installReactActEnvironment();
@@ -24,7 +28,8 @@ afterEach(() => {
24
28
 
25
29
  function renderMenu(props: {
26
30
  selection: DomEditSelection;
27
- onApplyZIndex?: () => void;
31
+ onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
32
+ onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
28
33
  onDelete?: (selection: DomEditSelection) => void;
29
34
  }) {
30
35
  root = createRoot(host);
@@ -36,6 +41,7 @@ function renderMenu(props: {
36
41
  selection: props.selection,
37
42
  onClose: () => {},
38
43
  onApplyZIndex: props.onApplyZIndex,
44
+ onZOrderCrossed: props.onZOrderCrossed,
39
45
  onDelete: props.onDelete,
40
46
  }),
41
47
  );
@@ -113,3 +119,148 @@ describe("CanvasContextMenu — handler gating", () => {
113
119
  expect(document.body.querySelector(".border-t")).toBeNull();
114
120
  });
115
121
  });
122
+
123
+ // ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ──
124
+
125
+ function pressMenuItem(label: string) {
126
+ const button = zOrderButtons().find((b) => b.textContent === label);
127
+ expect(button).toBeDefined();
128
+ act(() => {
129
+ button!.dispatchEvent(
130
+ new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
131
+ );
132
+ });
133
+ }
134
+
135
+ /** Target (static, earlier in DOM) below an equal-z sibling — z action must renumber. */
136
+ function makeStaticFamily() {
137
+ const parent = document.createElement("div");
138
+ const target = document.createElement("div");
139
+ target.id = "target";
140
+ // In happy-dom an unset computed position is "" (not "static"), which would
141
+ // skip the commit hook's static-position injection; declare it explicitly so
142
+ // the test exercises the browser default.
143
+ target.style.position = "static";
144
+ const other = document.createElement("div");
145
+ other.id = "other";
146
+ parent.append(target, other);
147
+ document.body.append(parent);
148
+ return { parent, target, other };
149
+ }
150
+
151
+ interface CapturedBatchCall {
152
+ batches: DomEditPatchBatch[];
153
+ options: { label: string; coalesceKey: string };
154
+ }
155
+
156
+ /** Mount the REAL commit hook (persist layer mocked at commitDomEditPatchBatches). */
157
+ function renderCommitHook(captured: CapturedBatchCall[]) {
158
+ type Commit = ReturnType<typeof useElementLifecycleOps>["handleDomZIndexReorderCommit"];
159
+ let commit: Commit | undefined;
160
+ function Harness() {
161
+ ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps({
162
+ activeCompPath: "index.html",
163
+ showToast: vi.fn(),
164
+ writeProjectFile: vi.fn(async () => {}),
165
+ domEditSaveTimestampRef: { current: 0 },
166
+ editHistory: { recordEdit: vi.fn(async () => {}) },
167
+ projectIdRef: { current: null },
168
+ reloadPreview: vi.fn(),
169
+ clearDomSelection: vi.fn(),
170
+ commitDomEditPatchBatches: async (batches, options) => {
171
+ captured.push({ batches, options });
172
+ },
173
+ }));
174
+ return null;
175
+ }
176
+ const hookHost = document.createElement("div");
177
+ document.body.append(hookHost);
178
+ const hookRoot = createRoot(hookHost);
179
+ act(() => hookRoot.render(<Harness />));
180
+ return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
181
+ }
182
+
183
+ describe("CanvasContextMenu — z-action commit path", () => {
184
+ it("never mutates live styles itself and persists the position patch for a static element", async () => {
185
+ const { target } = makeStaticFamily();
186
+ const selection = makeSelection("Target", target);
187
+ const captured: CapturedBatchCall[] = [];
188
+ const { commit, cleanup } = renderCommitHook(captured);
189
+
190
+ // Wire onApplyZIndex the way the app does (PreviewOverlays → the commit
191
+ // hook), asserting the menu has NOT touched the DOM when it fires — the
192
+ // hook must capture true pre-change styles for its rollback.
193
+ const stylesAtApply: Array<{ zIndex: string; position: string }> = [];
194
+ renderMenu({
195
+ selection,
196
+ onApplyZIndex: (patches, action) => {
197
+ stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
198
+ const { entries } = resolveZIndexEntries(selection, patches);
199
+ void commit(entries, undefined, action);
200
+ },
201
+ });
202
+
203
+ await act(async () => pressMenuItem("Bring forward"));
204
+
205
+ // The menu left the element pristine; only the commit hook wrote styles.
206
+ expect(stylesAtApply).toEqual([{ zIndex: "", position: "static" }]);
207
+ expect(target.style.zIndex).toBe("1");
208
+ expect(target.style.position).toBe("relative");
209
+
210
+ // The persisted payload carries BOTH the z-index and the injected position,
211
+ // so the reorder survives the post-commit reloadPreview().
212
+ expect(captured).toHaveLength(1);
213
+ const targetPatch = captured[0]?.batches
214
+ .flatMap((batch) => batch.patches)
215
+ .find((patch) => patch.target.id === "target");
216
+ expect(targetPatch?.operations).toEqual(
217
+ expect.arrayContaining([
218
+ { type: "inline-style", property: "z-index", value: "1" },
219
+ { type: "inline-style", property: "position", value: "relative" },
220
+ ]),
221
+ );
222
+ // F7: the action kind is part of the default undo coalesce key, so two
223
+ // different menu actions never merge into one undo step.
224
+ expect(captured[0]?.options.coalesceKey).toContain("bring-forward");
225
+
226
+ cleanup();
227
+ });
228
+
229
+ it("reports the crossed sibling to onZOrderCrossed for a forward step (resolved pre-mutation)", async () => {
230
+ // target (earlier in DOM) and other are tied — bring-forward steps over
231
+ // `other`, and the flash callback must receive exactly that element, after
232
+ // onApplyZIndex ran (call order lets the host measure post-commit rects).
233
+ const { target, other } = makeStaticFamily();
234
+ const selection = makeSelection("Target", target);
235
+ const calls: Array<{ kind: string; crossed?: HTMLElement }> = [];
236
+
237
+ renderMenu({
238
+ selection,
239
+ onApplyZIndex: () => calls.push({ kind: "apply" }),
240
+ onZOrderCrossed: (crossed, action) => {
241
+ expect(action).toBe("bring-forward");
242
+ calls.push({ kind: "crossed", crossed });
243
+ },
244
+ });
245
+
246
+ await act(async () => pressMenuItem("Bring forward"));
247
+
248
+ expect(calls.map((c) => c.kind)).toEqual(["apply", "crossed"]);
249
+ expect(calls[1]?.crossed).toBe(other);
250
+ });
251
+
252
+ it("does not call onZOrderCrossed for bring-to-front", async () => {
253
+ const { target } = makeStaticFamily();
254
+ const onZOrderCrossed = vi.fn();
255
+
256
+ renderMenu({
257
+ selection: makeSelection("Target", target),
258
+ onApplyZIndex: vi.fn(),
259
+ onZOrderCrossed,
260
+ });
261
+
262
+ await act(async () => pressMenuItem("Bring to front"));
263
+
264
+ expect(onZOrderCrossed).not.toHaveBeenCalled();
265
+ });
266
+ });
@@ -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,22 @@ 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).
45
53
  */
46
- onApplyZIndex?: (patches: ZOrderPatch[]) => void;
54
+ onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
55
+ /**
56
+ * Called after a successful bring-forward / send-backward with the sibling
57
+ * the target stepped over (resolved from the SAME pre-mutation state as the
58
+ * patches), so the host can flash a highlight on it in the studio overlay.
59
+ * Never called for front/back or no-op actions.
60
+ */
61
+ onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
47
62
  /**
48
63
  * Delete the selected element. Wire to handleDomEditElementDelete from
49
64
  * useDomEditActionsContext — same path as the Delete/Backspace hotkey.
@@ -68,6 +83,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
68
83
  selection,
69
84
  onClose,
70
85
  onApplyZIndex,
86
+ onZOrderCrossed,
71
87
  onDelete,
72
88
  }: CanvasContextMenuProps) {
73
89
  const menuRef = useContextMenuDismiss(onClose);
@@ -93,20 +109,19 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
93
109
  const el = selection.element;
94
110
 
95
111
  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
112
  if (!onApplyZIndex) return;
99
113
  const patches = resolveZOrderChange(el, action);
100
114
  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);
115
+ // Resolve the crossed neighbor BEFORE the commit path mutates live styles —
116
+ // both resolvers must read the same pre-change render order.
117
+ const crossed = onZOrderCrossed ? resolveCrossedNeighbor(el, action) : null;
118
+ // Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
119
+ // live z-index (and injects position:relative for static elements) in the
120
+ // same synchronous flow, so feedback is still instant — and it must read
121
+ // the PRE-change styles itself, both to capture true rollback values and
122
+ // to detect a static position that needs persisting.
123
+ onApplyZIndex(patches, action);
124
+ if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action);
110
125
  onClose();
111
126
  }
112
127
 
@@ -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,17 @@ 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
+ ) => void;
100
107
  }
101
108
 
102
109
  // fallow-ignore-next-line complexity
@@ -139,30 +146,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
139
146
  const snapGuidesRef = useRef<SnapGuidesState | null>(null);
140
147
  const rafPausedRef = useRef(false);
141
148
 
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
149
  const selectionRef = useRef(selection);
152
150
  selectionRef.current = selection;
153
151
 
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]);
152
+ // Brief highlight on the sibling a forward/backward z step crossed drawn
153
+ // in this studio overlay, never in the iframe DOM (see useZOrderCrossedFlash).
154
+ const { zOrderFlashRect, handleZOrderCrossed } = useZOrderCrossedFlash({ overlayRef, iframeRef });
166
155
 
167
156
  const activeCompositionPathRef = useRef(activeCompositionPath);
168
157
  activeCompositionPathRef.current = activeCompositionPath;
@@ -428,37 +417,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
428
417
  e.stopPropagation();
429
418
  };
430
419
 
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
- );
420
+ // Right-click state + handler: select the element under the pointer (if
421
+ // needed), then open the menu; closes when the selection moves off-target.
422
+ const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({
423
+ selection,
424
+ selectionRef,
425
+ hoverSelectionRef,
426
+ onCanvasPointerMoveRef,
427
+ onSelectionChangeRef,
428
+ });
462
429
 
463
430
  return (
464
431
  <div
@@ -551,24 +518,26 @@ export const DomEditOverlay = memo(function DomEditOverlay({
551
518
  x={contextMenu.x}
552
519
  y={contextMenu.y}
553
520
  selection={contextMenu.sel}
554
- onClose={() => setContextMenu(null)}
521
+ onClose={closeContextMenu}
555
522
  onDelete={
556
523
  onDeleteSelection
557
524
  ? (sel) => {
558
- setContextMenu(null);
525
+ closeContextMenu();
559
526
  onDeleteSelection(sel);
560
527
  }
561
528
  : undefined
562
529
  }
563
530
  onApplyZIndex={
564
531
  onApplyZIndex
565
- ? (patches) => {
566
- onApplyZIndex(contextMenu.sel, patches);
532
+ ? (patches, action) => {
533
+ onApplyZIndex(contextMenu.sel, patches, action);
567
534
  }
568
535
  : undefined
569
536
  }
537
+ onZOrderCrossed={handleZOrderCrossed}
570
538
  />
571
539
  )}
540
+ <ZOrderCrossedFlash rect={zOrderFlashRect} />
572
541
  <GridOverlay
573
542
  visible={gridVisible}
574
543
  spacing={gridSpacing}
@@ -15,6 +15,7 @@ import {
15
15
  import { Layers } from "../../icons/SystemIcons";
16
16
  import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
17
17
  import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering";
18
+ import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
18
19
 
19
20
  const TAG_ICONS: Record<string, string> = {
20
21
  video: "Vi",
@@ -280,9 +281,17 @@ export const LayersPanel = memo(function LayersPanel() {
280
281
  selector: layer.selector,
281
282
  selectorIndex: layer.selectorIndex,
282
283
  sourceFile: layer.sourceFile,
284
+ key: deriveTimelineStoreKey({
285
+ domId: layer.id,
286
+ selector: layer.selector,
287
+ selectorIndex: layer.selectorIndex,
288
+ sourceFile: layer.sourceFile,
289
+ }),
283
290
  }));
284
291
 
285
- handleDomZIndexReorderCommit(entries);
292
+ // "layer-drag" keeps consecutive drops of the same sibling set coalescing
293
+ // into one undo step, without merging with a context-menu z action.
294
+ handleDomZIndexReorderCommit(entries, undefined, "layer-drag");
286
295
  },
287
296
  [handleDomZIndexReorderCommit],
288
297
  );