1import { type RefObject, useLayoutEffect, useState } from "react";
2
3import { type ResizableWidthHandlers, useResizableWidth } from "./useResizableWidth";
4import { observeResize } from "../lib/observeResize";
5
6export interface PreviewPanelInlineSize {
7 readonly width: number;
8 readonly handlers: ResizableWidthHandlers;
9}
10
11const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width";
12const PREVIEW_PANEL_MIN_WIDTH = 360;
13/**
14 * Upper bound as a fraction of the viewport; only binds on wide screens.
15 * On narrow windows the container clamp below is what preserves the
16 * sibling column's usable width.
17 */
18const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7;
19/**
20 * Floor for the column beside the panel: below this the chat column's
21 * composer overflows, so the panel yields instead.
22 */
23const SIBLING_COLUMN_MIN_WIDTH = 360;
24const PREVIEW_PANEL_DEFAULT_WIDTH = 540;
25
26export function usePreviewPanelInlineSize(
27 hostRef?: RefObject<HTMLElement | null>,
28 options: {
29 readonly enabled?: boolean | undefined;
30 readonly widthStorageKey?: string | undefined;
31 readonly defaultWidth?: number | undefined;
32 /** Measure this row instead of the panel's parent. */
33 readonly container?: HTMLElement | null | undefined;
34 } = {},
35): PreviewPanelInlineSize {
36 const maxWidth = useViewportClampedMaxWidth(hostRef, options.enabled ?? true, options.container);
37 return useResizableWidth({
38 storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY,
39 defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH,
40 minWidth: PREVIEW_PANEL_MIN_WIDTH,
41 maxWidth,
42 edge: "left",
43 });
44}
45
46/**
47 * Keep the resizable panel's upper bound in sync with the window AND the row
48 * it sits in. Resize-aware so dragging the OS window narrower (or expanding
49 * the app sidebar) re-clamps the stored width on the next render.
50 */
51function useViewportClampedMaxWidth(
52 hostRef: RefObject<HTMLElement | null> | undefined,
53 enabled: boolean,
54 container: HTMLElement | null | undefined,
55): number {
56 const [maxWidth, setMaxWidth] = useState(() =>
57 getPreviewPanelMaxWidth(typeof window === "undefined" ? 1280 : window.innerWidth),
58 );
59 useLayoutEffect(() => {
60 if (typeof window === "undefined") return;
61 const row = container ?? (enabled ? hostRef?.current?.parentElement : null) ?? null;
62 // Measure before first paint: the persisted width must be clamped against
63 // the row on the initial render, not one observer tick later (the panel
64 // would flash over-wide on every mount). Only the derived cap is stored: the
65 // row resizes every frame of a sidebar drag, but the cap rarely moves, and an
66 // unchanged cap skips re-rendering the panel's owner.
67 const measure = () => {
68 setMaxWidth(getPreviewPanelMaxWidth(window.innerWidth, row?.clientWidth));
69 };
70 measure();
71 let frame = 0;
72 const onResize = () => {
73 if (frame !== 0) return;
74 frame = window.requestAnimationFrame(() => {
75 frame = 0;
76 measure();
77 });
78 };
79 window.addEventListener("resize", onResize);
80 const stopObserving = row ? observeResize(row, measure) : undefined;
81 return () => {
82 window.removeEventListener("resize", onResize);
83 if (frame !== 0) window.cancelAnimationFrame(frame);
84 stopObserving?.();
85 };
86 }, [container, hostRef, enabled]);
87 return maxWidth;
88}
89export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number {
90 const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION);
91 const containerCap =
92 containerWidth === undefined ? Infinity : Math.floor(containerWidth) - SIBLING_COLUMN_MIN_WIDTH;
93 // Never below the panel's own minimum: when the row cannot fit both
94 // columns' minimums the sibling yields, and useResizableWidth's clamp must
95 // not see max < min (it would resolve the inversion to min and, via
96 // drag-end persistence, overwrite the user's stored width).
97 return Math.max(PREVIEW_PANEL_MIN_WIDTH, Math.min(fractionCap, containerCap));
98}