@hyperframes/studio 0.8.101 → 0.8.103

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 (57) hide show
  1. package/dist/assets/{hyperframes-player-CFJLrHRF.js → hyperframes-player-DG795Hwu.js} +1 -1
  2. package/dist/assets/{index-BBNVnnyH.js → index-BPJRf2X9.js} +119 -119
  3. package/dist/assets/{index-CJOzbslX.js → index-BiEtDkae.js} +1 -1
  4. package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
  5. package/dist/assets/{index-C33EDBL2.js → index-DuCZ3pQw.js} +1 -1
  6. package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
  7. package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
  8. package/dist/chunk-DI4LCJHL.js.map +1 -0
  9. package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
  10. package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
  11. package/dist/index.d.ts +13 -3
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +932 -850
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/CanvasContextMenu.test.tsx +23 -2
  19. package/src/components/editor/CanvasContextMenu.tsx +7 -10
  20. package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -128
  21. package/src/components/editor/DomEditOverlay.tsx +2 -1
  22. package/src/components/editor/PropertyPanel.tsx +2 -2
  23. package/src/components/editor/canvasContextMenuZOrder.ts +13 -0
  24. package/src/components/editor/domEditOverlayGestures.ts +1 -1
  25. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  26. package/src/components/editor/gsapAnimatesProperty.ts +0 -29
  27. package/src/components/editor/gsapLivePreview.test.ts +46 -1
  28. package/src/components/editor/manualOffsetDrag.test.ts +16 -0
  29. package/src/components/editor/manualOffsetDrag.ts +5 -5
  30. package/src/components/editor/plainTranslate.test.ts +38 -22
  31. package/src/components/editor/plainTranslate.ts +66 -36
  32. package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
  33. package/src/components/editor/previewReadOnlyContext.tsx +3 -1
  34. package/src/components/editor/propertyPanelHelpers.ts +3 -0
  35. package/src/components/editor/propertyPanelTransformCommit.test.ts +51 -0
  36. package/src/components/editor/propertyPanelTransformCommit.ts +3 -1
  37. package/src/components/editor/useColorGradingController.test.ts +36 -1
  38. package/src/components/editor/useColorGradingController.ts +2 -2
  39. package/src/components/editor/useDomEditNudge.test.tsx +1 -0
  40. package/src/components/editor/useDomEditNudge.ts +6 -3
  41. package/src/components/editor/useDomEditZOrder.ts +185 -0
  42. package/src/components/nle/PreviewOverlaysTestMocks.ts +4 -0
  43. package/src/domEditExports.test.tsx +92 -0
  44. package/src/hooks/gsapRuntimeKeyframes.ts +6 -0
  45. package/src/hooks/useDomEditCommits.test.tsx +94 -90
  46. package/src/hooks/usePreviewInteraction.test.ts +32 -0
  47. package/src/hooks/usePreviewInteraction.ts +3 -2
  48. package/src/index.ts +3 -0
  49. package/src/player/components/TimelineClipFades.test.tsx +103 -3
  50. package/src/player/components/TimelineClipFades.tsx +53 -50
  51. package/src/styles/theme.css +1 -1
  52. package/src/utils/projectRouting.test.ts +9 -0
  53. package/src/utils/projectRouting.ts +3 -1
  54. package/dist/chunk-S65AQH6M.js.map +0 -1
  55. /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
  56. /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
  57. /package/dist/{domEditingLayers-NU6Z5QWA.js.map → domEditingLayers-AQBDFNUC.js.map} +0 -0
@@ -6,29 +6,65 @@ import type { PatchOperation } from "../../utils/sourcePatcher";
6
6
 
7
7
  type Point = { x: number; y: number };
8
8
 
