@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.
- package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /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
|
+
}
|