@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
@@ -1,8 +1,10 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
+ isElementVisibleForZOrder,
4
5
  isZOrderActionEnabled,
5
6
  parseZIndex,
7
+ resolveCrossedNeighbor,
6
8
  resolveZOrderChange,
7
9
  type ZOrderAction,
8
10
  type ZOrderPatch,
@@ -48,8 +50,12 @@ function makeFamily(
48
50
  }
49
51
 
50
52
  /** Resolve a z-order change and assert it produced patches (fails otherwise). */
51
- function resolveZOrderPatches(target: HTMLElement, action: ZOrderAction): ZOrderPatch[] {
52
- const patches = resolveZOrderChange(target, action);
53
+ function resolveZOrderPatches(
54
+ target: HTMLElement,
55
+ action: ZOrderAction,
56
+ options?: { isVisible?: (el: HTMLElement) => boolean },
57
+ ): ZOrderPatch[] {
58
+ const patches = resolveZOrderChange(target, action, options);
53
59
  expect(patches).not.toBeNull();
54
60
  if (!patches) throw new Error("expected z-order patches");
55
61
  return patches;
@@ -375,6 +381,27 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
375
381
  expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
376
382
  });
377
383
 
384
+ it("ignores <template>/<noscript> siblings in the family", () => {
385
+ // A renumber fallback once wrote z-index/position into <template> source
386
+ // markup because templates entered the sibling family. They never paint —
387
+ // exclude them like audio/script/style.
388
+ const parent = document.createElement("div");
389
+ const a = makeEl("a", "0");
390
+ const template = document.createElement("template");
391
+ template.style.zIndex = "2";
392
+ const noscript = document.createElement("noscript");
393
+ const target = makeEl("target", "0");
394
+ parent.append(a, template, noscript, target);
395
+
396
+ const patches = resolveZOrderPatches(target, "send-to-back");
397
+ for (const p of patches) {
398
+ expect(p.element).not.toBe(template);
399
+ expect(p.element).not.toBe(noscript);
400
+ }
401
+ const order = renderOrderIds(parent, { a, target }, patches);
402
+ expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
403
+ });
404
+
378
405
  it("a lone painting element beside only non-painting siblings has no family → null", () => {
379
406
  const parent = document.createElement("div");
380
407
  const target = makeEl("target", "1");
@@ -392,6 +419,163 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
392
419
  });
393
420
  });
394
421
 