9
- const TERM = /^(-?\d*\.?\d+(?:e[-+]?\d+)?)(px|%)$/i;
9
+ const TOKEN = /\s*(?:(\d*\.?\d+(?:e[-+]?\d+)?)(px|%)?|([a-z]+)\(|([-+*/(),]))/giy;
10
+ const FUNCTIONS: Record<string, (args: number[]) => number> = {
11
+ "(": ([a = Number.NaN]) => a,
12
+ "calc(": ([a = Number.NaN]) => a,
13
+ "min(": (args) => Math.min(...args),
14
+ "max(": (args) => Math.max(...args),
15
+ "clamp(": ([lo = Number.NaN, v = Number.NaN, hi = Number.NaN]) => Math.max(lo, Math.min(v, hi)),
16
+ };
10
17
 
11
- const SIGN: Record<string, number> = { "+": 1, "-": -1 };
12
-
13
- function termPx(term: string | undefined, side: number): number | null {
14
- const match = TERM.exec(term ?? "");
15
- return match ? Number(match[1]) * (match[2] === "%" ? side / 100 : 1) : null;
18
+ /** Lengths become px against `side`; functions keep their "(" so they can't pass for an operator. */
19
+ function tokenize(value: string, side: number): (number | string)[] {
20
+ const tokens: (number | string)[] = [];
21
+ const end = value.trimEnd().length;
22
+ TOKEN.lastIndex = 0;
23
+ for (let m = TOKEN.exec(value); m; m = TOKEN.lastIndex < end ? TOKEN.exec(value) : null) {
24
+ if (m[1]) tokens.push(Number(m[1]) * (m[2] === "%" ? side / 100 : 1));
25
+ else tokens.push(m[3] ? `${m[3].toLowerCase()}(` : (m[4] ?? ""));
26
+ }
27
+ return TOKEN.lastIndex < end ? [] : tokens;
16
28
  }
17
29
 
18
- /** px, %, or Chrome's computed `calc(P% + Lpx)`, against the border-box side; null for min()/max()/clamp(). */
19
- function resolveLength(value: string, side: number): number | null {
20
- const parts = (/^calc\((.*)\)$/.exec(value)?.[1] ?? value).split(" ");
21
- if (parts.length % 2 === 0) return null;
22
- let total = termPx(parts[0], side);
23
- for (let i = 1; i < parts.length && total !== null; i += 2) {
24
- const term = termPx(parts[i + 1], side);
25
- const sign = SIGN[parts[i] ?? ""];
26
- total = term === null || !sign ? null : total + sign * term;
30
+ /** Chrome's computed length (px, %, calc/min/max/clamp, + - * /) in px against `side`; NaN otherwise. */
31
+ function evaluateLength(value: string, side: number): number {
32
+ const tokens = tokenize(value, side);
33
+ let i = 0;
34
+ const next = () => tokens[i++];
35
+ const take = (...ops: string[]) => ops.includes(tokens[i] as string) && next();
36
+ function sum(): number {
37
+ let total = product();
38
+ for (let op = take("+", "-"); op; op = take("+", "-"))
39
+ total += (op === "+" ? 1 : -1) * product();
40
+ return total;
41
+ }
42
+ function product(): number {
43
+ let total = unary();
44
+ for (let op = take("*", "/"); op; op = take("*", "/"))
45
+ total = op === "*" ? total * unary() : total / unary();
46
+ return total;
47
+ }
48
+ function unary(): number {
49
+ return take("-") ? -unary() : atom();
50
+ }
51
+ function atom(): number {
52
+ const token = next();
53
+ if (typeof token === "number") return token;
54
+ const fn = FUNCTIONS[token ?? ""];
55
+ if (!fn) return Number.NaN;
56
+ const args = [sum()];
57
+ while (take(",")) args.push(sum());
58
+ return take(")") ? fn(args) : Number.NaN;
27
59
  }
28
- return total;
60
+ const total = tokens.length ? sum() : Number.NaN;
61
+ return i === tokens.length ? total : Number.NaN;
29
62
  }
30
63
 
31
- function borderBox(
64
+ const CONTENT_BOXES = new Set(["content-box", "fill-box"]);
65
+
66
+ /** The side % resolves against: the box `transform-box` names (fill-box is the content box on HTML). */
67
+ function referenceBox(
32
68
  cs: CSSStyleDeclaration,
33
69
  size: "width" | "height",
34
70
  a: string,
@@ -37,35 +73,29 @@ function borderBox(
37
73
  const n = (prop: string) => Number.parseFloat(cs.getPropertyValue(prop)) || 0;
38
74
  const pad =
39
75
  n(`padding-${a}`) + n(`padding-${b}`) + n(`border-${a}-width`) + n(`border-${b}-width`);
40
- return n(size) + (cs.boxSizing === "border-box" ? 0 : pad);
41
- }
42
-
43
- // Chrome resolves min()/max()/clamp() inside a transform function to a matrix; an unrendered box has none.
44
- function resolveInTransform(el: HTMLElement, x: string, y: string): Point {
45
- const style = el.style;
46
- const saved = [style.getPropertyValue("transform"), style.getPropertyPriority("transform")];
47
- style.setProperty("transform", `translate(${x}, ${y})`, "important");
48
- try {
49
- const resolved = el.ownerDocument.defaultView?.getComputedStyle(el).transform ?? "";
50
- const matrix = new DOMMatrixReadOnly(resolved.startsWith("matrix") ? resolved : undefined);
51
- return { x: matrix.m41, y: matrix.m42 };
52
- } finally {
53
- style.setProperty("transform", saved[0] ?? "", saved[1] ?? "");
54
- }
76
+ const content = n(size) - (cs.boxSizing === "border-box" ? pad : 0);
77
+ return CONTENT_BOXES.has(cs.getPropertyValue("transform-box")) ? content : content + pad;
55
78
  }
56
79
 
57
80
  /** The element's CSS `translate` in px, as it renders now. */
58
81
  // ponytail: a 3-value translate loses its z on the next move; keep z when a fixture needs it.
59
82
  export function readTranslatePx(el: HTMLElement): Point {
60
83
  const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
61
- const value = el.style.getPropertyValue("translate") || cs?.translate || "none";
84
+ // Computed first: Chrome resolves em, vw and var() there and leaves only % to work out.
85
+ const value = cs?.translate || el.style.getPropertyValue("translate") || "none";
62
86
  if (value === "none") return { x: 0, y: 0 };
63
87
  const [x = "0px", y = "0px"] = splitTopLevelWhitespace(value);
64
- const px = cs && resolveLength(x, borderBox(cs, "width", "left", "right"));
65
- const py = cs && resolveLength(y, borderBox(cs, "height", "top", "bottom"));
66
- return px != null && py != null ? { x: px, y: py } : resolveInTransform(el, x, y);
88
+ const side = (size: "width" | "height", a: string, b: string) =>
89
+ cs ? referenceBox(cs, size, a, b) : 0;
90
+ return {
91
+ x: evaluateLength(x, side("width", "left", "right")),
92
+ y: evaluateLength(y, side("height", "top", "bottom")),
93
+ };
67
94
  }
68
95
 
96
+ export const UNREADABLE_TRANSLATE =
97
+ "Studio can't read this layer's translate. Move it in the Code tab.";
98
+
69
99
  /** Plain px only: GSAP's CSSPlugin splits `translate` on spaces and drops a calc(). */
70
100
  function formatTranslatePx(p: Point): string {
71
101
  return `${roundTo3(p.x)}px ${roundTo3(p.y)}px`;
@@ -9,6 +9,7 @@ import { __resetForTests } from "../../utils/canvasNudgeGate";
9
9
  import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
10
10
  import "./domEditOverlayTestMocks";
11
11
  import { DomEditOverlay } from "./DomEditOverlay";
12
+ import { UNREADABLE_TRANSLATE } from "./plainTranslate";
12
13
 
13
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
15
 
@@ -475,3 +476,32 @@ describe("DomEditOverlay onTextEditingChange", () => {
475
476
  expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
476
477
  });
477
478
  });
479
+
480
+ describe("DomEditOverlay on a layer whose translate Studio can't read", () => {
481
+ it("refuses a drag and an arrow nudge out loud, and commits nothing", () => {
482
+ const { spies, overlay, selection } = fixture();
483
+ selection.element.style.setProperty("translate", "abs(10% - 50px) 0px");
484
+ fire(overlay.querySelector(BOX)!, "pointerdown");
485
+ const presses = [false, true, true].map(
486
+ (repeat) =>
487
+ new KeyboardEvent("keydown", {
488
+ key: "ArrowRight",
489
+ bubbles: true,
490
+ cancelable: true,
491
+ repeat,
492
+ }),
493
+ );
494
+ act(() => {
495
+ for (const press of presses) window.dispatchEvent(press);
496
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
497
+ });
498
+ // One toast for the drag and one for the whole held arrow, whose every press is swallowed.
499
+ expect(spies.onBlockedMove.mock.calls).toEqual([
500
+ [selection, UNREADABLE_TRANSLATE],
501
+ [selection, UNREADABLE_TRANSLATE],
502
+ ]);
503
+ expect(presses.map((press) => press.defaultPrevented)).toEqual([true, true, true]);
504
+ expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
505
+ expect(selection.element.style.getPropertyValue("translate")).toBe("abs(10% - 50px) 0px");
506
+ });
507
+ });
@@ -49,10 +49,12 @@ interface ManualEditCapabilities {
49
49
  */
50
50
  export function useManualEditDisabledFlags(
51
51
  capabilities: ManualEditCapabilities | null | undefined,
52
+ offset = { x: 0, y: 0 },
52
53
  ) {
53
54
  const { readOnly } = useContext(PreviewReadOnlyContext);
55
+ const unreadable = !Number.isFinite(offset.x) || !Number.isFinite(offset.y);
54
56
  return {
55
- manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly,
57
+ manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly || unreadable,
56
58
  manualSizeEditingDisabled: !capabilities?.canApplyManualSize || readOnly,
57
59
  manualRotationEditingDisabled: !capabilities?.canApplyManualRotation || readOnly,
58
60
  };
@@ -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
  }
@@ -1,9 +1,13 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
3
5
  import { describe, expect, it, vi } from "vitest";
6
+ import { useManualEditDisabledFlags } from "./previewReadOnlyContext";
4
7
  import type { DomEditSelection } from "./domEditingTypes";
5
8
  import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
6
9
  import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
10
+ import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
7
11
 
8
12
  describe("createTransformCommitHandlers", () => {
9
13
  it.each([
@@ -55,3 +59,50 @@ describe("createTransformCommitHandlers", () => {
55
59
  expect(onSetManualRotation).not.toHaveBeenCalled();
56
60
  });
57
61
  });
62
+
63
+ describe("the panel's position fields on a translate Studio can't read", () => {
64
+ it("are disabled", () => {
65
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
66
+ const box = document.createElement("div");
67
+ box.style.setProperty("translate", "abs(10% - 50px) 30px");
68
+ const seen: boolean[] = [];
69
+ function Probe({ element }: { element: HTMLElement }) {
70
+ seen.push(
71
+ useManualEditDisabledFlags({ canApplyManualOffset: true } as never, readMoveOffset(element))
72
+ .manualOffsetEditingDisabled,
73
+ );
74
+ return null;
75
+ }
76
+ const root = createRoot(document.createElement("div"));
77
+ act(() => root.render(React.createElement(Probe, { element: box })));
78
+ box.style.setProperty("translate", "40px 30px");
79
+ act(() => root.render(React.createElement(Probe, { element: box })));
80
+ act(() => root.unmount());
81
+ expect(seen).toEqual([true, false]);
82
+ });
83
+
84
+ it("commit nothing and say why", async () => {
85
+ const onSetManualOffset = vi.fn();
86
+ const showToast = vi.fn();
87
+ const box = document.createElement("div");
88
+ box.style.setProperty("translate", "abs(10% - 50px) 30px");
89
+ const element = { id: "box", selector: "#box", element: box } as unknown as DomEditSelection;
90
+ const handlers = createTransformCommitHandlers({
91
+ element,
92
+ styles: {},
93
+ hasGsapAnimation: false,
94
+ gsapAnimId: null,
95
+ gsapKeyframes: null,
96
+ currentPct: 0,
97
+ onCommitAnimatedProperty: undefined,
98
+ onAddKeyframe: undefined,
99
+ onSetManualOffset,
100
+ onSetManualSize: vi.fn(),
101
+ onSetManualRotation: vi.fn(),
102
+ showToast,
103
+ });
104
+ await handlers.commitManualOffset("y", "20px");
105
+ expect(onSetManualOffset).not.toHaveBeenCalled();
106
+ expect(showToast).toHaveBeenCalledWith(UNREADABLE_TRANSLATE);
107
+ });
108
+ });
@@ -1,6 +1,6 @@
1
1
  import type { DomEditSelection } from "./domEditingTypes";
2
2
  import { readStudioBoxSize } from "./manualEdits";
3
- import { readMoveOffset } from "./plainTranslate";
3
+ import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
4
4
  import { parsePxMetricValue, type PropertyPanelProps } from "./propertyPanelHelpers";
5
5
 
6
6
  interface TransformCommitDeps {
@@ -75,6 +75,8 @@ export function createTransformCommitHandlers({
75
75
  )
76
76
  return;
77
77
  const current = readMoveOffset(element.element);
78
+ if (!Number.isFinite(current.x) || !Number.isFinite(current.y))
79
+ return void showToast?.(UNREADABLE_TRANSLATE);
78
80
  await Promise.resolve(
79
81
  onSetManualOffset(element, {
80
82
  x: axis === "x" ? parsed : current.x,
@@ -71,7 +71,9 @@ function HookHost({
71
71
  element,
72
72
  previewIframeRef,
73
73
  onApplyScope,
74
+ projectId = "proj",
74
75
  }: {
76
+ projectId?: string;
75
77
  onState: (state: ReturnType<typeof useColorGradingController>) => void;
76
78
  onSetAttributeLive: (attr: string, value: string | null) => void;
77
79
  element: DomEditSelection;
@@ -79,7 +81,7 @@ function HookHost({
79
81
  onApplyScope?: ApplyScope;
80
82
  }) {
81
83
  const state = useColorGradingController({
82
- projectId: "proj",
84
+ projectId,
83
85
  element,
84
86
  previewIframeRef,
85
87
  onSetAttributeLive,
@@ -94,6 +96,7 @@ function renderHook(
94
96
  initialElement: DomEditSelection = makeElement(),
95
97
  previewIframeRef?: React.RefObject<HTMLIFrameElement | null>,
96
98
  onApplyScope?: ApplyScope,
99
+ projectId?: string,
97
100
  ) {
98
101
  const host = document.createElement("div");
99
102
  document.body.append(host);
@@ -108,6 +111,7 @@ function renderHook(
108
111
  element,
109
112
  previewIframeRef,
110
113
  onApplyScope,
114
+ projectId,
111
115
  }),
112
116
  );
113
117
  });
@@ -763,6 +767,37 @@ describe("useColorGradingController", () => {
763
767
  act(() => second.root.unmount());
764
768
  vi.unstubAllGlobals();
765
769
  });
770
+
771
+ it("keeps media metadata apart for 'p:a' + 'b.mp4' and 'p' + 'a:b.mp4'", async () => {
772
+ const video = (src: string) => {
773
+ const el = document.createElement("video");
774
+ el.setAttribute("src", src);
775
+ return el;
776
+ };
777
+ const fetchMock = vi.fn().mockResolvedValue({
778
+ ok: true,
779
+ json: () => Promise.resolve({ metadata: { kind: "video", color: { dynamicRange: "hdr" } } }),
780
+ } as unknown as Response);
781
+ vi.stubGlobal("fetch", fetchMock);
782
+ for (const [projectId, src] of [
783
+ ["p:a", "b.mp4"],
784
+ ["p", "a:b.mp4"],
785
+ ]) {
786
+ const { root } = renderHook(
787
+ vi.fn(),
788
+ makeElement({ element: video(src) }),
789
+ undefined,
790
+ undefined,
791
+ projectId,
792
+ );
793
+ await act(async () => {
794
+ await Promise.resolve();
795
+ });
796
+ act(() => root.unmount());
797
+ }
798
+ expect(fetchMock).toHaveBeenCalledTimes(2);
799
+ vi.unstubAllGlobals();
800
+ });
766
801
  });
767
802
 
768
803
  describe("useColorGradingController after the preview iframe is replaced", () => {
@@ -284,13 +284,13 @@ export function useColorGradingController({
284
284
  useEffect(() => {
285
285
  setMediaMetadata(null);
286
286
  if (!selectedAssetPath) return;
287
- const cacheKey = `${projectId}:${selectedAssetPath}`;
287
+ const cacheKey = mediaMetadataUrl(projectId, selectedAssetPath);
288
288
  if (MEDIA_METADATA_CACHE.has(cacheKey)) {
289
289
  setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null);
290
290
  return;
291
291
  }
292
292
  const controller = new AbortController();
293
- fetch(mediaMetadataUrl(projectId, selectedAssetPath), { signal: controller.signal })
293
+ fetch(cacheKey, { signal: controller.signal })
294
294
  .then(async (response) => {
295
295
  if (!response.ok) return { ok: false as const };
296
296
  const data: MediaMetadataResponse | null = await response.json();
@@ -46,6 +46,7 @@ function Harness({
46
46
  groupGestureRef: makeRef(null),
47
47
  blockedMoveRef: makeRef(null),
48
48
  onManualDragStartRef: makeRef(() => {}),
49
+ onBlockedMoveRef: makeRef(() => {}),
49
50
  onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
50
51
  onGroupPathOffsetCommitRef: makeRef(async () => {}),
51
52
  });
@@ -55,6 +55,7 @@ export interface UseDomEditNudgeParams {
55
55
  groupGestureRef: RefObject<GroupGestureState | null>;
56
56
  blockedMoveRef: RefObject<BlockedMoveState | null>;
57
57
  onManualDragStartRef: RefObject<(() => void) | undefined>;
58
+ onBlockedMoveRef: RefObject<(selection: DomEditSelection, reason?: string) => void>;
58
59
  onPathOffsetCommitRef: RefObject<
59
60
  (
60
61
  s: DomEditSelection,
@@ -179,7 +180,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
179
180
  // Build drag members for the current target set — the same member snapshot a
180
181
  // pointer drag starts from (startGesture / startGroupDrag), so the nudge
181
182
  // commit converts offsets → GSAP x/y with identical math.
182
- const beginSession = (): NudgeSession | null => {
183
+ const beginSession = (event: KeyboardEvent): NudgeSession | "refused" | null => {
183
184
  const p = paramsRef.current;
184
185
  const groupItems = p.groupOverlayItemsRef.current;
185
186
  const isGroup = groupItems.length > 1;
@@ -192,7 +193,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
192
193
  const result = createManualOffsetDragMember({ ...target, gesture: "nudge" });
193
194
  if (!result.ok) {
194
195
  restoreManualOffsetDragMembers(members);
195
- return null;
196
+ if (!event.repeat) p.onBlockedMoveRef.current(result.selection, result.reason);
197
+ return "refused";
196
198
  }
197
199
  members.push(result.member);
198
200
  }
@@ -207,7 +209,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
207
209
  if (shouldIgnoreNudgeKey(p, event)) return;
208
210
  const delta = resolveCanvasNudgeDelta(event);
209
211
  if (!delta) return;
210
- const session = sessionRef.current ?? beginSession();
212
+ const session = sessionRef.current ?? beginSession(event);
213
+ if (session === "refused") return void event.preventDefault();
211
214
  if (!session) return;
212
215
  sessionRef.current = session;
213
216
  event.preventDefault();
@@ -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,