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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user