422
+ // ── visibility scoping (forward/backward step over VISIBLE siblings only) ──────
423
+ //
424
+ // The visibility probe is injectable (ZOrderResolveOptions.isVisible) exactly
425
+ // like rect reading is stubbable — these tests drive the scoping logic with a
426
+ // stub, without a real style engine.
427
+
428
+ describe("resolveZOrderChange – visibility scoping (injectable stub)", () => {
429
+ /** Probe that hides exactly the given elements. */
430
+ function hiding(...hidden: HTMLElement[]) {
431
+ return { isVisible: (el: HTMLElement) => !hidden.includes(el) };
432
+ }
433
+
434
+ it("bring-forward steps over the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
435
+ // Render order: target(1), hidden(2), vis(3). The nearest z-neighbor above
436
+ // is invisible at the current frame — forward must land the target above
437
+ // `vis` (the next VISIBLE overlapping sibling), leaving `hidden` untouched.
438
+ const { target, byId } = makeFamily("1", [
439
+ ["hidden", "2"],
440
+ ["vis", "3"],
441
+ ]);
442
+ const patches = resolveZOrderPatches(target, "bring-forward", hiding(byId.hidden!));
443
+ expect(patches).toHaveLength(1);
444
+ expect(patchFor(patches, byId, "target")?.zIndex).toBe(4);
445
+ expect(patchFor(patches, byId, "hidden")).toBeUndefined();
446
+ });
447
+
448
+ it("send-backward steps below the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
449
+ // Render order: vis(1), hidden(2), target(3). Backward must drop the target
450
+ // below `vis`, not merely below the invisible `hidden`.
451
+ const { target, byId } = makeFamily("3", [
452
+ ["vis", "1"],
453
+ ["hidden", "2"],
454
+ ]);
455
+ const patches = resolveZOrderPatches(target, "send-backward", hiding(byId.hidden!));
456
+ expect(patches).toHaveLength(1);
457
+ expect(patchFor(patches, byId, "target")?.zIndex).toBe(0);
458
+ expect(patchFor(patches, byId, "hidden")).toBeUndefined();
459
+ });
460
+
461
+ it("forward/backward are no-ops when every overlapping sibling is invisible", () => {
462
+ const { target, byId } = makeFamily("1", [["hidden", "2"]]);
463
+ const opts = hiding(byId.hidden!);
464
+ expect(resolveZOrderChange(target, "bring-forward", opts)).toBeNull();
465
+ expect(resolveZOrderChange(target, "send-backward", opts)).toBeNull();
466
+ });
467
+
468
+ it("bring-to-front / send-to-back keep the FULL painting family (invisible siblings included)", () => {
469
+ // Unchanged semantics: front/back operate across all siblings, so an
470
+ // invisible sibling still counts and the actions stay meaningful.
471
+ const { target, byId } = makeFamily("1", [["hidden", "2"]]);
472
+ const opts = hiding(byId.hidden!);
473
+ const patches = resolveZOrderPatches(target, "bring-to-front", opts);
474
+ expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
475
+ expect(resolveZOrderChange(target, "send-to-back", opts)).toBeNull(); // already bottom
476
+ });
477
+
478
+ it("the target itself is retained even when the probe reports it invisible", () => {
479
+ const { target, byId } = makeFamily("1", [["vis", "2"]]);
480
+ const patches = resolveZOrderPatches(target, "bring-forward", hiding(target));
481
+ expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
482
+ });
483
+
484
+ it("isZOrderActionEnabled matches the resolver under the same visibility scope", () => {
485
+ const { target, byId } = makeFamily("1", [["hidden", "2"]]);
486
+ const opts = hiding(byId.hidden!);
487
+ // Forward/backward: scoped set collapses to the target alone → disabled.
488
+ expect(isZOrderActionEnabled(target, "bring-forward", opts)).toBe(false);
489
+ expect(isZOrderActionEnabled(target, "send-backward", opts)).toBe(false);
490
+ // Front/back: full family → enabled exactly where the resolver acts.
491
+ expect(isZOrderActionEnabled(target, "bring-to-front", opts)).toBe(true);
492
+ expect(isZOrderActionEnabled(target, "send-to-back", opts)).toBe(false);
493
+ });
494
+ });
495
+
496
+ // ── default visibility probe (element-level computed style) ───────────────────
497
+
498
+ describe("isElementVisibleForZOrder – default probe", () => {
499
+ function attachedEl(style: Partial<CSSStyleDeclaration> = {}): HTMLElement {
500
+ const el = document.createElement("div");
501
+ Object.assign(el.style, style);
502
+ document.body.appendChild(el);
503
+ return el;
504
+ }
505
+
506
+ it("treats display:none / visibility:hidden / opacity≈0 as invisible", () => {
507
+ expect(isElementVisibleForZOrder(attachedEl({ display: "none" }))).toBe(false);
508
+ expect(isElementVisibleForZOrder(attachedEl({ visibility: "hidden" }))).toBe(false);
509
+ expect(isElementVisibleForZOrder(attachedEl({ opacity: "0" }))).toBe(false);
510
+ expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.005" }))).toBe(false);
511
+ });
512
+
513
+ it("treats normal, translucent, and unstyled elements as visible", () => {
514
+ expect(isElementVisibleForZOrder(attachedEl())).toBe(true);
515
+ expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.5" }))).toBe(true);
516
+ expect(isElementVisibleForZOrder(attachedEl({ visibility: "visible" }))).toBe(true);
517
+ });
518
+
519
+ it("exempts a hidden color-grading source (its canvas paints in its place)", () => {
520
+ const el = attachedEl({ opacity: "0" });
521
+ el.setAttribute("data-hf-color-grading-source-hidden", "");
522
+ expect(isElementVisibleForZOrder(el)).toBe(true);
523
+ });
524
+
525
+ it("is the default probe: the runtime's inline visibility:hidden on a time-inactive clip is skipped", () => {
526
+ // End-to-end through resolveZOrderChange with NO injected probe: the
527
+ // runtime hides inactive clips with inline `visibility:hidden` (see core
528
+ // runtime syncTimedElementVisibility) — computed style picks that up.
529
+ const parent = document.createElement("div");
530
+ const target = makeEl("target", "1");
531
+ const hidden = makeEl("hidden", "2");
532
+ hidden.style.visibility = "hidden";
533
+ const vis = makeEl("vis", "3");
534
+ parent.append(target, hidden, vis);
535
+ document.body.appendChild(parent);
536
+ const patches = resolveZOrderPatches(target, "bring-forward");
537
+ expect(patchFor(patches, { target, hidden, vis }, "target")?.zIndex).toBe(4);
538
+ expect(patchFor(patches, { target, hidden, vis }, "hidden")).toBeUndefined();
539
+ });
540
+ });
541
+
542
+ // ── resolveCrossedNeighbor (the "show your work" flash target) ────────────────
543
+
544
+ describe("resolveCrossedNeighbor", () => {
545
+ it("returns the visible sibling directly above for bring-forward", () => {
546
+ const { target, byId } = makeFamily("1", [
547
+ ["hidden", "2"],
548
+ ["vis", "3"],
549
+ ]);
550
+ const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
551
+ expect(resolveCrossedNeighbor(target, "bring-forward", opts)).toBe(byId.vis);
552
+ });
553
+
554
+ it("returns the visible sibling directly below for send-backward", () => {
555
+ const { target, byId } = makeFamily("3", [
556
+ ["vis", "1"],
557
+ ["hidden", "2"],
558
+ ]);
559
+ const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
560
+ expect(resolveCrossedNeighbor(target, "send-backward", opts)).toBe(byId.vis);
561
+ });
562
+
563
+ it("returns null for front/back actions and for no-op steps", () => {
564
+ const { target } = makeFamily("1", [["a", "2"]]);
565
+ expect(resolveCrossedNeighbor(target, "bring-to-front")).toBeNull();
566
+ expect(resolveCrossedNeighbor(target, "send-to-back")).toBeNull();
567
+ expect(resolveCrossedNeighbor(target, "send-backward")).toBeNull(); // already bottom
568
+ const { target: top } = makeFamily("5", [["a", "2"]]);
569
+ expect(resolveCrossedNeighbor(top, "bring-forward")).toBeNull(); // already top
570
+ });
571
+
572
+ it("returns null when there are no siblings", () => {
573
+ const solo = makeEl("solo", "1");
574
+ document.createElement("div").appendChild(solo);
575
+ expect(resolveCrossedNeighbor(solo, "bring-forward")).toBeNull();
576
+ });
577
+ });
578
+
395
579
  // ── isZOrderActionEnabled ─────────────────────────────────────────────────────
