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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-09-19 17:35:05 -04:00
parent 2fca381db2
commit ee738cc8a1
12 changed files with 288 additions and 78 deletions
+4 -3
View File
@@ -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() {
/>
<StudioRightPanels
activeBlockParams={activeBlockParams}
onDismissBlockParams={() => setActiveBlockParams(null)}
onCloseBlockParams={() => {
setActiveBlockParams(null);
panelLayout.setRightPanelTab("design");
@@ -33,7 +33,7 @@ vi.mock("../contexts/StudioContext", () => ({
vi.mock("../contexts/PanelLayoutContext", () => ({
usePanelLayoutContext: () => ({
effectiveRightCollapsed: false,
rightCollapsed: false,
setRightCollapsed: vi.fn(),
setRightPanelTab: vi.fn(),
}),
@@ -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 (
<div className="flex items-center justify-between h-10 px-3 bg-surface border-b border-border-strong shrink-0">
@@ -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) =>
@@ -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;
@@ -249,59 +249,56 @@ export function TimelinePane({
);
return (
<div className="relative flex h-full flex-col" aria-disabled={timelineDisabled || undefined}>
<div
className="relative flex h-full flex-col"
aria-disabled={timelineDisabled || undefined}
className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (timelineDisabled) return;
if (compositionStack.length > 1) {
updateCompositionStack((prev) => prev.slice(0, -1));
}
}}
>
<div
className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (timelineDisabled) return;
if (compositionStack.length > 1) {
updateCompositionStack((prev) => prev.slice(0, -1));
}
}}
>
<div className="shrink-0">{timelineToolbar}</div>
<Timeline
sessionEpoch={timelineSessionEpoch}
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
onFileDrop={onFileDrop}
onDeleteElement={handleDeleteElement}
onAssetDrop={onAssetDrop}
onBlockDrop={onBlockDrop}
onCompositionDrop={onCompositionDrop}
onMoveElement={handleMoveElement}
onMoveElements={handleMoveElements}
onResizeElement={handleResizeElement}
onResizeElements={handleResizeElements}
onBlockedEditAttempt={onBlockedEditAttempt}
onSplitElement={handleSplitElement}
onSelectElement={onSelectTimelineElement}
onCopyClip={onCopyClip}
onPasteClip={onPasteClip}
onDuplicateClip={onDuplicateClip}
canPasteClip={canPasteClip}
/>
</div>
{timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
{timelineDisabled && (
<div
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
data-testid="timeline-loading-disabled-overlay"
role="status"
onPointerDown={(event) => event.preventDefault()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
>
<span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
Loading composition…
</span>
</div>
)}
<div className="shrink-0">{timelineToolbar}</div>
<Timeline
sessionEpoch={timelineSessionEpoch}
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
onFileDrop={onFileDrop}
onDeleteElement={handleDeleteElement}
onAssetDrop={onAssetDrop}
onBlockDrop={onBlockDrop}
onCompositionDrop={onCompositionDrop}
onMoveElement={handleMoveElement}
onMoveElements={handleMoveElements}
onResizeElement={handleResizeElement}
onResizeElements={handleResizeElements}
onBlockedEditAttempt={onBlockedEditAttempt}
onSplitElement={handleSplitElement}
onSelectElement={onSelectTimelineElement}
onCopyClip={onCopyClip}
onPasteClip={onPasteClip}
onDuplicateClip={onDuplicateClip}
canPasteClip={canPasteClip}
/>
</div>
{timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
{timelineDisabled && (
<div
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
data-testid="timeline-loading-disabled-overlay"
role="status"
onPointerDown={(event) => event.preventDefault()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
>
<span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
Loading composition…
</span>
</div>
)}
</div>
);
}
@@ -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<PanelId>(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();
});
});
+12 -2
View File
@@ -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);
@@ -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<Record<"left" | "center" | "right", PanelId>>;
}) {
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<P>(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<PanelId>(["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<PanelId>(["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<PanelId>(["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");
});
});
@@ -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),
);
}
@@ -9,7 +9,7 @@ import { makeSelection } from "./domSelectionTestHarness";
import { useInspectorState, type InspectorState } from "./useStudioContextValue";
interface HarnessProps {
visiblePanels: ReadonlySet<PanelId>;
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<PanelId>(["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<PanelId>(["design"]) }),
).shouldShowSelectedDomBounds,
renderInspectorState(selectedProps({ rightPanel: "design" })).shouldShowSelectedDomBounds,
).toBe(true);
expect(
renderInspectorState(selectedProps({ visiblePanels: new Set<PanelId>(["variables"]) }))
.shouldShowSelectedDomBounds,
renderInspectorState(selectedProps({ rightPanel: "variables" })).shouldShowSelectedDomBounds,
).toBe(true);
});
});
@@ -72,13 +72,13 @@ export interface InspectorState {
}
export function useInspectorState(
visiblePanels: ReadonlySet<PanelId>,
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. */