| ... | @@ -1,4 +1,11 @@ | ... | @@ -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 | import { isElectron } from "~/env"; | 10 | import { isElectron } from "~/env"; |
| 4 | import { useResizableWidth } from "~/hooks/useResizableWidth"; | 11 | import { useResizableWidth } from "~/hooks/useResizableWidth"; |
| ... | @@ -10,12 +17,31 @@ export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; | ... | @@ -10,12 +17,31 @@ export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; |
| 10 | | 17 | |
| 11 | const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; | 18 | const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; |
| 12 | const PREVIEW_PANEL_MIN_WIDTH = 360; | 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 | const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; | 25 | const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; |
| 15 | const PREVIEW_PANEL_DEFAULT_WIDTH = 540; | 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 { | 36 | export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { |
| 18 | return Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); | 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,7 +65,10 @@ export function PreviewPanelShell(props: { |
| 39 | }) { | 65 | }) { |
| 40 | const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; | 66 | const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; |
| 41 | const isInline = props.mode === "inline"; | 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 | const { width, handlers } = useResizableWidth({ | 72 | const { width, handlers } = useResizableWidth({ |
| 44 | storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, | 73 | storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, |
| 45 | defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, | 74 | defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, |
| ... | @@ -50,6 +79,7 @@ export function PreviewPanelShell(props: { | ... | @@ -50,6 +79,7 @@ export function PreviewPanelShell(props: { |
| 50 | | 79 | |
| 51 | return ( | 80 | return ( |
| 52 | <div | 81 | <div |
| | 82 | ref={hostRef} |
| 53 | className={cn( | 83 | className={cn( |
| 54 | "relative flex h-full min-h-0 min-w-0 max-w-full flex-col self-stretch bg-background", | 84 | "relative flex h-full min-h-0 min-w-0 max-w-full flex-col self-stretch bg-background", |
| 55 | isInline | 85 | isInline |
| ... | @@ -70,12 +100,17 @@ export function PreviewPanelShell(props: { | ... | @@ -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. | 103 | * Track viewport and flex-row widths to derive an upper bound for the panel. |
| 74 | * Resize-aware so dragging the OS window narrower re-clamps the stored | 104 | * Resize-aware so dragging the OS window narrower (or expanding the app |
| 75 | * width on the next render (the hook's clamp picks this up automatically). | 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 | const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); | 112 | const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); |
| | 113 | const [containerWidth, setContainerWidth] = useState<number | undefined>(undefined); |
| 79 | useEffect(() => { | 114 | useEffect(() => { |
| 80 | if (typeof window === "undefined") return; | 115 | if (typeof window === "undefined") return; |
| 81 | let frame = 0; | 116 | let frame = 0; |
| ... | @@ -93,5 +128,24 @@ function useViewportClampedMaxWidth(): number { | ... | @@ -93,5 +128,24 @@ function useViewportClampedMaxWidth(): number { |
| 93 | if (frame !== 0) window.cancelAnimationFrame(frame); | 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 | } |