import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState, } from "react"; import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; const WidthSchema = Schema.Finite; export interface UseResizableWidthOptions { /** localStorage key the persisted width is stored under. */ readonly storageKey: string; readonly defaultWidth: number; readonly minWidth: number; readonly maxWidth: number; /** * Which edge of the host element carries the drag handle: * - "left" → panel grows leftward (right-anchored panels) * - "right" → panel grows rightward (left-anchored panels) */ readonly edge: "left" | "right"; } export interface ResizableWidthHandlers { readonly onPointerDown: (event: ReactPointerEvent) => void; readonly onPointerMove: (event: ReactPointerEvent) => void; readonly onPointerUp: (event: ReactPointerEvent) => void; readonly onPointerCancel: (event: ReactPointerEvent) => void; readonly onLostPointerCapture: (event: ReactPointerEvent) => void; } /** * Width state for a side-anchored panel resized via a drag handle on the * specified edge. Width is read from localStorage on mount and persisted on * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec). * * The hook updates an internal `width` state during drag (so the panel * follows the cursor live) and only commits to localStorage when the user * lifts the pointer. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; readonly handlers: ResizableWidthHandlers; } { const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options; const clamp = useCallback( (value: number): number => { if (!Number.isFinite(value)) return defaultWidth; return Math.max(minWidth, Math.min(maxWidth, value)); }, [defaultWidth, maxWidth, minWidth], ); // No cross-tab subscription: panel width is per-window state. const [width, setWidth] = useState(() => { if (typeof window === "undefined") return defaultWidth; try { const stored = getLocalStorageItem(storageKey, WidthSchema); return clamp(stored ?? defaultWidth); } catch (error) { console.error("Could not read persisted panel width.", error); return defaultWidth; } }); const clampedWidth = clamp(width); const dragStateRef = useRef<{ pointerId: number; startX: number; startWidth: number; pending: number; rafId: number | null; target: HTMLElement; } | null>(null); const releasePointer = useCallback((pointerId: number) => { const state = dragStateRef.current; if (!state) return; // Clear first because releasing capture can trigger another cleanup. dragStateRef.current = null; if (state.rafId !== null) { cancelAnimationFrame(state.rafId); } try { if (state.target.hasPointerCapture(pointerId)) { state.target.releasePointerCapture(pointerId); } } catch { // pointer may already be released; harmless. } document.body.style.removeProperty("cursor"); document.body.style.removeProperty("user-select"); }, []); const cancelDrag = useCallback(() => { const state = dragStateRef.current; if (!state) return; releasePointer(state.pointerId); setWidth(state.startWidth); }, [releasePointer]); useEffect(() => { window.addEventListener("blur", cancelDrag); return () => { window.removeEventListener("blur", cancelDrag); const state = dragStateRef.current; if (state) releasePointer(state.pointerId); }; }, [cancelDrag, releasePointer]); const onPointerDown = useCallback( (event: ReactPointerEvent) => { if (event.button !== 0 || dragStateRef.current) return; event.preventDefault(); event.stopPropagation(); const target = event.currentTarget; try { target.setPointerCapture(event.pointerId); } catch { return; } document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; dragStateRef.current = { pointerId: event.pointerId, startX: event.clientX, startWidth: clampedWidth, pending: clampedWidth, rafId: null, target, }; }, [clampedWidth], ); const onPointerMove = useCallback( (event: ReactPointerEvent) => { const state = dragStateRef.current; if (!state || state.pointerId !== event.pointerId) return; event.preventDefault(); const delta = edge === "left" ? state.startX - event.clientX : event.clientX - state.startX; state.pending = clamp(state.startWidth + delta); if (state.rafId !== null) return; state.rafId = requestAnimationFrame(() => { const active = dragStateRef.current; if (!active) return; active.rafId = null; setWidth(active.pending); }); }, [clamp, edge], ); const onPointerUp = useCallback( (event: ReactPointerEvent) => { const state = dragStateRef.current; if (!state || state.pointerId !== event.pointerId) return; const finalWidth = clamp(state.pending); releasePointer(event.pointerId); // Commit once at drag-end to avoid 60Hz localStorage writes. try { setLocalStorageItem(storageKey, finalWidth, WidthSchema); } catch (error) { console.error("Could not persist panel width.", error); } setWidth(finalWidth); }, [clamp, releasePointer, storageKey], ); const onPointerCancel = useCallback( (event: ReactPointerEvent) => { const state = dragStateRef.current; if (!state || state.pointerId !== event.pointerId) return; // Don't persist a cancelled drag; revert to the start width. cancelDrag(); }, [cancelDrag], ); return { width: clampedWidth, handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture: onPointerCancel, }, }; }