1import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react";
2
3interface 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. */
13export 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}