mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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
This commit is contained in:
@@ -399,7 +399,7 @@ export function StudioApp() {
|
||||
} = useInspectorState(
|
||||
panelLayout.rightPanelTab,
|
||||
panelLayout.rightInspectorPanes,
|
||||
panelLayout.rightCollapsed,
|
||||
panelLayout.effectiveRightCollapsed,
|
||||
isPlaying,
|
||||
domEditSession.domEditSelection,
|
||||
gestureState === "recording",
|
||||
@@ -512,7 +512,7 @@ export function StudioApp() {
|
||||
/>
|
||||
}
|
||||
right={
|
||||
panelLayout.rightCollapsed ? null : (
|
||||
panelLayout.effectiveRightCollapsed ? null : (
|
||||
<StudioRightPanel
|
||||
designPanelActive={designPanelActive}
|
||||
activeBlockParams={activeBlockParams}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldOpenInspector } from "./StudioHeader";
|
||||
|
||||
describe("shouldOpenInspector", () => {
|
||||
it("opens when the panel is hidden", () => {
|
||||
expect(shouldOpenInspector(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("opens when a non-inspector tab is showing", () => {
|
||||
expect(shouldOpenInspector(false, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("closes when the inspector is genuinely on screen", () => {
|
||||
expect(shouldOpenInspector(false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("opens when the window railed the panel away", () => {
|
||||
// The regression this guards: the button used to branch on the raw
|
||||
// rightCollapsed intent, which is still `false` while the window has the
|
||||
// panel railed. That took the close branch, wrote rightCollapsed=true, and
|
||||
// since that value is synced into the shareable Studio URL, a click that
|
||||
// did nothing visible rewrote the link.
|
||||
const userIntentIsOpen = false;
|
||||
const windowRailedItAway = true;
|
||||
expect(shouldOpenInspector(windowRailedItAway, true)).toBe(true);
|
||||
expect(shouldOpenInspector(userIntentIsOpen, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -196,6 +196,21 @@ export function ViewModeToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the header's Inspector button open the panel, or close it?
|
||||
*
|
||||
* Takes the EFFECTIVE collapse state, so a panel the window has railed away
|
||||
* counts as closed even though the user's stored intent still says open. The
|
||||
* argument name is the guard: passing raw intent here is the bug this exists
|
||||
* to keep out.
|
||||
*/
|
||||
export function shouldOpenInspector(
|
||||
effectiveRightCollapsed: boolean,
|
||||
inspectorPanelActive: boolean,
|
||||
): boolean {
|
||||
return effectiveRightCollapsed || !inspectorPanelActive;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioHeader({
|
||||
captureFrameHref,
|
||||
@@ -208,7 +223,11 @@ export function StudioHeader({
|
||||
onExport,
|
||||
}: StudioHeaderProps) {
|
||||
const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
|
||||
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
|
||||
// effectiveRightCollapsed, not the raw intent: in the auto-railed state the
|
||||
// intent is still "open" while the panel is hidden, so branching on intent
|
||||
// made this button write rightCollapsed=true — and that value is synced into
|
||||
// the shareable Studio URL, so a dead click would rewrite a link.
|
||||
const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
|
||||
const isRendering = renderQueue.isRendering;
|
||||
|
||||
return (
|
||||
@@ -328,7 +347,7 @@ export function StudioHeader({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (rightCollapsed || !inspectorPanelActive) {
|
||||
if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
|
||||
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
||||
setRightPanelTab("design");
|
||||
setRightCollapsed(false);
|
||||
|
||||
@@ -36,7 +36,7 @@ export function StudioLeftSidebar({
|
||||
onAddCompositionToTimeline,
|
||||
}: StudioLeftSidebarProps) {
|
||||
const {
|
||||
leftCollapsed,
|
||||
effectiveLeftCollapsed,
|
||||
leftWidth,
|
||||
adjustPanelWidth,
|
||||
toggleLeftSidebar,
|
||||
@@ -71,7 +71,7 @@ export function StudioLeftSidebar({
|
||||
[renderQueue, waitForPendingDomEditSaves],
|
||||
);
|
||||
|
||||
if (leftCollapsed) {
|
||||
if (effectiveLeftCollapsed) {
|
||||
return (
|
||||
<div className="mr-0.5 flex w-10 flex-shrink-0 flex-col items-center rounded-lg border border-neutral-800/50 bg-neutral-950 pt-1">
|
||||
<button
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useTimelinePlayer, usePlayerStore } from "../../player";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
||||
import { useCompositionStack } from "./useCompositionStack";
|
||||
import { MIN_TIMELINE_H, MIN_PREVIEW_H } from "./TimelineResizeDivider";
|
||||
import { MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
|
||||
import { setCompositionSourceMap } from "../editor/domEditingDom";
|
||||
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
@@ -273,13 +273,22 @@ export function NLEProvider({
|
||||
}, []);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// A height persisted on a tall window can exceed this window's container and
|
||||
// collapse the flex-1 preview to 0px — clamp once the container is measurable
|
||||
// (the drag/keyboard paths already clamp; the restore path must too).
|
||||
// collapse the flex-1 preview to 0px. Observing the container rather than
|
||||
// clamping once at mount is what makes a window RESIZED after load behave the
|
||||
// same as one loaded at that size: dragging 760 -> 520 tall used to leave the
|
||||
// timeline at its stored 429px and the preview at 47px.
|
||||
useEffect(() => {
|
||||
const containerH = containerRef.current?.getBoundingClientRect().height;
|
||||
if (!containerH) return;
|
||||
const max = containerH - MIN_PREVIEW_H;
|
||||
setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev));
|
||||
const element = containerRef.current;
|
||||
if (!element || typeof ResizeObserver === "undefined") return;
|
||||
const reconcile = () => {
|
||||
const containerH = element.getBoundingClientRect().height;
|
||||
if (!containerH) return;
|
||||
setTimelineH((prev) => fitTimelineHeight(containerH, prev));
|
||||
};
|
||||
reconcile();
|
||||
const observer = new ResizeObserver(reconcile);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const hasLoadedOnceRef = useRef(false);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
export const MIN_TIMELINE_H = 100;
|
||||
export const MIN_PREVIEW_H = 120;
|
||||
import { MIN_PREVIEW_H, MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
|
||||
|
||||
/**
|
||||
* Horizontal drag/keyboard-resizable divider between the preview and the
|
||||
@@ -41,12 +39,7 @@ export function TimelineResizeDivider({
|
||||
if (!isDragging.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const containerH = rect.height;
|
||||
const newTimelineH = Math.max(
|
||||
MIN_TIMELINE_H,
|
||||
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
||||
);
|
||||
setTimelineH(newTimelineH);
|
||||
setTimelineH(fitTimelineHeight(rect.height, rect.height - mouseY));
|
||||
},
|
||||
[disabled, containerRef, setTimelineH],
|
||||
);
|
||||
@@ -61,10 +54,10 @@ export function TimelineResizeDivider({
|
||||
if (disabled) return;
|
||||
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
||||
e.preventDefault();
|
||||
const containerH = containerRef.current?.getBoundingClientRect().height ?? Infinity;
|
||||
const containerH = containerRef.current?.getBoundingClientRect().height ?? 0;
|
||||
const delta = e.key === "ArrowUp" ? 16 : -16;
|
||||
setTimelineH((prev) => {
|
||||
const next = Math.max(MIN_TIMELINE_H, Math.min(containerH - MIN_PREVIEW_H, prev + delta));
|
||||
const next = fitTimelineHeight(containerH, prev + delta);
|
||||
persistTimelineH(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -17,9 +17,10 @@ export function PanelLayoutProvider({
|
||||
rightWidth,
|
||||
adjustPanelWidth,
|
||||
leftCollapsed,
|
||||
setLeftCollapsed,
|
||||
rightCollapsed,
|
||||
setRightCollapsed,
|
||||
effectiveLeftCollapsed,
|
||||
effectiveRightCollapsed,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
rightInspectorPanes,
|
||||
@@ -41,9 +42,10 @@ export function PanelLayoutProvider({
|
||||
rightWidth,
|
||||
adjustPanelWidth,
|
||||
leftCollapsed,
|
||||
setLeftCollapsed,
|
||||
rightCollapsed,
|
||||
setRightCollapsed,
|
||||
effectiveLeftCollapsed,
|
||||
effectiveRightCollapsed,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
rightInspectorPanes,
|
||||
@@ -59,9 +61,10 @@ export function PanelLayoutProvider({
|
||||
rightWidth,
|
||||
adjustPanelWidth,
|
||||
leftCollapsed,
|
||||
setLeftCollapsed,
|
||||
rightCollapsed,
|
||||
setRightCollapsed,
|
||||
effectiveLeftCollapsed,
|
||||
effectiveRightCollapsed,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
rightInspectorPanes,
|
||||
|
||||
@@ -60,6 +60,11 @@ function renderPanelLayout() {
|
||||
return renderPanelLayoutWith(usePanelLayout);
|
||||
}
|
||||
|
||||
function resizeWindowTo(width: number) {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}
|
||||
|
||||
describe("usePanelLayout — right inspector panes", () => {
|
||||
it("opens Design with the intended viewport-scaled panel widths", () => {
|
||||
const harness = renderPanelLayout();
|
||||
@@ -160,6 +165,118 @@ describe("usePanelLayout — right inspector panes", () => {
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("caps a panel relative to the window instead of at a flat 600px", () => {
|
||||
resizeWindowTo(700);
|
||||
const harness = renderPanelLayout();
|
||||
// The old flat cap let the inspector claim 600 of a 700px window.
|
||||
expect(harness.getState().rightWidth).toBeLessThanOrEqual(280);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("rails both panels once the window cannot fit them", () => {
|
||||
resizeWindowTo(560);
|
||||
const harness = renderPanelLayout();
|
||||
expect(harness.getState()).toMatchObject({
|
||||
effectiveLeftCollapsed: true,
|
||||
effectiveRightCollapsed: true,
|
||||
leftCollapsed: false,
|
||||
rightCollapsed: false,
|
||||
});
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("auto-collapse never writes the user's persisted or URL-synced intent", () => {
|
||||
const harness = renderPanelLayout();
|
||||
act(() => resizeWindowTo(560));
|
||||
|
||||
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
|
||||
// localStorage carries leftCollapsed; the shareable URL carries rightCollapsed.
|
||||
// A ten-second window drag must rewrite neither.
|
||||
expect(readStudioUiPreferences().leftCollapsed).toBeUndefined();
|
||||
expect(harness.getState().leftCollapsed).toBe(false);
|
||||
expect(harness.getState().rightCollapsed).toBe(false);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("returns the user's own width when the window grows back", () => {
|
||||
const harness = renderPanelLayout();
|
||||
const wide = harness.getState().leftWidth;
|
||||
|
||||
act(() => resizeWindowTo(560));
|
||||
expect(harness.getState().leftWidth).toBeLessThan(wide);
|
||||
|
||||
act(() => resizeWindowTo(1496));
|
||||
expect(harness.getState().leftWidth).toBe(wide);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("keeps an explicitly collapsed sidebar collapsed after a narrow trip", () => {
|
||||
const harness = renderPanelLayout();
|
||||
act(() => harness.getState().toggleLeftSidebar());
|
||||
expect(readStudioUiPreferences().leftCollapsed).toBe(true);
|
||||
|
||||
act(() => resizeWindowTo(560));
|
||||
act(() => resizeWindowTo(1496));
|
||||
|
||||
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("lets the user reopen a panel the window auto-collapsed", () => {
|
||||
const harness = renderPanelLayout();
|
||||
act(() => resizeWindowTo(560));
|
||||
expect(harness.getState().effectiveRightCollapsed).toBe(true);
|
||||
|
||||
// Without this the header Inspector button would be dead below 700px.
|
||||
act(() => harness.getState().setRightCollapsed(false));
|
||||
expect(harness.getState().effectiveRightCollapsed).toBe(false);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("opens the sidebar when the rail's own button is clicked", () => {
|
||||
const harness = renderPanelLayout();
|
||||
act(() => resizeWindowTo(560));
|
||||
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
|
||||
|
||||
// Regression: the toggle used to flip stored INTENT, which was already
|
||||
// false here, so the click persisted leftCollapsed=true and the rail stayed
|
||||
// railed — a dead button that silently saved a collapse nobody asked for.
|
||||
act(() => harness.getState().toggleLeftSidebar());
|
||||
|
||||
expect(harness.getState().effectiveLeftCollapsed).toBe(false);
|
||||
expect(harness.getState().leftCollapsed).toBe(false);
|
||||
expect(readStudioUiPreferences().leftCollapsed).toBe(false);
|
||||
// And it gets a real width: rendering an expanded sidebar at the 42px rail
|
||||
// width would squash its own content. Only a real-UI click caught this.
|
||||
expect(harness.getState().leftWidth).toBeGreaterThanOrEqual(200);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("closes the sidebar again on the next click", () => {
|
||||
const harness = renderPanelLayout();
|
||||
act(() => resizeWindowTo(560));
|
||||
act(() => harness.getState().toggleLeftSidebar());
|
||||
act(() => harness.getState().toggleLeftSidebar());
|
||||
|
||||
expect(harness.getState().effectiveLeftCollapsed).toBe(true);
|
||||
expect(readStudioUiPreferences().leftCollapsed).toBe(true);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("forgets that reopen once the window is wide again", () => {
|
||||
const harness = renderPanelLayout();
|
||||
act(() => resizeWindowTo(560));
|
||||
act(() => harness.getState().setRightCollapsed(false));
|
||||
expect(harness.getState().effectiveRightCollapsed).toBe(false);
|
||||
|
||||
// Widening past the threshold clears the override, so a later narrow trip
|
||||
// rails again rather than staying open forever off one old click.
|
||||
act(() => resizeWindowTo(1496));
|
||||
act(() => resizeWindowTo(560));
|
||||
expect(harness.getState().effectiveRightCollapsed).toBe(true);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("setRightPanelTab is flat-aware: exclusivity holds for callers other than a direct in-panel tab click", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("../components/editor/manualEditingAvailability", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type {
|
||||
RightInspectorPane,
|
||||
RightInspectorPanes,
|
||||
@@ -7,6 +7,14 @@ import type {
|
||||
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;
|
||||
@@ -20,30 +28,27 @@ function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspect
|
||||
return { layers: false, design: true };
|
||||
}
|
||||
|
||||
function getInitialPanelWidths(): { left: number; right: number } {
|
||||
const viewportWidth = typeof window === "undefined" ? 1496 : window.innerWidth;
|
||||
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 leftDefault = Math.max(240, Math.min(384, Math.round(viewportWidth * 0.257)));
|
||||
const rightDefault = Math.max(320, Math.min(424, Math.round(viewportWidth * 0.284)));
|
||||
const defaults = defaultPanelWidths(readViewportWidth());
|
||||
return {
|
||||
left: Math.max(
|
||||
160,
|
||||
Math.min(Math.floor(viewportWidth * 0.5), preferences.leftWidth ?? leftDefault),
|
||||
),
|
||||
right: Math.max(160, Math.min(600, preferences.rightWidth ?? rightDefault)),
|
||||
left: preferences.leftWidth ?? defaults.left,
|
||||
right: preferences.rightWidth ?? defaults.right,
|
||||
};
|
||||
}
|
||||
|
||||
function clampPanelWidth(side: PanelSide, width: number): number {
|
||||
const max = side === "left" ? Math.floor(window.innerWidth * 0.5) : 600;
|
||||
return Math.max(160, Math.min(max, width));
|
||||
}
|
||||
|
||||
export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
const [initialPanelWidths] = useState(getInitialPanelWidths);
|
||||
const [leftWidth, setLeftWidth] = useState(initialPanelWidths.left);
|
||||
const [rightWidth, setRightWidth] = useState(initialPanelWidths.right);
|
||||
const panelWidthsRef = useRef(initialPanelWidths);
|
||||
const [preferredWidths, setPreferredWidths] = useState(getPreferredPanelWidths);
|
||||
const [viewportWidth, setViewportWidth] = useState(readViewportWidth);
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(
|
||||
() => readStudioUiPreferences().leftCollapsed ?? false,
|
||||
);
|
||||
@@ -54,41 +59,109 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
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);
|
||||
|
||||
const updatePanelWidth = useCallback((side: PanelSide, width: number) => {
|
||||
const next = clampPanelWidth(side, width);
|
||||
panelWidthsRef.current[side] = next;
|
||||
if (side === "left") setLeftWidth(next);
|
||||
else setRightWidth(next);
|
||||
// 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) => {
|
||||
const next = updatePanelWidth(side, Math.round(width));
|
||||
writeStudioUiPreferences(side === "left" ? { leftWidth: next } : { rightWidth: next });
|
||||
// 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 });
|
||||
},
|
||||
[updatePanelWidth],
|
||||
[setPreferred],
|
||||
);
|
||||
|
||||
const adjustPanelWidth = useCallback(
|
||||
(side: PanelSide, delta: number) => {
|
||||
commitPanelWidth(side, panelWidthsRef.current[side] + delta);
|
||||
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(() => {
|
||||
setLeftCollapsed((collapsed) => {
|
||||
writeStudioUiPreferences({ leftCollapsed: !collapsed });
|
||||
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: !collapsed });
|
||||
return !collapsed;
|
||||
});
|
||||
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) => {
|
||||
@@ -97,7 +170,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
panelDragRef.current = {
|
||||
side,
|
||||
startX: e.clientX,
|
||||
startW: panelWidthsRef.current[side],
|
||||
startW: fittedRef.current[side],
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -113,7 +186,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
|
||||
const handlePanelResizeEnd = useCallback(() => {
|
||||
const side = panelDragRef.current?.side;
|
||||
if (side) commitPanelWidth(side, panelWidthsRef.current[side]);
|
||||
if (side) commitPanelWidth(side, preferredRef.current[side]);
|
||||
panelDragRef.current = null;
|
||||
}, [commitPanelWidth]);
|
||||
|
||||
@@ -158,13 +231,22 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
}, []);
|
||||
|
||||
return {
|
||||
leftWidth,
|
||||
rightWidth,
|
||||
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,
|
||||
setLeftCollapsed,
|
||||
/** User intent. Synced into the shareable URL; never written by auto-collapse. */
|
||||
rightCollapsed,
|
||||
setRightCollapsed,
|
||||
setRightCollapsed: setRightCollapsedWithOverride,
|
||||
/** What the shell actually renders: intent OR the window forcing a rail. */
|
||||
effectiveLeftCollapsed: leftCollapsed || leftCollapsedByWidth,
|
||||
effectiveRightCollapsed: rightCollapsed || rightCollapsedByWidth,
|
||||
rightPanelTab,
|
||||
setRightPanelTab: trackedSetRightPanelTab,
|
||||
rightInspectorPanes,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MIN_PREVIEW_H,
|
||||
MIN_PREVIEW_W,
|
||||
MIN_TIMELINE_H,
|
||||
RAIL_W,
|
||||
defaultPanelWidths,
|
||||
fitPanelWidths,
|
||||
fitTimelineHeight,
|
||||
} from "./fitPanels";
|
||||
|
||||
function fitAt(viewportWidth: number) {
|
||||
return fitPanelWidths(viewportWidth, defaultPanelWidths(viewportWidth));
|
||||
}
|
||||
|
||||
describe("defaultPanelWidths", () => {
|
||||
// Frozen literals, deliberately NOT compared against the live hook: the hook
|
||||
// now delegates here, so asserting against it would be circular and would
|
||||
// silently stop guarding the "wide windows are untouched" promise.
|
||||
it.each([
|
||||
[1680, 384, 424],
|
||||
[1512, 384, 424],
|
||||
[1280, 329, 364],
|
||||
])("keeps %ipx identical to the pre-change defaults", (vw, left, right) => {
|
||||
expect(defaultPanelWidths(vw)).toEqual({ left, right });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitPanelWidths", () => {
|
||||
it.each([1680, 1512, 1280])("leaves %ipx untouched by reconciliation", (vw) => {
|
||||
const preferred = defaultPanelWidths(vw);
|
||||
const fitted = fitPanelWidths(vw, preferred);
|
||||
expect(fitted.left).toBe(preferred.left);
|
||||
expect(fitted.right).toBe(preferred.right);
|
||||
expect(fitted.autoCollapseLeft).toBe(false);
|
||||
expect(fitted.autoCollapseRight).toBe(false);
|
||||
});
|
||||
|
||||
it.each([1680, 1512, 1280, 1100, 960, 860, 760, 640, 560, 480])(
|
||||
"never starves the preview at %ipx",
|
||||
(vw) => {
|
||||
expect(fitAt(vw).preview).toBeGreaterThanOrEqual(MIN_PREVIEW_W);
|
||||
},
|
||||
);
|
||||
|
||||
it("matches the projected preview widths from the plan", () => {
|
||||
const projected: Array<[number, number]> = [
|
||||
[1680, 866],
|
||||
[1512, 698],
|
||||
[1280, 581],
|
||||
[1100, 499],
|
||||
[960, 427],
|
||||
[860, 360],
|
||||
[760, 432],
|
||||
[640, 592],
|
||||
[560, 512],
|
||||
[480, 432],
|
||||
];
|
||||
for (const [vw, preview] of projected) {
|
||||
expect({ vw, preview: fitAt(vw).preview }).toEqual({ vw, preview });
|
||||
}
|
||||
});
|
||||
|
||||
it("fits everything at its minimum at exactly 860, the derived threshold", () => {
|
||||
const fitted = fitAt(860);
|
||||
expect(fitted).toMatchObject({
|
||||
left: 214,
|
||||
right: 280,
|
||||
preview: MIN_PREVIEW_W,
|
||||
autoCollapseLeft: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rails the sidebar one pixel below the threshold", () => {
|
||||
expect(fitAt(860).autoCollapseLeft).toBe(false);
|
||||
expect(fitAt(859).autoCollapseLeft).toBe(true);
|
||||
expect(fitAt(859).left).toBe(RAIL_W);
|
||||
});
|
||||
|
||||
it("hides the inspector one pixel below its own threshold", () => {
|
||||
expect(fitAt(700).autoCollapseRight).toBe(false);
|
||||
expect(fitAt(700).right).toBeGreaterThan(0);
|
||||
expect(fitAt(699).autoCollapseRight).toBe(true);
|
||||
expect(fitAt(699).right).toBe(0);
|
||||
});
|
||||
|
||||
it("never drives an EXPANDED sidebar below its usable minimum", () => {
|
||||
// Guards the leftFloor/rightFloor split: the squeeze fallback may only reach
|
||||
// the rail width when the panel is actually railed.
|
||||
const fitted = fitPanelWidths(900, { left: 600, right: 600 });
|
||||
expect(fitted.left).toBeGreaterThanOrEqual(200);
|
||||
expect(fitted.right).toBeGreaterThanOrEqual(280);
|
||||
});
|
||||
|
||||
it("caps a single panel at 40% of the window", () => {
|
||||
expect(fitPanelWidths(1600, { left: 900, right: 280 }).left).toBe(640);
|
||||
});
|
||||
|
||||
it("lets the preview floor win when the cap alone is not enough", () => {
|
||||
// 40% of 1000 is 400, but 400 + 280 + 6 leaves the preview at 314.
|
||||
const fitted = fitPanelWidths(1000, { left: 900, right: 280 });
|
||||
expect(fitted.left).toBe(354);
|
||||
expect(fitted.preview).toBe(MIN_PREVIEW_W);
|
||||
});
|
||||
|
||||
it("survives a viewport narrower than the preview floor alone", () => {
|
||||
const fitted = fitAt(300);
|
||||
expect(fitted.preview).toBeGreaterThanOrEqual(0);
|
||||
expect(Number.isNaN(fitted.preview)).toBe(false);
|
||||
expect(fitted.left).toBe(RAIL_W);
|
||||
});
|
||||
|
||||
it("returns preferences untouched before the shell is measured", () => {
|
||||
const preferred = { left: 320, right: 400 };
|
||||
expect(fitPanelWidths(0, preferred)).toMatchObject({ ...preferred, preview: 0 });
|
||||
expect(fitPanelWidths(Number.NaN, preferred)).toMatchObject(preferred);
|
||||
});
|
||||
|
||||
it("is idempotent, so a resize loop cannot oscillate", () => {
|
||||
for (const vw of [1512, 1100, 860, 760, 560]) {
|
||||
const once = fitPanelWidths(vw, defaultPanelWidths(vw));
|
||||
const twice = fitPanelWidths(vw, once);
|
||||
expect(twice).toEqual(once);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitTimelineHeight", () => {
|
||||
it("keeps a height that fits", () => {
|
||||
expect(fitTimelineHeight(717, 429)).toBe(429);
|
||||
});
|
||||
|
||||
it("reclaims height for the preview when the shell shrinks", () => {
|
||||
// The measured bug: mounted at 760 tall, dragged to 520. Preview was 47px.
|
||||
expect(fitTimelineHeight(477, 429)).toBe(477 - MIN_PREVIEW_H);
|
||||
expect(477 - fitTimelineHeight(477, 429)).toBeGreaterThanOrEqual(MIN_PREVIEW_H);
|
||||
});
|
||||
|
||||
it("raises a too-small preference to the timeline minimum", () => {
|
||||
expect(fitTimelineHeight(717, 10)).toBe(MIN_TIMELINE_H);
|
||||
});
|
||||
|
||||
it("keeps the timeline usable when the shell is shorter than both minimums", () => {
|
||||
expect(fitTimelineHeight(120, 429)).toBe(MIN_TIMELINE_H);
|
||||
});
|
||||
|
||||
it("returns the preference before the shell is measured", () => {
|
||||
expect(fitTimelineHeight(0, 429)).toBe(429);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = fitTimelineHeight(477, 429);
|
||||
expect(fitTimelineHeight(477, once)).toBe(once);
|
||||
});
|
||||
|
||||
it("would return the preferred height if callers kept one", () => {
|
||||
// The helper is preference-preserving; the vertical CALLER still stores the
|
||||
// clamped value, so a shrink-then-grow does not restore the old height the
|
||||
// way panel widths do. Tracked as a known asymmetry, not fixed here.
|
||||
const preferred = 429;
|
||||
expect(fitTimelineHeight(477, preferred)).toBe(277);
|
||||
expect(fitTimelineHeight(717, preferred)).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Panel reconciliation for the Studio shell.
|
||||
*
|
||||
* The shell is `[sidebar | preview | inspector]` over a full-width timeline.
|
||||
* Only the preview is flexible (`flex-1 min-w-0`), so without an explicit floor
|
||||
* it absorbs every squeeze: at a 560px window the old 240/320 panel floors alone
|
||||
* overflowed the viewport and the preview rendered at 2px, on a *fresh* load.
|
||||
* The same held vertically once the window was resized after mount.
|
||||
*
|
||||
* Every caller (mount, resize, drag, keyboard nudge, preference restore) routes
|
||||
* through {@link fitPanelWidths} / {@link fitTimelineHeight} so exactly one place
|
||||
* decides who yields. The rule is: the preview gets its floor first, panels
|
||||
* shrink toward their minimums, then collapse to rails.
|
||||
*/
|
||||
|
||||
/** Smallest preview box worth editing in. Chosen, not derived — see the plan. */
|
||||
export const MIN_PREVIEW_W = 360;
|
||||
export const MIN_PREVIEW_H = 200;
|
||||
|
||||
const MIN_LEFT = 200;
|
||||
const MIN_RIGHT = 280;
|
||||
/**
|
||||
* Collapsed sidebar rail, as a layout FOOTPRINT: the `w-10` box (40) plus the
|
||||
* `mr-0.5` gap (2) it contributes to the flex row. Measured in the browser at a
|
||||
* 760px window: rail box 40, margin-right 2, preview starts at x=43.
|
||||
*/
|
||||
export const RAIL_W = 42;
|
||||
export const MIN_TIMELINE_H = 100;
|
||||
|
||||
/** The two 3px resize seams between the three top-row panels. */
|
||||
const PANEL_SEAM_W = 6;
|
||||
|
||||
/** No single panel may claim more than this share of the window. */
|
||||
const PANEL_MAX_RATIO = 0.4;
|
||||
|
||||
/*
|
||||
* Thresholds below are DERIVED from the minimums above, not chosen:
|
||||
*
|
||||
* MIN_LEFT 200 + MIN_RIGHT 280 + MIN_PREVIEW_W 360 + PANEL_SEAM_W 6 = 846
|
||||
* -> below ~860 the sidebar can no longer fit at its minimum.
|
||||
* RAIL_W 40 + MIN_RIGHT 280 + MIN_PREVIEW_W 360 + PANEL_SEAM_W 6 = 686
|
||||
* -> below ~700 the inspector can no longer fit either.
|
||||
*
|
||||
* Change a minimum and move the matching threshold with it.
|
||||
*/
|
||||
const RAIL_LEFT_BELOW = 860;
|
||||
const RAIL_RIGHT_BELOW = 700;
|
||||
|
||||
/**
|
||||
* True when the window is narrow enough to force at least one panel to a rail.
|
||||
* The sidebar threshold is the wider of the two, so it is the whole condition.
|
||||
*/
|
||||
export function railsEngaged(viewportWidth: number): boolean {
|
||||
return viewportWidth < RAIL_LEFT_BELOW;
|
||||
}
|
||||
|
||||
export interface PanelWidths {
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
export interface FittedPanelWidths extends PanelWidths {
|
||||
preview: number;
|
||||
/**
|
||||
* Width-driven collapse. Derived per render; NEVER persisted and never written
|
||||
* back to the user's own collapse preference — `leftCollapsed` lives in
|
||||
* localStorage and `rightCollapsed` is synced into the shareable Studio URL,
|
||||
* so a ten-second window drag must not rewrite either.
|
||||
*/
|
||||
autoCollapseLeft: boolean;
|
||||
autoCollapseRight: boolean;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* The width each panel wants on a given viewport, before reconciliation.
|
||||
* Upper bounds (384 / 424) are unchanged from the original implementation, which
|
||||
* is what keeps windows at or above 1280px byte-identical to before.
|
||||
*/
|
||||
export function defaultPanelWidths(viewportWidth: number): PanelWidths {
|
||||
return {
|
||||
left: Math.max(MIN_LEFT, Math.min(384, Math.round(viewportWidth * 0.257))),
|
||||
right: Math.max(MIN_RIGHT, Math.min(424, Math.round(viewportWidth * 0.284))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve preferred panel widths against the viewport, reserving the preview.
|
||||
*
|
||||
* Order of yielding: window-relative cap, then the inspector shrinks toward its
|
||||
* minimum, then the sidebar, then rails. A viewport of 0 (element not measured
|
||||
* yet) returns the preferences untouched so a pre-measurement frame cannot
|
||||
* clobber a real preference.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function fitPanelWidths(
|
||||
viewportWidth: number,
|
||||
preferred: PanelWidths,
|
||||
/**
|
||||
* Sides the user explicitly reopened while the window was narrow. A side
|
||||
* listed here keeps its real width instead of the rail: without this the
|
||||
* panel would render expanded at 42px and squash its own content.
|
||||
*/
|
||||
reopened: { left: boolean; right: boolean } = { left: false, right: false },
|
||||
): FittedPanelWidths {
|
||||
const vw = Number.isFinite(viewportWidth) ? Math.max(0, viewportWidth) : 0;
|
||||
if (vw <= 0) {
|
||||
return { ...preferred, preview: 0, autoCollapseLeft: false, autoCollapseRight: false };
|
||||
}
|
||||
|
||||
const autoCollapseLeft = vw < RAIL_LEFT_BELOW && !reopened.left;
|
||||
const autoCollapseRight = vw < RAIL_RIGHT_BELOW && !reopened.right;
|
||||
const cap = Math.floor(vw * PANEL_MAX_RATIO);
|
||||
|
||||
// A railed panel's floor is its rail width; an open panel's floor is its
|
||||
// minimum usable width. Without this split the squeeze fallback below would
|
||||
// drive an *expanded* sidebar down to 40px and render its content unusable.
|
||||
const leftFloor = autoCollapseLeft ? RAIL_W : MIN_LEFT;
|
||||
const rightFloor = autoCollapseRight ? 0 : MIN_RIGHT;
|
||||
|
||||
let left = autoCollapseLeft ? RAIL_W : clamp(preferred.left, MIN_LEFT, Math.max(MIN_LEFT, cap));
|
||||
let right = autoCollapseRight ? 0 : clamp(preferred.right, MIN_RIGHT, Math.max(MIN_RIGHT, cap));
|
||||
|
||||
const budget = vw - MIN_PREVIEW_W - PANEL_SEAM_W;
|
||||
if (left + right > budget) right = Math.max(rightFloor, budget - left);
|
||||
if (left + right > budget) left = Math.max(leftFloor, budget - right);
|
||||
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
preview: Math.max(0, vw - left - right - PANEL_SEAM_W),
|
||||
autoCollapseLeft,
|
||||
autoCollapseRight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a preferred timeline height against the shell's measured height.
|
||||
*
|
||||
* Subsumes the clamping that used to be inlined in TimelineResizeDivider's
|
||||
* pointer and keyboard handlers and duplicated (mount-only) in NLEContext.
|
||||
*/
|
||||
export function fitTimelineHeight(containerHeight: number, preferred: number): number {
|
||||
const h = Number.isFinite(containerHeight) ? containerHeight : 0;
|
||||
const wanted = Number.isFinite(preferred) ? preferred : MIN_TIMELINE_H;
|
||||
if (h <= 0) return Math.max(MIN_TIMELINE_H, wanted);
|
||||
return clamp(wanted, MIN_TIMELINE_H, Math.max(MIN_TIMELINE_H, h - MIN_PREVIEW_H));
|
||||
}
|
||||
Reference in New Issue
Block a user