@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.
Files changed (75) hide show
  1. package/dist/assets/{hyperframes-player-I5YRzB02.js → hyperframes-player-DKWM-Ons.js} +1 -1
  2. package/dist/assets/index-DrHQAuGO.css +1 -0
  3. package/dist/assets/index-HfP3sap2.js +390 -0
  4. package/dist/assets/{index-BTAfgesM.js → index-UvW6BEwf.js} +1 -1
  5. package/dist/assets/{index-Du5x7KUR.js → index-Y4OnpTEM.js} +1 -1
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +13 -6
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +7664 -7953
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles.css +1 -0
  13. package/package.json +8 -7
  14. package/src/App.tsx +42 -48
  15. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  16. package/src/components/EditorShell.tsx +52 -56
  17. package/src/components/StudioHeader.dom.test.tsx +1 -1
  18. package/src/components/StudioHeader.test.ts +0 -12
  19. package/src/components/StudioHeader.tsx +10 -13
  20. package/src/components/StudioLeftPanels.tsx +202 -0
  21. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -206
  22. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  23. package/src/components/dock/Dock.test.tsx +16 -2
  24. package/src/components/dock/Dock.tsx +13 -3
  25. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  26. package/src/components/dock/dockLayoutStore.ts +21 -4
  27. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  28. package/src/components/nle/NLEContext.tsx +0 -43
  29. package/src/components/nle/TimelinePane.tsx +46 -67
  30. package/src/components/panels/SlideshowPanel.tsx +1 -1
  31. package/src/components/panels/VariablesPanel.tsx +1 -1
  32. package/src/components/renders/RenderQueue.test.tsx +133 -25
  33. package/src/components/renders/RenderQueue.tsx +99 -92
  34. package/src/components/renders/RenderQueueItem.tsx +69 -64
  35. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  36. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  37. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  38. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  39. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  40. package/src/components/ui/Select.tsx +4 -0
  41. package/src/contexts/PanelLayoutContext.tsx +3 -57
  42. package/src/headerExports.test.tsx +23 -0
  43. package/src/hooks/appHotkeysDispatch.ts +3 -4
  44. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  45. package/src/hooks/useAppHotkeys.test.ts +0 -1
  46. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  47. package/src/hooks/useAppHotkeys.ts +0 -4
  48. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  49. package/src/hooks/useDomEditSession.ts +0 -7
  50. package/src/hooks/useDomEditWiring.ts +4 -9
  51. package/src/hooks/usePanelLayout.test.ts +108 -296
  52. package/src/hooks/usePanelLayout.ts +50 -255
  53. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  54. package/src/hooks/useRightPanelIntents.ts +64 -0
  55. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  56. package/src/hooks/useSlideshowTabState.ts +6 -19
  57. package/src/hooks/useStudioContextValue.test.ts +6 -18
  58. package/src/hooks/useStudioContextValue.ts +5 -26
  59. package/src/index.ts +2 -1
  60. package/src/main.tsx +26 -0
  61. package/src/player/components/Timeline.tsx +1 -1
  62. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  63. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  64. package/src/utils/studioHelpers.ts +0 -7
  65. package/src/utils/studioUiPreferences.test.ts +10 -16
  66. package/src/utils/studioUiPreferences.ts +0 -16
  67. package/dist/assets/index-CxAu_Okg.css +0 -1
  68. package/dist/assets/index-DocJlW2v.js +0 -376
  69. package/src/components/StudioLeftSidebar.tsx +0 -207
  70. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  71. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  72. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  73. package/src/hooks/useInspectorSplitResize.ts +0 -51
  74. package/src/utils/fitPanels.test.ts +0 -164
  75. package/src/utils/fitPanels.ts +0 -151
@@ -1,275 +1,70 @@
1
- import { useState, useCallback, useRef, useEffect } from "react";
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 { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability";
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
- type PanelSide = "left" | "right";
25
-
26
- function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspectorPanes {
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
- * What the user WANTS each panel to be, before the window gets a say. Stored
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 [preferredWidths, setPreferredWidths] = useState(getPreferredPanelWidths);
51
- const [viewportWidth, setViewportWidth] = useState(readViewportWidth);
52
- const [leftCollapsed, setLeftCollapsed] = useState(
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
- // Reconciliation is a live window resize away, not a mount-time snapshot: a
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 (typeof window === "undefined") return;
79
- const handleResize = () => {
80
- const width = window.innerWidth;
81
- setViewportWidth(width);
82
- // Cleared here rather than in an effect watching derived state: the rail
83
- // flags depend only on width, and width only changes in this handler.
84
- if (!railsEngaged(width)) {
85
- setAutoCollapseOverride((prev) => (prev.left || prev.right ? NO_OVERRIDE : prev));
86
- }
87
- };
88
- window.addEventListener("resize", handleResize);
89
- return () => window.removeEventListener("resize", handleResize);
90
- }, []);
91
-
92
- const fitted = fitPanelWidths(viewportWidth, preferredWidths, autoCollapseOverride);
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 handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => {
174
- e.preventDefault();
175
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
176
- panelDragRef.current = {
177
- side,
178
- startX: e.clientX,
179
- startW: fittedRef.current[side],
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
- rightPanelTab: params.rightPanelTab,
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, rightPanelTab: "design" });
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, rightPanelTab: "design" });
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, rightPanelTab: "design" });
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, rightPanelTab: "design" });
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
  });