| ... | ... | @@ -1,4 +1,11 @@ |
| 1 | | import { type ReactNode, useEffect, useState } from "react"; |
| 1 | import { |
| 2 | type ReactNode, |
| 3 | type RefObject, |
| 4 | useEffect, |
| 5 | useLayoutEffect, |
| 6 | useRef, |
| 7 | useState, |
| 8 | } from "react"; |
| 2 | 9 | |
| 3 | 10 | import { isElectron } from "~/env"; |
| 4 | 11 | import { useResizableWidth } from "~/hooks/useResizableWidth"; |
| ... | ... | @@ -10,12 +17,31 @@ export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; |
| 10 | 17 | |
| 11 | 18 | const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; |
| 12 | 19 | const PREVIEW_PANEL_MIN_WIDTH = 360; |
| 13 | | /** Fraction of the viewport allowed, preserving the remaining space for chat. */ |
| 20 | /** |
| 21 | * Upper bound as a fraction of the viewport; only binds on wide screens. |
| 22 | * On narrow windows the container clamp below is what preserves the |
| 23 | * sibling column's space. |
| 24 | */ |
| 14 | 25 | const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; |
| 15 | 26 | const PREVIEW_PANEL_DEFAULT_WIDTH = 540; |
| 27 | /** |
| 28 | * Width reserved for the sibling column (chat, pull-request list) sharing the |
| 29 | * panel's flex row. The viewport fraction alone is not enough: the app |
| 30 | * sidebar sits outside the row, so on narrow windows (any MacBook, even |
| 31 | * fullscreen) the remaining 30% of the viewport minus the sidebar left the |
| 32 | * sibling below its usable width and the composer overflowed. |
| 33 | */ |
| 34 | const SIBLING_COLUMN_MIN_WIDTH = 360; |
| 16 | 35 | |
| 17 | | export function getPreviewPanelMaxWidth(viewportWidth: number): number { |
| 18 | | return Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); |
| 36 | export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { |
| 37 | const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); |
| 38 | const containerCap = |
| 39 | containerWidth === undefined ? Infinity : Math.floor(containerWidth) - SIBLING_COLUMN_MIN_WIDTH; |
| 40 | // Never below the panel's own minimum: when the row cannot fit both |
| 41 | // columns' minimums the sibling yields, and useResizableWidth's clamp |
| 42 | // must not see max < min (it would resolve the inversion to min and, |
| 43 | // via drag-end persistence, overwrite the user's stored width). |
| 44 | return Math.max(PREVIEW_PANEL_MIN_WIDTH, Math.min(fractionCap, containerCap)); |
| 19 | 45 | } |
| 20 | 46 | |
| 21 | 47 | /** |
| ... | ... | @@ -39,7 +65,10 @@ export function PreviewPanelShell(props: { |
| 39 | 65 | }) { |
| 40 | 66 | const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; |
| 41 | 67 | const isInline = props.mode === "inline"; |
| 42 | | const maxWidth = useViewportClampedMaxWidth(); |
| 68 | const hostRef = useRef<HTMLDivElement | null>(null); |
| 69 | // Only inline non-maximized mode applies `width`/`maxWidth`; skip the |
| 70 | // container measurement (and its re-renders) everywhere else. |
| 71 | const maxWidth = useClampedMaxWidth(hostRef, isInline && !props.maximized); |
| 43 | 72 | const { width, handlers } = useResizableWidth({ |
| 44 | 73 | storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, |
| 45 | 74 | defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, |
| ... | ... | @@ -50,6 +79,7 @@ export function PreviewPanelShell(props: { |
| 50 | 79 | |
| 51 | 80 | return ( |
| 52 | 81 | <div |
| 82 | ref={hostRef} |
| 53 | 83 | className={cn( |
| 54 | 84 | "relative flex h-full min-h-0 min-w-0 max-w-full flex-col self-stretch bg-background", |
| 55 | 85 | isInline |
| ... | ... | @@ -70,12 +100,17 @@ export function PreviewPanelShell(props: { |
| 70 | 100 | } |
| 71 | 101 | |
| 72 | 102 | /** |
| 73 | | * Track viewport width to derive a sensible upper bound for the panel. |
| 74 | | * Resize-aware so dragging the OS window narrower re-clamps the stored |
| 75 | | * width on the next render (the hook's clamp picks this up automatically). |
| 103 | * Track viewport and flex-row widths to derive an upper bound for the panel. |
| 104 | * Resize-aware so dragging the OS window narrower (or expanding the app |
| 105 | * sidebar) re-clamps the stored width on the next render (the hook's clamp |
| 106 | * picks this up automatically). The row is observed rather than the panel |
| 107 | * itself because the panel competes with its sibling column for row space. |
| 108 | * Row measurement only runs when `enabled`; modes without a resize handle |
| 109 | * never apply the resulting width, so they skip the observer entirely. |
| 76 | 110 | */ |
| 77 | | function useViewportClampedMaxWidth(): number { |
| 111 | function useClampedMaxWidth(hostRef: RefObject<HTMLDivElement | null>, enabled: boolean): number { |
| 78 | 112 | const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); |
| 113 | const [containerWidth, setContainerWidth] = useState<number | undefined>(undefined); |
| 79 | 114 | useEffect(() => { |
| 80 | 115 | if (typeof window === "undefined") return; |
| 81 | 116 | let frame = 0; |
| ... | ... | @@ -93,5 +128,24 @@ function useViewportClampedMaxWidth(): number { |
| 93 | 128 | if (frame !== 0) window.cancelAnimationFrame(frame); |
| 94 | 129 | }; |
| 95 | 130 | }, []); |
| 96 | | return getPreviewPanelMaxWidth(vw); |
| 131 | useLayoutEffect(() => { |
| 132 | if (!enabled) return; |
| 133 | const parent = hostRef.current?.parentElement; |
| 134 | if (!parent) return; |
| 135 | // Measure before first paint: the persisted width must be clamped |
| 136 | // against the row on the initial render, not one observer tick later |
| 137 | // (the panel would flash over-wide on every mount). clientWidth is |
| 138 | // integral, so sub-pixel resize deltas bail out of re-rendering. |
| 139 | const measure = () => { |
| 140 | setContainerWidth(parent.clientWidth); |
| 141 | }; |
| 142 | measure(); |
| 143 | if (typeof ResizeObserver === "undefined") return; |
| 144 | const observer = new ResizeObserver(measure); |
| 145 | observer.observe(parent); |
| 146 | return () => { |
| 147 | observer.disconnect(); |
| 148 | }; |
| 149 | }, [hostRef, enabled]); |
| 150 | return getPreviewPanelMaxWidth(vw, containerWidth); |
| 97 | 151 | } |