@hyperframes/studio 0.8.52 → 0.8.53
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-I5YRzB02.js → hyperframes-player-DKWM-Ons.js} +1 -1
- package/dist/assets/index-DrHQAuGO.css +1 -0
- package/dist/assets/index-HfP3sap2.js +390 -0
- package/dist/assets/{index-BTAfgesM.js → index-UvW6BEwf.js} +1 -1
- package/dist/assets/{index-Du5x7KUR.js → index-Y4OnpTEM.js} +1 -1
- package/dist/index.css +29 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +13 -6
- package/dist/index.html +2 -2
- package/dist/index.js +7664 -7953
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -0
- package/package.json +8 -7
- package/src/App.tsx +42 -48
- package/src/components/EditorShell.selectionSync.test.tsx +9 -4
- package/src/components/EditorShell.tsx +52 -56
- package/src/components/StudioHeader.dom.test.tsx +1 -1
- package/src/components/StudioHeader.test.ts +0 -12
- package/src/components/StudioHeader.tsx +10 -13
- package/src/components/StudioLeftPanels.tsx +202 -0
- package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -206
- package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
- package/src/components/dock/Dock.test.tsx +16 -2
- package/src/components/dock/Dock.tsx +13 -3
- package/src/components/dock/dockLayoutStore.test.ts +28 -0
- package/src/components/dock/dockLayoutStore.ts +21 -4
- package/src/components/editor/SnapToolbar.test.tsx +0 -3
- package/src/components/nle/NLEContext.tsx +0 -43
- package/src/components/nle/TimelinePane.tsx +46 -67
- package/src/components/panels/SlideshowPanel.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +1 -1
- package/src/components/renders/RenderQueue.test.tsx +133 -25
- package/src/components/renders/RenderQueue.tsx +99 -92
- package/src/components/renders/RenderQueueItem.tsx +69 -64
- package/src/components/renders/RenderQueuePanel.tsx +1 -1
- package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
- package/src/components/sidebar/CompositionsPanel.tsx +15 -0
- package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
- package/src/components/sidebar/SidebarLintButton.tsx +50 -0
- package/src/components/ui/Select.tsx +4 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -57
- package/src/headerExports.test.tsx +23 -0
- package/src/hooks/appHotkeysDispatch.ts +3 -4
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
- package/src/hooks/useAppHotkeys.test.ts +0 -1
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
- package/src/hooks/useAppHotkeys.ts +0 -4
- package/src/hooks/useDomEditPreviewSync.ts +4 -6
- package/src/hooks/useDomEditSession.ts +0 -7
- package/src/hooks/useDomEditWiring.ts +4 -9
- package/src/hooks/usePanelLayout.test.ts +108 -296
- package/src/hooks/usePanelLayout.ts +50 -255
- package/src/hooks/useRightPanelIntents.test.ts +181 -0
- package/src/hooks/useRightPanelIntents.ts +64 -0
- package/src/hooks/useSlideshowTabState.test.ts +9 -31
- package/src/hooks/useSlideshowTabState.ts +6 -19
- package/src/hooks/useStudioContextValue.test.ts +6 -18
- package/src/hooks/useStudioContextValue.ts +5 -26
- package/src/index.ts +2 -1
- package/src/main.tsx +26 -0
- package/src/player/components/Timeline.tsx +1 -1
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
- package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
- package/src/utils/studioHelpers.ts +0 -7
- package/src/utils/studioUiPreferences.test.ts +10 -16
- package/src/utils/studioUiPreferences.ts +0 -16
- package/dist/assets/index-CxAu_Okg.css +0 -1
- package/dist/assets/index-DocJlW2v.js +0 -376
- package/src/components/StudioLeftSidebar.tsx +0 -207
- package/src/components/nle/TimelineResizeDivider.tsx +0 -101
- package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
- package/src/components/sidebar/LeftSidebar.tsx +0 -348
- package/src/hooks/useInspectorSplitResize.ts +0 -51
- package/src/utils/fitPanels.test.ts +0 -164
- package/src/utils/fitPanels.ts +0 -151
|
@@ -1,275 +1,70 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
RightInspectorPane,
|
|
4
|
-
RightInspectorPanes,
|
|
5
|
-
RightPanelTab,
|
|
6
|
-
} from "../utils/studioHelpers";
|
|
7
|
-
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
8
3
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
defaultPanelWidths,
|
|
12
|
-
fitPanelWidths,
|
|
13
|
-
railsEngaged,
|
|
14
|
-
type PanelWidths,
|
|
15
|
-
} from "../utils/fitPanels";
|
|
16
|
-
|
|
17
|
-
const NO_OVERRIDE = { left: false, right: false } as const;
|
|
4
|
+
import { useDockLayoutStore, visiblePanelInZone } from "../components/dock/dockLayoutStore";
|
|
5
|
+
import { PANEL_DEFINITIONS, type PanelId } from "../components/dock/panelRegistry";
|
|
18
6
|
|
|
19
7
|
export interface InitialPanelLayoutState {
|
|
20
8
|
rightCollapsed?: boolean | null;
|
|
21
9
|
rightPanelTab?: RightPanelTab | null;
|
|
22
10
|
}
|
|
23
11
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (tab === "layers") return { layers: true, design: false };
|
|
28
|
-
return { layers: false, design: true };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function readViewportWidth(): number {
|
|
32
|
-
return typeof window === "undefined" ? 1496 : window.innerWidth;
|
|
12
|
+
/** The dock panel each legacy right-panel tab name opens; block params render inside Design. */
|
|
13
|
+
function panelForTab(tab: RightPanelTab): PanelId {
|
|
14
|
+
return tab === "block-params" ? "design" : tab;
|
|
33
15
|
}
|
|
34
16
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
* preferences win over the width-derived defaults; neither is clamped here —
|
|
38
|
-
* `fitPanelWidths` owns every clamp so there is one place that decides.
|
|
39
|
-
*/
|
|
40
|
-
function getPreferredPanelWidths(): PanelWidths {
|
|
41
|
-
const preferences = readStudioUiPreferences();
|
|
42
|
-
const defaults = defaultPanelWidths(readViewportWidth());
|
|
43
|
-
return {
|
|
44
|
-
left: preferences.leftWidth ?? defaults.left,
|
|
45
|
-
right: preferences.rightWidth ?? defaults.right,
|
|
46
|
-
};
|
|
17
|
+
function tabForPanel(id: PanelId | null): RightPanelTab {
|
|
18
|
+
return id !== null && PANEL_DEFINITIONS[id].zone === "right" ? (id as RightPanelTab) : "design";
|
|
47
19
|
}
|
|
48
20
|
|
|
21
|
+
/** The right column as the rest of Studio reads it, backed by the dock's panel state. */
|
|
49
22
|
export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
() => readStudioUiPreferences().leftCollapsed ?? false,
|
|
54
|
-
);
|
|
55
|
-
const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? false);
|
|
56
|
-
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
|
|
57
|
-
initialState?.rightPanelTab ?? "design",
|
|
58
|
-
);
|
|
59
|
-
const rightPanelTabRef = useRef(rightPanelTab);
|
|
60
|
-
rightPanelTabRef.current = rightPanelTab;
|
|
61
|
-
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
|
|
62
|
-
getInitialRightInspectorPanes(initialState?.rightPanelTab),
|
|
63
|
-
);
|
|
64
|
-
const rightInspectorPanesRef = useRef(rightInspectorPanes);
|
|
65
|
-
rightInspectorPanesRef.current = rightInspectorPanes;
|
|
66
|
-
// Set when the user explicitly reopens a panel the window had auto-collapsed,
|
|
67
|
-
// so the rail cannot immediately swallow it again. Cleared once the window is
|
|
68
|
-
// wide enough that auto-collapse is no longer in play.
|
|
69
|
-
const [autoCollapseOverride, setAutoCollapseOverride] = useState<{
|
|
70
|
-
left: boolean;
|
|
71
|
-
right: boolean;
|
|
72
|
-
}>(NO_OVERRIDE);
|
|
23
|
+
const controller = useDockLayoutStore((state) => state.controller);
|
|
24
|
+
const lastActive = useDockLayoutStore((state) => state.lastActive);
|
|
25
|
+
const visiblePanels = useDockLayoutStore((state) => state.visiblePanels);
|
|
73
26
|
|
|
74
|
-
|
|
75
|
-
// Studio loaded at 1440 and dragged to a half-screen used to keep its pixel
|
|
76
|
-
// widths and squeeze the preview to nothing.
|
|
27
|
+
const initialRef = useRef(initialState);
|
|
77
28
|
useEffect(() => {
|
|
78
|
-
if (
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const leftCollapsedByWidth = fitted.autoCollapseLeft;
|
|
94
|
-
const rightCollapsedByWidth = fitted.autoCollapseRight;
|
|
95
|
-
|
|
96
|
-
// Rendered widths, which the drag handles measure from so the seam does not
|
|
97
|
-
// jump when a panel is currently narrower than its stored preference.
|
|
98
|
-
const fittedRef = useRef(fitted);
|
|
99
|
-
fittedRef.current = fitted;
|
|
100
|
-
|
|
101
|
-
const panelDragRef = useRef<{
|
|
102
|
-
side: PanelSide;
|
|
103
|
-
startX: number;
|
|
104
|
-
startW: number;
|
|
105
|
-
} | null>(null);
|
|
106
|
-
|
|
107
|
-
// Preferred widths are also held in a ref so a burst of pointer moves or
|
|
108
|
-
// keyboard nudges inside one React batch accumulates, instead of every call
|
|
109
|
-
// in the batch reading the same pre-render value.
|
|
110
|
-
const preferredRef = useRef(preferredWidths);
|
|
111
|
-
|
|
112
|
-
const setPreferred = useCallback((side: PanelSide, width: number) => {
|
|
113
|
-
const next = Math.max(0, Math.round(width));
|
|
114
|
-
preferredRef.current = { ...preferredRef.current, [side]: next };
|
|
115
|
-
setPreferredWidths(preferredRef.current);
|
|
116
|
-
return next;
|
|
117
|
-
}, []);
|
|
118
|
-
|
|
119
|
-
/** Transient: moves the panel without touching the stored preference. */
|
|
120
|
-
const updatePanelWidth = useCallback(
|
|
121
|
-
(side: PanelSide, width: number) => {
|
|
122
|
-
setPreferred(side, width);
|
|
123
|
-
},
|
|
124
|
-
[setPreferred],
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Durable: only an explicit drag or keyboard nudge writes a preference. A
|
|
129
|
-
* width the window forced on us is never persisted, so the user's real
|
|
130
|
-
* preference survives a temporary squeeze and returns when the window grows.
|
|
131
|
-
*/
|
|
132
|
-
const commitPanelWidth = useCallback(
|
|
133
|
-
(side: PanelSide, width: number) => {
|
|
134
|
-
// Persist what the window will actually allow, not a raw pointer delta.
|
|
135
|
-
const candidate = { ...preferredRef.current, [side]: Math.max(0, Math.round(width)) };
|
|
136
|
-
const settled = fitPanelWidths(readViewportWidth(), candidate)[side];
|
|
137
|
-
setPreferred(side, settled);
|
|
138
|
-
writeStudioUiPreferences(side === "left" ? { leftWidth: settled } : { rightWidth: settled });
|
|
139
|
-
},
|
|
140
|
-
[setPreferred],
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
const adjustPanelWidth = useCallback(
|
|
144
|
-
(side: PanelSide, delta: number) => {
|
|
145
|
-
commitPanelWidth(side, preferredRef.current[side] + delta);
|
|
146
|
-
},
|
|
147
|
-
[commitPanelWidth],
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
// The toggle acts on what the user can SEE, not on stored intent. Toggling
|
|
151
|
-
// stored intent instead made the rail's "Show sidebar" button dead in the
|
|
152
|
-
// auto-collapsed state: intent was already false, so the click flipped it to
|
|
153
|
-
// true (persisting a collapse the user never asked for) while the rail stayed
|
|
154
|
-
// railed and nothing visibly happened.
|
|
155
|
-
const effectiveLeftCollapsedRef = useRef(false);
|
|
156
|
-
effectiveLeftCollapsedRef.current = leftCollapsed || leftCollapsedByWidth;
|
|
157
|
-
|
|
158
|
-
const toggleLeftSidebar = useCallback(() => {
|
|
159
|
-
const next = !effectiveLeftCollapsedRef.current;
|
|
160
|
-
setLeftCollapsed(next);
|
|
161
|
-
writeStudioUiPreferences({ leftCollapsed: next });
|
|
162
|
-
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: next });
|
|
163
|
-
if (!next) setAutoCollapseOverride((prev) => ({ ...prev, left: true }));
|
|
164
|
-
}, []);
|
|
165
|
-
|
|
166
|
-
const setRightCollapsedWithOverride = useCallback((collapsed: boolean) => {
|
|
167
|
-
setRightCollapsed(collapsed);
|
|
168
|
-
if (!collapsed) {
|
|
169
|
-
setAutoCollapseOverride((prev) => (prev.right ? prev : { ...prev, right: true }));
|
|
29
|
+
if (!controller) return;
|
|
30
|
+
const { rightCollapsed, rightPanelTab } = initialRef.current ?? {};
|
|
31
|
+
initialRef.current = undefined;
|
|
32
|
+
const store = useDockLayoutStore.getState();
|
|
33
|
+
const wanted = rightPanelTab ? panelForTab(rightPanelTab) : null;
|
|
34
|
+
let unsubscribe = () => {};
|
|
35
|
+
if (wanted && store.openPanels.has(wanted)) {
|
|
36
|
+
store.activatePanel(wanted);
|
|
37
|
+
} else if (wanted === "slideshow") {
|
|
38
|
+
// The slideshow panel only opens once the composition loads and turns out to be one.
|
|
39
|
+
unsubscribe = useDockLayoutStore.subscribe((state) => {
|
|
40
|
+
if (!state.openPanels.has("slideshow")) return;
|
|
41
|
+
unsubscribe();
|
|
42
|
+
state.activatePanel("slideshow");
|
|
43
|
+
});
|
|
170
44
|
}
|
|
45
|
+
if (rightCollapsed != null) store.setZoneVisible("right", !rightCollapsed);
|
|
46
|
+
return () => unsubscribe();
|
|
47
|
+
}, [controller]);
|
|
48
|
+
|
|
49
|
+
const visibleRight = visiblePanelInZone("right", lastActive, visiblePanels);
|
|
50
|
+
const rightPanelTab = tabForPanel(visibleRight);
|
|
51
|
+
const rightCollapsed = visibleRight === null;
|
|
52
|
+
|
|
53
|
+
const setRightPanelTab = useCallback((tab: RightPanelTab) => {
|
|
54
|
+
const store = useDockLayoutStore.getState();
|
|
55
|
+
const shown = visiblePanelInZone("right", store.lastActive, store.visiblePanels);
|
|
56
|
+
store.activatePanel(panelForTab(tab));
|
|
57
|
+
if (tabForPanel(shown) !== tab) trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
|
171
58
|
}, []);
|
|
172
59
|
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
};
|
|
181
|
-
}, []);
|
|
182
|
-
|
|
183
|
-
const handlePanelResizeMove = useCallback(
|
|
184
|
-
(e: React.PointerEvent) => {
|
|
185
|
-
const drag = panelDragRef.current;
|
|
186
|
-
if (!drag) return;
|
|
187
|
-
const delta = e.clientX - drag.startX;
|
|
188
|
-
updatePanelWidth(drag.side, drag.startW + (drag.side === "left" ? delta : -delta));
|
|
189
|
-
},
|
|
190
|
-
[updatePanelWidth],
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
const handlePanelResizeEnd = useCallback(() => {
|
|
194
|
-
const side = panelDragRef.current?.side;
|
|
195
|
-
if (side) commitPanelWidth(side, preferredRef.current[side]);
|
|
196
|
-
panelDragRef.current = null;
|
|
197
|
-
}, [commitPanelWidth]);
|
|
198
|
-
|
|
199
|
-
const trackedSetRightPanelTab = useCallback(
|
|
200
|
-
(tab: RightPanelTab) => {
|
|
201
|
-
const paneAlreadySelected =
|
|
202
|
-
tab !== "design" && tab !== "layers"
|
|
203
|
-
? true
|
|
204
|
-
: STUDIO_FLAT_INSPECTOR_ENABLED
|
|
205
|
-
? rightInspectorPanesRef.current[tab] &&
|
|
206
|
-
!rightInspectorPanesRef.current[tab === "design" ? "layers" : "design"]
|
|
207
|
-
: rightInspectorPanesRef.current[tab];
|
|
208
|
-
if (rightPanelTabRef.current === tab && paneAlreadySelected) return;
|
|
209
|
-
rightPanelTabRef.current = tab;
|
|
210
|
-
if (tab === "design" || tab === "layers") {
|
|
211
|
-
// Flat inspector: Layers always renders full-height by itself (see
|
|
212
|
-
// StudioRightPanel's render gate), so this MUST land on the same
|
|
213
|
-
// radio-style exclusivity setExclusiveRightInspectorPane enforces for
|
|
214
|
-
// the direct in-panel tab click — every OTHER path that reaches here
|
|
215
|
-
// (element select, closing block-params, the header Inspector
|
|
216
|
-
// button, and this function's own callers outside an active
|
|
217
|
-
// inspector tab) would otherwise additively leave both panes `true`
|
|
218
|
-
// and reproduce the "both tabs highlight, only one renders" bug this
|
|
219
|
-
// still-additive branch used to cause under the flat flag.
|
|
220
|
-
const nextPanes = STUDIO_FLAT_INSPECTOR_ENABLED
|
|
221
|
-
? { design: tab === "design", layers: tab === "layers" }
|
|
222
|
-
: { ...rightInspectorPanesRef.current, [tab]: true };
|
|
223
|
-
rightInspectorPanesRef.current = nextPanes;
|
|
224
|
-
setRightInspectorPanes(nextPanes);
|
|
225
|
-
}
|
|
226
|
-
setRightPanelTab(tab);
|
|
227
|
-
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
|
228
|
-
},
|
|
229
|
-
[setRightPanelTab],
|
|
230
|
-
);
|
|
231
|
-
|
|
232
|
-
const toggleRightInspectorPane = useCallback((pane: RightInspectorPane) => {
|
|
233
|
-
setRightInspectorPanes((panes) => {
|
|
234
|
-
const next = { ...panes, [pane]: !panes[pane] };
|
|
235
|
-
if (!next.design && !next.layers) return panes;
|
|
236
|
-
return next;
|
|
237
|
-
});
|
|
238
|
-
}, []);
|
|
239
|
-
|
|
240
|
-
// Radio-style variant for the flat inspector: Layers always renders full-
|
|
241
|
-
// height by itself there (never split-shared with Design), so leaving both
|
|
242
|
-
// panes independently toggleable would highlight both tabs as "active"
|
|
243
|
-
// while only one actually shows. Selecting one turns the other off.
|
|
244
|
-
const setExclusiveRightInspectorPane = useCallback((pane: RightInspectorPane) => {
|
|
245
|
-
setRightInspectorPanes({ design: pane === "design", layers: pane === "layers" });
|
|
60
|
+
const setRightCollapsed = useCallback((collapsed: boolean) => {
|
|
61
|
+
const store = useDockLayoutStore.getState();
|
|
62
|
+
if (!collapsed && visiblePanelInZone("right", store.lastActive, store.visiblePanels) === null) {
|
|
63
|
+
store.activatePanel("design");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
store.setZoneVisible("right", !collapsed);
|
|
246
67
|
}, []);
|
|
247
68
|
|
|
248
|
-
return {
|
|
249
|
-
leftWidth: fitted.left,
|
|
250
|
-
rightWidth: fitted.right,
|
|
251
|
-
adjustPanelWidth,
|
|
252
|
-
/**
|
|
253
|
-
* User intent. Persisted to localStorage; never written by auto-collapse.
|
|
254
|
-
* Deliberately read-only outside this hook: `toggleLeftSidebar` is the only
|
|
255
|
-
* writer, so it cannot be flipped without also clearing the rail override
|
|
256
|
-
* (which would open the sidebar and then immediately rail it again).
|
|
257
|
-
*/
|
|
258
|
-
leftCollapsed,
|
|
259
|
-
/** User intent. Synced into the shareable URL; never written by auto-collapse. */
|
|
260
|
-
rightCollapsed,
|
|
261
|
-
setRightCollapsed: setRightCollapsedWithOverride,
|
|
262
|
-
/** What the shell actually renders: intent OR the window forcing a rail. */
|
|
263
|
-
effectiveLeftCollapsed: leftCollapsed || leftCollapsedByWidth,
|
|
264
|
-
effectiveRightCollapsed: rightCollapsed || rightCollapsedByWidth,
|
|
265
|
-
rightPanelTab,
|
|
266
|
-
setRightPanelTab: trackedSetRightPanelTab,
|
|
267
|
-
rightInspectorPanes,
|
|
268
|
-
toggleRightInspectorPane,
|
|
269
|
-
setExclusiveRightInspectorPane,
|
|
270
|
-
toggleLeftSidebar,
|
|
271
|
-
handlePanelResizeStart,
|
|
272
|
-
handlePanelResizeMove,
|
|
273
|
-
handlePanelResizeEnd,
|
|
274
|
-
};
|
|
69
|
+
return { rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab };
|
|
275
70
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useDockLayoutStore, type DockController } from "../components/dock/dockLayoutStore";
|
|
7
|
+
import { PANEL_IDS, type PanelId } from "../components/dock/panelRegistry";
|
|
8
|
+
import {
|
|
9
|
+
useBlockParamsDismissal,
|
|
10
|
+
useCaptionDesignFocus,
|
|
11
|
+
useDismissingTabSetter,
|
|
12
|
+
useRightPanelIntent,
|
|
13
|
+
useSlideshowDockPanel,
|
|
14
|
+
} from "./useRightPanelIntents";
|
|
15
|
+
|
|
16
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
17
|
+
|
|
18
|
+
function controllerMock(): DockController {
|
|
19
|
+
return {
|
|
20
|
+
open: vi.fn(),
|
|
21
|
+
activate: vi.fn(),
|
|
22
|
+
close: vi.fn(),
|
|
23
|
+
setTitle: vi.fn(),
|
|
24
|
+
setGroupVisible: vi.fn(),
|
|
25
|
+
reset: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function seed(state: {
|
|
30
|
+
open?: PanelId[];
|
|
31
|
+
visible?: PanelId[];
|
|
32
|
+
lastActive?: Partial<Record<"left" | "center" | "right", PanelId>>;
|
|
33
|
+
}) {
|
|
34
|
+
const controller = controllerMock();
|
|
35
|
+
useDockLayoutStore.setState({
|
|
36
|
+
controller,
|
|
37
|
+
openPanels: new Set(state.open ?? PANEL_IDS.filter((id) => id !== "slideshow")),
|
|
38
|
+
visiblePanels: new Set(state.visible ?? []),
|
|
39
|
+
lastActive: state.lastActive ?? {},
|
|
40
|
+
activePanel: null,
|
|
41
|
+
});
|
|
42
|
+
return controller;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mountedRoots: Array<() => void> = [];
|
|
46
|
+
|
|
47
|
+
function mountHook<P>(hook: (props: P) => unknown, initial: P) {
|
|
48
|
+
const host = document.createElement("div");
|
|
49
|
+
document.body.append(host);
|
|
50
|
+
const root = createRoot(host);
|
|
51
|
+
mountedRoots.push(() => act(() => root.unmount()));
|
|
52
|
+
let latest: unknown;
|
|
53
|
+
function Harness({ props }: { props: P }) {
|
|
54
|
+
latest = hook(props);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const render = (props: P) => act(() => root.render(React.createElement(Harness, { props })));
|
|
58
|
+
render(initial);
|
|
59
|
+
return { render, value: () => latest, unmount: () => act(() => root.unmount()) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
for (const unmount of mountedRoots.splice(0)) unmount();
|
|
64
|
+
vi.restoreAllMocks();
|
|
65
|
+
document.body.innerHTML = "";
|
|
66
|
+
useDockLayoutStore.setState({ controller: null });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("useSlideshowDockPanel", () => {
|
|
70
|
+
it("opens the panel when the file becomes a slideshow and closes it when it stops", () => {
|
|
71
|
+
const controller = seed({});
|
|
72
|
+
const view = mountHook(useSlideshowDockPanel, false);
|
|
73
|
+
expect(controller.open).not.toHaveBeenCalled();
|
|
74
|
+
view.render(true);
|
|
75
|
+
expect(controller.open).toHaveBeenCalledWith("slideshow");
|
|
76
|
+
act(() =>
|
|
77
|
+
useDockLayoutStore.setState({ openPanels: new Set<PanelId>(["design", "slideshow"]) }),
|
|
78
|
+
);
|
|
79
|
+
view.render(false);
|
|
80
|
+
expect(controller.close).toHaveBeenCalledWith("slideshow");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("keeps a restored slideshow placement while the file is still loading", () => {
|
|
84
|
+
const controller = seed({ open: ["design", "slideshow"] });
|
|
85
|
+
const view = mountHook(useSlideshowDockPanel, false);
|
|
86
|
+
expect(controller.close).not.toHaveBeenCalled();
|
|
87
|
+
view.render(true);
|
|
88
|
+
expect(controller.open).not.toHaveBeenCalled();
|
|
89
|
+
view.render(false);
|
|
90
|
+
expect(controller.close).toHaveBeenCalledWith("slideshow");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("leaves a slideshow panel that a restored layout already holds alone", () => {
|
|
94
|
+
const controller = seed({ open: ["design", "slideshow"] });
|
|
95
|
+
mountHook(useSlideshowDockPanel, true);
|
|
96
|
+
expect(controller.open).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("waits for the dock to mount, then opens the panel once", () => {
|
|
100
|
+
const controller = seed({});
|
|
101
|
+
useDockLayoutStore.setState({ controller: null });
|
|
102
|
+
mountHook(useSlideshowDockPanel, true);
|
|
103
|
+
expect(controller.open).not.toHaveBeenCalled();
|
|
104
|
+
act(() => useDockLayoutStore.setState({ controller }));
|
|
105
|
+
expect(controller.open).toHaveBeenCalledWith("slideshow");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does not reopen a panel the user closed while the file is still a slideshow", () => {
|
|
109
|
+
const controller = seed({});
|
|
110
|
+
const view = mountHook(useSlideshowDockPanel, true);
|
|
111
|
+
expect(controller.open).toHaveBeenCalledTimes(1);
|
|
112
|
+
act(() => useDockLayoutStore.setState({ openPanels: new Set<PanelId>(["design"]) }));
|
|
113
|
+
view.render(true);
|
|
114
|
+
expect(controller.open).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("useBlockParamsDismissal", () => {
|
|
119
|
+
const props = { hasBlockParams: true, onDismiss: vi.fn() };
|
|
120
|
+
|
|
121
|
+
it("keeps block params while the Design panel is showing", () => {
|
|
122
|
+
seed({ visible: ["design"] });
|
|
123
|
+
const onDismiss = vi.fn();
|
|
124
|
+
mountHook(useBlockParamsDismissal, { ...props, onDismiss });
|
|
125
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("dismisses block params when the Design panel is no longer showing", () => {
|
|
129
|
+
seed({ visible: ["design"] });
|
|
130
|
+
const onDismiss = vi.fn();
|
|
131
|
+
mountHook(useBlockParamsDismissal, { ...props, onDismiss });
|
|
132
|
+
act(() => useDockLayoutStore.setState({ visiblePanels: new Set<PanelId>(["layers"]) }));
|
|
133
|
+
expect(onDismiss).toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("useDismissingTabSetter", () => {
|
|
138
|
+
it("dismisses block params for any tab but block-params, then switches", () => {
|
|
139
|
+
const setTab = vi.fn();
|
|
140
|
+
const dismiss = vi.fn();
|
|
141
|
+
const view = mountHook(
|
|
142
|
+
({ tab }: { tab: string }) => {
|
|
143
|
+
const set = useDismissingTabSetter(setTab, dismiss);
|
|
144
|
+
return () => set(tab);
|
|
145
|
+
},
|
|
146
|
+
{ tab: "design" },
|
|
147
|
+
);
|
|
148
|
+
(view.value() as () => void)();
|
|
149
|
+
expect(dismiss).toHaveBeenCalledTimes(1);
|
|
150
|
+
expect(setTab).toHaveBeenCalledWith("design");
|
|
151
|
+
view.render({ tab: "block-params" });
|
|
152
|
+
(view.value() as () => void)();
|
|
153
|
+
expect(dismiss).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(setTab).toHaveBeenLastCalledWith("block-params");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("useCaptionDesignFocus", () => {
|
|
159
|
+
it("brings Design forward when caption editing starts over another right panel", () => {
|
|
160
|
+
seed({ visible: ["layers"], lastActive: { right: "layers" } });
|
|
161
|
+
const view = mountHook(useCaptionDesignFocus, false);
|
|
162
|
+
const activate = vi.spyOn(useDockLayoutStore.getState(), "activatePanel");
|
|
163
|
+
view.render(true);
|
|
164
|
+
expect(activate).toHaveBeenCalledWith("design");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("leaves a collapsed right column alone", () => {
|
|
168
|
+
seed({ visible: [], lastActive: { right: "layers" } });
|
|
169
|
+
const activate = vi.spyOn(useDockLayoutStore.getState(), "activatePanel");
|
|
170
|
+
mountHook(useCaptionDesignFocus, true);
|
|
171
|
+
expect(activate).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("useRightPanelIntent", () => {
|
|
176
|
+
it("still names the inspector panel while the right column is collapsed", () => {
|
|
177
|
+
seed({ visible: [], lastActive: { right: "design" } });
|
|
178
|
+
const view = mountHook(useRightPanelIntent, undefined);
|
|
179
|
+
expect(view.value()).toBe("design");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { useDockLayoutStore, visiblePanelInZone } from "../components/dock/dockLayoutStore";
|
|
3
|
+
|
|
4
|
+
/** Opens the Slideshow panel when the file becomes a slideshow, closes it when it stops being one.
|
|
5
|
+
* A user's own close, and a restored layout's placement, stick. */
|
|
6
|
+
export function useSlideshowDockPanel(isSlideshowComposition: boolean) {
|
|
7
|
+
const controller = useDockLayoutStore((state) => state.controller);
|
|
8
|
+
const wasSlideshow = useRef(false);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!controller) return;
|
|
11
|
+
const inDock = useDockLayoutStore.getState().openPanels.has("slideshow");
|
|
12
|
+
if (isSlideshowComposition && !inDock) controller.open("slideshow");
|
|
13
|
+
if (!isSlideshowComposition && wasSlideshow.current && inDock) controller.close("slideshow");
|
|
14
|
+
wasSlideshow.current = isSlideshowComposition;
|
|
15
|
+
}, [isSlideshowComposition, controller]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Block params replace the Design body until Design stops showing; picking another tab dismisses them elsewhere. */
|
|
19
|
+
export function useBlockParamsDismissal({
|
|
20
|
+
hasBlockParams,
|
|
21
|
+
onDismiss,
|
|
22
|
+
}: {
|
|
23
|
+
hasBlockParams: boolean;
|
|
24
|
+
onDismiss: () => void;
|
|
25
|
+
}) {
|
|
26
|
+
const designVisible = useDockLayoutStore((state) => state.visiblePanels.has("design"));
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (hasBlockParams && !designVisible) onDismiss();
|
|
29
|
+
}, [hasBlockParams, designVisible, onDismiss]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Wraps a tab setter so any tab other than block-params first dismisses the block-params view. */
|
|
33
|
+
export function useDismissingTabSetter<Tab extends string>(
|
|
34
|
+
setTab: (tab: Tab) => void,
|
|
35
|
+
dismissBlockParams: () => void,
|
|
36
|
+
) {
|
|
37
|
+
return useCallback(
|
|
38
|
+
(tab: Tab) => {
|
|
39
|
+
if (tab !== "block-params") dismissBlockParams();
|
|
40
|
+
setTab(tab);
|
|
41
|
+
},
|
|
42
|
+
[setTab, dismissBlockParams],
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Caption edit mode owns the Design panel: whenever the right column shows something else, bring Design back. */
|
|
47
|
+
export function useCaptionDesignFocus(captionEditMode: boolean) {
|
|
48
|
+
const designVisible = useDockLayoutStore((state) => state.visiblePanels.has("design"));
|
|
49
|
+
const rightShown = useDockLayoutStore(
|
|
50
|
+
(state) => visiblePanelInZone("right", state.lastActive, state.visiblePanels) !== null,
|
|
51
|
+
);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (captionEditMode && rightShown && !designVisible) {
|
|
54
|
+
useDockLayoutStore.getState().activatePanel("design");
|
|
55
|
+
}
|
|
56
|
+
}, [captionEditMode, rightShown, designVisible]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The right-column panel the user last focused, whether or not the column is currently showing. */
|
|
60
|
+
export function useRightPanelIntent() {
|
|
61
|
+
return useDockLayoutStore((state) =>
|
|
62
|
+
visiblePanelInZone("right", state.lastActive, state.openPanels),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -4,7 +4,6 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { useSlideshowTabState } from "./useSlideshowTabState";
|
|
7
|
-
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
8
7
|
|
|
9
8
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
9
|
|
|
@@ -15,14 +14,10 @@ afterEach(() => {
|
|
|
15
14
|
document.body.innerHTML = "";
|
|
16
15
|
});
|
|
17
16
|
|
|
18
|
-
function renderHook(params: {
|
|
19
|
-
editingFileContent: string | null | undefined;
|
|
20
|
-
rightPanelTab: RightPanelTab;
|
|
21
|
-
}) {
|
|
17
|
+
function renderHook(params: { editingFileContent: string | null | undefined }) {
|
|
22
18
|
const host = document.createElement("div");
|
|
23
19
|
document.body.append(host);
|
|
24
20
|
const root = createRoot(host);
|
|
25
|
-
const setRightPanelTabCalls: RightPanelTab[] = [];
|
|
26
21
|
let current: ReturnType<typeof useSlideshowTabState> | null = null;
|
|
27
22
|
|
|
28
23
|
function Harness() {
|
|
@@ -30,8 +25,7 @@ function renderHook(params: {
|
|
|
30
25
|
editingFileContent: params.editingFileContent,
|
|
31
26
|
previewIframeRef: { current: null },
|
|
32
27
|
refreshKey: 0,
|
|
33
|
-
|
|
34
|
-
setRightPanelTab: (tab) => setRightPanelTabCalls.push(tab),
|
|
28
|
+
slideshowVisible: false,
|
|
35
29
|
});
|
|
36
30
|
return null;
|
|
37
31
|
}
|
|
@@ -45,52 +39,36 @@ function renderHook(params: {
|
|
|
45
39
|
if (!current) throw new Error("useSlideshowTabState did not render");
|
|
46
40
|
return current;
|
|
47
41
|
},
|
|
48
|
-
setRightPanelTabCalls,
|
|
49
42
|
unmount: () => act(() => root.unmount()),
|
|
50
43
|
};
|
|
51
44
|
}
|
|
52
45
|
|
|
46
|
+
// Whether "slideshow" stays the active dock tab across a file switch is
|
|
47
|
+
// StudioRightPanels' own effect now (it opens/closes the dock panel), not
|
|
48
|
+
// this hook's job — this only derives the two values that effect reads.
|
|
53
49
|
describe("useSlideshowTabState", () => {
|
|
54
50
|
it("detects a slideshow composition via the JSON island", () => {
|
|
55
|
-
const harness = renderHook({ editingFileContent: SLIDESHOW_HTML
|
|
51
|
+
const harness = renderHook({ editingFileContent: SLIDESHOW_HTML });
|
|
56
52
|
expect(harness.getState().isSlideshowComposition).toBe(true);
|
|
57
53
|
harness.unmount();
|
|
58
54
|
});
|
|
59
55
|
|
|
60
56
|
it("reports false for a plain (non-slideshow) composition", () => {
|
|
61
|
-
const harness = renderHook({ editingFileContent: PLAIN_HTML
|
|
57
|
+
const harness = renderHook({ editingFileContent: PLAIN_HTML });
|
|
62
58
|
expect(harness.getState().isSlideshowComposition).toBe(false);
|
|
63
59
|
harness.unmount();
|
|
64
60
|
});
|
|
65
61
|
|
|
66
62
|
it("reports false when there is no editing file yet", () => {
|
|
67
|
-
const harness = renderHook({ editingFileContent: undefined
|
|
63
|
+
const harness = renderHook({ editingFileContent: undefined });
|
|
68
64
|
expect(harness.getState().isSlideshowComposition).toBe(false);
|
|
69
65
|
harness.unmount();
|
|
70
66
|
});
|
|
71
67
|
|
|
72
68
|
it("still detects a malformed island — presence-only, not full manifest validation", () => {
|
|
73
69
|
const malformed = `<html><body><script type="application/hyperframes-slideshow+json">{not valid json</script></body></html>`;
|
|
74
|
-
const harness = renderHook({ editingFileContent: malformed
|
|
70
|
+
const harness = renderHook({ editingFileContent: malformed });
|
|
75
71
|
expect(harness.getState().isSlideshowComposition).toBe(true);
|
|
76
72
|
harness.unmount();
|
|
77
73
|
});
|
|
78
|
-
|
|
79
|
-
it("bounces rightPanelTab off 'slideshow' to 'renders' on a non-slideshow composition", () => {
|
|
80
|
-
const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "slideshow" });
|
|
81
|
-
expect(harness.setRightPanelTabCalls).toEqual(["renders"]);
|
|
82
|
-
harness.unmount();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("does not bounce when the composition is a slideshow", () => {
|
|
86
|
-
const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "slideshow" });
|
|
87
|
-
expect(harness.setRightPanelTabCalls).toEqual([]);
|
|
88
|
-
harness.unmount();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("does not bounce a tab other than 'slideshow'", () => {
|
|
92
|
-
const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "renders" });
|
|
93
|
-
expect(harness.setRightPanelTabCalls).toEqual([]);
|
|
94
|
-
harness.unmount();
|
|
95
|
-
});
|
|
96
74
|
});
|