396
580
 
397
581
  describe("isZOrderActionEnabled", () => {
@@ -7,8 +7,23 @@
7
7
  * the computed value. Treat missing / "auto" as 0 for comparison purposes.
8
8
  *
9
9
  * "Overlapping siblings" = siblings whose bounding rects intersect the
10
- * target's bounding rect. Forward/backward operate within that set;
11
- * front/back operate across all siblings.
10
+ * target's bounding rect AND are actually visible at the current frame.
11
+ * Forward/backward operate within that set; front/back operate across all
12
+ * siblings (full painting family, visible or not — unchanged semantics).
13
+ *
14
+ * ── Visibility ───────────────────────────────────────────────────────────────
15
+ * In HyperFrames compositions the nearest z-neighbor is often INVISIBLE at the
16
+ * paused frame: the runtime hides time-inactive clips with inline
17
+ * `visibility:hidden` / `display:none` (see core runtime
18
+ * syncTimedElementVisibility), and GSAP timelines park elements at `opacity:0`.
19
+ * Stepping "forward" over such a sibling looks like a silent no-op. The
20
+ * forward/backward comparison set therefore keeps only siblings whose
21
+ * element-level computed style is visible (display ≠ none, visibility ≠
22
+ * hidden, opacity > 0.01) — all runtime hiding signals are inline styles, so
23
+ * computed style covers them. Ancestor-chain checks are unnecessary here:
24
+ * siblings share the target's ancestors. The probe is injectable
25
+ * (ZOrderResolveOptions.isVisible) so the pure-module tests stay meaningful
26
+ * without a real style engine, mirroring how tests stub rect reading.
12
27
  *
13
28
  * ── Tie-awareness ────────────────────────────────────────────────────────────
14
29
  * CSS paint order for elements that share a z-index is DOM document order:
@@ -26,6 +41,8 @@
26
41
  * (project convention clamps z ≥ 0).
27
42
  */
28
43
 
44
+ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
45
+
29
46
  export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
30
47
 
31
48
  /** A resolved change: set `element`'s z-index to `zIndex`. */
@@ -34,6 +51,48 @@ export interface ZOrderPatch {
34
51
  zIndex: number;
35
52
  }
36
53
 
54
+ /** Injectable knobs for the pure resolver (kept mockable like rect reading). */
55
+ export interface ZOrderResolveOptions {
56
+ /**
57
+ * Element-level visibility probe used to scope the forward/backward
58
+ * comparison set. Defaults to `isElementVisibleForZOrder` (computed-style
59
+ * display/visibility/opacity). Injectable so tests can run without a real
60
+ * style engine.
61
+ */
62
+ isVisible?: (el: HTMLElement) => boolean;
63
+ }
64
+
65
+ /**
66
+ * Default visibility probe: is this element itself visible at the current
67
+ * frame? Element-level only (siblings share the target's ancestor chain).
68
+ * Covers the runtime's inactive-clip hiding (inline `visibility:hidden` /
69
+ * `display:none`) and animation-parked `opacity:0`, all of which surface
70
+ * through computed style. A color-grading source (hidden at opacity:0 while
71
+ * its canvas paints in its place) still counts as visible, matching
72
+ * isElementVisibleThroughAncestors in domEditingDom.
73
+ */
74
+ export function isElementVisibleForZOrder(el: HTMLElement): boolean {
75
+ try {
76
+ const win = el.ownerDocument?.defaultView;
77
+ if (!win) return true;
78
+ const computed = win.getComputedStyle(el);
79
+ if (computed.display === "none") return false;
80
+ if (computed.visibility === "hidden" || computed.visibility === "collapse") return false;
81
+ const opacity = Number.parseFloat(computed.opacity);
82
+ if (
83
+ Number.isFinite(opacity) &&
84
+ opacity <= 0.01 &&
85
+ !el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
86
+ ) {
87
+ return false;
88
+ }
89
+ return true;
90
+ } catch {
91
+ /* cross-origin / detached — assume visible (fail open, matches rect fallback) */
92
+ return true;
93
+ }
94
+ }
95
+
37
96
  interface RenderEntry {
38
97
  element: HTMLElement;
39
98
  zIndex: number;
@@ -81,8 +140,18 @@ function isElementNode(node: Node): node is HTMLElement {
81
140
  * z-index onto the qa-clean audio element, and counting it as a sibling skews the
82
141
  * renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
83
142
  * non-painting and could otherwise pad the family / eat a z slot.
143
+ * `<template>/<noscript>` never paint either — letting them in meant renumber
144
+ * fallbacks wrote z-index/position into template source markup.
84
145
  */
85
- const NON_PAINTING_TAGS = new Set(["AUDIO", "SCRIPT", "STYLE", "LINK", "META"]);
146
+ const NON_PAINTING_TAGS = new Set([
147
+ "AUDIO",
148
+ "SCRIPT",
149
+ "STYLE",
150
+ "LINK",
151
+ "META",
152
+ "TEMPLATE",
153
+ "NOSCRIPT",
154
+ ]);
86
155
 
87
156
  /** A painting element: an element node whose tag actually renders pixels. */
88
157
  function isPaintingElement(node: Node): node is HTMLElement {
@@ -112,7 +181,7 @@ function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex:
112
181
  return { entries, targetIndex };
113
182
  }
114
183
 
115
- /** True if two DOM bounding rects intersect (even if touching). */
184
+ /** True if two DOM bounding rects strictly overlap (rects that merely touch do NOT intersect). */
116
185
  function rectsIntersect(
117
186
  a: { left: number; top: number; right: number; bottom: number },
118
187
  b: { left: number; top: number; right: number; bottom: number },
@@ -121,26 +190,35 @@ function rectsIntersect(
121
190
  }
122
191
 
123
192
  /**
124
- * Restrict a family to the target plus siblings whose bounding rect overlaps
125
- * the target's rect. The target is always retained. If the target's rect is
126
- * unavailable or empty (headless / happy-dom returns 0×0), all entries are
127
- * kept matching the prior behavior.
193
+ * Restrict a family to the target plus siblings that are VISIBLE and whose
194
+ * bounding rect overlaps the target's rect. The target is always retained
195
+ * (even when itself hidden at the current frame it is the user's explicit
196
+ * selection). If the target's rect is unavailable or empty (headless /
197
+ * happy-dom returns 0×0), the overlap filter is skipped and all VISIBLE
198
+ * entries are kept — matching the prior rect-fallback behavior.
128
199
  */
129
- function getOverlappingFamily(target: HTMLElement, entries: RenderEntry[]): RenderEntry[] {
200
+ function getOverlappingFamily(
201
+ target: HTMLElement,
202
+ entries: RenderEntry[],
203
+ isVisible: (el: HTMLElement) => boolean,
204
+ ): RenderEntry[] {
205
+ const visibleEntries = entries.filter(
206
+ (entry) => entry.element === target || isVisible(entry.element),
207
+ );
130
208
  let targetRect: DOMRect;
131
209
  try {
132
210
  targetRect = target.getBoundingClientRect();
133
211
  } catch {
134
- return entries;
212
+ return visibleEntries;
135
213
  }
136
- if (targetRect.width === 0 && targetRect.height === 0) return entries;
214
+ if (targetRect.width === 0 && targetRect.height === 0) return visibleEntries;
137
215
  const tr = {
138
216
  left: targetRect.left,
139
217
  top: targetRect.top,
140
218
  right: targetRect.right,
141
219
  bottom: targetRect.bottom,
142
220
  };
143
- return entries.filter((entry) => {
221
+ return visibleEntries.filter((entry) => {
144
222
  if (entry.element === target) return true;
145
223
  try {
146
224
  const r = entry.element.getBoundingClientRect();
@@ -296,28 +374,46 @@ function buildGlobalOrder(
296
374
  }
297
375
 
298
376
  /**
299
- * Resolve the z-order patches for an action.
300
- *
301
- * Returns null when the action is a no-op (target already at the relevant
302
- * end of its set), otherwise the minimal list of {element, zIndex} changes.
377
+ * The shared scoping pipeline: full painting family for front/back, visible
378
+ * overlapping siblings for forward/backward, sorted into render order with the
379
+ * target's position. Null when the family/scope is too small to act on.
303
380
  */
304
- export function resolveZOrderChange(
381
+ function resolveScopedRenderOrder(
305
382
  target: HTMLElement,
306
383
  action: ZOrderAction,
307
- ): ZOrderPatch[] | null {
384
+ options?: ZOrderResolveOptions,
385
+ ): { entries: RenderEntry[]; order: RenderEntry[]; pos: number } | null {
308
386
  const { entries } = getFamily(target);
309
387
  // Family always includes the target; fewer than 2 means no siblings at all.
310
388
  if (entries.length < 2) return null;
311
389
 
390
+ const isVisible = options?.isVisible ?? isElementVisibleForZOrder;
312
391
  const scoped =
313
392
  action === "bring-to-front" || action === "send-to-back"
314
393
  ? entries
315
- : getOverlappingFamily(target, entries);
394
+ : getOverlappingFamily(target, entries, isVisible);
316
395
  if (scoped.length < 2) return null;
317
396
 
318
397
  const order = toRenderOrder(scoped);
319
398
  const pos = order.findIndex((e) => e.element === target);
320
399
  if (pos === -1) return null;
400
+ return { entries, order, pos };
401
+ }
402
+
403
+ /**
404
+ * Resolve the z-order patches for an action.
405
+ *
406
+ * Returns null when the action is a no-op (target already at the relevant
407
+ * end of its set), otherwise the minimal list of {element, zIndex} changes.
408
+ */
409
+ export function resolveZOrderChange(
410
+ target: HTMLElement,
411
+ action: ZOrderAction,
412
+ options?: ZOrderResolveOptions,
413
+ ): ZOrderPatch[] | null {
414
+ const resolved = resolveScopedRenderOrder(target, action, options);
415
+ if (!resolved) return null;
416
+ const { entries, order, pos } = resolved;
321
417
 
322
418
  const desired = [...order];
323
419
  const [moved] = desired.splice(pos, 1);
@@ -343,10 +439,36 @@ export function resolveZOrderChange(
343
439
  return realizeOrder(order, desired, target, entries);
344
440
  }
345
441
 
442
+ /**
443
+ * The sibling a forward/backward step crosses: the visible overlapping
444
+ * neighbor directly above (bring-forward) or below (send-backward) the target
445
+ * in render order. Null for front/back, for a no-op step, or when the scope is
446
+ * too small. Uses the SAME scoping as resolveZOrderChange, so call it with the
447
+ * same options BEFORE any live styles are applied.
448
+ */
449
+ export function resolveCrossedNeighbor(
450
+ target: HTMLElement,
451
+ action: ZOrderAction,
452
+ options?: ZOrderResolveOptions,
453
+ ): HTMLElement | null {
454
+ if (action !== "bring-forward" && action !== "send-backward") return null;
455
+ const resolved = resolveScopedRenderOrder(target, action, options);
456
+ if (!resolved) return null;
457
+ const { order, pos } = resolved;
458
+ const neighbor = action === "bring-forward" ? order[pos + 1] : order[pos - 1];
459
+ return neighbor?.element ?? null;
460
+ }
461
+
346
462
  /**
347
463
  * Whether a z-order action is available for the target.
348
- * "disabled" = the element is already at that limit.
464
+ * "disabled" = the element is already at that limit. Shares the resolver (and
465
+ * its visibility scoping), so enable/disable always matches what the action
466
+ * would actually do.
349
467
  */
350
- export function isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
351
- return resolveZOrderChange(target, action) !== null;
468
+ export function isZOrderActionEnabled(
469
+ target: HTMLElement,
470
+ action: ZOrderAction,
471
+ options?: ZOrderResolveOptions,
472
+ ): boolean {
473
+ return resolveZOrderChange(target, action, options) !== null;
352
474
  }
@@ -24,6 +24,10 @@ import type { DomEditSelection } from "./domEditing";
24
24
  import { ColorGradingControls } from "./propertyPanelColorGradingControls";
25
25
  import { stripQueryAndHash } from "./propertyPanelHelpers";
26
26
  import { Section } from "./propertyPanelPrimitives";
27
+ import {
28
+ acceptStudioRuntimeMessage,
29
+ postRuntimeControlMessage,
30
+ } from "../../player/lib/runtimeProtocol";
27
31
 
28
32
  const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
29
33
  const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const;
@@ -393,35 +397,23 @@ export function ColorGradingSection({
393
397
 
394
398
  const postColorGrading = useCallback(
395
399
  (nextGrading: NormalizedHfColorGrading) => {
396
- previewIframeRef?.current?.contentWindow?.postMessage(
397
- {
398
- source: "hf-parent",
399
- type: "control",
400
- action: "set-color-grading",
401
- target,
402
- grading: toBridgeColorGrading(nextGrading),
403
- },
404
- "*",
405
- );
400
+ postRuntimeControlMessage(previewIframeRef?.current?.contentWindow, "set-color-grading", {
401
+ target,
402
+ grading: toBridgeColorGrading(nextGrading),
403
+ });
406
404
  },
407
405
  [previewIframeRef, target],
408
406
  );
409
407
 
410
408
  const postCompare = useCallback(
411
409
  (enabled: boolean) => {
412
- previewIframeRef?.current?.contentWindow?.postMessage(
410
+ postRuntimeControlMessage(
411
+ previewIframeRef?.current?.contentWindow,
412
+ "set-color-grading-compare",
413
413
  {
414
- source: "hf-parent",
415
- type: "control",
416
- action: "set-color-grading-compare",
417
414
  target,
418
- compare: {
419
- enabled,
420
- position: 1,
421
- lineWidth: 0,
422
- },
415
+ compare: { enabled, position: 1, lineWidth: 0 },
423
416
  },
424
- "*",
425
417
  );
426
418
  },
427
419
  [previewIframeRef, target],
@@ -440,7 +432,9 @@ export function ColorGradingSection({
440
432
  const onMessage = (event: MessageEvent) => {
441
433
  if (event.source !== iframe.contentWindow) return;
442
434
  const data = event.data as { source?: unknown; type?: unknown } | null;
443
- if (data?.source === "hf-preview" && data.type === "ready") refreshAndReplay();
435
+ if (data?.source !== "hf-preview" || data.type !== "ready") return;
436
+ if (!acceptStudioRuntimeMessage(data)) return;
437
+ refreshAndReplay();
444
438
  };
445
439
  iframe.addEventListener("load", refreshAndReplay);
446
440
  window.addEventListener("message", onMessage);
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Canvas right-click context-menu state for DomEditOverlay: where the menu is
3
+ * open (viewport x/y) and which selection it targets, plus the right-click
4
+ * handler that resolves/selects the element under the pointer before opening.
5
+ */
6
+ import { useCallback, useEffect, useState, type RefObject } from "react";
7
+ import type { DomEditSelection } from "./domEditing";
8
+
9
+ export interface CanvasContextMenuState {
10
+ x: number;
11
+ y: number;
12
+ sel: DomEditSelection;
13
+ }
14
+
15
+ interface UseCanvasContextMenuStateParams {
16
+ selection: DomEditSelection | null;
17
+ selectionRef: RefObject<DomEditSelection | null>;
18
+ hoverSelectionRef: RefObject<DomEditSelection | null>;
19
+ onCanvasPointerMoveRef: RefObject<
20
+ (
21
+ event: React.PointerEvent<HTMLDivElement>,
22
+ options?: { preferClipAncestor?: boolean },
23
+ ) => Promise<DomEditSelection | null>
24
+ >;
25
+ onSelectionChangeRef: RefObject<
26
+ (selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
27
+ >;
28
+ }
29
+
30
+ export function useCanvasContextMenuState({
31
+ selection,
32
+ selectionRef,
33
+ hoverSelectionRef,
34
+ onCanvasPointerMoveRef,
35
+ onSelectionChangeRef,
36
+ }: UseCanvasContextMenuStateParams): {
37
+ contextMenu: CanvasContextMenuState | null;
38
+ closeContextMenu: () => void;
39
+ handleContextMenu: (event: React.MouseEvent<HTMLDivElement>) => Promise<void>;
40
+ } {
41
+ // Context menu state: position of the right-click that opened it.
42
+ // contextMenu.sel is the element the menu targets — captured at right-click
43
+ // time so the menu can open even before the React selection state settles.
44
+ const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(null);
45
+ const closeContextMenu = useCallback(() => setContextMenu(null), []);
46
+
47
+ // Close the context menu whenever the selection moves off the element the menu
48
+ // targets (a click that reselects elsewhere, a deselect, or a preview reload
49
+ // that rebuilds the selection). Without this the menu can linger — orphaned —
50
+ // over a stale target after the underlying element is gone. A right-click that
51
+ // OPENS the menu also selects its target, so the common open path keeps the
52
+ // menu (same element) rather than immediately dismissing it.
53
+ useEffect(() => {
54
+ if (!contextMenu) return;
55
+ if (!selection || selection.element !== contextMenu.sel.element) {
56
+ setContextMenu(null);
57
+ }
58
+ }, [selection, contextMenu]);
59
+
60
+ // Right-click: select element first (if not already selected), then open menu.
61
+ const handleContextMenu = useCallback(
62
+ async (event: React.MouseEvent<HTMLDivElement>) => {
63
+ event.preventDefault();
64
+
65
+ // If no element is selected yet, resolve it from the pointer position first.
66
+ const currentSel = selectionRef.current;
67
+ let activeSel: DomEditSelection | null = currentSel;
68
+ if (!currentSel) {
69
+ const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
70
+ const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
71
+ if (!resolved) return; // Nothing under the cursor — skip menu.
72
+ onSelectionChangeRef.current(resolved, { revealPanel: true });
73
+ // Use `resolved` directly: React state (and therefore selectionRef) won't
74
+ // update synchronously after onSelectionChange — we'd be reading stale null.
75
+ activeSel = resolved;
76
+ } else {
77
+ // Check if the user right-clicked on an unselected element (hover target).
78
+ const hover = hoverSelectionRef.current;
79
+ if (hover && hover.element !== currentSel.element) {
80
+ onSelectionChangeRef.current(hover, { revealPanel: true });
81
+ activeSel = hover;
82
+ }
83
+ }
84
+
85
+ if (!activeSel) return;
86
+ setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
87
+ },
88
+ [selectionRef, hoverSelectionRef, onCanvasPointerMoveRef, onSelectionChangeRef],
89
+ );
90
+
91
+ return { contextMenu, closeContextMenu, handleContextMenu };
92
+ }