mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 12:16:12 -05:00
@@ -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>
|
||||
|
||||
141
app/hooks/useDragToScroll.browser.test.ts
Normal file
141
app/hooks/useDragToScroll.browser.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
158
app/hooks/useDragToScroll.ts
Normal file
158
app/hooks/useDragToScroll.ts
Normal 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;
|
||||
}
|
||||
@@ -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
13
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user