import { useCallback, useLayoutEffect, useRef, useState } from "react"; import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react"; export const SESSION_PANEL_DEFAULT_WIDTH = 384; export const SESSION_PANEL_MIN_WIDTH = 320; export const SESSION_PANEL_FALLBACK_MAX_WIDTH = 576; export const SESSION_PANEL_RIGHT_MIN_WIDTH = 512; export const SESSION_PANEL_STORAGE_KEY = "thothii:session-documents-panel-width"; type Bounds = { min: number; max: number }; export function getSessionPanelBounds(containerWidth: number): Bounds { return { min: SESSION_PANEL_MIN_WIDTH, max: Math.max( SESSION_PANEL_MIN_WIDTH, Math.min(containerWidth * 0.5, containerWidth - SESSION_PANEL_RIGHT_MIN_WIDTH), ), }; } function clampWidth(width: number, bounds: Bounds): number { const candidate = Number.isFinite(width) ? width : SESSION_PANEL_DEFAULT_WIDTH; return Math.min(bounds.max, Math.max(bounds.min, candidate)); } function browserStorage(): Storage | null { if (typeof window === "undefined") return null; try { return window.localStorage; } catch { return null; } } function loadWidth(storage: Pick | null): number { if (!storage) return SESSION_PANEL_DEFAULT_WIDTH; try { const raw = storage.getItem(SESSION_PANEL_STORAGE_KEY); if (!raw?.trim()) return SESSION_PANEL_DEFAULT_WIDTH; const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : SESSION_PANEL_DEFAULT_WIDTH; } catch { return SESSION_PANEL_DEFAULT_WIDTH; } } function saveWidth(width: number, storage: Pick | null): void { if (!storage) return; try { storage.setItem(SESSION_PANEL_STORAGE_KEY, String(Math.round(width))); } catch { // Storage may be unavailable in privacy-restricted browser contexts. } } function widthFromKey(width: number, key: string, shiftKey: boolean, bounds: Bounds) { const step = shiftKey ? 48 : 16; if (key === "ArrowLeft") return clampWidth(width - step, bounds); if (key === "ArrowRight") return clampWidth(width + step, bounds); if (key === "Home") return bounds.min; if (key === "End") return bounds.max; return null; } type DragState = { pointerId: number; startX: number; startWidth: number; target: HTMLDivElement; }; export function useSessionPanelResize( containerRef: RefObject, enabled: boolean, ) { const storageRef = useRef(browserStorage()); const initialWidthRef = useRef(loadWidth(storageRef.current)); const widthRef = useRef(initialWidthRef.current); const boundsRef = useRef({ min: SESSION_PANEL_MIN_WIDTH, max: SESSION_PANEL_FALLBACK_MAX_WIDTH, }); const dragRef = useRef(null); const [panelWidth, setPanelWidth] = useState(initialWidthRef.current); const [bounds, setBounds] = useState(boundsRef.current); const [desktopSplit, setDesktopSplit] = useState(false); const [resizing, setResizing] = useState(false); const applyWidth = useCallback((rawWidth: number, persist: boolean) => { const next = clampWidth(rawWidth, boundsRef.current); widthRef.current = next; setPanelWidth(next); if (persist) saveWidth(next, storageRef.current); return next; }, []); useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const cancelDrag = () => { const drag = dragRef.current; if (drag?.target.hasPointerCapture?.(drag.pointerId)) { drag.target.releasePointerCapture?.(drag.pointerId); } dragRef.current = null; setResizing(false); }; const syncLayout = () => { const viewportIsDesktop = window.matchMedia?.("(min-width: 1024px)").matches ?? window.innerWidth >= 1024; const usableWidth = SESSION_PANEL_MIN_WIDTH + SESSION_PANEL_RIGHT_MIN_WIDTH; const nextDesktopSplit = enabled && viewportIsDesktop && container.clientWidth >= usableWidth; setDesktopSplit(nextDesktopSplit); if (!nextDesktopSplit) { cancelDrag(); return; } const nextBounds = getSessionPanelBounds(container.clientWidth); boundsRef.current = nextBounds; setBounds(nextBounds); applyWidth(widthRef.current, true); }; syncLayout(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncLayout); observer?.observe(container); const media = window.matchMedia?.("(min-width: 1024px)"); media?.addEventListener?.("change", syncLayout); window.addEventListener("resize", syncLayout); return () => { cancelDrag(); observer?.disconnect(); media?.removeEventListener?.("change", syncLayout); window.removeEventListener("resize", syncLayout); }; }, [applyWidth, containerRef, enabled]); const onPointerDown: PointerEventHandler = (event) => { dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startWidth: widthRef.current, target: event.currentTarget, }; event.currentTarget.setPointerCapture?.(event.pointerId); setResizing(true); }; const onPointerMove: PointerEventHandler = (event) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; applyWidth(drag.startWidth + event.clientX - drag.startX, false); }; const onPointerUp: PointerEventHandler = (event) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; applyWidth(drag.startWidth + event.clientX - drag.startX, true); if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { event.currentTarget.releasePointerCapture?.(event.pointerId); } dragRef.current = null; setResizing(false); }; const onPointerCancel: PointerEventHandler = (event) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; applyWidth(drag.startWidth, false); dragRef.current = null; setResizing(false); }; const onKeyDown: KeyboardEventHandler = (event) => { const next = widthFromKey(widthRef.current, event.key, event.shiftKey, boundsRef.current); if (next === null) return; event.preventDefault(); applyWidth(next, true); }; return { panelWidth, desktopSplit, resizing, separatorProps: { role: "separator" as const, tabIndex: 0, "aria-label": "Resize session summary", "aria-orientation": "vertical" as const, "aria-valuemin": bounds.min, "aria-valuemax": bounds.max, "aria-valuenow": panelWidth, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onKeyDown, }, }; }