import { type PointerEvent, useState } from "react"; export interface ResizeBounds { readonly min: number; readonly max: number; } interface ResizeSession { width: number; edge: "left" | "right"; /** Pointer widths are clamped to these before `resize` sees them. */ bounds: ResizeBounds; resize: (width: number) => number; finish: (width: number, moved: boolean) => void; cleanup?: (committed: boolean) => void; } function clampToBounds(width: number, bounds: ResizeBounds): number { return Math.max(bounds.min, Math.min(bounds.max, width)); } interface ActiveDrag { session: ResizeSession; target: T; pointerId: number; startX: number; pendingX: number; width: number; moved: boolean; frame: number | null; onBlur: () => void; } function createResizeDrag() { let drag: ActiveDrag | null = null; const flush = () => { const active = drag; if (!active) return; const delta = (active.pendingX - active.startX) * (active.session.edge === "left" ? -1 : 1); if (Math.abs(delta) > 2) active.moved = true; active.width = active.session.resize( clampToBounds(active.session.width + delta, active.session.bounds), ); }; const finish = (commit: boolean) => { const active = drag; if (!active) return; if (active.frame !== null) cancelAnimationFrame(active.frame); window.removeEventListener("blur", active.onBlur); if (commit) flush(); // Release can synchronously dispatch lostpointercapture. drag = null; try { if (active.target.hasPointerCapture(active.pointerId)) { active.target.releasePointerCapture(active.pointerId); } } catch {} document.body.style.removeProperty("cursor"); document.body.style.removeProperty("user-select"); active.session.cleanup?.(commit); if (commit) active.session.finish(active.width, active.moved); }; const end = (event: PointerEvent, usePosition: boolean) => { const active = drag; if (!active || active.pointerId !== event.pointerId) return; event.preventDefault(); if (usePosition) active.pendingX = event.clientX; finish(true); }; return { ref: (node: T | null) => (node === null ? undefined : () => finish(false)), cancel: () => finish(false), refresh(bounds?: ResizeBounds) { if (!drag) return; if (bounds) drag.session.bounds = bounds; flush(); }, begin(event: PointerEvent, start: (event: PointerEvent) => ResizeSession | null) { if (event.button !== 0 || drag) return; const session = start(event); if (!session) return; try { event.currentTarget.setPointerCapture(event.pointerId); } catch { session.cleanup?.(false); return; } event.preventDefault(); event.stopPropagation(); // Leaving the window mid-drag commits where the pointer was. const onBlur = () => finish(true); window.addEventListener("blur", onBlur); drag = { session, target: event.currentTarget, pointerId: event.pointerId, startX: event.clientX, pendingX: event.clientX, width: session.width, moved: false, frame: null, onBlur, }; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; }, onPointerMove(event: PointerEvent) { const active = drag; if (!active || active.pointerId !== event.pointerId) return; event.preventDefault(); active.pendingX = event.clientX; if (Math.abs(event.clientX - active.startX) > 2) active.moved = true; if (active.frame !== null) return; active.frame = requestAnimationFrame(() => { active.frame = null; flush(); }); }, onPointerUp: (event: PointerEvent) => end(event, true), onPointerCancel: (event: PointerEvent) => end(event, false), onLostPointerCapture: (event: PointerEvent) => end(event, false), }; } /** * A pointer drag that resizes along one edge. The session `start` returns owns * everything the drag needs, so options read at pointer-down hold for the whole * drag. Attach `ref` to the handle so unmounting it abandons the drag. */ export function useResizeDrag( start: (event: PointerEvent) => ResizeSession | null, ) { // Created once: `ref` must keep its identity, or a re-render mid-drag would run its cleanup. const [drag] = useState(createResizeDrag); const { begin, ...rest } = drag; return { ...rest, onPointerDown: (event: PointerEvent) => begin(event, start) }; }