Files
hyperframes/packages/studio/src/hooks/usePanelLayout.ts
T
Miguel Ángel d5cc1c9c62 fix(studio): keep the preview alive when the window is tight (#3091)
Panel sizes are reconciled against the window on every resize, with the preview holding a 360x200 floor that panels yield to before it gives.

Measured preview pane: 760px window 192 -> 433, 560px window 2 -> 516. Windows at or above 1280px are unchanged.

- fitPanels owns the who-yields decision for both axes
- panel caps are window-relative, replacing a flat 600px inspector cap
- below 860 the sidebar rails, below 700 the inspector collapses too
- auto-collapse is derived render state and never writes leftCollapsed
  (localStorage) or rightCollapsed (synced into the shareable URL)
- the rail and header toggles act on the effective state, so neither is a
  dead click that silently persists a collapse the user never asked for
2026-08-07 13:35:39 -07:00

261 lines
10 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from "react";
import type {
RightInspectorPane,
RightInspectorPanes,
RightPanelTab,
} from "../utils/studioHelpers";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability";
import {
defaultPanelWidths,
fitPanelWidths,
railsEngaged,
type PanelWidths,
} from "../utils/fitPanels";
const NO_OVERRIDE = { left: false, right: false } as const;
export interface InitialPanelLayoutState {
rightCollapsed?: boolean | null;
rightPanelTab?: RightPanelTab | null;
}
type PanelSide = "left" | "right";
function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspectorPanes {
if (tab === "layers") return { layers: true, design: false };
return { layers: false, design: true };
}
function readViewportWidth(): number {
return typeof window === "undefined" ? 1496 : window.innerWidth;
}
/**
* What the user WANTS each panel to be, before the window gets a say. Stored
* preferences win over the width-derived defaults; neither is clamped here —
* `fitPanelWidths` owns every clamp so there is one place that decides.
*/
function getPreferredPanelWidths(): PanelWidths {
const preferences = readStudioUiPreferences();
const defaults = defaultPanelWidths(readViewportWidth());
return {
left: preferences.leftWidth ?? defaults.left,
right: preferences.rightWidth ?? defaults.right,
};
}
export function usePanelLayout(initialState?: InitialPanelLayoutState) {
const [preferredWidths, setPreferredWidths] = useState(getPreferredPanelWidths);
const [viewportWidth, setViewportWidth] = useState(readViewportWidth);
const [leftCollapsed, setLeftCollapsed] = useState(
() => readStudioUiPreferences().leftCollapsed ?? false,
);
const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? false);
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
initialState?.rightPanelTab ?? "design",
);
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
getInitialRightInspectorPanes(initialState?.rightPanelTab),
);
// Set when the user explicitly reopens a panel the window had auto-collapsed,
// so the rail cannot immediately swallow it again. Cleared once the window is
// wide enough that auto-collapse is no longer in play.
const [autoCollapseOverride, setAutoCollapseOverride] = useState<{
left: boolean;
right: boolean;
}>(NO_OVERRIDE);
// Reconciliation is a live window resize away, not a mount-time snapshot: a
// Studio loaded at 1440 and dragged to a half-screen used to keep its pixel
// widths and squeeze the preview to nothing.
useEffect(() => {
if (typeof window === "undefined") return;
const handleResize = () => {
const width = window.innerWidth;
setViewportWidth(width);
// Cleared here rather than in an effect watching derived state: the rail
// flags depend only on width, and width only changes in this handler.
if (!railsEngaged(width)) {
setAutoCollapseOverride((prev) => (prev.left || prev.right ? NO_OVERRIDE : prev));
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const fitted = fitPanelWidths(viewportWidth, preferredWidths, autoCollapseOverride);
const leftCollapsedByWidth = fitted.autoCollapseLeft;
const rightCollapsedByWidth = fitted.autoCollapseRight;
// Rendered widths, which the drag handles measure from so the seam does not
// jump when a panel is currently narrower than its stored preference.
const fittedRef = useRef(fitted);
fittedRef.current = fitted;
const panelDragRef = useRef<{
side: PanelSide;
startX: number;
startW: number;
} | null>(null);
// Preferred widths are also held in a ref so a burst of pointer moves or
// keyboard nudges inside one React batch accumulates, instead of every call
// in the batch reading the same pre-render value.
const preferredRef = useRef(preferredWidths);
const setPreferred = useCallback((side: PanelSide, width: number) => {
const next = Math.max(0, Math.round(width));
preferredRef.current = { ...preferredRef.current, [side]: next };
setPreferredWidths(preferredRef.current);
return next;
}, []);
/** Transient: moves the panel without touching the stored preference. */
const updatePanelWidth = useCallback(
(side: PanelSide, width: number) => {
setPreferred(side, width);
},
[setPreferred],
);
/**
* Durable: only an explicit drag or keyboard nudge writes a preference. A
* width the window forced on us is never persisted, so the user's real
* preference survives a temporary squeeze and returns when the window grows.
*/
const commitPanelWidth = useCallback(
(side: PanelSide, width: number) => {
// Persist what the window will actually allow, not a raw pointer delta.
const candidate = { ...preferredRef.current, [side]: Math.max(0, Math.round(width)) };
const settled = fitPanelWidths(readViewportWidth(), candidate)[side];
setPreferred(side, settled);
writeStudioUiPreferences(side === "left" ? { leftWidth: settled } : { rightWidth: settled });
},
[setPreferred],
);
const adjustPanelWidth = useCallback(
(side: PanelSide, delta: number) => {
commitPanelWidth(side, preferredRef.current[side] + delta);
},
[commitPanelWidth],
);
// The toggle acts on what the user can SEE, not on stored intent. Toggling
// stored intent instead made the rail's "Show sidebar" button dead in the
// auto-collapsed state: intent was already false, so the click flipped it to
// true (persisting a collapse the user never asked for) while the rail stayed
// railed and nothing visibly happened.
const effectiveLeftCollapsedRef = useRef(false);
effectiveLeftCollapsedRef.current = leftCollapsed || leftCollapsedByWidth;
const toggleLeftSidebar = useCallback(() => {
const next = !effectiveLeftCollapsedRef.current;
setLeftCollapsed(next);
writeStudioUiPreferences({ leftCollapsed: next });
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: next });
if (!next) setAutoCollapseOverride((prev) => ({ ...prev, left: true }));
}, []);
const setRightCollapsedWithOverride = useCallback((collapsed: boolean) => {
setRightCollapsed(collapsed);
if (!collapsed) setAutoCollapseOverride((prev) => ({ ...prev, right: true }));
}, []);
const handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
panelDragRef.current = {
side,
startX: e.clientX,
startW: fittedRef.current[side],
};
}, []);
const handlePanelResizeMove = useCallback(
(e: React.PointerEvent) => {
const drag = panelDragRef.current;
if (!drag) return;
const delta = e.clientX - drag.startX;
updatePanelWidth(drag.side, drag.startW + (drag.side === "left" ? delta : -delta));
},
[updatePanelWidth],
);
const handlePanelResizeEnd = useCallback(() => {
const side = panelDragRef.current?.side;
if (side) commitPanelWidth(side, preferredRef.current[side]);
panelDragRef.current = null;
}, [commitPanelWidth]);
const trackedSetRightPanelTab = useCallback(
(tab: RightPanelTab) => {
if (tab === "design" || tab === "layers") {
// Flat inspector: Layers always renders full-height by itself (see
// StudioRightPanel's render gate), so this MUST land on the same
// radio-style exclusivity setExclusiveRightInspectorPane enforces for
// the direct in-panel tab click — every OTHER path that reaches here
// (element select, closing block-params, the header Inspector
// button, and this function's own callers outside an active
// inspector tab) would otherwise additively leave both panes `true`
// and reproduce the "both tabs highlight, only one renders" bug this
// still-additive branch used to cause under the flat flag.
setRightInspectorPanes(
STUDIO_FLAT_INSPECTOR_ENABLED
? { design: tab === "design", layers: tab === "layers" }
: (panes) => ({ ...panes, [tab]: true }),
);
}
setRightPanelTab(tab);
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
},
[setRightPanelTab],
);
const toggleRightInspectorPane = useCallback((pane: RightInspectorPane) => {
setRightInspectorPanes((panes) => {
const next = { ...panes, [pane]: !panes[pane] };
if (!next.design && !next.layers) return panes;
return next;
});
}, []);
// Radio-style variant for the flat inspector: Layers always renders full-
// height by itself there (never split-shared with Design), so leaving both
// panes independently toggleable would highlight both tabs as "active"
// while only one actually shows. Selecting one turns the other off.
const setExclusiveRightInspectorPane = useCallback((pane: RightInspectorPane) => {
setRightInspectorPanes({ design: pane === "design", layers: pane === "layers" });
}, []);
return {
leftWidth: fitted.left,
rightWidth: fitted.right,
adjustPanelWidth,
/**
* User intent. Persisted to localStorage; never written by auto-collapse.
* Deliberately read-only outside this hook: `toggleLeftSidebar` is the only
* writer, so it cannot be flipped without also clearing the rail override
* (which would open the sidebar and then immediately rail it again).
*/
leftCollapsed,
/** User intent. Synced into the shareable URL; never written by auto-collapse. */
rightCollapsed,
setRightCollapsed: setRightCollapsedWithOverride,
/** What the shell actually renders: intent OR the window forcing a rail. */
effectiveLeftCollapsed: leftCollapsed || leftCollapsedByWidth,
effectiveRightCollapsed: rightCollapsed || rightCollapsedByWidth,
rightPanelTab,
setRightPanelTab: trackedSetRightPanelTab,
rightInspectorPanes,
toggleRightInspectorPane,
setExclusiveRightInspectorPane,
toggleLeftSidebar,
handlePanelResizeStart,
handlePanelResizeMove,
handlePanelResizeEnd,
};
}