authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-13 02:30:17-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 02:30:17-03:00
log46140c96afe1d8a8b5b4bde1fccb671f8760017a
tree55882a68a78c2b3d4c9cc3d4b3ef082f3d4462e5
parent3138f5716098a331f9a7d4cfc1bcd07118967a83
signature Signed by PGP key B5690EEEBB952194

fix(web): unify panel resizing and retain final drag width (#11529)


4 files changed, 281 insertions(+), 307 deletions(-)

apps/web/src/components/ui/sidebar.tsx+85-184
......@@ -17,6 +17,7 @@ import {
1717} from "~/components/ui/sheet";
1818import { Skeleton } from "~/components/ui/skeleton";
1919import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
20import { useResizeDrag } from "~/hooks/useResizeDrag";
2021import { useIsMobile } from "~/hooks/useMediaQuery";
2122import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
2223import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState";
......@@ -353,6 +354,7 @@ function SidebarRail({
353354 className,
354355 onClick,
355356 onPointerCancel,
357 onLostPointerCapture,
356358 onPointerDown,
357359 onPointerMove,
358360 onPointerUp,
......@@ -362,182 +364,79 @@ function SidebarRail({
362364 const sidebarInstance = React.use(SidebarInstanceContext);
363365 const railRef = React.useRef<HTMLButtonElement | null>(null);
364366 const suppressClickRef = React.useRef(false);
365 const resizeStateRef = React.useRef<{
366 moved: boolean;
367 pointerId: number;
368 pendingWidth: number;
369 rail: HTMLButtonElement;
370 rafId: number | null;
371 sidebarRoot: HTMLElement;
372 side: "left" | "right";
373 startWidth: number;
374 startX: number;
375 transitionTargets: HTMLElement[];
376 width: number;
377 wrapper: HTMLElement;
378 } | null>(null);
379367 const resolvedResizable = sidebarInstance?.resizable ?? null;
368 const latestResizable = React.useRef(resolvedResizable);
369 React.useLayoutEffect(() => {
370 latestResizable.current = resolvedResizable;
371 }, [resolvedResizable]);
380372 const canResize = resolvedResizable !== null && open;
381373 const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar";
382374 const railTitle = canResize ? "Drag to resize sidebar" : "Toggle Sidebar";
375 const resize = useResizeDrag<HTMLButtonElement>((event) => {
376 if (!resolvedResizable || !open) return null;
377 const rail = event.currentTarget;
378 const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
379 const sidebarRoot = rail.closest<HTMLElement>("[data-slot='sidebar']");
380 const sidebarContainer = sidebarRoot?.querySelector<HTMLElement>(
381 "[data-slot='sidebar-container']",
382 );
383 if (!wrapper || !sidebarRoot || !sidebarContainer) return null;
383384
384 const stopResize = React.useCallback(
385 (pointerId: number) => {
386 const resizeState = resizeStateRef.current;
387 if (!resizeState) {
388 return;
389 }
390 if (resizeState.rafId !== null) {
391 window.cancelAnimationFrame(resizeState.rafId);
392 }
393 resizeState.transitionTargets.forEach((element) => {
394 element.style.removeProperty("transition-duration");
395 });
396 if (resolvedResizable?.storageKey && typeof window !== "undefined") {
397 setLocalStorageItem(resolvedResizable.storageKey, resizeState.width, Schema.Finite);
398 }
399 resolvedResizable?.onResize?.(resizeState.width);
400 resizeStateRef.current = null;
401 if (resizeState.rail.hasPointerCapture(pointerId)) {
402 resizeState.rail.releasePointerCapture(pointerId);
403 }
404 document.body.style.removeProperty("cursor");
405 document.body.style.removeProperty("user-select");
406 },
407 [resolvedResizable],
408 );
409
410 const handlePointerDown = React.useCallback(
411 (event: React.PointerEvent<HTMLButtonElement>) => {
412 onPointerDown?.(event);
413 if (event.defaultPrevented) return;
414 if (!resolvedResizable || !open || event.button !== 0) return;
415
416 const wrapper = event.currentTarget.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
417 const sidebarRoot = event.currentTarget.closest<HTMLElement>("[data-slot='sidebar']");
418 if (!wrapper || !sidebarRoot) {
419 return;
420 }
421
422 const sidebarContainer = sidebarRoot.querySelector<HTMLElement>(
423 "[data-slot='sidebar-container']",
424 );
425 if (!sidebarContainer) {
426 return;
427 }
428
429 const startWidth = sidebarContainer.getBoundingClientRect().width;
430 const initialWidth = clampSidebarWidth(startWidth, resolvedResizable);
431 const transitionTargets = [
432 sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-gap']"),
433 sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-container']"),
434 ].filter((element): element is HTMLElement => element !== null);
435 transitionTargets.forEach((element) => {
436 element.style.setProperty("transition-duration", "0ms");
437 });
438
439 event.preventDefault();
440 event.stopPropagation();
441 resizeStateRef.current = {
442 moved: false,
443 pointerId: event.pointerId,
444 pendingWidth: initialWidth,
445 rail: event.currentTarget,
446 rafId: null,
447 sidebarRoot,
448 side: sidebarInstance?.side ?? "left",
449 startWidth: initialWidth,
450 startX: event.clientX,
451 transitionTargets,
452 width: initialWidth,
453 wrapper,
454 };
455 wrapper.style.setProperty("--sidebar-width", `${initialWidth}px`);
456 event.currentTarget.setPointerCapture(event.pointerId);
457 document.body.style.cursor = "col-resize";
458 document.body.style.userSelect = "none";
459 },
460 [onPointerDown, open, resolvedResizable, sidebarInstance?.side],
461 );
462
463 const handlePointerMove = React.useCallback(
464 (event: React.PointerEvent<HTMLButtonElement>) => {
465 onPointerMove?.(event);
466 if (event.defaultPrevented) return;
467 const resizeState = resizeStateRef.current;
468 if (!resizeState || resizeState.pointerId !== event.pointerId || !resolvedResizable) return;
469
470 event.preventDefault();
471 const delta =
472 resizeState.side === "right"
473 ? resizeState.startX - event.clientX
474 : event.clientX - resizeState.startX;
475 if (Math.abs(delta) > 2) {
476 resizeState.moved = true;
477 }
478 resizeState.pendingWidth = clampSidebarWidth(
479 resizeState.startWidth + delta,
480 resolvedResizable,
481 );
482 if (resizeState.rafId !== null) {
483 return;
484 }
485
486 resizeState.rafId = window.requestAnimationFrame(() => {
487 const activeResizeState = resizeStateRef.current;
488 if (!activeResizeState || !resolvedResizable) return;
385 const side = sidebarInstance?.side ?? "left";
386 let width = clampSidebarWidth(
387 sidebarContainer.getBoundingClientRect().width,
388 resolvedResizable,
389 );
390 const transitionTargets = [
391 sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-gap']"),
392 sidebarContainer,
393 ].filter((element): element is HTMLElement => element !== null);
394 transitionTargets.forEach((element) => {
395 element.style.setProperty("transition-duration", "0ms");
396 });
397 wrapper.style.setProperty("--sidebar-width", `${width}px`);
489398
490 activeResizeState.rafId = null;
491 const nextWidth = activeResizeState.pendingWidth;
399 return {
400 width,
401 edge: side === "left" ? "right" : "left",
402 resize(value) {
403 const options = latestResizable.current;
404 if (!options) return width;
405 const nextWidth = clampSidebarWidth(value, options);
492406 const accepted =
493 resolvedResizable.shouldAcceptWidth?.({
494 currentWidth: activeResizeState.width,
407 options.shouldAcceptWidth?.({
408 currentWidth: width,
495409 nextWidth,
496 rail: activeResizeState.rail,
497 side: activeResizeState.side,
498 sidebarRoot: activeResizeState.sidebarRoot,
499 wrapper: activeResizeState.wrapper,
410 rail,
411 side,
412 sidebarRoot,
413 wrapper,
500414 }) ?? true;
501 if (!accepted) {
502 return;
415 if (accepted) {
416 wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
417 width = nextWidth;
503418 }
504
505 activeResizeState.wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
506 activeResizeState.width = nextWidth;
507 });
508 },
509 [onPointerMove, resolvedResizable],
510 );
511
512 const endResizeInteraction = React.useCallback(
513 (event: React.PointerEvent<HTMLButtonElement>) => {
514 const resizeState = resizeStateRef.current;
515 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
516
517 event.preventDefault();
518 suppressClickRef.current = resizeState.moved;
519 stopResize(event.pointerId);
520 },
521 [stopResize],
522 );
523
524 const handlePointerUp = React.useCallback(
525 (event: React.PointerEvent<HTMLButtonElement>) => {
526 onPointerUp?.(event);
527 if (event.defaultPrevented) return;
528 endResizeInteraction(event);
529 },
530 [endResizeInteraction, onPointerUp],
531 );
532
533 const handlePointerCancel = React.useCallback(
534 (event: React.PointerEvent<HTMLButtonElement>) => {
535 onPointerCancel?.(event);
536 if (event.defaultPrevented) return;
537 endResizeInteraction(event);
538 },
539 [endResizeInteraction, onPointerCancel],
540 );
419 return width;
420 },
421 finish(finalWidth, moved) {
422 suppressClickRef.current = moved;
423 const options = latestResizable.current;
424 if (options?.storageKey) {
425 try {
426 setLocalStorageItem(options.storageKey, finalWidth, Schema.Finite);
427 } catch (error) {
428 console.error("Could not persist sidebar width.", error);
429 }
430 }
431 options?.onResize?.(finalWidth);
432 },
433 cleanup() {
434 transitionTargets.forEach((element) => {
435 element.style.removeProperty("transition-duration");
436 });
437 },
438 };
439 });
541440
542441 const handleClick = React.useCallback(
543442 (event: React.MouseEvent<HTMLButtonElement>) => {
......@@ -579,20 +478,6 @@ function SidebarRail({
579478 resolvedResizable.onResize?.(clampedWidth);
580479 }, [resolvedResizable]);
581480
582 React.useEffect(() => {
583 return () => {
584 const resizeState = resizeStateRef.current;
585 if (resizeState?.rafId != null) {
586 window.cancelAnimationFrame(resizeState.rafId);
587 }
588 resizeState?.transitionTargets.forEach((element) => {
589 element.style.removeProperty("transition-duration");
590 });
591 document.body.style.removeProperty("cursor");
592 document.body.style.removeProperty("user-select");
593 };
594 }, []);
595
596481 return (
597482 <Tooltip>
598483 <TooltipTrigger
......@@ -613,10 +498,26 @@ function SidebarRail({
613498 data-sidebar="rail"
614499 data-slot="sidebar-rail"
615500 onClick={handleClick}
616 onPointerCancel={handlePointerCancel}
617 onPointerDown={handlePointerDown}
618 onPointerMove={handlePointerMove}
619 onPointerUp={handlePointerUp}
501 onLostPointerCapture={(event) => {
502 onLostPointerCapture?.(event);
503 if (!event.defaultPrevented) resize.onLostPointerCapture(event);
504 }}
505 onPointerCancel={(event) => {
506 onPointerCancel?.(event);
507 if (!event.defaultPrevented) resize.onPointerCancel(event);
508 }}
509 onPointerDown={(event) => {
510 onPointerDown?.(event);
511 if (!event.defaultPrevented) resize.onPointerDown(event);
512 }}
513 onPointerMove={(event) => {
514 onPointerMove?.(event);
515 if (!event.defaultPrevented) resize.onPointerMove(event);
516 }}
517 onPointerUp={(event) => {
518 onPointerUp?.(event);
519 if (!event.defaultPrevented) resize.onPointerUp(event);
520 }}
620521 ref={railRef}
621522 tabIndex={-1}
622523 type="button"
apps/web/src/hooks/useResizableWidth.test.tsx+62-5
......@@ -40,13 +40,13 @@ function pointer(clientX = 100) {
4040 } as unknown as PointerEvent<HTMLElement>;
4141}
4242
43function Panel() {
43function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; maxWidth?: number }) {
4444 const resize = useResizableWidth({
4545 storageKey: "test-panel-width",
4646 defaultWidth: 400,
4747 minWidth: 200,
48 maxWidth: 800,
49 edge: "left",
48 maxWidth,
49 edge,
5050 });
5151 useLayoutEffect(() => {
5252 result = resize;
......@@ -107,11 +107,68 @@ describe("panel resize cleanup", () => {
107107 expect(style.userSelect).toBe("");
108108 expect(captured).toBe(false);
109109 expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
110 expect(setItem).not.toHaveBeenCalled();
111 if (reason !== "unmount") expect(result.width).toBe(400);
110 if (reason === "unmount") {
111 expect(setItem).not.toHaveBeenCalled();
112 } else {
113 expect(result.width).toBe(475);
114 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "475");
115 }
112116 },
113117 );
114118
119 it.each(["left", "right"] as const)(
120 "uses the release position for a fast %s-edge drag",
121 async (edge) => {
122 await act(() => renderer.update(<Panel edge={edge} />));
123 await act(() => {
124 result.handlers.onPointerDown(pointer());
125 result.handlers.onPointerMove(pointer(edge === "left" ? 50 : 150));
126 result.handlers.onPointerUp(pointer(edge === "left" ? 25 : 175));
127 });
128 expect(result.width).toBe(475);
129 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "475");
130 expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
131 },
132 );
133
134 it.each([
135 [800, 450],
136 [450, 800],
137 ])("uses updated bounds during a drag from max %s to %s", async (initialMax, nextMax) => {
138 await act(() => renderer.update(<Panel maxWidth={initialMax} />));
139 await act(() => {
140 result.handlers.onPointerDown(pointer());
141 result.handlers.onPointerMove(pointer(-200));
142 });
143 await act(() => frame?.(0));
144 expect(result.width).toBe(Math.min(700, initialMax));
145 await act(() => renderer.update(<Panel maxWidth={nextMax} />));
146 await act(() => result.handlers.onPointerMove(pointer(-250)));
147 await act(() => frame?.(0));
148 expect(result.width).toBe(Math.min(750, nextMax));
149 await act(() => result.handlers.onPointerUp(pointer(-300)));
150 expect(result.width).toBe(nextMax);
151 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", String(nextMax));
152 });
153
154 it("handles release before any move event", async () => {
155 await act(() => {
156 result.handlers.onPointerDown(pointer());
157 result.handlers.onPointerUp(pointer(25));
158 });
159 expect(result.width).toBe(475);
160 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "475");
161 });
162
163 it("clamps the release position to the panel bounds", async () => {
164 await act(() => {
165 result.handlers.onPointerDown(pointer());
166 result.handlers.onPointerUp(pointer(-1000));
167 });
168 expect(result.width).toBe(800);
169 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "800");
170 });
171
115172 it("saves the final width when release is followed by lost capture", async () => {
116173 await act(() => {
117174 result.handlers.onPointerDown(pointer());
apps/web/src/hooks/useResizableWidth.ts+18-118
......@@ -2,12 +2,13 @@ import * as Schema from "effect/Schema";
22import {
33 type PointerEvent as ReactPointerEvent,
44 useCallback,
5 useEffect,
5 useLayoutEffect,
66 useRef,
77 useState,
88} from "react";
99
1010import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage";
11import { useResizeDrag } from "./useResizeDrag";
1112
1213const WidthSchema = Schema.Finite;
1314
......@@ -40,7 +41,7 @@ export interface ResizableWidthHandlers {
4041 *
4142 * The hook updates an internal `width` state during drag (so the panel
4243 * follows the cursor live) and only commits to localStorage when the user
43 * lifts the pointer.
44 * lifts the pointer or the drag is interrupted.
4445 */
4546export function useResizableWidth(options: UseResizableWidthOptions): {
4647 readonly width: number;
......@@ -69,129 +70,28 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
6970 });
7071
7172 const clampedWidth = clamp(width);
73 const latestOptions = useRef({ clamp, storageKey });
74 useLayoutEffect(() => {
75 latestOptions.current = { clamp, storageKey };
76 }, [clamp, storageKey]);
7277
73 const dragStateRef = useRef<{
74 pointerId: number;
75 startX: number;
76 startWidth: number;
77 pending: number;
78 rafId: number | null;
79 target: HTMLElement;
80 } | null>(null);
81
82 const releasePointer = useCallback((pointerId: number) => {
83 const state = dragStateRef.current;
84 if (!state) return;
85 // Clear first because releasing capture can trigger another cleanup.
86 dragStateRef.current = null;
87 if (state.rafId !== null) {
88 cancelAnimationFrame(state.rafId);
89 }
90 try {
91 if (state.target.hasPointerCapture(pointerId)) {
92 state.target.releasePointerCapture(pointerId);
93 }
94 } catch {
95 // pointer may already be released; harmless.
96 }
97 document.body.style.removeProperty("cursor");
98 document.body.style.removeProperty("user-select");
99 }, []);
100
101 const cancelDrag = useCallback(() => {
102 const state = dragStateRef.current;
103 if (!state) return;
104 releasePointer(state.pointerId);
105 setWidth(state.startWidth);
106 }, [releasePointer]);
107
108 useEffect(() => {
109 window.addEventListener("blur", cancelDrag);
110 return () => {
111 window.removeEventListener("blur", cancelDrag);
112 const state = dragStateRef.current;
113 if (state) releasePointer(state.pointerId);
114 };
115 }, [cancelDrag, releasePointer]);
116
117 const onPointerDown = useCallback(
118 (event: ReactPointerEvent<HTMLElement>) => {
119 if (event.button !== 0 || dragStateRef.current) return;
120 event.preventDefault();
121 event.stopPropagation();
122 const target = event.currentTarget;
123 try {
124 target.setPointerCapture(event.pointerId);
125 } catch {
126 return;
127 }
128 document.body.style.cursor = "col-resize";
129 document.body.style.userSelect = "none";
130 dragStateRef.current = {
131 pointerId: event.pointerId,
132 startX: event.clientX,
133 startWidth: clampedWidth,
134 pending: clampedWidth,
135 rafId: null,
136 target,
137 };
138 },
139 [clampedWidth],
140 );
141
142 const onPointerMove = useCallback(
143 (event: ReactPointerEvent<HTMLElement>) => {
144 const state = dragStateRef.current;
145 if (!state || state.pointerId !== event.pointerId) return;
146 event.preventDefault();
147 const delta = edge === "left" ? state.startX - event.clientX : event.clientX - state.startX;
148 state.pending = clamp(state.startWidth + delta);
149 if (state.rafId !== null) return;
150 state.rafId = requestAnimationFrame(() => {
151 const active = dragStateRef.current;
152 if (!active) return;
153 active.rafId = null;
154 setWidth(active.pending);
155 });
78 const handlers = useResizeDrag<HTMLElement>(() => ({
79 width: clampedWidth,
80 edge,
81 resize(value) {
82 const nextWidth = latestOptions.current.clamp(value);
83 setWidth(nextWidth);
84 return nextWidth;
15685 },
157 [clamp, edge],
158 );
159
160 const onPointerUp = useCallback(
161 (event: ReactPointerEvent<HTMLElement>) => {
162 const state = dragStateRef.current;
163 if (!state || state.pointerId !== event.pointerId) return;
164 const finalWidth = clamp(state.pending);
165 releasePointer(event.pointerId);
86 finish(finalWidth) {
16687 // Commit once at drag-end to avoid 60Hz localStorage writes.
16788 try {
168 setLocalStorageItem(storageKey, finalWidth, WidthSchema);
89 setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema);
16990 } catch (error) {
17091 console.error("Could not persist panel width.", error);
17192 }
172 setWidth(finalWidth);
17393 },
174 [clamp, releasePointer, storageKey],
175 );
94 }));
17695
177 const onPointerCancel = useCallback(
178 (event: ReactPointerEvent<HTMLElement>) => {
179 const state = dragStateRef.current;
180 if (!state || state.pointerId !== event.pointerId) return;
181 // Don't persist a cancelled drag; revert to the start width.
182 cancelDrag();
183 },
184 [cancelDrag],
185 );
186
187 return {
188 width: clampedWidth,
189 handlers: {
190 onPointerDown,
191 onPointerMove,
192 onPointerUp,
193 onPointerCancel,
194 onLostPointerCapture: onPointerCancel,
195 },
196 };
96 return { width: clampedWidth, handlers };
19797}
apps/web/src/hooks/useResizeDrag.ts created+116
......@@ -0,0 +1,116 @@
1import { type PointerEvent, useCallback, useEffect, useRef } from "react";
2
3interface ResizeSession {
4 width: number;
5 edge: "left" | "right";
6 resize: (width: number) => number;
7 finish: (width: number, moved: boolean) => void;
8 cleanup?: () => void;
9}
10
11/** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */
12export function useResizeDrag<T extends HTMLElement>(
13 start: (event: PointerEvent<T>) => ResizeSession | null,
14) {
15 const drag = useRef<{
16 session: ResizeSession;
17 target: T;
18 pointerId: number;
19 startX: number;
20 pendingX: number;
21 width: number;
22 moved: boolean;
23 frame: number | null;
24 } | null>(null);
25
26 const flush = useCallback(() => {
27 const active = drag.current;
28 if (!active) return;
29 const delta = (active.pendingX - active.startX) * (active.session.edge === "left" ? -1 : 1);
30 active.moved ||= Math.abs(delta) > 2;
31 active.width = active.session.resize(active.session.width + delta);
32 }, []);
33
34 const finish = useCallback(
35 (commit = true) => {
36 const active = drag.current;
37 if (!active) return;
38 if (active.frame !== null) cancelAnimationFrame(active.frame);
39 if (commit) flush();
40 // Release can synchronously dispatch lostpointercapture.
41 drag.current = null;
42 try {
43 if (active.target.hasPointerCapture(active.pointerId)) {
44 active.target.releasePointerCapture(active.pointerId);
45 }
46 } catch {
47 // Capture may already have been released by the browser.
48 }
49 document.body.style.removeProperty("cursor");
50 document.body.style.removeProperty("user-select");
51 active.session.cleanup?.();
52 if (commit) active.session.finish(active.width, active.moved);
53 },
54 [flush],
55 );
56
57 useEffect(() => {
58 const onBlur = () => finish();
59 window.addEventListener("blur", onBlur);
60 return () => {
61 window.removeEventListener("blur", onBlur);
62 finish(false);
63 };
64 }, [finish]);
65
66 const end = (event: PointerEvent<T>, usePosition: boolean) => {
67 const active = drag.current;
68 if (!active || active.pointerId !== event.pointerId) return;
69 event.preventDefault();
70 if (usePosition) active.pendingX = event.clientX;
71 finish();
72 };
73
74 return {
75 onPointerDown(event: PointerEvent<T>) {
76 if (event.button !== 0 || drag.current) return;
77 const session = start(event);
78 if (!session) return;
79 try {
80 event.currentTarget.setPointerCapture(event.pointerId);
81 } catch {
82 session.cleanup?.();
83 return;
84 }
85 event.preventDefault();
86 event.stopPropagation();
87 drag.current = {
88 session,
89 target: event.currentTarget,
90 pointerId: event.pointerId,
91 startX: event.clientX,
92 pendingX: event.clientX,
93 width: session.width,
94 moved: false,
95 frame: null,
96 };
97 document.body.style.cursor = "col-resize";
98 document.body.style.userSelect = "none";
99 },
100 onPointerMove(event: PointerEvent<T>) {
101 const active = drag.current;
102 if (!active || active.pointerId !== event.pointerId) return;
103 event.preventDefault();
104 active.pendingX = event.clientX;
105 active.moved ||= Math.abs(event.clientX - active.startX) > 2;
106 if (active.frame !== null) return;
107 active.frame = requestAnimationFrame(() => {
108 active.frame = null;
109 flush();
110 });
111 },
112 onPointerUp: (event: PointerEvent<T>) => end(event, true),
113 onPointerCancel: (event: PointerEvent<T>) => end(event, false),
114 onLostPointerCapture: (event: PointerEvent<T>) => end(event, false),
115 };
116}