| 1 | import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react"; |
| 2 | |
| 3 | interface ResizeSession { |
| 4 | size: number; |
| 5 | edge: "left" | "right" | "top" | "bottom"; |
| 6 | resize: (size: number) => number; |
| 7 | finish: (size: number, moved: boolean) => void; |
| 8 | /** Runs on every end; `committed` is false when the drag was abandoned without `finish`. */ |
| 9 | cleanup?: (committed: boolean) => void; |
| 10 | } |
| 11 | |
| 12 | /** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */ |
| 13 | export function useResizeDrag<T extends HTMLElement>( |
| 14 | start: (event: PointerEvent<T>) => ResizeSession | null, |
| 15 | resetKey?: string, |
| 16 | ) { |
| 17 | const drag = useRef<{ |
| 18 | session: ResizeSession; |
| 19 | target: T; |
| 20 | pointerId: number; |
| 21 | startPosition: number; |
| 22 | pendingPosition: number; |
| 23 | size: number; |
| 24 | moved: boolean; |
| 25 | frame: number | null; |
| 26 | } | null>(null); |
| 27 | |
| 28 | const flush = useCallback(() => { |
| 29 | const active = drag.current; |
| 30 | if (!active) return; |
| 31 | const delta = |
| 32 | (active.pendingPosition - active.startPosition) * |
| 33 | (active.session.edge === "left" || active.session.edge === "top" ? -1 : 1); |
| 34 | active.moved ||= Math.abs(delta) > 2; |
| 35 | active.size = active.session.resize(active.session.size + delta); |
| 36 | }, []); |
| 37 | |
| 38 | const finish = useCallback( |
| 39 | (commit = true) => { |
| 40 | const active = drag.current; |
| 41 | if (!active) return; |
| 42 | if (active.frame !== null) cancelAnimationFrame(active.frame); |
| 43 | if (commit) flush(); |
| 44 | // Release can synchronously dispatch lostpointercapture. |
| 45 | drag.current = null; |
| 46 | try { |
| 47 | if (active.target.hasPointerCapture(active.pointerId)) { |
| 48 | active.target.releasePointerCapture(active.pointerId); |
| 49 | } |
| 50 | } catch { |
| 51 | // Capture may already have been released by the browser. |
| 52 | } |
| 53 | document.body.style.removeProperty("cursor"); |
| 54 | document.body.style.removeProperty("user-select"); |
| 55 | active.session.cleanup?.(commit); |
| 56 | if (commit) active.session.finish(active.size, active.moved); |
| 57 | }, |
| 58 | [flush], |
| 59 | ); |
| 60 | |
| 61 | const previousResetKey = useRef(resetKey); |
| 62 | useLayoutEffect(() => { |
| 63 | if (previousResetKey.current !== resetKey) { |
| 64 | finish(false); |
| 65 | previousResetKey.current = resetKey; |
| 66 | } |
| 67 | }, [finish, resetKey]); |
| 68 | |
| 69 | useEffect(() => { |
| 70 | const onBlur = () => finish(); |
| 71 | window.addEventListener("blur", onBlur); |
| 72 | return () => { |
| 73 | window.removeEventListener("blur", onBlur); |
| 74 | finish(false); |
| 75 | }; |
| 76 | }, [finish]); |
| 77 | |
| 78 | const end = (event: PointerEvent<T>, usePosition: boolean) => { |
| 79 | const active = drag.current; |
| 80 | if (!active || active.pointerId !== event.pointerId) return; |
| 81 | event.preventDefault(); |
| 82 | if (usePosition) |
| 83 | active.pendingPosition = |
| 84 | active.session.edge === "top" || active.session.edge === "bottom" |
| 85 | ? event.clientY |
| 86 | : event.clientX; |
| 87 | finish(); |
| 88 | }; |
| 89 | |
| 90 | return { |
| 91 | /** Re-applies the pointer position to an active drag, e.g. after its bounds change. */ |
| 92 | refresh: flush, |
| 93 | onPointerDown(event: PointerEvent<T>) { |
| 94 | if (event.button !== 0 || drag.current || event.target !== event.currentTarget) return; |
| 95 | const session = start(event); |
| 96 | if (!session) return; |
| 97 | try { |
| 98 | event.currentTarget.setPointerCapture(event.pointerId); |
| 99 | } catch { |
| 100 | session.cleanup?.(false); |
| 101 | return; |
| 102 | } |
| 103 | const position = |
| 104 | session.edge === "top" || session.edge === "bottom" ? event.clientY : event.clientX; |
| 105 | event.preventDefault(); |
| 106 | event.stopPropagation(); |
| 107 | drag.current = { |
| 108 | session, |
| 109 | target: event.currentTarget, |
| 110 | pointerId: event.pointerId, |
| 111 | startPosition: position, |
| 112 | pendingPosition: position, |
| 113 | size: session.size, |
| 114 | moved: false, |
| 115 | frame: null, |
| 116 | }; |
| 117 | document.body.style.cursor = |
| 118 | session.edge === "top" || session.edge === "bottom" ? "row-resize" : "col-resize"; |
| 119 | document.body.style.userSelect = "none"; |
| 120 | }, |
| 121 | onPointerMove(event: PointerEvent<T>) { |
| 122 | const active = drag.current; |
| 123 | if (!active || active.pointerId !== event.pointerId) return; |
| 124 | event.preventDefault(); |
| 125 | active.pendingPosition = |
| 126 | active.session.edge === "top" || active.session.edge === "bottom" |
| 127 | ? event.clientY |
| 128 | : event.clientX; |
| 129 | active.moved ||= Math.abs(active.pendingPosition - active.startPosition) > 2; |
| 130 | if (active.frame !== null) return; |
| 131 | active.frame = requestAnimationFrame(() => { |
| 132 | active.frame = null; |
| 133 | flush(); |
| 134 | }); |
| 135 | }, |
| 136 | onPointerUp: (event: PointerEvent<T>) => end(event, true), |
| 137 | onPointerCancel: (event: PointerEvent<T>) => end(event, false), |
| 138 | onLostPointerCapture: (event: PointerEvent<T>) => end(event, false), |
| 139 | }; |
| 140 | } |