| author | |
| committer | |
| log | 46140c96afe1d8a8b5b4bde1fccb671f8760017a |
| tree | 55882a68a78c2b3d4c9cc3d4b3ef082f3d4462e5 |
| parent | 3138f5716098a331f9a7d4cfc1bcd07118967a83 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 281 insertions(+), 307 deletions(-)
apps/web/src/components/ui/sidebar.tsx+85-184| ... | ... | @@ -17,6 +17,7 @@ import { |
| 17 | 17 | } from "~/components/ui/sheet"; |
| 18 | 18 | import { Skeleton } from "~/components/ui/skeleton"; |
| 19 | 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 20 | import { useResizeDrag } from "~/hooks/useResizeDrag"; | |
| 20 | 21 | import { useIsMobile } from "~/hooks/useMediaQuery"; |
| 21 | 22 | import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; |
| 22 | 23 | import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; |
| ... | ... | @@ -353,6 +354,7 @@ function SidebarRail({ |
| 353 | 354 | className, |
| 354 | 355 | onClick, |
| 355 | 356 | onPointerCancel, |
| 357 | onLostPointerCapture, | |
| 356 | 358 | onPointerDown, |
| 357 | 359 | onPointerMove, |
| 358 | 360 | onPointerUp, |
| ... | ... | @@ -362,182 +364,79 @@ function SidebarRail({ |
| 362 | 364 | const sidebarInstance = React.use(SidebarInstanceContext); |
| 363 | 365 | const railRef = React.useRef<HTMLButtonElement | null>(null); |
| 364 | 366 | 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); | |
| 379 | 367 | const resolvedResizable = sidebarInstance?.resizable ?? null; |
| 368 | const latestResizable = React.useRef(resolvedResizable); | |
| 369 | React.useLayoutEffect(() => { | |
| 370 | latestResizable.current = resolvedResizable; | |
| 371 | }, [resolvedResizable]); | |
| 380 | 372 | const canResize = resolvedResizable !== null && open; |
| 381 | 373 | const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar"; |
| 382 | 374 | 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; | |
| 383 | 384 | |
| 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`); | |
| 489 | 398 | |
| 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); | |
| 492 | 406 | const accepted = |
| 493 | resolvedResizable.shouldAcceptWidth?.({ | |
| 494 | currentWidth: activeResizeState.width, | |
| 407 | options.shouldAcceptWidth?.({ | |
| 408 | currentWidth: width, | |
| 495 | 409 | 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, | |
| 500 | 414 | }) ?? true; |
| 501 | if (!accepted) { | |
| 502 | return; | |
| 415 | if (accepted) { | |
| 416 | wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); | |
| 417 | width = nextWidth; | |
| 503 | 418 | } |
| 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 | }); | |
| 541 | 440 | |
| 542 | 441 | const handleClick = React.useCallback( |
| 543 | 442 | (event: React.MouseEvent<HTMLButtonElement>) => { |
| ... | ... | @@ -579,20 +478,6 @@ function SidebarRail({ |
| 579 | 478 | resolvedResizable.onResize?.(clampedWidth); |
| 580 | 479 | }, [resolvedResizable]); |
| 581 | 480 | |
| 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 | ||
| 596 | 481 | return ( |
| 597 | 482 | <Tooltip> |
| 598 | 483 | <TooltipTrigger |
| ... | ... | @@ -613,10 +498,26 @@ function SidebarRail({ |
| 613 | 498 | data-sidebar="rail" |
| 614 | 499 | data-slot="sidebar-rail" |
| 615 | 500 | 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 | }} | |
| 620 | 521 | ref={railRef} |
| 621 | 522 | tabIndex={-1} |
| 622 | 523 | type="button" |
apps/web/src/hooks/useResizableWidth.test.tsx+62-5| ... | ... | @@ -40,13 +40,13 @@ function pointer(clientX = 100) { |
| 40 | 40 | } as unknown as PointerEvent<HTMLElement>; |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | function Panel() { | |
| 43 | function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; maxWidth?: number }) { | |
| 44 | 44 | const resize = useResizableWidth({ |
| 45 | 45 | storageKey: "test-panel-width", |
| 46 | 46 | defaultWidth: 400, |
| 47 | 47 | minWidth: 200, |
| 48 | maxWidth: 800, | |
| 49 | edge: "left", | |
| 48 | maxWidth, | |
| 49 | edge, | |
| 50 | 50 | }); |
| 51 | 51 | useLayoutEffect(() => { |
| 52 | 52 | result = resize; |
| ... | ... | @@ -107,11 +107,68 @@ describe("panel resize cleanup", () => { |
| 107 | 107 | expect(style.userSelect).toBe(""); |
| 108 | 108 | expect(captured).toBe(false); |
| 109 | 109 | 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 | } | |
| 112 | 116 | }, |
| 113 | 117 | ); |
| 114 | 118 | |
| 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 | ||
| 115 | 172 | it("saves the final width when release is followed by lost capture", async () => { |
| 116 | 173 | await act(() => { |
| 117 | 174 | result.handlers.onPointerDown(pointer()); |
apps/web/src/hooks/useResizableWidth.ts+18-118| ... | ... | @@ -2,12 +2,13 @@ import * as Schema from "effect/Schema"; |
| 2 | 2 | import { |
| 3 | 3 | type PointerEvent as ReactPointerEvent, |
| 4 | 4 | useCallback, |
| 5 | useEffect, | |
| 5 | useLayoutEffect, | |
| 6 | 6 | useRef, |
| 7 | 7 | useState, |
| 8 | 8 | } from "react"; |
| 9 | 9 | |
| 10 | 10 | import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; |
| 11 | import { useResizeDrag } from "./useResizeDrag"; | |
| 11 | 12 | |
| 12 | 13 | const WidthSchema = Schema.Finite; |
| 13 | 14 | |
| ... | ... | @@ -40,7 +41,7 @@ export interface ResizableWidthHandlers { |
| 40 | 41 | * |
| 41 | 42 | * The hook updates an internal `width` state during drag (so the panel |
| 42 | 43 | * 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. | |
| 44 | 45 | */ |
| 45 | 46 | export function useResizableWidth(options: UseResizableWidthOptions): { |
| 46 | 47 | readonly width: number; |
| ... | ... | @@ -69,129 +70,28 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 69 | 70 | }); |
| 70 | 71 | |
| 71 | 72 | const clampedWidth = clamp(width); |
| 73 | const latestOptions = useRef({ clamp, storageKey }); | |
| 74 | useLayoutEffect(() => { | |
| 75 | latestOptions.current = { clamp, storageKey }; | |
| 76 | }, [clamp, storageKey]); | |
| 72 | 77 | |
| 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; | |
| 156 | 85 | }, |
| 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) { | |
| 166 | 87 | // Commit once at drag-end to avoid 60Hz localStorage writes. |
| 167 | 88 | try { |
| 168 | setLocalStorageItem(storageKey, finalWidth, WidthSchema); | |
| 89 | setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); | |
| 169 | 90 | } catch (error) { |
| 170 | 91 | console.error("Could not persist panel width.", error); |
| 171 | 92 | } |
| 172 | setWidth(finalWidth); | |
| 173 | 93 | }, |
| 174 | [clamp, releasePointer, storageKey], | |
| 175 | ); | |
| 94 | })); | |
| 176 | 95 | |
| 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 }; | |
| 197 | 97 | } |
apps/web/src/hooks/useResizeDrag.ts created+116| ... | ... | @@ -0,0 +1,116 @@ |
| 1 | import { type PointerEvent, useCallback, useEffect, useRef } from "react"; | |
| 2 | ||
| 3 | interface 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. */ | |
| 12 | export 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 | } |