react-use-draggable-scroll → tiny handwritten hook

Closes #3357
This commit is contained in:
Kalle
2026-08-21 21:56:09 +03:00
parent 4c5d0eea69
commit 564f17b640
5 changed files with 302 additions and 23 deletions

View File

@@ -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<HTMLDivElement>(
null,
) as React.MutableRefObject<HTMLDivElement>;
const { events } = useDraggable(ref, {
applyRubberBandEffect: true,
});
const ref = useDragToScroll<HTMLDivElement>();
usePublishBracketTopOffset(ref);
return (
@@ -106,7 +101,6 @@ function ScrollableBracketContainer({
className={clsx(styles.bracket, styles.scrollingBracket)}
data-testid="brackets-viewer"
ref={ref}
{...events}
>
{children}
</div>

View File

@@ -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);
});
});

View File

@@ -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<T | null> {
const ref = React.useRef<T>(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;
}

View File

@@ -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",

13
pnpm-lock.yaml generated
View File

@@ -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: