diff --git a/app/features/tournament-bracket/components/Bracket/index.tsx b/app/features/tournament-bracket/components/Bracket/index.tsx index 709465687..8464c2181 100644 --- a/app/features/tournament-bracket/components/Bracket/index.tsx +++ b/app/features/tournament-bracket/components/Bracket/index.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; -import * as React from "react"; -import { useDraggable } from "react-use-draggable-scroll"; +import type * as React from "react"; import { useBracketExpanded } from "~/features/tournament/routes/to.$id"; +import { useDragToScroll } from "~/hooks/useDragToScroll"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import type { Bracket as BracketType } from "../../core/Bracket"; import styles from "./bracket.module.css"; @@ -92,12 +92,7 @@ function ScrollableBracketContainer({ }: { children: React.ReactNode; }) { - const ref = React.useRef( - null, - ) as React.MutableRefObject; - const { events } = useDraggable(ref, { - applyRubberBandEffect: true, - }); + const ref = useDragToScroll(); usePublishBracketTopOffset(ref); return ( @@ -106,7 +101,6 @@ function ScrollableBracketContainer({ className={clsx(styles.bracket, styles.scrollingBracket)} data-testid="brackets-viewer" ref={ref} - {...events} > {children} diff --git a/app/hooks/useDragToScroll.browser.test.ts b/app/hooks/useDragToScroll.browser.test.ts new file mode 100644 index 000000000..502a80dc5 --- /dev/null +++ b/app/hooks/useDragToScroll.browser.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { dragToScroll } from "./useDragToScroll"; + +let cleanupFns: Array<() => void> = []; + +afterEach(() => { + for (const cleanup of cleanupFns) { + cleanup(); + } + cleanupFns = []; +}); + +function setUpScrollableElement() { + const element = document.createElement("div"); + element.style.width = "100px"; + element.style.height = "100px"; + element.style.overflow = "scroll"; + + const child = document.createElement("div"); + child.style.width = "1000px"; + child.style.height = "1000px"; + element.appendChild(child); + + document.body.appendChild(element); + + const detach = dragToScroll(element); + cleanupFns.push(() => { + detach(); + element.remove(); + }); + + return { element, child, detach }; +} + +function mouseDownOn(element: HTMLElement, clientX: number, clientY: number) { + element.dispatchEvent( + new MouseEvent("mousedown", { buttons: 1, clientX, clientY }), + ); +} + +function mouseMoveTo(clientX: number, clientY: number) { + window.dispatchEvent( + new MouseEvent("mousemove", { buttons: 1, clientX, clientY }), + ); +} + +function mouseUp() { + window.dispatchEvent(new MouseEvent("mouseup")); +} + +describe("dragToScroll", () => { + test("scrolls the element by the dragged distance", () => { + const { element } = setUpScrollableElement(); + + mouseDownOn(element, 50, 50); + mouseMoveTo(20, 40); + mouseUp(); + + expect(element.scrollLeft).toBe(30); + expect(element.scrollTop).toBe(10); + }); + + test("keeps scrolling with momentum after release", async () => { + const { element } = setUpScrollableElement(); + + mouseDownOn(element, 90, 50); + mouseMoveTo(70, 50); + mouseMoveTo(50, 50); + const scrollLeftAtRelease = element.scrollLeft; + mouseUp(); + + await vi.waitFor(() => { + expect(element.scrollLeft).toBeGreaterThan(scrollLeftAtRelease); + }); + }); + + test("shows a grabbing cursor while dragging and restores it on release", () => { + const { element, child } = setUpScrollableElement(); + + mouseDownOn(element, 50, 50); + mouseMoveTo(45, 50); + expect(getComputedStyle(child).cursor).toBe("grabbing"); + + mouseUp(); + expect(getComputedStyle(child).cursor).not.toBe("grabbing"); + }); + + test("suppresses the click that concludes a drag", () => { + const { element, child } = setUpScrollableElement(); + const onClick = vi.fn(); + child.addEventListener("click", onClick); + + mouseDownOn(element, 50, 50); + mouseMoveTo(10, 50); + mouseUp(); + child.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClick).not.toHaveBeenCalled(); + }); + + test("lets a click through when the mouse barely moved", () => { + const { element, child } = setUpScrollableElement(); + const onClick = vi.fn(); + child.addEventListener("click", onClick); + + mouseDownOn(element, 50, 50); + mouseMoveTo(47, 50); + mouseUp(); + child.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("lets a later click through after a drag suppressed one", () => { + const { element, child } = setUpScrollableElement(); + const onClick = vi.fn(); + child.addEventListener("click", onClick); + + mouseDownOn(element, 50, 50); + mouseMoveTo(10, 50); + mouseUp(); + + mouseDownOn(element, 50, 50); + mouseUp(); + child.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("stops reacting to the mouse after cleanup", () => { + const { element, detach } = setUpScrollableElement(); + + detach(); + + mouseDownOn(element, 50, 50); + mouseMoveTo(20, 50); + mouseUp(); + + expect(element.scrollLeft).toBe(0); + }); +}); diff --git a/app/hooks/useDragToScroll.ts b/app/hooks/useDragToScroll.ts new file mode 100644 index 000000000..c221fb1a0 --- /dev/null +++ b/app/hooks/useDragToScroll.ts @@ -0,0 +1,158 @@ +import * as React from "react"; + +const DRAG_ACTIVATION_DISTANCE_PX = 10; +const MOMENTUM_DECAY_PER_FRAME = 0.95; +const MOMENTUM_MIN_SPEED_PX_PER_MS = 0.05; +const MOMENTUM_FRAME_MS = 1000 / 60; +const VELOCITY_IDLE_TIMEOUT_MS = 100; + +/** + * Makes an element with overflowing content scrollable by dragging with the + * mouse. Returns the ref to attach to the scrollable element. + */ +export function useDragToScroll< + T extends HTMLElement, +>(): React.RefObject { + const ref = React.useRef(null); + + React.useEffect(() => { + if (!ref.current) return; + + return dragToScroll(ref.current); + }, []); + + return ref; +} + +/** + * Attaches mouse drag-to-scroll behavior with release momentum to an element, + * returning a cleanup function. A grabbing cursor is shown while dragging and + * clicks that conclude a drag are suppressed. + * Framework-agnostic on purpose (usable as a Svelte attachment as is). + */ +export function dragToScroll(element: HTMLElement): () => void { + let grabbingCursorStyle: HTMLStyleElement | null = null; + let isMouseDown = false; + let isDragging = false; + let suppressNextClick = false; + let lastClientX = 0; + let lastClientY = 0; + let lastMoveAt = 0; + let totalMovementX = 0; + let totalMovementY = 0; + let velocityX = 0; + let velocityY = 0; + let momentumFrame = 0; + + const onMouseDown = (event: MouseEvent) => { + suppressNextClick = false; + if (event.buttons !== 1) return; + + cancelAnimationFrame(momentumFrame); + isMouseDown = true; + isDragging = false; + lastClientX = event.clientX; + lastClientY = event.clientY; + lastMoveAt = performance.now(); + totalMovementX = 0; + totalMovementY = 0; + velocityX = 0; + velocityY = 0; + }; + + const onMouseMove = (event: MouseEvent) => { + if (!isMouseDown) return; + + event.preventDefault(); + + grabbingCursorStyle ??= createGrabbingCursorStyle(); + + const now = performance.now(); + const elapsedMs = Math.max(now - lastMoveAt, 1); + const deltaX = lastClientX - event.clientX; + const deltaY = lastClientY - event.clientY; + lastClientX = event.clientX; + lastClientY = event.clientY; + lastMoveAt = now; + totalMovementX += Math.abs(deltaX); + totalMovementY += Math.abs(deltaY); + + element.scrollLeft += deltaX; + element.scrollTop += deltaY; + velocityX = deltaX / elapsedMs; + velocityY = deltaY / elapsedMs; + + if ( + !isDragging && + (totalMovementX > DRAG_ACTIVATION_DISTANCE_PX || + totalMovementY > DRAG_ACTIVATION_DISTANCE_PX) + ) { + isDragging = true; + } + }; + + const onMouseUp = () => { + if (!isMouseDown) return; + + isMouseDown = false; + grabbingCursorStyle?.remove(); + grabbingCursorStyle = null; + suppressNextClick = isDragging; + + const idledSinceLastMove = + performance.now() - lastMoveAt > VELOCITY_IDLE_TIMEOUT_MS; + if (isDragging && !idledSinceLastMove) { + previousFrameAt = performance.now(); + momentumFrame = requestAnimationFrame(momentumScrollStep); + } + isDragging = false; + }; + + const onClick = (event: MouseEvent) => { + if (!suppressNextClick) return; + + suppressNextClick = false; + event.preventDefault(); + event.stopPropagation(); + }; + + let previousFrameAt = 0; + const momentumScrollStep = (now: number) => { + const elapsedMs = Math.min(now - previousFrameAt, 3 * MOMENTUM_FRAME_MS); + previousFrameAt = now; + + const decay = MOMENTUM_DECAY_PER_FRAME ** (elapsedMs / MOMENTUM_FRAME_MS); + velocityX *= decay; + velocityY *= decay; + element.scrollLeft += velocityX * elapsedMs; + element.scrollTop += velocityY * elapsedMs; + + if ( + Math.abs(velocityX) > MOMENTUM_MIN_SPEED_PX_PER_MS || + Math.abs(velocityY) > MOMENTUM_MIN_SPEED_PX_PER_MS + ) { + momentumFrame = requestAnimationFrame(momentumScrollStep); + } + }; + + element.addEventListener("mousedown", onMouseDown); + element.addEventListener("click", onClick, { capture: true }); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + + return () => { + element.removeEventListener("mousedown", onMouseDown); + element.removeEventListener("click", onClick, { capture: true }); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + cancelAnimationFrame(momentumFrame); + grabbingCursorStyle?.remove(); + }; +} + +function createGrabbingCursorStyle() { + const style = document.createElement("style"); + style.textContent = "* { cursor: grabbing !important; }"; + document.head.appendChild(style); + return style; +} diff --git a/package.json b/package.json index adc34f815..2443348ef 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,6 @@ "react-flip-toolkit": "7.2.4", "react-i18next": "17.0.11", "react-router": "8.3.0", - "react-use-draggable-scroll": "0.4.7", "remeda": "2.39.0", "remix-auth": "4.2.0", "remix-auth-oauth2": "3.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b38ac111..1f271f7e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,9 +156,6 @@ importers: react-router: specifier: 8.3.0 version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react-use-draggable-scroll: - specifier: 0.4.7 - version: 0.4.7(react@19.2.8) remeda: specifier: 2.39.0 version: 2.39.0 @@ -4004,12 +4001,6 @@ packages: '@types/react': optional: true - react-use-draggable-scroll@0.4.7: - resolution: {integrity: sha512-6gCxGPO9WV5dIsBaDrgUKBaac8CY07PkygcArfajijYSNDwAq0girDRjaBuF1+lRqQryoLFQfpVaV2u/Yh6CrQ==} - engines: {node: '>=10'} - peerDependencies: - react: '>=16' - react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -8329,10 +8320,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - react-use-draggable-scroll@0.4.7(react@19.2.8): - dependencies: - react: 19.2.8 - react@19.2.8: {} readable-stream@3.6.2: