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. */