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