import { useLayoutEffect, useRef, type PointerEvent as ReactPointerEvent } from "react"; import { flushSync } from "react-dom"; type Position = { x: number; y: number }; /** Move only the active DOM layer per frame; persist React bounds on release. */ export function useFloatingWindowDrag({ enabled, position, width, height, onCommit }: { enabled: boolean; position: Position; width: number; height: number; onCommit: (position: Position) => void; }) { const cancel = useRef<(() => void) | null>(null); useLayoutEffect(() => { const stop = () => cancel.current?.(); window.addEventListener("resize", stop); return () => { stop(); window.removeEventListener("resize", stop); }; }, [enabled]); return (event: ReactPointerEvent) => { if (!enabled || !event.isPrimary || event.button !== 0 || cancel.current || (event.target as Element).closest("button,input,select,textarea,a")) return; const heading = event.currentTarget; const element = heading.closest("[data-floating-window]"); if (!element) return; event.preventDefault(); heading.focus({ preventScroll: true }); const pointerId = event.pointerId; const startX = event.clientX; const startY = event.clientY; const maxX = window.innerWidth - width - 8; const maxY = window.innerHeight - height - 8; const previousTranslate = element.style.translate; const previousWillChange = element.style.willChange; let next = position; let frame = 0; element.style.willChange = "translate"; const updatePosition = (pointer: PointerEvent) => { next = { x: Math.max(8, Math.min(position.x + pointer.clientX - startX, maxX)), y: Math.max(8, Math.min(position.y + pointer.clientY - startY, maxY)) }; }; const move = (pointer: PointerEvent) => { if (pointer.pointerId !== pointerId) return; updatePosition(pointer); if (!frame) frame = requestAnimationFrame(() => { frame = 0; element.style.translate = `${next.x - position.x}px ${next.y - position.y}px`; }); }; const cleanup = () => { cancel.current = null; cancelAnimationFrame(frame); heading.removeEventListener("pointermove", move); heading.removeEventListener("pointerup", finish); heading.removeEventListener("pointercancel", abort); heading.removeEventListener("lostpointercapture", abort); element.style.translate = previousTranslate; element.style.willChange = previousWillChange; if (heading.hasPointerCapture(pointerId)) heading.releasePointerCapture(pointerId); }; const abort = (pointer: PointerEvent) => { if (pointer.pointerId === pointerId) cleanup(); }; const finish = (pointer: PointerEvent) => { if (pointer.pointerId !== pointerId) return; updatePosition(pointer); // Commit and remove the temporary offset in one turn to avoid a snap. if (next.x !== position.x || next.y !== position.y) flushSync(() => onCommit(next)); cleanup(); }; cancel.current = cleanup; heading.addEventListener("pointermove", move); heading.addEventListener("pointerup", finish); heading.addEventListener("pointercancel", abort); heading.addEventListener("lostpointercapture", abort); heading.setPointerCapture(pointerId); }; }