@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
@@ -0,0 +1,169 @@
1
+ // @vitest-environment jsdom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ LAYER_REVEAL_LIFT_Z,
7
+ LAYER_REVEAL_PENDING_COMMIT_ATTR,
8
+ beginLayerRevealCommit,
9
+ completeLayerRevealCommit,
10
+ liftElementToTop,
11
+ restoreLiftedElement,
12
+ useLayerRevealOverride,
13
+ } from "./useLayerRevealOverride";
14
+ import { readEffectiveZIndex } from "./canvasContextMenuZOrder";
15
+ import { getElementZIndex } from "../../player/lib/layerOrdering";
16
+ import {
17
+ LAYER_REVEAL_PRIOR_Z_ATTR,
18
+ readTimelineElementZIndex,
19
+ } from "../../player/lib/timelineElementHelpers";
20
+ import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
21
+
22
+ installReactActEnvironment();
23
+
24
+ function makeEl(zIndex?: string, position?: string): HTMLElement {
25
+ const el = document.createElement("div");
26
+ if (zIndex != null) el.style.zIndex = zIndex;
27
+ if (position != null) el.style.position = position;
28
+ document.body.appendChild(el);
29
+ return el;
30
+ }
31
+
32
+ describe("liftElementToTop / restoreLiftedElement", () => {
33
+ it("paints on top but every z reader keeps reporting the TRUE z", () => {
34
+ const el = makeEl("6", "absolute");
35
+ const lift = liftElementToTop(el);
36
+ expect(lift).not.toBeNull();
37
+ // The renderer sees the lifted value…
38
+ expect(el.style.zIndex).toBe(LAYER_REVEAL_LIFT_Z);
39
+ // …every studio reader sees the true z.
40
+ expect(readEffectiveZIndex(el)).toBe(6);
41
+ expect(getElementZIndex(el)).toBe(6);
42
+ expect(readTimelineElementZIndex(el)).toBe(6);
43
+
44
+ restoreLiftedElement(el, lift!);
45
+ expect(el.style.zIndex).toBe("6");
46
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
47
+ });
48
+
49
+ it("gives a static element a temporary position:relative and restores it", () => {
50
+ const el = makeEl();
51
+ const lift = liftElementToTop(el)!;
52
+ expect(el.style.position).toBe("relative");
53
+ expect(lift.positionLifted).toBe(true);
54
+ restoreLiftedElement(el, lift);
55
+ expect(el.style.position).toBe("");
56
+ expect(el.style.zIndex).toBe("");
57
+ });
58
+
59
+ it("a z-reorder commit consumes the lift: restore becomes a no-op", () => {
60
+ const el = makeEl("3", "absolute");
61
+ const lift = liftElementToTop(el)!;
62
+ // Simulate handleDomZIndexReorderCommit: real z written, attrs removed.
63
+ el.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
64
+ el.style.zIndex = "8";
65
+ restoreLiftedElement(el, lift);
66
+ expect(el.style.zIndex).toBe("8"); // the commit's value survives
67
+ expect(readEffectiveZIndex(el)).toBe(8);
68
+ });
69
+
70
+ it("durable z persistence consumes its pending reveal ownership", () => {
71
+ const el = makeEl("3", "absolute");
72
+ const lift = liftElementToTop(el)!;
73
+ const ownership = beginLayerRevealCommit(el)!;
74
+ el.style.zIndex = "8";
75
+
76
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(true);
77
+ completeLayerRevealCommit(el, ownership);
78
+ restoreLiftedElement(el, lift);
79
+
80
+ expect(el.style.zIndex).toBe("8");
81
+ expect(el.hasAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR)).toBe(false);
82
+ expect(el.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
83
+ });
84
+
85
+ it("does not clobber a z someone else wrote while lifted", () => {
86
+ const el = makeEl("3", "absolute");
87
+ const lift = liftElementToTop(el)!;
88
+ el.style.zIndex = "42"; // e.g. a GSAP seek or manual edit
89
+ restoreLiftedElement(el, lift);
90
+ expect(el.style.zIndex).toBe("42");
91
+ });
92
+ });
93
+
94
+ describe("useLayerRevealOverride — delayed reveal ownership", () => {
95
+ beforeEach(() => {
96
+ vi.useFakeTimers();
97
+ });
98
+
99
+ afterEach(() => {
100
+ vi.useRealTimers();
101
+ document.body.innerHTML = "";
102
+ });
103
+
104
+ it("cancels a pending reveal when selection changes or playback begins", () => {
105
+ const host = document.createElement("div");
106
+ const selected = makeEl("2", "absolute");
107
+ const other = makeEl("3", "absolute");
108
+ document.body.appendChild(host);
109
+ const root = createRoot(host);
110
+ let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
111
+
112
+ function Harness({ element, isPlaying }: { element: HTMLElement; isPlaying: boolean }) {
113
+ ({ scheduleReveal } = useLayerRevealOverride({
114
+ isPlaying,
115
+ selectedElement: element,
116
+ }));
117
+ return null;
118
+ }
119
+
120
+ act(() => {
121
+ root.render(React.createElement(Harness, { element: selected, isPlaying: false }));
122
+ });
123
+ act(() => scheduleReveal!(selected, 150));
124
+ expect(vi.getTimerCount()).toBe(1);
125
+
126
+ act(() => {
127
+ root.render(React.createElement(Harness, { element: other, isPlaying: false }));
128
+ });
129
+ expect(vi.getTimerCount()).toBe(0);
130
+
131
+ act(() => scheduleReveal!(other, 150));
132
+ expect(vi.getTimerCount()).toBe(1);
133
+ act(() => {
134
+ root.render(React.createElement(Harness, { element: other, isPlaying: true }));
135
+ });
136
+ expect(vi.getTimerCount()).toBe(0);
137
+
138
+ act(() => vi.advanceTimersByTime(150));
139
+ expect(selected.style.zIndex).toBe("2");
140
+ expect(other.style.zIndex).toBe("3");
141
+ act(() => root.unmount());
142
+ });
143
+
144
+ it("revalidates the current selection before a delayed reveal runs", () => {
145
+ const host = document.createElement("div");
146
+ const selected = makeEl("2", "absolute");
147
+ const stale = makeEl("3", "absolute");
148
+ document.body.appendChild(host);
149
+ const root = createRoot(host);
150
+ let scheduleReveal: ((element: HTMLElement, delayMs: number) => void) | undefined;
151
+
152
+ function Harness() {
153
+ ({ scheduleReveal } = useLayerRevealOverride({
154
+ isPlaying: false,
155
+ selectedElement: selected,
156
+ }));
157
+ return null;
158
+ }
159
+
160
+ act(() => root.render(React.createElement(Harness)));
161
+ act(() => scheduleReveal!(stale, 150));
162
+ expect(vi.getTimerCount()).toBe(1);
163
+ act(() => vi.advanceTimersByTime(150));
164
+
165
+ expect(stale.style.zIndex).toBe("3");
166
+ expect(stale.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)).toBe(false);
167
+ act(() => root.unmount());
168
+ });
169
+ });
@@ -0,0 +1,389 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import {
3
+ LAYER_REVEAL_PRIOR_POSITION_ATTR,
4
+ LAYER_REVEAL_PRIOR_Z_ATTR,
5
+ } from "../../player/lib/timelineElementHelpers";
6
+ import { readEffectiveZIndex } from "./canvasContextMenuZOrder";
7
+
8
+ /** The lifted paint order — far above any authored z. Only the RENDERER sees
9
+ * it: every studio z reader is reveal-transparent (readLayerRevealPriorZ). */
10
+ export const LAYER_REVEAL_LIFT_Z = "2147483000";
11
+ export const LAYER_REVEAL_PENDING_COMMIT_ATTR = "data-hf-studio-reveal-pending-commit";
12
+
13
+ interface RevealedNode {
14
+ element: HTMLElement;
15
+ priors: { display: string; visibility: string; opacity: string };
16
+ /** Values THIS override wrote — restore only while they are still in place. */
17
+ applied: { display?: string; visibility?: string; opacity?: string };
18
+ }
19
+
20
+ interface RevealLift {
21
+ priors: { zIndex: string; position: string };
22
+ positionLifted: boolean;
23
+ }
24
+
25
+ type PendingRevealCommit = {
26
+ token: string;
27
+ releasedPriors?: RevealLift["priors"];
28
+ };
29
+
30
+ export type LayerRevealCommitOwnership = {
31
+ token: string;
32
+ priorZ: string;
33
+ priorPosition: string | null;
34
+ activeLiftStyles: RevealLift["priors"];
35
+ };
36
+
37
+ let revealCommitSequence = 0;
38
+ const zPersistCounts = new WeakMap<HTMLElement, number>();
39
+ const REVEAL_RETRY_MS = 16;
40
+
41
+ /** Prevent delayed reveals from capturing an optimistic z as authored state. */
42
+ export function beginLayerZPersist(element: HTMLElement): () => void {
43
+ zPersistCounts.set(element, (zPersistCounts.get(element) ?? 0) + 1);
44
+ let released = false;
45
+ return () => {
46
+ if (released) return;
47
+ released = true;
48
+ const remaining = (zPersistCounts.get(element) ?? 1) - 1;
49
+ if (remaining > 0) zPersistCounts.set(element, remaining);
50
+ else zPersistCounts.delete(element);
51
+ };
52
+ }
53
+
54
+ interface RevealState {
55
+ /** The layer element the reveal was applied for (deselect detection). */
56
+ base: HTMLElement;
57
+ nodes: RevealedNode[];
58
+ lift: RevealLift | null;
59
+ }
60
+
61
+ function restoreInline(el: HTMLElement, property: string, prior: string): void {
62
+ if (prior) el.style.setProperty(property, prior);
63
+ else el.style.removeProperty(property);
64
+ }
65
+
66
+ // This decoder owns the complete serialized reveal-commit schema. Splitting
67
+ // field checks across helpers would create multiple authorities for validity.
68
+ // fallow-ignore-next-line complexity
69
+ function readPendingRevealCommit(element: HTMLElement): PendingRevealCommit | null {
70
+ const raw = element.getAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR);
71
+ if (!raw) return null;
72
+ try {
73
+ const value: unknown = JSON.parse(raw);
74
+ if (typeof value !== "object" || value === null || !("token" in value)) return null;
75
+ const token = value.token;
76
+ if (typeof token !== "string") return null;
77
+ if (!("releasedPriors" in value) || value.releasedPriors === undefined) return { token };
78
+ const releasedPriors = value.releasedPriors;
79
+ if (
80
+ typeof releasedPriors !== "object" ||
81
+ releasedPriors === null ||
82
+ !("zIndex" in releasedPriors) ||
83
+ !("position" in releasedPriors)
84
+ ) {
85
+ return null;
86
+ }
87
+ const { zIndex, position } = releasedPriors;
88
+ return typeof zIndex === "string" && typeof position === "string"
89
+ ? { token, releasedPriors: { zIndex, position } }
90
+ : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function writePendingRevealCommit(element: HTMLElement, state: PendingRevealCommit): void {
97
+ element.setAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR, JSON.stringify(state));
98
+ }
99
+
100
+ /** Hand an active reveal lift to one pending durable z commit. */
101
+ export function beginLayerRevealCommit(element: HTMLElement): LayerRevealCommitOwnership | null {
102
+ const priorZ = element.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
103
+ if (priorZ == null) return null;
104
+ const ownership = {
105
+ token: `reveal-z-${revealCommitSequence++}`,
106
+ priorZ,
107
+ priorPosition: element.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR),
108
+ activeLiftStyles: { zIndex: element.style.zIndex, position: element.style.position },
109
+ };
110
+ writePendingRevealCommit(element, { token: ownership.token });
111
+ element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
112
+ element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
113
+ return ownership;
114
+ }
115
+
116
+ /** Durable success consumes the reveal regardless of whether selection released it meanwhile. */
117
+ export function completeLayerRevealCommit(
118
+ element: HTMLElement,
119
+ ownership: LayerRevealCommitOwnership,
120
+ ): void {
121
+ if (readPendingRevealCommit(element)?.token === ownership.token) {
122
+ element.removeAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Roll back only the reveal transaction this commit owns. An active reveal gets
128
+ * its temporary lift back; a reveal released while persistence was pending gets
129
+ * its exact authored inline styles back, with no resurrected metadata.
130
+ */
131
+ export function rollbackLayerRevealCommit(
132
+ element: HTMLElement,
133
+ ownership: LayerRevealCommitOwnership,
134
+ ): void {
135
+ const pending = readPendingRevealCommit(element);
136
+ if (pending?.token !== ownership.token) return;
137
+ element.removeAttribute(LAYER_REVEAL_PENDING_COMMIT_ATTR);
138
+ element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
139
+ element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
140
+ if (pending.releasedPriors) {
141
+ restoreInline(element, "z-index", pending.releasedPriors.zIndex);
142
+ restoreInline(element, "position", pending.releasedPriors.position);
143
+ return;
144
+ }
145
+ restoreInline(element, "z-index", ownership.activeLiftStyles.zIndex);
146
+ restoreInline(element, "position", ownership.activeLiftStyles.position);
147
+ element.setAttribute(LAYER_REVEAL_PRIOR_Z_ATTR, ownership.priorZ);
148
+ if (ownership.priorPosition != null) {
149
+ element.setAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR, ownership.priorPosition);
150
+ }
151
+ }
152
+
153
+ /** Restore a property ONLY when its current inline value is still the one this
154
+ * override wrote — a later real edit (commit, animation seek) is the new
155
+ * truth and must not be clobbered. */
156
+ function restoreIfOurs(
157
+ el: HTMLElement,
158
+ property: "display" | "visibility" | "opacity",
159
+ applied: string | undefined,
160
+ prior: string,
161
+ ): void {
162
+ if (applied == null) return;
163
+ if (el.style.getPropertyValue(property) !== applied) return;
164
+ restoreInline(el, property, prior);
165
+ }
166
+
167
+ /** What hides this node at the current frame, per computed style. */
168
+ function readHideSignals(el: HTMLElement, win: Window) {
169
+ const computed = win.getComputedStyle(el);
170
+ const opacity = Number.parseFloat(computed.opacity);
171
+ return {
172
+ display: computed.display === "none",
173
+ visibility: computed.visibility === "hidden" || computed.visibility === "collapse",
174
+ opacity: Number.isFinite(opacity) && opacity <= 0.01,
175
+ };
176
+ }
177
+
178
+ /** Force one hidden node visible with inline styles; returns priors + applied. */
179
+ function revealNode(
180
+ el: HTMLElement,
181
+ win: Window,
182
+ needs: ReturnType<typeof readHideSignals>,
183
+ ): RevealedNode {
184
+ const priors = {
185
+ display: el.style.display,
186
+ visibility: el.style.visibility,
187
+ opacity: el.style.opacity,
188
+ };
189
+ const applied: RevealedNode["applied"] = {};
190
+ if (needs.display) {
191
+ // Prefer whatever the stylesheet says once the inline hide is lifted;
192
+ // only force block when the sheet itself hides it.
193
+ el.style.removeProperty("display");
194
+ if (win.getComputedStyle(el).display === "none") el.style.display = "block";
195
+ applied.display = el.style.display;
196
+ }
197
+ if (needs.visibility) {
198
+ el.style.visibility = "visible";
199
+ applied.visibility = "visible";
200
+ }
201
+ if (needs.opacity) {
202
+ el.style.opacity = "1";
203
+ applied.opacity = "1";
204
+ }
205
+ return { element: el, priors, applied };
206
+ }
207
+
208
+ /** Walk `element` → body, force-revealing every hiding node; returns the touched nodes. */
209
+ function revealHiddenChain(element: HTMLElement): RevealedNode[] {
210
+ const doc = element.ownerDocument;
211
+ const win = doc.defaultView;
212
+ if (!win) return [];
213
+ const nodes: RevealedNode[] = [];
214
+ let el: HTMLElement | null = element;
215
+ while (el && el !== doc.body && el !== doc.documentElement) {
216
+ let needs: ReturnType<typeof readHideSignals>;
217
+ try {
218
+ needs = readHideSignals(el, win);
219
+ } catch {
220
+ break; // detached / cross-realm — leave the rest alone
221
+ }
222
+ if (needs.display || needs.visibility || needs.opacity) nodes.push(revealNode(el, win, needs));
223
+ el = el.parentElement;
224
+ }
225
+ return nodes;
226
+ }
227
+
228
+ /**
229
+ * Lift the selected element to the TOP of the paint order while selected —
230
+ * regardless of its authored z or panel position. The true z is parked in
231
+ * LAYER_REVEAL_PRIOR_Z_ATTR so every studio z reader keeps reporting it (the
232
+ * lift is invisible to menus, badges, the lane mirror, and the panel sort);
233
+ * only the renderer sees the lifted inline value. A static element gets a
234
+ * temporary position:relative (layout-preserving) so the z applies, with the
235
+ * prior position parked in LAYER_REVEAL_PRIOR_POSITION_ATTR for the z-commit's
236
+ * static check. Exported for direct unit testing.
237
+ */
238
+ export function liftElementToTop(element: HTMLElement): RevealLift | null {
239
+ const win = element.ownerDocument.defaultView;
240
+ if (!win) return null;
241
+ const priors = { zIndex: element.style.zIndex, position: element.style.position };
242
+ let positionLifted = false;
243
+ try {
244
+ element.setAttribute(LAYER_REVEAL_PRIOR_Z_ATTR, String(readEffectiveZIndex(element)));
245
+ if (win.getComputedStyle(element).position === "static") {
246
+ element.setAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR, "static");
247
+ element.style.position = "relative";
248
+ positionLifted = true;
249
+ }
250
+ } catch {
251
+ element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
252
+ return null; // detached / cross-realm — no lift
253
+ }
254
+ element.style.zIndex = LAYER_REVEAL_LIFT_Z;
255
+ return { priors, positionLifted };
256
+ }
257
+
258
+ /**
259
+ * Undo an active lift. Skipped entirely when the prior-z attribute is gone —
260
+ * a z-reorder commit consumed the lift (handleDomZIndexReorderCommit removes
261
+ * the attributes and writes the new real z), and that commit is the truth.
262
+ * Exported for direct unit testing.
263
+ */
264
+ export function restoreLiftedElement(element: HTMLElement, lift: RevealLift): void {
265
+ if (!element.hasAttribute(LAYER_REVEAL_PRIOR_Z_ATTR)) {
266
+ const pending = readPendingRevealCommit(element);
267
+ if (pending && !pending.releasedPriors) {
268
+ // Persistence temporarily owns the live z. Hand it the exact authored
269
+ // priors so a later rejection can restore them after this hook lets go.
270
+ writePendingRevealCommit(element, { ...pending, releasedPriors: lift.priors });
271
+ }
272
+ return;
273
+ }
274
+ element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
275
+ element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
276
+ if (element.style.zIndex === LAYER_REVEAL_LIFT_Z) {
277
+ restoreInline(element, "z-index", lift.priors.zIndex);
278
+ }
279
+ if (lift.positionLifted && element.style.position === "relative") {
280
+ restoreInline(element, "position", lift.priors.position);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Temporary "show me this element" override for the Layers panel
286
+ * (Webflow-navigator style): clicking a layer forces it (and every hiding
287
+ * ancestor up to the body) visible with LIVE inline styles, and paints it on
288
+ * TOP of the stack while selected (see liftElementToTop).
289
+ *
290
+ * Strictly ephemeral by construction:
291
+ * - Exact prior inline values are recorded per touched node and restored on
292
+ * every exit path — reveal of a different layer, deselect, playback start,
293
+ * unmount. Nothing is ever sent to a persist path, and each property is
294
+ * restored only while it still holds the value this override wrote.
295
+ * - A post-edit iframe reload replaces the DOM; detached nodes are skipped on
296
+ * restore (the fresh document never had the override).
297
+ * - Scrubbing/playing lets the runtime and GSAP rewrite these same inline
298
+ * styles — that is the animation showing reality, and the override is
299
+ * dropped on play for exactly that reason.
300
+ */
301
+ export function useLayerRevealOverride({
302
+ isPlaying,
303
+ selectedElement,
304
+ }: {
305
+ isPlaying: boolean;
306
+ selectedElement: HTMLElement | null;
307
+ }): {
308
+ scheduleReveal: (element: HTMLElement, delayMs: number) => void;
309
+ } {
310
+ const stateRef = useRef<RevealState | null>(null);
311
+ const pendingRevealRef = useRef<{ timer: number; element: HTMLElement } | null>(null);
312
+ const currentRef = useRef({ isPlaying, selectedElement });
313
+ currentRef.current = { isPlaying, selectedElement };
314
+
315
+ const restoreReveal = useCallback(() => {
316
+ const state = stateRef.current;
317
+ stateRef.current = null;
318
+ if (!state) return;
319
+ for (const { element, priors, applied } of state.nodes) {
320
+ if (!element.isConnected) continue;
321
+ restoreIfOurs(element, "display", applied.display, priors.display);
322
+ restoreIfOurs(element, "visibility", applied.visibility, priors.visibility);
323
+ restoreIfOurs(element, "opacity", applied.opacity, priors.opacity);
324
+ }
325
+ if (state.lift && state.base.isConnected) restoreLiftedElement(state.base, state.lift);
326
+ }, []);
327
+
328
+ const reveal = useCallback(
329
+ (element: HTMLElement) => {
330
+ restoreReveal();
331
+ const nodes = revealHiddenChain(element);
332
+ const lift = liftElementToTop(element);
333
+ if (nodes.length > 0 || lift) stateRef.current = { base: element, nodes, lift };
334
+ },
335
+ [restoreReveal],
336
+ );
337
+
338
+ const cancelScheduledReveal = useCallback(() => {
339
+ const pending = pendingRevealRef.current;
340
+ pendingRevealRef.current = null;
341
+ if (pending) window.clearTimeout(pending.timer);
342
+ }, []);
343
+
344
+ const scheduleReveal = useCallback(
345
+ (element: HTMLElement, delayMs: number) => {
346
+ cancelScheduledReveal();
347
+ const current = currentRef.current;
348
+ if (current.isPlaying) return;
349
+ const attemptReveal = () => {
350
+ const latest = currentRef.current;
351
+ if (latest.isPlaying || latest.selectedElement !== element || !element.isConnected) {
352
+ pendingRevealRef.current = null;
353
+ return;
354
+ }
355
+ if (zPersistCounts.has(element)) {
356
+ const timer = window.setTimeout(attemptReveal, REVEAL_RETRY_MS);
357
+ pendingRevealRef.current = { timer, element };
358
+ return;
359
+ }
360
+ pendingRevealRef.current = null;
361
+ reveal(element);
362
+ };
363
+ const timer = window.setTimeout(attemptReveal, delayMs);
364
+ pendingRevealRef.current = { timer, element };
365
+ },
366
+ [cancelScheduledReveal, reveal],
367
+ );
368
+
369
+ // Selection and playback jointly own eligibility for both pending and active
370
+ // reveals. Cancel first; playback or a different selection then restores the
371
+ // animation/authored styles.
372
+ useEffect(() => {
373
+ const pending = pendingRevealRef.current;
374
+ if (isPlaying || (pending && pending.element !== selectedElement)) cancelScheduledReveal();
375
+ const base = stateRef.current?.base;
376
+ if (isPlaying || (base && selectedElement !== base)) restoreReveal();
377
+ }, [cancelScheduledReveal, isPlaying, restoreReveal, selectedElement]);
378
+
379
+ // Unmount: never leave a timer or override behind.
380
+ useEffect(
381
+ () => () => {
382
+ cancelScheduledReveal();
383
+ restoreReveal();
384
+ },
385
+ [cancelScheduledReveal, restoreReveal],
386
+ );
387
+
388
+ return { scheduleReveal };
389
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Z-order "show your work" flash: after a bring-forward / send-backward, the
3
+ * sibling that was stepped over gets a brief (600ms) highlight so the action
4
+ * is legible even when the visual change is subtle.
5
+ *
6
+ * Drawn in the STUDIO's own overlay layer above the preview iframe — nothing
7
+ * is written into the iframe DOM or the composition, so a concurrent preview
8
+ * reload can never leave a stuck highlight; the timeout merely clears
9
+ * studio-local state. The crossed element is resolved by the context menu
10
+ * (resolveCrossedNeighbor) from the same pre-mutation render order as the
11
+ * z patches; z-index writes don't move layout, so measuring its rect after
12
+ * the commit applied live styles is still accurate.
13
+ */
14
+ import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
15
+ import { toVisibleOverlayRect, type OverlayRect } from "./domEditOverlayGeometry";
16
+
17
+ const Z_ORDER_CROSSED_FLASH_MS = 600;
18
+
19
+ interface UseZOrderCrossedFlashParams {
20
+ overlayRef: RefObject<HTMLDivElement | null>;
21
+ iframeRef: RefObject<HTMLIFrameElement | null>;
22
+ }
23
+
24
+ export function useZOrderCrossedFlash({ overlayRef, iframeRef }: UseZOrderCrossedFlashParams): {
25
+ zOrderFlashRect: OverlayRect | null;
26
+ handleZOrderCrossed: (crossed: HTMLElement) => void;
27
+ } {
28
+ const [zOrderFlashRect, setZOrderFlashRect] = useState<OverlayRect | null>(null);
29
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
30
+ useEffect(
31
+ () => () => {
32
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
33
+ },
34
+ [],
35
+ );
36
+
37
+ const handleZOrderCrossed = useCallback(
38
+ (crossed: HTMLElement) => {
39
+ const overlayEl = overlayRef.current;
40
+ const iframe = iframeRef.current;
41
+ if (!overlayEl || !iframe) return;
42
+ const rect = toVisibleOverlayRect(overlayEl, iframe, crossed);
43
+ if (!rect || rect.width <= 0 || rect.height <= 0) return;
44
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
45
+ setZOrderFlashRect(rect);
46
+ timeoutRef.current = setTimeout(() => {
47
+ timeoutRef.current = null;
48
+ setZOrderFlashRect(null);
49
+ }, Z_ORDER_CROSSED_FLASH_MS);
50
+ },
51
+ [overlayRef, iframeRef],
52
+ );
53
+
54
+ return { zOrderFlashRect, handleZOrderCrossed };
55
+ }
56
+
57
+ /** The flash chrome itself — a pulsing accent outline over the crossed sibling. */
58
+ export function ZOrderCrossedFlash({ rect }: { rect: OverlayRect | null }) {
59
+ if (!rect) return null;
60
+ return (
61
+ <div
62
+ aria-hidden="true"
63
+ data-dom-edit-z-flash="true"
64
+ className="pointer-events-none absolute rounded-md border-2 border-studio-accent shadow-[0_0_0_2px_rgba(60,230,172,0.35)] animate-pulse"
65
+ style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
66
+ />
67
+ );
68
+ }