@hyperframes/studio 0.8.101 → 0.8.102

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.101",
3
+ "version": "0.8.102",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/parsers": "0.8.101",
64
- "@hyperframes/sdk": "0.8.101",
65
- "@hyperframes/shader-transitions": "0.8.101",
66
- "@hyperframes/studio-server": "0.8.101",
67
- "@hyperframes/player": "0.8.101",
68
- "@hyperframes/core": "0.8.101"
63
+ "@hyperframes/core": "0.8.102",
64
+ "@hyperframes/player": "0.8.102",
65
+ "@hyperframes/sdk": "0.8.102",
66
+ "@hyperframes/parsers": "0.8.102",
67
+ "@hyperframes/studio-server": "0.8.102",
68
+ "@hyperframes/shader-transitions": "0.8.102"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.101"
84
+ "@hyperframes/producer": "0.8.102"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -3,7 +3,7 @@ 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 "./ConnectedDomEditOverlay";
6
+ import { resolveZIndexEntries } from "./useDomEditZOrder";
7
7
  import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
8
8
  import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
9
9
  import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
@@ -193,6 +193,27 @@ function renderCommitHook(captured: CapturedBatchCall[]) {
193
193
  return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
194
194
  }
195
195
 
196
+ describe("resolveZIndexEntries", () => {
197
+ it("keys a same-class sibling by its index among them, not as the first of its class", () => {
198
+ const parent = document.body.appendChild(document.createElement("div"));
199
+ const [first, second, selected] = ["card", "card", "picked"].map((name) => {
200
+ const child = parent.appendChild(document.createElement("div"));
201
+ child.className = name;
202
+ return child;
203
+ });
204
+ const { entries } = resolveZIndexEntries(
205
+ makeSelection("Picked", selected!),
206
+ [first!, second!].map((element, zIndex) => ({ element, zIndex })),
207
+ null,
208
+ );
209
+ expect(entries.map((entry) => [entry.selector, entry.selectorIndex])).toEqual([
210
+ [".card", 0],
211
+ [".card", 1],
212
+ ]);
213
+ parent.remove();
214
+ });
215
+ });
216
+
196
217
  describe("CanvasContextMenu — z-action commit path", () => {
197
218
  it("never mutates live styles itself and persists the position patch for a static element", async () => {
198
219
  const { target } = makeStaticFamily();
@@ -208,7 +229,7 @@ describe("CanvasContextMenu — z-action commit path", () => {
208
229
  selection,
209
230
  onApplyZIndex: (patches, action) => {
210
231
  stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
211
- const { entries } = resolveZIndexEntries(selection, patches);
232
+ const { entries } = resolveZIndexEntries(selection, patches, null);
212
233
  void commit(entries, undefined, action);
213
234
  },
214
235
  });
@@ -16,7 +16,7 @@
16
16
  * flow, and captures the TRUE prior styles for its failure rollback.
17
17
  *
18
18
  * The prop MUST be wired at the call site to route through the full persist
19
- * path. ConnectedDomEditOverlay.tsx builds the per-patch PatchTargets (the selected
19
+ * path. useDomEditZOrder.ts builds the per-patch PatchTargets (the selected
20
20
  * element carries its full selection identity; sibling elements are iframe DOM
21
21
  * nodes, so their id / selector are derived from the node and they share the
22
22
  * selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
@@ -29,8 +29,7 @@ import type { DomEditSelection } from "./domEditing";
29
29
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
30
30
  import {
31
31
  isZOrderActionEnabled,
32
- resolveCrossedNeighbor,
33
- resolveZOrderChange,
32
+ resolveZOrderStep,
34
33
  type ZOrderAction,
35
34
  type ZOrderPatch,
36
35
  } from "./canvasContextMenuZOrder";
@@ -170,13 +169,11 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
170
169
 
171
170
  function handleZAction(action: ZAction) {
172
171
  if (!onApplyZIndex) return;
173
- const patches = resolveZOrderChange(el, action);
174
- if (patches === null) return;
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);
172
+ // Resolved BEFORE the commit path mutates live styles. `crossed` is always
173
+ // resolved (not only for the flash): the host mirrors it into a lane move.
174
+ const step = resolveZOrderStep(el, action);
175
+ if (step === null) return;
176
+ const { patches, crossed } = step;
180
177
  // Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
181
178
  // live z-index (and injects position:relative for static elements) in the
182
179
  // same synchronous flow, so feedback is still instant — and it must read
@@ -4,12 +4,7 @@ import {
4
4
  useDomEditActionsContext,
5
5
  useDomEditSelectionContext,
6
6
  } from "../../contexts/DomEditContext";
7
- import { readHfId, type DomEditSelection } from "./domEditing";
8
- import { buildStableSelector } from "./domEditingDom";
9
- import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
10
- import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
11
- import { useCanvasZOrderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
12
- import { runZLaneGesture } from "../nle/zLaneGesture";
7
+ import { useDomEditZOrder } from "./useDomEditZOrder";
13
8
 
14
9
  type HostInputProps = Pick<
15
10
  ComponentProps<typeof DomEditOverlay>,
@@ -25,94 +20,6 @@ export interface ConnectedDomEditOverlayProps extends HostInputProps {
25
20
  onTextEditingChange?: (editing: boolean) => void;
26
21
  }
27
22
 
28
- type ZIndexReorderEntry = {
29
- element: HTMLElement;
30
- zIndex: number;
31
- id?: string;
32
- selector?: string;
33
- selectorIndex?: number;
34
- sourceFile: string;
35
- /** Timeline store key — lets the commit update the store zIndex synchronously. */
36
- key?: string;
37
- };
38
-
39
- /** Can this element be robustly re-targeted for a persisted z change? */
40
- function canTargetZIndexElement(
41
- element: HTMLElement,
42
- id: string | undefined,
43
- selector: string | undefined,
44
- ): boolean {
45
- return Boolean(id || selector || readHfId(element));
46
- }
47
-
48
- /** The selected element carries its full selection identity. */
49
- function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry {
50
- return {
51
- element: sel.element,
52
- zIndex,
53
- id: sel.id ?? undefined,
54
- selector: sel.selector,
55
- selectorIndex: sel.selectorIndex,
56
- sourceFile: sel.sourceFile,
57
- key: deriveTimelineStoreKey({
58
- domId: sel.id ?? undefined,
59
- selector: sel.selector,
60
- selectorIndex: sel.selectorIndex,
61
- sourceFile: sel.sourceFile,
62
- }),
63
- };
64
- }
65
-
66
- /** A raw iframe sibling in the selection's file; null with no id or selector (z stays live). */
67
- function siblingZIndexEntry(
68
- element: HTMLElement,
69
- zIndex: number,
70
- sourceFile: string,
71
- ): ZIndexReorderEntry | null {
72
- const id = element.id || undefined;
73
- const selector = buildStableSelector(element);
74
- if (!canTargetZIndexElement(element, id, selector)) return null;
75
- return {
76
- element,
77
- zIndex,
78
- id,
79
- selector,
80
- selectorIndex: undefined,
81
- sourceFile,
82
- key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
83
- };
84
- }
85
-
86
- /** Short human-readable label for a dropped sibling, for the console warning below. */
87
- function describeZIndexElement(element: HTMLElement): string {
88
- if (element.id) return `#${element.id}`;
89
- const firstClass = element.classList.item(0);
90
- return firstClass
91
- ? `${element.tagName.toLowerCase()}.${firstClass}`
92
- : element.tagName.toLowerCase();
93
- }
94
-
95
- // Resolve z-index patches into commit entries; a sibling with no stable
96
- // id/selector can't be written to source, so it is returned as `dropped` for
97
- // the revert-on-reload warning. Exported so tests can drive the menu → commit path.
98
- export function resolveZIndexEntries(
99
- sel: DomEditSelection,
100
- patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
101
- ): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
102
- const entries: ZIndexReorderEntry[] = [];
103
- const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
104
- for (const patch of patches) {
105
- if (patch.element === sel.element) {
106
- entries.push(selectedZIndexEntry(sel, patch.zIndex));
107
- continue;
108
- }
109
- const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
110
- if (entry) entries.push(entry);
111
- else dropped.push(patch);
112
- }
113
- return { entries, dropped };
114
- }
115
-
116
23
  // Studio's DomEditOverlay wired to the session in DomEditProvider, so a host
117
24
  // outside EditorShell mounts the same canvas editing without its own callbacks.
118
25
  export function ConnectedDomEditOverlay({
@@ -142,9 +49,8 @@ export function ConnectedDomEditOverlay({
142
49
  handleDomStyleCommit,
143
50
  applyMarqueeSelection,
144
51
  handleDomEditElementDelete,
145
- handleDomZIndexReorderCommit,
146
52
  } = useDomEditActionsContext();
147
- const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
53
+ const zOrder = useDomEditZOrder();
148
54
 
149
55
  return (
150
56
  <DomEditOverlay
@@ -170,38 +76,7 @@ export function ConnectedDomEditOverlay({
170
76
  onRotationCommit={handleDomRotationCommit}
171
77
  onStyleCommit={handleDomStyleCommit}
172
78
  onDeleteSelection={handleDomEditElementDelete}
173
- onApplyZIndex={(sel, patches, action, crossed) => {
174
- const { entries, dropped } = resolveZIndexEntries(sel, patches);
175
- if (dropped.length > 0) {
176
- // These siblings can't be written to source. Apply their live z
177
- // anyway so the resolved stacking order renders coherently — it
178
- // just reverts to the prior order on the next reload.
179
- for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
180
- console.warn(
181
- "[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
182
- "(will revert on reload):",
183
- dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
184
- );
185
- }
186
- if (entries.length === 0) return;
187
- // One coalesce key for the z persist AND the lane mirror folds both into one undo entry;
188
- // passed explicitly so the mirror shares it by construction, not by formula duplication.
189
- const coalesceKey = zReorderCoalesceKey(entries, action);
190
- // One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
191
- // no second gesture interleaves (see runZLaneGesture). A failed z commit has already
192
- // toasted and rolled back, so the catch only keeps its rejection from going unhandled.
193
- runZLaneGesture({
194
- commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
195
- mirror: () =>
196
- mirrorZOrderToTimeline({
197
- selectionKey: entries.find((e) => e.element === sel.element)?.key,
198
- action,
199
- crossed,
200
- sourceFile: sel.sourceFile,
201
- coalesceKey,
202
- }),
203
- }).catch(() => undefined);
204
- }}
79
+ onApplyZIndex={zOrder.commit}
205
80
  onMarqueeSelect={applyMarqueeSelection}
206
81
  />
207
82
  );
@@ -511,3 +511,16 @@ export function isZOrderActionEnabled(
511
511
  ): boolean {
512
512
  return resolveZOrderChange(target, action, options) !== null;
513
513
  }
514
+
515
+ /**
516
+ * One z-order step: the patches plus the sibling it crosses, both read from the
517
+ * same pre-change render order. Null when the action is a no-op.
518
+ */
519
+ export function resolveZOrderStep(
520
+ target: HTMLElement,
521
+ action: ZOrderAction,
522
+ ): { patches: ZOrderPatch[]; crossed: HTMLElement | null } | null {
523
+ const patches = resolveZOrderChange(target, action);
524
+ if (patches === null) return null;
525
+ return { patches, crossed: resolveCrossedNeighbor(target, action) };
526
+ }
@@ -1,32 +1,3 @@
1
- // GSAP's CSSPlugin takes ownership of the element's entire transform stack
2
- // when it tweens ANY of these — it bakes the CSS `translate` longhand into
3
- // style.transform at init and writes `translate: none` every tick. Position
4
- // reapply/strip logic must therefore stand down for all of them, not just x/y.
5
- const GSAP_TRANSFORM_PROPS = [
6
- "x",
7
- "y",
8
- "xPercent",
9
- "yPercent",
10
- "scale",
11
- "scaleX",
12
- "scaleY",
13
- "rotation",
14
- "rotate",
15
- "rotationX",
16
- "rotationY",
17
- "skewX",
18
- "skewY",
19
- "transform",
20
- ];
21
-
22
- /**
23
- * True when GSAP animates any transform-affecting property on the element,
24
- * meaning GSAP owns `style.transform` and has neutralized CSS `translate`.
25
- */
26
- export function gsapAnimatesTransform(el: HTMLElement): boolean {
27
- return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS);
28
- }
29
-
30
1
  /**
31
2
  * Checks whether GSAP actively animates one or more CSS/GSAP properties on
32
3
  * the given element by inspecting all registered `__timelines`.
@@ -1,10 +1,15 @@
1
1
  // @vitest-environment happy-dom
2
- import { expect, it, vi } from "vitest";
2
+ import { afterEach, expect, it, vi } from "vitest";
3
3
  import { createGsapLivePreview } from "./gsapLivePreview";
4
4
  import type { DomEditSelection } from "./domEditingTypes";
5
5
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
6
6
  import { readGsapRuntimeValuesForPanel } from "./propertyPanelHelpers";
7
7
 
8
+ afterEach(() => {
9
+ delete (window as { gsap?: unknown }).gsap;
10
+ delete (window as { __timelines?: unknown }).__timelines;
11
+ });
12
+
8
13
  it("previews on the selected element, not an earlier same-id copy in a sub-composition", () => {
9
14
  document.body.innerHTML =
10
15
  '<div data-composition-id="strip"><div id="card" data-hf-id="hf-inner"></div></div>' +
@@ -61,3 +66,43 @@ it("the panel reads GSAP values off the node the live preview moves", () => {
61
66
  });
62
67
  expect(getProperty.mock.calls[0]?.[0]).toBe(root);
63
68
  });
69
+
70
+ it.each([
71
+ ["fades it", { opacity: 0 }, ["opacity"]],
72
+ ["fades it about its corner", { opacity: 0, transformOrigin: "0 0" }, ["opacity"]],
73
+ [
74
+ "moves it",
75
+ { x: 100 },
76
+ [
77
+ "x",
78
+ "y",
79
+ "rotation",
80
+ "rotationX",
81
+ "rotationY",
82
+ "rotationZ",
83
+ "z",
84
+ "scale",
85
+ "transformPerspective",
86
+ "opacity",
87
+ ],
88
+ ],
89
+ ])(
90
+ "the panel reads GSAP's transform only off an element GSAP positions: a tween that %s",
91
+ (_, vars, read) => {
92
+ document.body.innerHTML = '<div id="card"></div>';
93
+ const card = document.querySelector<HTMLElement>("#card");
94
+ const tween = { targets: () => [card], vars, duration: () => 1 };
95
+ const getProperty = vi.fn(() => 1);
96
+ const contentWindow = Object.assign(window, {
97
+ gsap: { getProperty },
98
+ __timelines: { main: { getChildren: () => [tween] } },
99
+ });
100
+ const selection = { id: "card", sourceFile: "index.html", element: card } as DomEditSelection;
101
+ const animations = [{ properties: vars }] as unknown as GsapAnimation[];
102
+ readGsapRuntimeValuesForPanel("anim", animations, selection, {
103
+ current: { contentWindow, contentDocument: document } as unknown as HTMLIFrameElement,
104
+ });
105
+ const props = getProperty.mock.calls.map((call) => (call as unknown[])[1]);
106
+ expect(props).toEqual(read);
107
+ },
108
+ );
@@ -9,6 +9,7 @@ import {
9
9
  playbackStartAttributeForElement,
10
10
  readPlaybackStartAttributes,
11
11
  } from "../../player/lib/timelineElementHelpers";
12
+ import { GSAP_TRANSFORM_KEYS, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
12
13
 
13
14
  export type {
14
15
  BackgroundRemovalProgress,
@@ -496,8 +497,10 @@ export function readGsapRuntimeValuesForPanel(
496
497
  const el = findPreviewNode(iframe.contentDocument, element);
497
498
  if (!el) return null;
498
499
  const propKeys = collectPanelPropKeys(gsapAnimations);
500
+ const readsTransform = gsapWritesPosition(el);
499
501
  const result: Record<string, number> = {};
500
502
  for (const prop of propKeys) {
503
+ if (!readsTransform && GSAP_TRANSFORM_KEYS.has(prop)) continue;
501
504
  const v = Number(gsap.getProperty(el, prop));
502
505
  if (Number.isFinite(v)) result[prop] = roundToCenti(v);
503
506
  }
@@ -0,0 +1,185 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { useDomEditActionsContext } from "../../contexts/DomEditContext";
3
+ import { readHfId, type DomEditSelection } from "./domEditing";
4
+ import { buildStableSelector, getSelectorIndex } from "./domEditingDom";
5
+ import { usePreviewReadOnly } from "./previewReadOnlyContext";
6
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
7
+ import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
8
+ import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
9
+ import { useCanvasZOrderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
10
+ import { runZLaneGesture } from "../nle/zLaneGesture";
11
+ import {
12
+ isZOrderActionEnabled,
13
+ resolveZOrderStep,
14
+ type ZOrderAction,
15
+ type ZOrderPatch,
16
+ } from "./canvasContextMenuZOrder";
17
+
18
+ type ZIndexReorderEntry = {
19
+ element: HTMLElement;
20
+ zIndex: number;
21
+ id?: string;
22
+ selector?: string;
23
+ selectorIndex?: number;
24
+ sourceFile: string;
25
+ /** Timeline store key — lets the commit update the store zIndex synchronously. */
26
+ key?: string;
27
+ };
28
+
29
+ /** Can this element be robustly re-targeted for a persisted z change? */
30
+ function canTargetZIndexElement(
31
+ element: HTMLElement,
32
+ id: string | undefined,
33
+ selector: string | undefined,
34
+ ): boolean {
35
+ return Boolean(id || selector || readHfId(element));
36
+ }
37
+
38
+ /** The selected element carries its full selection identity. */
39
+ function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry {
40
+ return {
41
+ element: sel.element,
42
+ zIndex,
43
+ id: sel.id ?? undefined,
44
+ selector: sel.selector,
45
+ selectorIndex: sel.selectorIndex,
46
+ sourceFile: sel.sourceFile,
47
+ key: deriveTimelineStoreKey({
48
+ domId: sel.id ?? undefined,
49
+ selector: sel.selector,
50
+ selectorIndex: sel.selectorIndex,
51
+ sourceFile: sel.sourceFile,
52
+ }),
53
+ };
54
+ }
55
+
56
+ /** A raw iframe sibling in the selection's file; null with no id or selector (z stays live). Its selector index
57
+ * picks it among same-class siblings, the way the lane mirror keys `crossed`. */
58
+ function siblingZIndexEntry(
59
+ element: HTMLElement,
60
+ zIndex: number,
61
+ sourceFile: string,
62
+ activeCompPath: string | null,
63
+ ): ZIndexReorderEntry | null {
64
+ const id = element.id || undefined;
65
+ const selector = buildStableSelector(element);
66
+ if (!canTargetZIndexElement(element, id, selector)) return null;
67
+ const selectorIndex = id
68
+ ? undefined
69
+ : getSelectorIndex(element.ownerDocument, element, selector, sourceFile, activeCompPath);
70
+ return {
71
+ element,
72
+ zIndex,
73
+ id,
74
+ selector,
75
+ selectorIndex,
76
+ sourceFile,
77
+ key: deriveTimelineStoreKey({ domId: id, selector, selectorIndex, sourceFile }),
78
+ };
79
+ }
80
+
81
+ /** Short human-readable label for a dropped sibling, for the console warning below. */
82
+ function describeZIndexElement(element: HTMLElement): string {
83
+ if (element.id) return `#${element.id}`;
84
+ const firstClass = element.classList.item(0);
85
+ return firstClass
86
+ ? `${element.tagName.toLowerCase()}.${firstClass}`
87
+ : element.tagName.toLowerCase();
88
+ }
89
+
90
+ // Patches as commit entries; a sibling with no id or selector is `dropped` and reverts on reload.
91
+ export function resolveZIndexEntries(
92
+ sel: DomEditSelection,
93
+ patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
94
+ activeCompPath: string | null,
95
+ ): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
96
+ const entries: ZIndexReorderEntry[] = [];
97
+ const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
98
+ for (const patch of patches) {
99
+ if (patch.element === sel.element) {
100
+ entries.push(selectedZIndexEntry(sel, patch.zIndex));
101
+ continue;
102
+ }
103
+ const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile, activeCompPath);
104
+ if (entry) entries.push(entry);
105
+ else dropped.push(patch);
106
+ }
107
+ return { entries, dropped };
108
+ }
109
+
110
+ export interface DomEditZOrder {
111
+ /** False in a read-only preview, for an element no longer in the live preview, or at that end of its stacking set. */
112
+ enabled: (sel: DomEditSelection, action: ZOrderAction) => boolean;
113
+ /** Resolve and commit one step; false when nothing is sent to be saved. */
114
+ apply: (sel: DomEditSelection, action: ZOrderAction) => boolean;
115
+ /** Commit patches already resolved (the canvas menu resolves its own); false when none could be sent to be saved. */
116
+ commit: (
117
+ sel: DomEditSelection,
118
+ patches: ReadonlyArray<ZOrderPatch>,
119
+ action: ZOrderAction,
120
+ crossed: HTMLElement | null,
121
+ ) => boolean;
122
+ }
123
+
124
+ /** A selection from before a preview reload holds an element of a document no window shows, with stale z values. */
125
+ const isLive = (element: HTMLElement) =>
126
+ element.isConnected && element.ownerDocument.defaultView !== null;
127
+
128
+ // The canvas menu's z-order (write, undo, lane mirror) for any caller in DomEditProvider.
129
+ export function useDomEditZOrder(): DomEditZOrder {
130
+ const { handleDomZIndexReorderCommit } = useDomEditActionsContext();
131
+ const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
132
+ const readOnly = usePreviewReadOnly();
133
+ const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null;
134
+
135
+ const enabled = useCallback<DomEditZOrder["enabled"]>(
136
+ (sel, action) => !readOnly && isLive(sel.element) && isZOrderActionEnabled(sel.element, action),
137
+ [readOnly],
138
+ );
139
+
140
+ const commit = useCallback<DomEditZOrder["commit"]>(
141
+ (sel, patches, action, crossed) => {
142
+ if (readOnly) return false;
143
+ const { entries, dropped } = resolveZIndexEntries(sel, patches, activeCompPath);
144
+ if (dropped.length > 0) {
145
+ // Not writable to source: their live z still applies, so the order renders, until a reload.
146
+ for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
147
+ console.warn(
148
+ "[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
149
+ "(will revert on reload):",
150
+ dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
151
+ );
152
+ }
153
+ if (entries.length === 0) return false;
154
+ // One coalesce key for the z write and the lane mirror: one undo entry.
155
+ const coalesceKey = zReorderCoalesceKey(entries, action);
156
+ // One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
157
+ // no second gesture interleaves (see runZLaneGesture). A failed z commit has already
158
+ // toasted and rolled back, so the catch only keeps its rejection from going unhandled.
159
+ runZLaneGesture({
160
+ commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
161
+ mirror: () =>
162
+ mirrorZOrderToTimeline({
163
+ selectionKey: entries.find((e) => e.element === sel.element)?.key,
164
+ action,
165
+ crossed,
166
+ sourceFile: sel.sourceFile,
167
+ coalesceKey,
168
+ }),
169
+ }).catch(() => undefined);
170
+ return true;
171
+ },
172
+ [activeCompPath, handleDomZIndexReorderCommit, mirrorZOrderToTimeline, readOnly],
173
+ );
174
+
175
+ const apply = useCallback<DomEditZOrder["apply"]>(
176
+ (sel, action) => {
177
+ if (!isLive(sel.element)) return false;
178
+ const step = resolveZOrderStep(sel.element, action);
179
+ return step !== null && commit(sel, step.patches, action, step.crossed);
180
+ },
181
+ [commit],
182
+ );
183
+
184
+ return useMemo(() => ({ enabled, apply, commit }), [enabled, apply, commit]);
185
+ }
@@ -8,6 +8,10 @@ export const iframeRef = { current: null as HTMLIFrameElement | null };
8
8
 
9
9
  vi.mock("../../contexts/StudioContext", () => ({
10
10
  useStudioShellContext: () => ({ activeCompPath: "index.html", previewIframeRef: iframeRef }),
11
+ useStudioShellContextOptional: () => ({
12
+ activeCompPath: "index.html",
13
+ previewIframeRef: iframeRef,
14
+ }),
11
15
  useStudioPlaybackContext: () => ({
12
16
  captionEditMode: previewState.captionEditMode,
13
17
  compositionLoading: false,