@hyperframes/studio 0.7.56 → 0.7.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -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,9 @@
26
41
  * (project convention clamps z ≥ 0).
27
42
  */
28
43
 
44
+ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
45
+ import { readLayerRevealPriorZ } from "../../player/lib/timelineElementHelpers";
46
+
29
47
  export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
30
48
 
31
49
  /** A resolved change: set `element`'s z-index to `zIndex`. */
@@ -34,6 +52,48 @@ export interface ZOrderPatch {
34
52
  zIndex: number;
35
53
  }
36
54
 
55
+ /** Injectable knobs for the pure resolver (kept mockable like rect reading). */
56
+ export interface ZOrderResolveOptions {
57
+ /**
58
+ * Element-level visibility probe used to scope the forward/backward
59
+ * comparison set. Defaults to `isElementVisibleForZOrder` (computed-style
60
+ * display/visibility/opacity). Injectable so tests can run without a real
61
+ * style engine.
62
+ */
63
+ isVisible?: (el: HTMLElement) => boolean;
64
+ }
65
+
66
+ /**
67
+ * Default visibility probe: is this element itself visible at the current
68
+ * frame? Element-level only (siblings share the target's ancestor chain).
69
+ * Covers the runtime's inactive-clip hiding (inline `visibility:hidden` /
70
+ * `display:none`) and animation-parked `opacity:0`, all of which surface
71
+ * through computed style. A color-grading source (hidden at opacity:0 while
72
+ * its canvas paints in its place) still counts as visible, matching
73
+ * isElementVisibleThroughAncestors in domEditingDom.
74
+ */
75
+ export function isElementVisibleForZOrder(el: HTMLElement): boolean {
76
+ try {
77
+ const win = el.ownerDocument?.defaultView;
78
+ if (!win) return true;
79
+ const computed = win.getComputedStyle(el);
80
+ if (computed.display === "none") return false;
81
+ if (computed.visibility === "hidden" || computed.visibility === "collapse") return false;
82
+ const opacity = Number.parseFloat(computed.opacity);
83
+ if (
84
+ Number.isFinite(opacity) &&
85
+ opacity <= 0.01 &&
86
+ !el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
87
+ ) {
88
+ return false;
89
+ }
90
+ return true;
91
+ } catch {
92
+ /* cross-origin / detached — assume visible (fail open, matches rect fallback) */
93
+ return true;
94
+ }
95
+ }
96
+
37
97
  interface RenderEntry {
38
98
  element: HTMLElement;
39
99
  zIndex: number;
@@ -48,8 +108,11 @@ export function parseZIndex(value: string | null | undefined): number {
48
108
  return Number.isFinite(n) ? n : 0;
49
109
  }
50
110
 
51
- /** Read the effective z-index for an element (inline style preferred). */
111
+ /** Read the effective z-index for an element (inline style preferred).
112
+ * Reveal-lift transparent: an active Layers-panel lift reports the TRUE z. */
52
113
  export function readEffectiveZIndex(el: HTMLElement): number {
114
+ const prior = readLayerRevealPriorZ(el);
115
+ if (prior != null) return prior;
53
116
  const inline = el.style.zIndex;
54
117
  if (inline && inline !== "auto") return parseZIndex(inline);
55
118
  try {
@@ -81,8 +144,18 @@ function isElementNode(node: Node): node is HTMLElement {
81
144
  * z-index onto the qa-clean audio element, and counting it as a sibling skews the
82
145
  * renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
83
146
  * non-painting and could otherwise pad the family / eat a z slot.
147
+ * `<template>/<noscript>` never paint either — letting them in meant renumber
148
+ * fallbacks wrote z-index/position into template source markup.
84
149
  */
85
- const NON_PAINTING_TAGS = new Set(["AUDIO", "SCRIPT", "STYLE", "LINK", "META"]);
150
+ const NON_PAINTING_TAGS = new Set([
151
+ "AUDIO",
152
+ "SCRIPT",
153
+ "STYLE",
154
+ "LINK",
155
+ "META",
156
+ "TEMPLATE",
157
+ "NOSCRIPT",
158
+ ]);
86
159
 
87
160
  /** A painting element: an element node whose tag actually renders pixels. */
88
161
  function isPaintingElement(node: Node): node is HTMLElement {
@@ -112,7 +185,7 @@ function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex:
112
185
  return { entries, targetIndex };
113
186
  }
114
187
 
115
- /** True if two DOM bounding rects intersect (even if touching). */
188
+ /** True if two DOM bounding rects strictly overlap (rects that merely touch do NOT intersect). */
116
189
  function rectsIntersect(
117
190
  a: { left: number; top: number; right: number; bottom: number },
118
191
  b: { left: number; top: number; right: number; bottom: number },
@@ -121,26 +194,35 @@ function rectsIntersect(
121
194
  }
122
195
 
123
196
  /**
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.
197
+ * Restrict a family to the target plus siblings that are VISIBLE and whose
198
+ * bounding rect overlaps the target's rect. The target is always retained
199
+ * (even when itself hidden at the current frame it is the user's explicit
200
+ * selection). If the target's rect is unavailable or empty (headless /
201
+ * happy-dom returns 0×0), the overlap filter is skipped and all VISIBLE
202
+ * entries are kept — matching the prior rect-fallback behavior.
128
203
  */
129
- function getOverlappingFamily(target: HTMLElement, entries: RenderEntry[]): RenderEntry[] {
204
+ function getOverlappingFamily(
205
+ target: HTMLElement,
206
+ entries: RenderEntry[],
207
+ isVisible: (el: HTMLElement) => boolean,
208
+ ): RenderEntry[] {
209
+ const visibleEntries = entries.filter(
210
+ (entry) => entry.element === target || isVisible(entry.element),
211
+ );
130
212
  let targetRect: DOMRect;
131
213
  try {
132
214
  targetRect = target.getBoundingClientRect();
133
215
  } catch {
134
- return entries;
216
+ return visibleEntries;
135
217
  }
136
- if (targetRect.width === 0 && targetRect.height === 0) return entries;
218
+ if (targetRect.width === 0 && targetRect.height === 0) return visibleEntries;
137
219
  const tr = {
138
220
  left: targetRect.left,
139
221
  top: targetRect.top,
140
222
  right: targetRect.right,
141
223
  bottom: targetRect.bottom,
142
224
  };
143
- return entries.filter((entry) => {
225
+ return visibleEntries.filter((entry) => {
144
226
  if (entry.element === target) return true;
145
227
  try {
146
228
  const r = entry.element.getBoundingClientRect();
@@ -296,28 +378,46 @@ function buildGlobalOrder(
296
378
  }
297
379
 
298
380
  /**
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.
381
+ * The shared scoping pipeline: full painting family for front/back, visible
382
+ * overlapping siblings for forward/backward, sorted into render order with the
383
+ * target's position. Null when the family/scope is too small to act on.
303
384
  */
304
- export function resolveZOrderChange(
385
+ function resolveScopedRenderOrder(
305
386
  target: HTMLElement,
306
387
  action: ZOrderAction,
307
- ): ZOrderPatch[] | null {
388
+ options?: ZOrderResolveOptions,
389
+ ): { entries: RenderEntry[]; order: RenderEntry[]; pos: number } | null {
308
390
  const { entries } = getFamily(target);
309
391
  // Family always includes the target; fewer than 2 means no siblings at all.
310
392
  if (entries.length < 2) return null;
311
393
 
394
+ const isVisible = options?.isVisible ?? isElementVisibleForZOrder;
312
395
  const scoped =
313
396
  action === "bring-to-front" || action === "send-to-back"
314
397
  ? entries
315
- : getOverlappingFamily(target, entries);
398
+ : getOverlappingFamily(target, entries, isVisible);
316
399
  if (scoped.length < 2) return null;
317
400
 
318
401
  const order = toRenderOrder(scoped);
319
402
  const pos = order.findIndex((e) => e.element === target);
320
403
  if (pos === -1) return null;
404
+ return { entries, order, pos };
405
+ }
406
+
407
+ /**
408
+ * Resolve the z-order patches for an action.
409
+ *
410
+ * Returns null when the action is a no-op (target already at the relevant
411
+ * end of its set), otherwise the minimal list of {element, zIndex} changes.
412
+ */
413
+ export function resolveZOrderChange(
414
+ target: HTMLElement,
415
+ action: ZOrderAction,
416
+ options?: ZOrderResolveOptions,
417
+ ): ZOrderPatch[] | null {
418
+ const resolved = resolveScopedRenderOrder(target, action, options);
419
+ if (!resolved) return null;
420
+ const { entries, order, pos } = resolved;
321
421
 
322
422
  const desired = [...order];
323
423
  const [moved] = desired.splice(pos, 1);
@@ -343,10 +443,71 @@ export function resolveZOrderChange(
343
443
  return realizeOrder(order, desired, target, entries);
344
444
  }
345
445
 
446
+ /**
447
+ * Realize an ARBITRARY repositioning of `target` within a scoped sibling set —
448
+ * the Layers-panel drag, which can jump several siblings in one drop, unlike
449
+ * the menu's four fixed actions. `desiredOrderBottomToTop` is the scoped set
450
+ * (target included at its new slot) in the intended render order. Reuses the
451
+ * menu's minimal-write realization (realizeOrder): one between-z write when a
452
+ * strict gap exists, band-safe scoped renumber otherwise — replacing the old
453
+ * LayersPanel computeReorderZValues path that stamped EVERY sibling.
454
+ *
455
+ * Null when nothing changes, the set is too small, or an element in the
456
+ * desired order is not actually a painting sibling of `target`.
457
+ */
458
+ export function resolveZOrderReposition(
459
+ target: HTMLElement,
460
+ desiredOrderBottomToTop: readonly HTMLElement[],
461
+ ): ZOrderPatch[] | null {
462
+ const { entries } = getFamily(target);
463
+ if (entries.length < 2) return null;
464
+ const byElement = new Map(entries.map((entry) => [entry.element, entry]));
465
+ const desired: RenderEntry[] = [];
466
+ for (const el of desiredOrderBottomToTop) {
467
+ const entry = byElement.get(el);
468
+ if (!entry) return null;
469
+ desired.push(entry);
470
+ }
471
+ if (desired.length < 2 || !desired.some((entry) => entry.element === target)) return null;
472
+ const currentOrder = toRenderOrder(desired);
473
+ // A drop back into the same slot is a no-op. The menu actions guard this via
474
+ // their position checks before realizeOrder; an arbitrary reposition must
475
+ // compare the orders itself — realizeOrder would otherwise "normalize" an
476
+ // end-of-set target to a fresh z value it doesn't need.
477
+ if (currentOrder.every((entry, i) => entry.element === desired[i].element)) return null;
478
+ return realizeOrder(currentOrder, desired, target, entries);
479
+ }
480
+
481
+ /**
482
+ * The sibling a forward/backward step crosses: the visible overlapping
483
+ * neighbor directly above (bring-forward) or below (send-backward) the target
484
+ * in render order. Null for front/back, for a no-op step, or when the scope is
485
+ * too small. Uses the SAME scoping as resolveZOrderChange, so call it with the
486
+ * same options BEFORE any live styles are applied.
487
+ */
488
+ export function resolveCrossedNeighbor(
489
+ target: HTMLElement,
490
+ action: ZOrderAction,
491
+ options?: ZOrderResolveOptions,
492
+ ): HTMLElement | null {
493
+ if (action !== "bring-forward" && action !== "send-backward") return null;
494
+ const resolved = resolveScopedRenderOrder(target, action, options);
495
+ if (!resolved) return null;
496
+ const { order, pos } = resolved;
497
+ const neighbor = action === "bring-forward" ? order[pos + 1] : order[pos - 1];
498
+ return neighbor?.element ?? null;
499
+ }
500
+
346
501
  /**
347
502
  * Whether a z-order action is available for the target.
348
- * "disabled" = the element is already at that limit.
503
+ * "disabled" = the element is already at that limit. Shares the resolver (and
504
+ * its visibility scoping), so enable/disable always matches what the action
505
+ * would actually do.
349
506
  */
350
- export function isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
351
- return resolveZOrderChange(target, action) !== null;
507
+ export function isZOrderActionEnabled(
508
+ target: HTMLElement,
509
+ action: ZOrderAction,
510
+ options?: ZOrderResolveOptions,
511
+ ): boolean {
512
+ return resolveZOrderChange(target, action, options) !== null;
352
513
  }
@@ -4,6 +4,7 @@
4
4
  * No imports from other domEditing* modules — safe to import from anywhere.
5
5
  */
6
6
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
7
+ import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
7
8
  import { CURATED_STYLE_PROPERTIES } from "./domEditingTypes";
8
9
 
9
10
  // ─── Type guard ───────────────────────────────────────────────────────────────
@@ -296,11 +297,9 @@ export function getSelectorIndex(
296
297
  ): number | undefined {
297
298
  if (!selector?.startsWith(".")) return undefined;
298
299
 
299
- const candidates = querySelectorAllSafely(doc, selector).filter(
300
- (candidate): candidate is HTMLElement =>
301
- isHtmlElement(candidate) &&
302
- getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile,
300
+ return getSourceScopedSelectorIndex(doc, el, selector, sourceFile, (candidate) =>
301
+ isHtmlElement(candidate)
302
+ ? getSourceFileForElement(candidate, activeCompositionPath).sourceFile
303
+ : undefined,
303
304
  );
304
- const index = candidates.indexOf(el);
305
- return index >= 0 ? index : undefined;
306
305
  }
@@ -246,45 +246,28 @@ export function findElementForSelection(
246
246
  selection: FindElementSelection,
247
247
  activeCompositionPath: string | null = null,
248
248
  ): HTMLElement | null {
249
+ const sourceMatches = (candidate: Element): candidate is HTMLElement =>
250
+ isHtmlElement(candidate) &&
251
+ (!selection.sourceFile ||
252
+ getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
253
+ selection.sourceFile);
254
+ const findAll = (selector: string): HTMLElement[] =>
255
+ querySelectorAllSafely(doc, selector).filter(sourceMatches);
256
+
249
257
  if (selection.hfId) {
250
- const byHfId = doc.querySelector(`[data-hf-id="${CSS.escape(selection.hfId)}"]`);
251
- if (isHtmlElement(byHfId)) return byHfId;
258
+ const byHfId = findAll(`[data-hf-id="${escapeCssString(selection.hfId)}"]`)[0];
259
+ if (byHfId) return byHfId;
252
260
  }
253
261
 
254
262
  if (selection.id) {
255
- const byId = doc.getElementById(selection.id);
256
- if (
257
- isHtmlElement(byId) &&
258
- (!selection.sourceFile ||
259
- getSourceFileForElement(byId, activeCompositionPath).sourceFile === selection.sourceFile)
260
- ) {
261
- return byId;
262
- }
263
+ // Flattened sub-compositions can repeat authored ids. getElementById returns
264
+ // only the first document match, so filter every id match by source first.
265
+ const byId = findAll(`[id="${escapeCssString(selection.id)}"]`)[0];
266
+ if (byId) return byId;
263
267
  }
264
268
 
265
269
  if (!selection.selector) return null;
266
-
267
- // fallow-ignore-next-line code-duplication
268
- if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
269
- const matches = querySelectorAllSafely(doc, selection.selector).filter(
270
- (candidate): candidate is HTMLElement =>
271
- isHtmlElement(candidate) &&
272
- (!selection.sourceFile ||
273
- getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
274
- selection.sourceFile),
275
- );
276
- return matches[selection.selectorIndex] ?? null;
277
- }
278
-
279
- // fallow-ignore-next-line code-duplication
280
- const matches = querySelectorAllSafely(doc, selection.selector).filter(
281
- (candidate): candidate is HTMLElement =>
282
- isHtmlElement(candidate) &&
283
- (!selection.sourceFile ||
284
- getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
285
- selection.sourceFile),
286
- );
287
- return matches[0] ?? null;
270
+ return findAll(selection.selector)[selection.selectorIndex ?? 0] ?? null;
288
271
  }
289
272
 
290
273
  // fallow-ignore-next-line complexity
@@ -0,0 +1,80 @@
1
+ import type { DomEditLayerItem } from "./domEditingTypes";
2
+ import { getElementZIndex } from "../../player/lib/layerOrdering";
3
+
4
+ interface CollapsedState {
5
+ [key: string]: boolean;
6
+ }
7
+
8
+ // ── Pure helpers ──────────────────────────────────────────────────────
9
+
10
+ // fallow-ignore-next-line complexity
11
+ export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] {
12
+ if (layers.length <= 1) return layers;
13
+
14
+ const minDepth = layers[0].depth;
15
+ for (let i = 1; i < layers.length; i++) {
16
+ if (layers[i].depth < minDepth) return layers;
17
+ }
18
+
19
+ const chunks: Array<{ root: DomEditLayerItem; children: DomEditLayerItem[]; domIndex: number }> =
20
+ [];
21
+
22
+ for (let i = 0; i < layers.length; i++) {
23
+ if (layers[i].depth === minDepth) {
24
+ const children: DomEditLayerItem[] = [];
25
+ let j = i + 1;
26
+ while (j < layers.length && layers[j].depth > minDepth) {
27
+ children.push(layers[j]);
28
+ j++;
29
+ }
30
+ chunks.push({ root: layers[i], children, domIndex: chunks.length });
31
+ }
32
+ }
33
+
34
+ if (chunks.length <= 1) {
35
+ if (chunks.length === 1 && chunks[0].children.length > 0) {
36
+ const sorted = sortLayersByZIndex(chunks[0].children);
37
+ return [chunks[0].root, ...sorted];
38
+ }
39
+ return layers;
40
+ }
41
+
42
+ chunks.sort((a, b) => {
43
+ const zA = getElementZIndex(a.root.element);
44
+ const zB = getElementZIndex(b.root.element);
45
+ if (zA !== zB) return zB - zA;
46
+ return b.domIndex - a.domIndex;
47
+ });
48
+
49
+ const result: DomEditLayerItem[] = [];
50
+ for (const chunk of chunks) {
51
+ result.push(chunk.root);
52
+ if (chunk.children.length > 0) {
53
+ result.push(...sortLayersByZIndex(chunk.children));
54
+ }
55
+ }
56
+ return result;
57
+ }
58
+
59
+ export function getVisibleLayers(
60
+ layers: DomEditLayerItem[],
61
+ collapsed: CollapsedState,
62
+ ): DomEditLayerItem[] {
63
+ if (Object.keys(collapsed).length === 0) return layers;
64
+
65
+ const result: DomEditLayerItem[] = [];
66
+ let skipDepth = -1;
67
+
68
+ for (const layer of layers) {
69
+ if (skipDepth >= 0 && layer.depth > skipDepth) continue;
70
+ skipDepth = -1;
71
+
72
+ result.push(layer);
73
+
74
+ if (collapsed[layer.key] && layer.childCount > 0) {
75
+ skipDepth = layer.depth;
76
+ }
77
+ }
78
+
79
+ return result;
80
+ }
@@ -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
+ }