| 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,6 +17,7 @@ import { |
| 17 | } from "~/components/ui/sheet"; | 17 | } from "~/components/ui/sheet"; |
| 18 | import { Skeleton } from "~/components/ui/skeleton"; | 18 | import { Skeleton } from "~/components/ui/skeleton"; |
| 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 20 | import { useResizeDrag } from "~/hooks/useResizeDrag"; | ||
| 20 | import { useIsMobile } from "~/hooks/useMediaQuery"; | 21 | import { useIsMobile } from "~/hooks/useMediaQuery"; |
| 21 | import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; | 22 | import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; |
| 22 | import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; | 23 | import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; |
| ... | @@ -353,6 +354,7 @@ function SidebarRail({ | ... | @@ -353,6 +354,7 @@ function SidebarRail({ |
| 353 | className, | 354 | className, |
| 354 | onClick, | 355 | onClick, |
| 355 | onPointerCancel, | 356 | onPointerCancel, |
| 357 | onLostPointerCapture, | ||
| 356 | onPointerDown, | 358 | onPointerDown, |
| 357 | onPointerMove, | 359 | onPointerMove, |
| 358 | onPointerUp, | 360 | onPointerUp, |
| ... | @@ -362,182 +364,79 @@ function SidebarRail({ | ... | @@ -362,182 +364,79 @@ function SidebarRail({ |
| 362 | const sidebarInstance = React.use(SidebarInstanceContext); | 364 | const sidebarInstance = React.use(SidebarInstanceContext); |
| 363 | const railRef = React.useRef<HTMLButtonElement | null>(null); | 365 | const railRef = React.useRef<HTMLButtonElement | null>(null); |
| 364 | const suppressClickRef = React.useRef(false); | 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 | const resolvedResizable = sidebarInstance?.resizable ?? null; | 367 | const resolvedResizable = sidebarInstance?.resizable ?? null; |
| 368 | const latestResizable = React.useRef(resolvedResizable); | ||
| 369 | React.useLayoutEffect(() => { | ||
| 370 | latestResizable.current = resolvedResizable; | ||
| 371 | }, [resolvedResizable]); | ||
| 380 | const canResize = resolvedResizable !== null && open; | 372 | const canResize = resolvedResizable !== null && open; |
| 381 | const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar"; | 373 | const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar"; |
| 382 | const railTitle = canResize ? "Drag to resize sidebar" : "Toggle Sidebar"; | 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 | const side = sidebarInstance?.side ?? "left"; |
| 385 | (pointerId: number) => { | 386 | let width = clampSidebarWidth( |
| 386 | const resizeState = resizeStateRef.current; | 387 | sidebarContainer.getBoundingClientRect().width, |
| 387 | if (!resizeState) { | 388 | resolvedResizable, |
| 388 | return; | 389 | ); |
| 389 | } | 390 | const transitionTargets = [ |
| 390 | if (resizeState.rafId !== null) { | 391 | sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-gap']"), |
| 391 | window.cancelAnimationFrame(resizeState.rafId); | 392 | sidebarContainer, |
| 392 | } | 393 | ].filter((element): element is HTMLElement => element !== null); |
| 393 | resizeState.transitionTargets.forEach((element) => { | 394 | transitionTargets.forEach((element) => { |
| 394 | element.style.removeProperty("transition-duration"); | 395 | element.style.setProperty("transition-duration", "0ms"); |
| 395 | }); | 396 | }); |
| 396 | if (resolvedResizable?.storageKey && typeof window !== "undefined") { | 397 | wrapper.style.setProperty("--sidebar-width", `${width}px`); |
| 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; | ||
| 489 | 398 | ||
| 490 | activeResizeState.rafId = null; | 399 | return { |
| 491 | const nextWidth = activeResizeState.pendingWidth; | 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 | const accepted = | 406 | const accepted = |
| 493 | resolvedResizable.shouldAcceptWidth?.({ | 407 | options.shouldAcceptWidth?.({ |
| 494 | currentWidth: activeResizeState.width, | 408 | currentWidth: width, |
| 495 | nextWidth, | 409 | nextWidth, |
| 496 | rail: activeResizeState.rail, | 410 | rail, |
| 497 | side: activeResizeState.side, | 411 | side, |
| 498 | sidebarRoot: activeResizeState.sidebarRoot, | 412 | sidebarRoot, |
| 499 | wrapper: activeResizeState.wrapper, | 413 | wrapper, |
| 500 | }) ?? true; | 414 | }) ?? true; |
| 501 | if (!accepted) { | 415 | if (accepted) { |
| 502 | return; | 416 | wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); |
| 417 | width = nextWidth; | ||
| 503 | } | 418 | } |
| 504 | 419 | return width; | |
| 505 | activeResizeState.wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); | 420 | }, |
| 506 | activeResizeState.width = nextWidth; | 421 | finish(finalWidth, moved) { |
| 507 | }); | 422 | suppressClickRef.current = moved; |
| 508 | }, | 423 | const options = latestResizable.current; |
| 509 | [onPointerMove, resolvedResizable], | 424 | if (options?.storageKey) { |
| 510 | ); | 425 | try { |
| 511 | 426 | setLocalStorageItem(options.storageKey, finalWidth, Schema.Finite); | |
| 512 | const endResizeInteraction = React.useCallback( | 427 | } catch (error) { |
| 513 | (event: React.PointerEvent<HTMLButtonElement>) => { | 428 | console.error("Could not persist sidebar width.", error); |
| 514 | const resizeState = resizeStateRef.current; | 429 | } |
| 515 | if (!resizeState || resizeState.pointerId !== event.pointerId) return; | 430 | } |
| 516 | 431 | options?.onResize?.(finalWidth); | |
| 517 | event.preventDefault(); | 432 | }, |
| 518 | suppressClickRef.current = resizeState.moved; | 433 | cleanup() { |
| 519 | stopResize(event.pointerId); | 434 | transitionTargets.forEach((element) => { |
| 520 | }, | 435 | element.style.removeProperty("transition-duration"); |
| 521 | [stopResize], | 436 | }); |
| 522 | ); | 437 | }, |
| 523 | 438 | }; | |
| 524 | const handlePointerUp = React.useCallback( | 439 | }); |
| 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 | ); | ||
| 541 | 440 | ||
| 542 | const handleClick = React.useCallback( | 441 | const handleClick = React.useCallback( |
| 543 | (event: React.MouseEvent<HTMLButtonElement>) => { | 442 | (event: React.MouseEvent<HTMLButtonElement>) => { |
| ... | @@ -579,20 +478,6 @@ function SidebarRail({ | ... | @@ -579,20 +478,6 @@ function SidebarRail({ |
| 579 | resolvedResizable.onResize?.(clampedWidth); | 478 | resolvedResizable.onResize?.(clampedWidth); |
| 580 | }, [resolvedResizable]); | 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 | return ( | 481 | return ( |
| 597 | <Tooltip> | 482 | <Tooltip> |
| 598 | <TooltipTrigger | 483 | <TooltipTrigger |
| ... | @@ -613,10 +498,26 @@ function SidebarRail({ | ... | @@ -613,10 +498,26 @@ function SidebarRail({ |
| 613 | data-sidebar="rail" | 498 | data-sidebar="rail" |
| 614 | data-slot="sidebar-rail" | 499 | data-slot="sidebar-rail" |
| 615 | onClick={handleClick} | 500 | onClick={handleClick} |
| 616 | onPointerCancel={handlePointerCancel} | 501 | onLostPointerCapture={(event) => { |
| 617 | onPointerDown={handlePointerDown} | 502 | onLostPointerCapture?.(event); |
| 618 | onPointerMove={handlePointerMove} | 503 | if (!event.defaultPrevented) resize.onLostPointerCapture(event); |
| 619 | onPointerUp={handlePointerUp} | 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 | ref={railRef} | 521 | ref={railRef} |
| 621 | tabIndex={-1} | 522 | tabIndex={-1} |
| 622 | type="button" | 523 | type="button" |
apps/web/src/hooks/useResizableWidth.test.tsx+62-5| ... | @@ -40,13 +40,13 @@ function pointer(clientX = 100) { | ... | @@ -40,13 +40,13 @@ function pointer(clientX = 100) { |
| 40 | } as unknown as PointerEvent<HTMLElement>; | 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 | const resize = useResizableWidth({ | 44 | const resize = useResizableWidth({ |
| 45 | storageKey: "test-panel-width", | 45 | storageKey: "test-panel-width", |
| 46 | defaultWidth: 400, | 46 | defaultWidth: 400, |
| 47 | minWidth: 200, | 47 | minWidth: 200, |
| 48 | maxWidth: 800, | 48 | maxWidth, |
| 49 | edge: "left", | 49 | edge, |
| 50 | }); | 50 | }); |
| 51 | useLayoutEffect(() => { | 51 | useLayoutEffect(() => { |
| 52 | result = resize; | 52 | result = resize; |
| ... | @@ -107,11 +107,68 @@ describe("panel resize cleanup", () => { | ... | @@ -107,11 +107,68 @@ describe("panel resize cleanup", () => { |
| 107 | expect(style.userSelect).toBe(""); | 107 | expect(style.userSelect).toBe(""); |
| 108 | expect(captured).toBe(false); | 108 | expect(captured).toBe(false); |
| 109 | expect(cancelAnimationFrame).toHaveBeenCalledWith(42); | 109 | expect(cancelAnimationFrame).toHaveBeenCalledWith(42); |
| 110 | expect(setItem).not.toHaveBeenCalled(); | 110 | if (reason === "unmount") { |
| 111 | if (reason !== "unmount") expect(result.width).toBe(400); | 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 | it("saves the final width when release is followed by lost capture", async () => { | 172 | it("saves the final width when release is followed by lost capture", async () => { |
| 116 | await act(() => { | 173 | await act(() => { |
| 117 | result.handlers.onPointerDown(pointer()); | 174 | result.handlers.onPointerDown(pointer()); |
apps/web/src/hooks/useResizableWidth.ts+18-118| ... | @@ -2,12 +2,13 @@ import * as Schema from "effect/Schema"; | ... | @@ -2,12 +2,13 @@ import * as Schema from "effect/Schema"; |
| 2 | import { | 2 | import { |
| 3 | type PointerEvent as ReactPointerEvent, | 3 | type PointerEvent as ReactPointerEvent, |
| 4 | useCallback, | 4 | useCallback, |
| 5 | useEffect, | 5 | useLayoutEffect, |
| 6 | useRef, | 6 | useRef, |
| 7 | useState, | 7 | useState, |
| 8 | } from "react"; | 8 | } from "react"; |
| 9 | 9 | ||
| 10 | import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; | 10 | import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; |
| 11 | import { useResizeDrag } from "./useResizeDrag"; | ||
| 11 | 12 | ||
| 12 | const WidthSchema = Schema.Finite; | 13 | const WidthSchema = Schema.Finite; |
| 13 | 14 | ||
| ... | @@ -40,7 +41,7 @@ export interface ResizableWidthHandlers { | ... | @@ -40,7 +41,7 @@ export interface ResizableWidthHandlers { |
| 40 | * | 41 | * |
| 41 | * The hook updates an internal `width` state during drag (so the panel | 42 | * The hook updates an internal `width` state during drag (so the panel |
| 42 | * follows the cursor live) and only commits to localStorage when the user | 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 | export function useResizableWidth(options: UseResizableWidthOptions): { | 46 | export function useResizableWidth(options: UseResizableWidthOptions): { |
| 46 | readonly width: number; | 47 | readonly width: number; |
| ... | @@ -69,129 +70,28 @@ export function useResizableWidth(options: UseResizableWidthOptions): { | ... | @@ -69,129 +70,28 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 69 | }); | 70 | }); |
| 70 | 71 | ||
| 71 | const clampedWidth = clamp(width); | 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<{ | 78 | const handlers = useResizeDrag<HTMLElement>(() => ({ |
| 74 | pointerId: number; | 79 | width: clampedWidth, |
| 75 | startX: number; | 80 | edge, |
| 76 | startWidth: number; | 81 | resize(value) { |
| 77 | pending: number; | 82 | const nextWidth = latestOptions.current.clamp(value); |
| 78 | rafId: number | null; | 83 | setWidth(nextWidth); |
| 79 | target: HTMLElement; | 84 | return nextWidth; |
| 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 | }); | ||
| 156 | }, | 85 | }, |
| 157 | [clamp, edge], | 86 | finish(finalWidth) { |
| 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); | ||
| 166 | // Commit once at drag-end to avoid 60Hz localStorage writes. | 87 | // Commit once at drag-end to avoid 60Hz localStorage writes. |
| 167 | try { | 88 | try { |
| 168 | setLocalStorageItem(storageKey, finalWidth, WidthSchema); | 89 | setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); |
| 169 | } catch (error) { | 90 | } catch (error) { |
| 170 | console.error("Could not persist panel width.", error); | 91 | console.error("Could not persist panel width.", error); |
| 171 | } | 92 | } |
| 172 | setWidth(finalWidth); | ||
| 173 | }, | 93 | }, |
| 174 | [clamp, releasePointer, storageKey], | 94 | })); |
| 175 | ); | ||
| 176 | 95 | ||
| 177 | const onPointerCancel = useCallback( | 96 | return { width: clampedWidth, handlers }; |
| 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 | }; | ||
| 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 | } | ||