mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +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(
|
} = useInspectorState(
|
||||||
panelLayout.rightPanelTab,
|
panelLayout.rightPanelTab,
|
||||||
panelLayout.rightInspectorPanes,
|
panelLayout.rightInspectorPanes,
|
||||||
panelLayout.rightCollapsed,
|
panelLayout.effectiveRightCollapsed,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
domEditSession.domEditSelection,
|
domEditSession.domEditSelection,
|
||||||
gestureState === "recording",
|
gestureState === "recording",
|
||||||
@@ -512,7 +512,7 @@ export function StudioApp() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
panelLayout.rightCollapsed ? null : (
|
panelLayout.effectiveRightCollapsed ? null : (
|
||||||
<StudioRightPanel
|
<StudioRightPanel
|
||||||
designPanelActive={designPanelActive}
|
designPanelActive={designPanelActive}
|
||||||
activeBlockParams={activeBlockParams}
|
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
|
// fallow-ignore-next-line complexity
|
||||||
export function StudioHeader({
|
export function StudioHeader({
|
||||||
captureFrameHref,
|
captureFrameHref,
|
||||||
@@ -208,7 +223,11 @@ export function StudioHeader({
|
|||||||
onExport,
|
onExport,
|
||||||
}: StudioHeaderProps) {
|
}: StudioHeaderProps) {
|
||||||
const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
|
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;
|
const isRendering = renderQueue.isRendering;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -328,7 +347,7 @@ export function StudioHeader({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (rightCollapsed || !inspectorPanelActive) {
|
if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
|
||||||
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
||||||
setRightPanelTab("design");
|
setRightPanelTab("design");
|
||||||
setRightCollapsed(false);
|
setRightCollapsed(false);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function StudioLeftSidebar({
|
|||||||
onAddCompositionToTimeline,
|
onAddCompositionToTimeline,
|
||||||
}: StudioLeftSidebarProps) {
|
}: StudioLeftSidebarProps) {
|
||||||
const {
|
const {
|
||||||
leftCollapsed,
|
effectiveLeftCollapsed,
|
||||||
leftWidth,
|
leftWidth,
|
||||||
adjustPanelWidth,
|
adjustPanelWidth,
|
||||||
toggleLeftSidebar,
|
toggleLeftSidebar,
|
||||||
@@ -71,7 +71,7 @@ export function StudioLeftSidebar({
|
|||||||
[renderQueue, waitForPendingDomEditSaves],
|
[renderQueue, waitForPendingDomEditSaves],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (leftCollapsed) {
|
if (effectiveLeftCollapsed) {
|
||||||
return (
|
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">
|
<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
|
<button
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useTimelinePlayer, usePlayerStore } from "../../player";
|
|||||||
import type { TimelineElement } from "../../player";
|
import type { TimelineElement } from "../../player";
|
||||||
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
||||||
import { useCompositionStack } from "./useCompositionStack";
|
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 { setCompositionSourceMap } from "../editor/domEditingDom";
|
||||||
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
||||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||||
@@ -273,13 +273,22 @@ export function NLEProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
// A height persisted on a tall window can exceed this window's container and
|
// 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
|
// collapse the flex-1 preview to 0px. Observing the container rather than
|
||||||
// (the drag/keyboard paths already clamp; the restore path must too).
|
// 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(() => {
|
useEffect(() => {
|
||||||
const containerH = containerRef.current?.getBoundingClientRect().height;
|
const element = containerRef.current;
|
||||||
if (!containerH) return;
|
if (!element || typeof ResizeObserver === "undefined") return;
|
||||||
const max = containerH - MIN_PREVIEW_H;
|
const reconcile = () => {
|
||||||
setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev));
|
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);
|
const hasLoadedOnceRef = useRef(false);
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
|
import { MIN_PREVIEW_H, MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
|
||||||
export const MIN_TIMELINE_H = 100;
|
|
||||||
export const MIN_PREVIEW_H = 120;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal drag/keyboard-resizable divider between the preview and the
|
* Horizontal drag/keyboard-resizable divider between the preview and the
|
||||||
@@ -41,12 +39,7 @@ export function TimelineResizeDivider({
|
|||||||
if (!isDragging.current || !containerRef.current) return;
|
if (!isDragging.current || !containerRef.current) return;
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
const mouseY = e.clientY - rect.top;
|
const mouseY = e.clientY - rect.top;
|
||||||
const containerH = rect.height;
|
setTimelineH(fitTimelineHeight(rect.height, rect.height - mouseY));
|
||||||
const newTimelineH = Math.max(
|
|
||||||
MIN_TIMELINE_H,
|
|
||||||
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
|
||||||
);
|
|
||||||
setTimelineH(newTimelineH);
|
|
||||||
},
|
},
|
||||||
[disabled, containerRef, setTimelineH],
|
[disabled, containerRef, setTimelineH],
|
||||||
);
|
);
|
||||||
@@ -61,10 +54,10 @@ export function TimelineResizeDivider({
|
|||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const containerH = containerRef.current?.getBoundingClientRect().height ?? Infinity;
|
const containerH = containerRef.current?.getBoundingClientRect().height ?? 0;
|
||||||
const delta = e.key === "ArrowUp" ? 16 : -16;
|
const delta = e.key === "ArrowUp" ? 16 : -16;
|
||||||
setTimelineH((prev) => {
|
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);
|
persistTimelineH(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ export function PanelLayoutProvider({
|
|||||||
rightWidth,
|
rightWidth,
|
||||||
adjustPanelWidth,
|
adjustPanelWidth,
|
||||||
leftCollapsed,
|
leftCollapsed,
|
||||||
setLeftCollapsed,
|
|
||||||
rightCollapsed,
|
rightCollapsed,
|
||||||
setRightCollapsed,
|
setRightCollapsed,
|
||||||
|
effectiveLeftCollapsed,
|
||||||
|
effectiveRightCollapsed,
|
||||||
rightPanelTab,
|
rightPanelTab,
|
||||||
setRightPanelTab,
|
setRightPanelTab,
|
||||||
rightInspectorPanes,
|
rightInspectorPanes,
|
||||||
@@ -41,9 +42,10 @@ export function PanelLayoutProvider({
|
|||||||
rightWidth,
|
rightWidth,
|
||||||
adjustPanelWidth,
|
adjustPanelWidth,
|
||||||
leftCollapsed,
|
leftCollapsed,
|
||||||
setLeftCollapsed,
|
|
||||||
rightCollapsed,
|
rightCollapsed,
|
||||||
setRightCollapsed,
|
setRightCollapsed,
|
||||||
|
effectiveLeftCollapsed,
|
||||||
|
effectiveRightCollapsed,
|
||||||
rightPanelTab,
|
rightPanelTab,
|
||||||
setRightPanelTab,
|
setRightPanelTab,
|
||||||
rightInspectorPanes,
|
rightInspectorPanes,
|
||||||
@@ -59,9 +61,10 @@ export function PanelLayoutProvider({
|
|||||||
rightWidth,
|
rightWidth,
|
||||||
adjustPanelWidth,
|
adjustPanelWidth,
|
||||||
leftCollapsed,
|
leftCollapsed,
|
||||||
setLeftCollapsed,
|
|
||||||
rightCollapsed,
|
rightCollapsed,
|
||||||
setRightCollapsed,
|
setRightCollapsed,
|
||||||
|
effectiveLeftCollapsed,
|
||||||
|
effectiveRightCollapsed,
|
||||||
rightPanelTab,
|
rightPanelTab,
|
||||||
setRightPanelTab,
|
setRightPanelTab,
|
||||||
rightInspectorPanes,
|
rightInspectorPanes,
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ function renderPanelLayout() {
|
|||||||
return renderPanelLayoutWith(usePanelLayout);
|
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", () => {
|
describe("usePanelLayout — right inspector panes", () => {
|
||||||
it("opens Design with the intended viewport-scaled panel widths", () => {
|
it("opens Design with the intended viewport-scaled panel widths", () => {
|
||||||
const harness = renderPanelLayout();
|
const harness = renderPanelLayout();
|
||||||
@@ -160,6 +165,118 @@ describe("usePanelLayout — right inspector panes", () => {
|
|||||||
harness.unmount();
|
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 () => {
|
it("setRightPanelTab is flat-aware: exclusivity holds for callers other than a direct in-panel tab click", async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doMock("../components/editor/manualEditingAvailability", async () => {
|
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 {
|
import type {
|
||||||
RightInspectorPane,
|
RightInspectorPane,
|
||||||
RightInspectorPanes,
|
RightInspectorPanes,
|
||||||
@@ -7,6 +7,14 @@ import type {
|
|||||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
||||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||||
import { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability";
|
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 {
|
export interface InitialPanelLayoutState {
|
||||||
rightCollapsed?: boolean | null;
|
rightCollapsed?: boolean | null;
|
||||||
@@ -20,30 +28,27 @@ function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspect
|
|||||||
return { layers: false, design: true };
|
return { layers: false, design: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitialPanelWidths(): { left: number; right: number } {
|
function readViewportWidth(): number {
|
||||||
const viewportWidth = typeof window === "undefined" ? 1496 : window.innerWidth;
|
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 preferences = readStudioUiPreferences();
|
||||||
const leftDefault = Math.max(240, Math.min(384, Math.round(viewportWidth * 0.257)));
|
const defaults = defaultPanelWidths(readViewportWidth());
|
||||||
const rightDefault = Math.max(320, Math.min(424, Math.round(viewportWidth * 0.284)));
|
|
||||||
return {
|
return {
|
||||||
left: Math.max(
|
left: preferences.leftWidth ?? defaults.left,
|
||||||
160,
|
right: preferences.rightWidth ?? defaults.right,
|
||||||
Math.min(Math.floor(viewportWidth * 0.5), preferences.leftWidth ?? leftDefault),
|
|
||||||
),
|
|
||||||
right: Math.max(160, Math.min(600, preferences.rightWidth ?? rightDefault)),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||||
const [initialPanelWidths] = useState(getInitialPanelWidths);
|
const [preferredWidths, setPreferredWidths] = useState(getPreferredPanelWidths);
|
||||||
const [leftWidth, setLeftWidth] = useState(initialPanelWidths.left);
|
const [viewportWidth, setViewportWidth] = useState(readViewportWidth);
|
||||||
const [rightWidth, setRightWidth] = useState(initialPanelWidths.right);
|
|
||||||
const panelWidthsRef = useRef(initialPanelWidths);
|
|
||||||
const [leftCollapsed, setLeftCollapsed] = useState(
|
const [leftCollapsed, setLeftCollapsed] = useState(
|
||||||
() => readStudioUiPreferences().leftCollapsed ?? false,
|
() => readStudioUiPreferences().leftCollapsed ?? false,
|
||||||
);
|
);
|
||||||
@@ -54,41 +59,109 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|||||||
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
|
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
|
||||||
getInitialRightInspectorPanes(initialState?.rightPanelTab),
|
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<{
|
const panelDragRef = useRef<{
|
||||||
side: PanelSide;
|
side: PanelSide;
|
||||||
startX: number;
|
startX: number;
|
||||||
startW: number;
|
startW: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const updatePanelWidth = useCallback((side: PanelSide, width: number) => {
|
// Preferred widths are also held in a ref so a burst of pointer moves or
|
||||||
const next = clampPanelWidth(side, width);
|
// keyboard nudges inside one React batch accumulates, instead of every call
|
||||||
panelWidthsRef.current[side] = next;
|
// in the batch reading the same pre-render value.
|
||||||
if (side === "left") setLeftWidth(next);
|
const preferredRef = useRef(preferredWidths);
|
||||||
else setRightWidth(next);
|
|
||||||
|
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;
|
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(
|
const commitPanelWidth = useCallback(
|
||||||
(side: PanelSide, width: number) => {
|
(side: PanelSide, width: number) => {
|
||||||
const next = updatePanelWidth(side, Math.round(width));
|
// Persist what the window will actually allow, not a raw pointer delta.
|
||||||
writeStudioUiPreferences(side === "left" ? { leftWidth: next } : { rightWidth: next });
|
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(
|
const adjustPanelWidth = useCallback(
|
||||||
(side: PanelSide, delta: number) => {
|
(side: PanelSide, delta: number) => {
|
||||||
commitPanelWidth(side, panelWidthsRef.current[side] + delta);
|
commitPanelWidth(side, preferredRef.current[side] + delta);
|
||||||
},
|
},
|
||||||
[commitPanelWidth],
|
[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 toggleLeftSidebar = useCallback(() => {
|
||||||
setLeftCollapsed((collapsed) => {
|
const next = !effectiveLeftCollapsedRef.current;
|
||||||
writeStudioUiPreferences({ leftCollapsed: !collapsed });
|
setLeftCollapsed(next);
|
||||||
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: !collapsed });
|
writeStudioUiPreferences({ leftCollapsed: next });
|
||||||
return !collapsed;
|
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) => {
|
const handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => {
|
||||||
@@ -97,7 +170,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|||||||
panelDragRef.current = {
|
panelDragRef.current = {
|
||||||
side,
|
side,
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
startW: panelWidthsRef.current[side],
|
startW: fittedRef.current[side],
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -113,7 +186,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|||||||
|
|
||||||
const handlePanelResizeEnd = useCallback(() => {
|
const handlePanelResizeEnd = useCallback(() => {
|
||||||
const side = panelDragRef.current?.side;
|
const side = panelDragRef.current?.side;
|
||||||
if (side) commitPanelWidth(side, panelWidthsRef.current[side]);
|
if (side) commitPanelWidth(side, preferredRef.current[side]);
|
||||||
panelDragRef.current = null;
|
panelDragRef.current = null;
|
||||||
}, [commitPanelWidth]);
|
}, [commitPanelWidth]);
|
||||||
|
|
||||||
@@ -158,13 +231,22 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leftWidth,
|
leftWidth: fitted.left,
|
||||||
rightWidth,
|
rightWidth: fitted.right,
|
||||||
adjustPanelWidth,
|
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,
|
leftCollapsed,
|
||||||
setLeftCollapsed,
|
/** User intent. Synced into the shareable URL; never written by auto-collapse. */
|
||||||
rightCollapsed,
|
rightCollapsed,
|
||||||
setRightCollapsed,
|
setRightCollapsed: setRightCollapsedWithOverride,
|
||||||
|
/** What the shell actually renders: intent OR the window forcing a rail. */
|
||||||
|
effectiveLeftCollapsed: leftCollapsed || leftCollapsedByWidth,
|
||||||
|
effectiveRightCollapsed: rightCollapsed || rightCollapsedByWidth,
|
||||||
rightPanelTab,
|
rightPanelTab,
|
||||||
setRightPanelTab: trackedSetRightPanelTab,
|
setRightPanelTab: trackedSetRightPanelTab,
|
||||||
rightInspectorPanes,
|
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