From ee738cc8a140bdd791667fbe23489dbcca536f41 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 08:07:32 -0400 Subject: [PATCH] fix(studio): keep right-panel behaviours the dock migration dropped Block params dismiss on selection or when Design is hidden, the slideshow panel can be closed, the selection outline follows the inspector intent, caption editing focuses Design, and a URL slideshow tab opens once the panel exists. --- packages/studio/src/App.tsx | 7 +- .../src/components/StudioHeader.dom.test.tsx | 2 +- .../studio/src/components/StudioHeader.tsx | 5 + .../src/components/StudioRightPanels.tsx | 21 ++- .../src/components/StudioRightPanels.types.ts | 2 + .../src/components/nle/TimelinePane.tsx | 99 +++++++------ .../studio/src/hooks/usePanelLayout.test.ts | 12 ++ packages/studio/src/hooks/usePanelLayout.ts | 14 +- .../src/hooks/useRightPanelIntents.test.ts | 133 ++++++++++++++++++ .../studio/src/hooks/useRightPanelIntents.ts | 48 +++++++ .../src/hooks/useStudioContextValue.test.ts | 13 +- .../studio/src/hooks/useStudioContextValue.ts | 10 +- 12 files changed, 288 insertions(+), 78 deletions(-) create mode 100644 packages/studio/src/hooks/useRightPanelIntents.test.ts create mode 100644 packages/studio/src/hooks/useRightPanelIntents.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 07dfb859b6..f25ff9fcfa 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,7 +1,7 @@ import { useOwnPreviewIframe, usePreviewIframeStore } from "./player/store/previewIframeStore"; import { buildProjectApiPath } from "./utils/projectRouting"; import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react"; -import { useDockLayoutStore } from "./components/dock/dockLayoutStore"; +import { useRightPanelIntent } from "./hooks/useRightPanelIntents"; import { useRenderQueue } from "./components/renders/useRenderQueue"; import { usePlayerStore } from "./player"; import { StudioOverlays } from "./components/StudioOverlays"; @@ -359,10 +359,10 @@ export function StudioApp() { }, [appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion], ); - const visiblePanels = useDockLayoutStore((state) => state.visiblePanels); + const rightPanel = useRightPanelIntent(); const { inspectorPanelActive, shouldShowMotionPath, shouldShowSelectedDomBounds } = useInspectorState( - visiblePanels, + rightPanel, isPlaying, domEditSession.domEditSelection, gestureState === "recording", @@ -469,6 +469,7 @@ export function StudioApp() { /> setActiveBlockParams(null)} onCloseBlockParams={() => { setActiveBlockParams(null); panelLayout.setRightPanelTab("design"); diff --git a/packages/studio/src/components/StudioHeader.dom.test.tsx b/packages/studio/src/components/StudioHeader.dom.test.tsx index b6d79f1939..45b926e910 100644 --- a/packages/studio/src/components/StudioHeader.dom.test.tsx +++ b/packages/studio/src/components/StudioHeader.dom.test.tsx @@ -33,7 +33,7 @@ vi.mock("../contexts/StudioContext", () => ({ vi.mock("../contexts/PanelLayoutContext", () => ({ usePanelLayoutContext: () => ({ - effectiveRightCollapsed: false, + rightCollapsed: false, setRightCollapsed: vi.fn(), setRightPanelTab: vi.fn(), }), diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index 1656a59514..4720e56418 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -165,6 +165,11 @@ export function StudioHeader({ const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext(); const isRendering = renderQueue.isRendering; const ffmpegMissing = renderQueue.ffmpegMissing; + // The dock has no separate "railed by window width" state (it shrinks + // panels, never auto-hides the group), so rightCollapsed is already the + // effective value: the button reads pressed only when the panel is both + // open and actually showing the Inspector. + const inspectorButtonActive = !rightCollapsed && inspectorPanelActive; return (
diff --git a/packages/studio/src/components/StudioRightPanels.tsx b/packages/studio/src/components/StudioRightPanels.tsx index 03b85e3723..67cd2927f1 100644 --- a/packages/studio/src/components/StudioRightPanels.tsx +++ b/packages/studio/src/components/StudioRightPanels.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import type { StudioRightPanelsProps } from "./StudioRightPanels.types"; import { PropertyPanel } from "./editor/PropertyPanel"; @@ -13,6 +13,11 @@ import { useDockLayoutStore } from "./dock/dockLayoutStore"; import type { RenderJob } from "./renders/useRenderQueue"; import { useSlideshowPersist } from "../hooks/useSlideshowPersist"; import { useSlideshowTabState } from "../hooks/useSlideshowTabState"; +import { + useBlockParamsDismissal, + useCaptionDesignFocus, + useSlideshowDockPanel, +} from "../hooks/useRightPanelIntents"; import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider"; import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; import { useFileManagerContext } from "../contexts/FileManagerContext"; @@ -31,6 +36,7 @@ import { useRemoveBackground } from "../hooks/useRemoveBackground"; export function StudioRightPanels({ activeBlockParams, onCloseBlockParams, + onDismissBlockParams, recordingState, recordingDuration, onToggleRecording, @@ -145,12 +151,13 @@ export function StudioRightPanels({ refreshKey, slideshowVisible, }); - const slideshowInDock = useDockLayoutStore((state) => state.openPanels.has("slideshow")); - useEffect(() => { - const { controller } = useDockLayoutStore.getState(); - if (isSlideshowComposition && !slideshowInDock) controller?.open("slideshow"); - if (!isSlideshowComposition && slideshowInDock) controller?.close("slideshow"); - }, [isSlideshowComposition, slideshowInDock]); + useSlideshowDockPanel(isSlideshowComposition); + useBlockParamsDismissal({ + hasBlockParams: activeBlockParams != null, + hasSelection: domEditSelection != null, + onDismiss: onDismissBlockParams, + }); + useCaptionDesignFocus(captionEditMode); const handleApplyColorGradingScope = useCallback( async (scope: ColorGradingScope, value: string | null) => diff --git a/packages/studio/src/components/StudioRightPanels.types.ts b/packages/studio/src/components/StudioRightPanels.types.ts index 74ee141c88..666908ea47 100644 --- a/packages/studio/src/components/StudioRightPanels.types.ts +++ b/packages/studio/src/components/StudioRightPanels.types.ts @@ -22,6 +22,8 @@ export interface StudioRightPanelsProps extends StudioEditPersistenceProps { compositionPath: string; } | null; onCloseBlockParams?: () => void; + /** Drops the block-params view without touching the dock (selection or leaving Design). */ + onDismissBlockParams?: () => void; recordingState?: "idle" | "recording" | "preview"; recordingDuration?: number; onToggleRecording?: () => void; diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 7bb21f1eff..063916a1ad 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -249,59 +249,56 @@ export function TimelinePane({ ); return ( +
{ + if ((e.target as HTMLElement).closest("[data-clip]")) return; + if (timelineDisabled) return; + if (compositionStack.length > 1) { + updateCompositionStack((prev) => prev.slice(0, -1)); + } + }} > -
{ - if ((e.target as HTMLElement).closest("[data-clip]")) return; - if (timelineDisabled) return; - if (compositionStack.length > 1) { - updateCompositionStack((prev) => prev.slice(0, -1)); - } - }} - > -
{timelineToolbar}
- -
- {timelineFooter &&
{timelineFooter}
} - {timelineDisabled && ( -
event.preventDefault()} - onDragOver={(event) => event.preventDefault()} - onDrop={(event) => event.preventDefault()} - > - - Loading composition… - -
- )} +
{timelineToolbar}
+
+ {timelineFooter &&
{timelineFooter}
} + {timelineDisabled && ( +
event.preventDefault()} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => event.preventDefault()} + > + + Loading composition… + +
+ )} +
); } diff --git a/packages/studio/src/hooks/usePanelLayout.test.ts b/packages/studio/src/hooks/usePanelLayout.test.ts index b19c6e32e8..ac7dd836fa 100644 --- a/packages/studio/src/hooks/usePanelLayout.test.ts +++ b/packages/studio/src/hooks/usePanelLayout.test.ts @@ -124,4 +124,16 @@ describe("usePanelLayout over the dock", () => { expect(later.setGroupVisible).not.toHaveBeenCalled(); view.unmount(); }); + + it("keeps a URL's slideshow tab until the panel opens, then activates it once", () => { + const opened = new Set(PANEL_IDS.filter((id) => id !== "slideshow")); + useDockLayoutStore.setState({ controller: null, openPanels: opened }); + const view = renderLayout({ rightPanelTab: "slideshow" }); + const controller = fakeController(); + act(() => useDockLayoutStore.getState().attach(controller)); + expect(controller.activate).not.toHaveBeenCalled(); + act(() => useDockLayoutStore.setState({ openPanels: new Set(PANEL_IDS) })); + expect(controller.activate).toHaveBeenCalledWith("slideshow"); + view.unmount(); + }); }); diff --git a/packages/studio/src/hooks/usePanelLayout.ts b/packages/studio/src/hooks/usePanelLayout.ts index ab41e0deb3..26311cf258 100644 --- a/packages/studio/src/hooks/usePanelLayout.ts +++ b/packages/studio/src/hooks/usePanelLayout.ts @@ -30,10 +30,20 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { const { rightCollapsed, rightPanelTab } = initialRef.current ?? {}; initialRef.current = undefined; const store = useDockLayoutStore.getState(); - if (rightPanelTab && store.openPanels.has(panelForTab(rightPanelTab))) { - store.activatePanel(panelForTab(rightPanelTab)); + const wanted = rightPanelTab ? panelForTab(rightPanelTab) : null; + let unsubscribe = () => {}; + if (wanted && store.openPanels.has(wanted)) { + store.activatePanel(wanted); + } else if (wanted === "slideshow") { + // The slideshow panel only opens once the composition loads and turns out to be one. + unsubscribe = useDockLayoutStore.subscribe((state) => { + if (!state.openPanels.has("slideshow")) return; + unsubscribe(); + state.activatePanel("slideshow"); + }); } if (rightCollapsed != null) store.setZoneVisible("right", !rightCollapsed); + return () => unsubscribe(); }, [controller]); const visibleRight = visiblePanelInZone("right", lastActive, visiblePanels); diff --git a/packages/studio/src/hooks/useRightPanelIntents.test.ts b/packages/studio/src/hooks/useRightPanelIntents.test.ts new file mode 100644 index 0000000000..a8fae2aa87 --- /dev/null +++ b/packages/studio/src/hooks/useRightPanelIntents.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useDockLayoutStore, type DockController } from "../components/dock/dockLayoutStore"; +import { PANEL_IDS, type PanelId } from "../components/dock/panelRegistry"; +import { + useBlockParamsDismissal, + useCaptionDesignFocus, + useRightPanelIntent, + useSlideshowDockPanel, +} from "./useRightPanelIntents"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function controllerMock(): DockController { + return { + open: vi.fn(), + activate: vi.fn(), + close: vi.fn(), + setTitle: vi.fn(), + setGroupVisible: vi.fn(), + reset: vi.fn(), + }; +} + +function seed(state: { + open?: PanelId[]; + visible?: PanelId[]; + lastActive?: Partial>; +}) { + const controller = controllerMock(); + useDockLayoutStore.setState({ + controller, + openPanels: new Set(state.open ?? PANEL_IDS.filter((id) => id !== "slideshow")), + visiblePanels: new Set(state.visible ?? []), + lastActive: state.lastActive ?? {}, + activePanel: null, + }); + return controller; +} + +function mountHook

(hook: (props: P) => unknown, initial: P) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + let latest: unknown; + function Harness({ props }: { props: P }) { + latest = hook(props); + return null; + } + const render = (props: P) => act(() => root.render(React.createElement(Harness, { props }))); + render(initial); + return { render, value: () => latest, unmount: () => act(() => root.unmount()) }; +} + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + useDockLayoutStore.setState({ controller: null }); +}); + +describe("useSlideshowDockPanel", () => { + it("opens the panel when the file becomes a slideshow and closes it when it stops", () => { + const controller = seed({}); + const view = mountHook(useSlideshowDockPanel, false); + expect(controller.open).not.toHaveBeenCalled(); + view.render(true); + expect(controller.open).toHaveBeenCalledWith("slideshow"); + act(() => + useDockLayoutStore.setState({ openPanels: new Set(["design", "slideshow"]) }), + ); + view.render(false); + expect(controller.close).toHaveBeenCalledWith("slideshow"); + }); + + it("does not reopen a panel the user closed while the file is still a slideshow", () => { + const controller = seed({}); + const view = mountHook(useSlideshowDockPanel, true); + expect(controller.open).toHaveBeenCalledTimes(1); + act(() => useDockLayoutStore.setState({ openPanels: new Set(["design"]) })); + view.render(true); + expect(controller.open).toHaveBeenCalledTimes(1); + }); +}); + +describe("useBlockParamsDismissal", () => { + const props = { hasBlockParams: true, hasSelection: false, onDismiss: vi.fn() }; + + it("dismisses block params when an element is selected", () => { + seed({ visible: ["design"] }); + const onDismiss = vi.fn(); + const view = mountHook(useBlockParamsDismissal, { ...props, onDismiss }); + expect(onDismiss).not.toHaveBeenCalled(); + view.render({ ...props, onDismiss, hasSelection: true }); + expect(onDismiss).toHaveBeenCalled(); + }); + + it("dismisses block params when the Design panel is no longer showing", () => { + seed({ visible: ["design"] }); + const onDismiss = vi.fn(); + mountHook(useBlockParamsDismissal, { ...props, onDismiss }); + expect(onDismiss).not.toHaveBeenCalled(); + act(() => useDockLayoutStore.setState({ visiblePanels: new Set(["layers"]) })); + expect(onDismiss).toHaveBeenCalled(); + }); +}); + +describe("useCaptionDesignFocus", () => { + it("brings Design forward when caption editing starts over another right panel", () => { + seed({ visible: ["layers"], lastActive: { right: "layers" } }); + const view = mountHook(useCaptionDesignFocus, false); + const activate = vi.spyOn(useDockLayoutStore.getState(), "activatePanel"); + view.render(true); + expect(activate).toHaveBeenCalledWith("design"); + }); + + it("leaves a collapsed right column alone", () => { + seed({ visible: [], lastActive: { right: "layers" } }); + const activate = vi.spyOn(useDockLayoutStore.getState(), "activatePanel"); + mountHook(useCaptionDesignFocus, true); + expect(activate).not.toHaveBeenCalled(); + }); +}); + +describe("useRightPanelIntent", () => { + it("still names the inspector panel while the right column is collapsed", () => { + seed({ visible: [], lastActive: { right: "design" } }); + const view = mountHook(useRightPanelIntent, undefined); + expect(view.value()).toBe("design"); + }); +}); diff --git a/packages/studio/src/hooks/useRightPanelIntents.ts b/packages/studio/src/hooks/useRightPanelIntents.ts new file mode 100644 index 0000000000..a3938f388a --- /dev/null +++ b/packages/studio/src/hooks/useRightPanelIntents.ts @@ -0,0 +1,48 @@ +import { useEffect } from "react"; +import { useDockLayoutStore, visiblePanelInZone } from "../components/dock/dockLayoutStore"; + +/** Opens the Slideshow panel when the file becomes a slideshow and closes it when it stops being one; a user's own close sticks. */ +export function useSlideshowDockPanel(isSlideshowComposition: boolean) { + useEffect(() => { + const { controller, openPanels } = useDockLayoutStore.getState(); + const inDock = openPanels.has("slideshow"); + if (isSlideshowComposition && !inDock) controller?.open("slideshow"); + if (!isSlideshowComposition && inDock) controller?.close("slideshow"); + }, [isSlideshowComposition]); +} + +/** Block params replace the Design body only until something else takes the user's attention. */ +export function useBlockParamsDismissal({ + hasBlockParams, + hasSelection, + onDismiss, +}: { + hasBlockParams: boolean; + hasSelection: boolean; + onDismiss?: () => void; +}) { + const designVisible = useDockLayoutStore((state) => state.visiblePanels.has("design")); + useEffect(() => { + if (hasBlockParams && (hasSelection || !designVisible)) onDismiss?.(); + }, [hasBlockParams, hasSelection, designVisible, onDismiss]); +} + +/** Caption edit mode owns the Design panel: whenever the right column shows something else, bring Design back. */ +export function useCaptionDesignFocus(captionEditMode: boolean) { + const designVisible = useDockLayoutStore((state) => state.visiblePanels.has("design")); + const rightShown = useDockLayoutStore( + (state) => visiblePanelInZone("right", state.lastActive, state.visiblePanels) !== null, + ); + useEffect(() => { + if (captionEditMode && rightShown && !designVisible) { + useDockLayoutStore.getState().activatePanel("design"); + } + }, [captionEditMode, rightShown, designVisible]); +} + +/** The right-column panel the user last focused, whether or not the column is currently showing. */ +export function useRightPanelIntent() { + return useDockLayoutStore((state) => + visiblePanelInZone("right", state.lastActive, state.openPanels), + ); +} diff --git a/packages/studio/src/hooks/useStudioContextValue.test.ts b/packages/studio/src/hooks/useStudioContextValue.test.ts index 5f9ef84f69..557c7e7162 100644 --- a/packages/studio/src/hooks/useStudioContextValue.test.ts +++ b/packages/studio/src/hooks/useStudioContextValue.test.ts @@ -9,7 +9,7 @@ import { makeSelection } from "./domSelectionTestHarness"; import { useInspectorState, type InspectorState } from "./useStudioContextValue"; interface HarnessProps { - visiblePanels: ReadonlySet; + rightPanel: PanelId | null; isPlaying: boolean; isGestureRecording: boolean; domEditSelection: DomEditSelection | null; @@ -20,7 +20,7 @@ function renderInspectorState(props: HarnessProps): InspectorState { function Harness() { state = useInspectorState( - props.visiblePanels, + props.rightPanel, props.isPlaying, props.domEditSelection, props.isGestureRecording, @@ -38,7 +38,7 @@ function selectedProps( ): HarnessProps & { domEditSelection: DomEditSelection } { const element = document.createElement("div"); return { - visiblePanels: new Set(["preview", "timeline", "renders"]), + rightPanel: "renders", isPlaying: false, isGestureRecording: false, domEditSelection: makeSelection("Selected", element), @@ -72,13 +72,10 @@ describe("useInspectorState", () => { it("keeps selected DOM bounds coupled to the inspector or variables panel", () => { expect(renderInspectorState(selectedProps()).shouldShowSelectedDomBounds).toBe(false); expect( - renderInspectorState( - selectedProps({ visiblePanels: new Set(["design"]) }), - ).shouldShowSelectedDomBounds, + renderInspectorState(selectedProps({ rightPanel: "design" })).shouldShowSelectedDomBounds, ).toBe(true); expect( - renderInspectorState(selectedProps({ visiblePanels: new Set(["variables"]) })) - .shouldShowSelectedDomBounds, + renderInspectorState(selectedProps({ rightPanel: "variables" })).shouldShowSelectedDomBounds, ).toBe(true); }); }); diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index 07dc5f101a..783c51d22c 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -72,13 +72,13 @@ export interface InspectorState { } export function useInspectorState( - visiblePanels: ReadonlySet, + rightPanel: PanelId | null, isPlaying: boolean, domEditSelection: DomEditSelection | null, isGestureRecording?: boolean, ): InspectorState { return useMemo(() => { - const inspectorPanelActive = visiblePanels.has("layers") || visiblePanels.has("design"); + const inspectorPanelActive = rightPanel === "layers" || rightPanel === "design"; return { inspectorPanelActive, // Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path @@ -90,11 +90,9 @@ export function useInspectorState( // The Variables tab also works against the canvas selection (bind card), // so the selection outline stays visible there too. shouldShowSelectedDomBounds: - (inspectorPanelActive || visiblePanels.has("variables")) && - !isPlaying && - !isGestureRecording, + (inspectorPanelActive || rightPanel === "variables") && !isPlaying && !isGestureRecording, }; - }, [visiblePanels, isPlaying, isGestureRecording, domEditSelection]); + }, [rightPanel, isPlaying, isGestureRecording, domEditSelection]); } /** Lets an OS file drop reach `onDrop` anywhere in the shell; the timeline shows its own landing preview. */