Foundations

Dragging

Press, move, release: telling a drag from a click, following the pointer, and letting people cancel.

Markdown

Dragging is how people move things with their hands: a thing on a canvas, the edge of a column, a marquee, the view itself. Every drag is the same gesture — press, move, release — and every one has to tell a drag from a click, follow the pointer wherever it goes, and let people change their mind.

A press that becomes a drag

Press the puck and release: a click. Press and move a little farther: a drag, which follows the pointer out of the box. Hold Shift part-way to keep to one direction; press Escape to put it back.

Press the puck and move.

Code
"use client";

import { DRAG_THRESHOLD, useDrag } from "@publictheta/ui/primitives/drag.ts";
import type { Drag } from "@publictheta/ui/primitives/drag.ts";
import { focusRingClassName } from "@publictheta/ui/lib/focus-class.ts";
import { useState } from "react";
import type { KeyboardEvent } from "react";
import { Readout } from "../../ui/controls.tsx";

const HOME = { x: 24, y: 24 };

/** How far a drag has carried the puck: with Shift, along the direction it has gone farther in. */
const travel = (drag: Drag) =>
  drag.shiftKey
    ? Math.abs(drag.dx) >= Math.abs(drag.dy)
      ? { x: drag.dx, y: 0 }
      : { x: 0, y: drag.dy }
    : { x: drag.dx, y: drag.dy };

export default function DragPuck() {
  // Where the puck rests, and how far the drag in progress has carried it.
  const [at, setAt] = useState(HOME);
  const [carried, setCarried] = useState<{ x: number; y: number } | null>(null);
  const [said, setSaid] = useState("Press the puck and move.");

  const drag = useDrag({
    onPress(press) {
      const threshold =
        press.pointerType === "mouse" || press.pointerType === "pen" ? press.pointerType : "touch";
      setSaid(
        `Pressed. A drag begins ${DRAG_THRESHOLD[threshold]} pixels from here; released sooner, it is a click.`,
      );
    },
    onMove(move) {
      const by = travel(move);
      setCarried(by);
      setSaid(
        `Dragging: ${Math.round(by.x)}, ${Math.round(by.y)}${move.shiftKey ? " · Shift: one direction" : ""}`,
      );
    },
    onEnd(end) {
      const by = travel(end);
      setAt({ x: at.x + by.x, y: at.y + by.y });
      setCarried(null);
      setSaid("Released: the drag is one change.");
    },
    onCancel() {
      setCarried(null);
      setSaid("Cancelled: back where it was.");
    },
  });

  // A drag is never the only way: the arrow keys move the puck too.
  function onKeyDown(event: KeyboardEvent): void {
    const step = event.shiftKey ? 10 : 1;
    const by = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[
      event.key
    ];
    if (by === undefined) return;
    event.preventDefault();
    setAt({ x: at.x + by[0]!, y: at.y + by[1]! });
    setSaid("Moved by the keyboard.");
  }

  return (
    <div className="w-full">
      <div className="relative h-56 w-full overflow-clip rounded-lg border border-border-muted bg-surface">
        {carried !== null && (
          <div
            aria-hidden
            className="absolute size-14 rounded-full border border-dashed border-border"
            style={{ left: at.x, top: at.y }}
          />
        )}
        <button
          type="button"
          aria-label="Puck"
          {...drag.props}
          onKeyDown={onKeyDown}
          onClick={() => setSaid("Clicked: the press never became a drag.")}
          // A finger's movement is the drag's, not the page's scroll.
          className={`absolute size-14 touch-none rounded-full bg-selected text-on-selected select-none ${focusRingClassName}`}
          style={{ left: at.x + (carried?.x ?? 0), top: at.y + (carried?.y ?? 0) }}
        />
      </div>
      <Readout>{said}</Readout>
    </div>
  );
}

Best practices

Let a click stay a click. A press becomes a drag only after it has travelled a little way. A hand is not perfectly still when it presses; without this, every click would nudge what it clicked.

Fit the distance to the pointer. A mouse can be held to a few pixels; a pen slips a little as it lands; a fingertip covers many pixels and rolls as it presses. Wait for about 3 pixels from a mouse, 6 from a pen and 10 from a finger. Measure in a straight line from the press, so that a diagonal drag begins as soon as a level one.

Follow the pointer everywhere. Once a drag has begun it continues wherever the pointer goes — over other things, out of the area it began in — and ends where the pointer is released.

Measure from the press. Work out what a drag has done from how far the pointer is from where it was pressed, not from the last step it took. Steps accumulate error; a distance does not, and it lets a drag be put back exactly.

Read the modifier keys as they are now. A key held changes what a drag means — constrain, copy, move freely — and people press and release such keys in the middle of a drag. Apply the change at once, even while the pointer rests.

Always let people cancel. Escape abandons a drag and puts everything back as it was. So does anything that takes the pointer away: the system claiming a touch, a menu opening, the window losing the pointer.

Keep a cancelled drag from doing anything else. The Escape that cancels a drag does not also close the dialog the drag was in. The release that follows a drag — finished or cancelled — is not a click on what is under it.

One pointer, one drag. A second finger or pointer pressed during a drag does not take it over. The secondary button never begins a drag: it is for a menu.

Keep the page still. A drag does not select the text it passes over, start the browser's own drag of an image or a link, or scroll the page under a finger. Say which directions, if any, a finger may still scroll in.

Report a drag as one change. However many steps it took, a drag is one action to undo. Show its progress as it goes; record it when it ends.

Conventions

IntentPointerKeyboardTouch
BeginPress and move 3 pixels (a pen: 6)—Press and move 10
Cancel—Escape—
Change the meaningHold or release a modifier key——
EndRelease—Lift

Whatever can be dragged can also be done from the keyboard, by other means: a drag is never the only way.

See also Snapping, Resizing and Pan and zoom.

Behaviour

Threshold

A press is a drag only once it has travelled past the threshold, in any direction
expect(pastThreshold(3, 0, 3)).toBe(false);
expect(pastThreshold(0, -3.1, 3)).toBe(true);
// Three across and three down is more than four away.
expect(pastThreshold(3, 3, 4)).toBe(true);
expect(dragDistance(3, 4)).toBe(5);
The threshold is 3 pixels for a mouse, 6 for a pen and 10 for a finger
expect(DRAG_THRESHOLD).toEqual({ mouse: 3, pen: 6, touch: 10 });
expect(["mouse", "pen", "touch"].map(dragThreshold)).toEqual([3, 6, 10]);
A pointer of an unknown kind is held to the coarsest threshold
expect(dragThreshold("")).toBe(10);
A press released where it was made is a click, not a drag
const log: string[] = [];
const onClick = vi.fn();
await render(
  // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- hears the click a drag would swallow
  <div onClick={onClick}>
    <Pad log={log} />
  </div>,
);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(2, 0, 1);
await commands.mouseUp();
expect(log).toEqual([]);
expect(onClick).toHaveBeenCalledOnce();
A press that moves past the threshold becomes a drag, and says where it began
const onStart = vi.fn();
await render(<Pad onStart={onStart} />);
const { pad } = watch();
// Where the press itself said it was: an emulated phone does not put the
// mouse on the exact pixel asked for.
const pressed = { x: Number.NaN, y: Number.NaN };
pad.addEventListener(
  "pointerdown",
  (event) => {
    pressed.x = event.clientX;
    pressed.y = event.clientY;
  },
  { capture: true },
);
const box = pad.getBoundingClientRect();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(8, 0, 1);
expect(onStart).toHaveBeenCalledOnce();
const start = onStart.mock.calls[0]![0] as Drag;
expect(start.pointerType).toBe("mouse");
expect(start.startX).toBe(pressed.x);
expect(start.startY).toBe(pressed.y);
expect(Math.abs(pressed.x - (box.left + box.width / 2))).toBeLessThan(2);
expect(Math.abs(pressed.y - (box.top + box.height / 2))).toBeLessThan(2);
await commands.mouseUp();
A finger must travel farther than a mouse before it dragsChromium · touch

Not yet written.

The drag

Each move reports the whole distance from the press
const log: string[] = [];
await render(<Pad log={log} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(10, 0, 1);
await commands.mouseMoveBy(10, 5, 1);
await commands.mouseMoveBy(-30, 0, 1);
expect(log).toEqual(["start 10,0", "move 10,0", "move 20,5", "move -10,5"]);
await commands.mouseUp();
The element captures the pointer once the drag begins, and not before
await render(<Pad />);
const { pad, pointer } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(2, 0, 1);
expect(pad.hasPointerCapture(pointer.id)).toBe(false);
await commands.mouseMoveBy(10, 0, 1);
expect(pad.hasPointerCapture(pointer.id)).toBe(true);
await commands.mouseUp();
The drag goes on outside the element, and ends where the pointer is released
const log: string[] = [];
await render(<Pad log={log} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(0, 200, 4);
await commands.mouseUp();
expect(log.at(-1)).toBe("end 0,200");
The modifier keys are reported as they are at each move
const onMove = vi.fn();
await render(<Pad onMove={onMove} />);
const { pad, pointer } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(10, 0, 1);
expect((onMove.mock.lastCall![0] as Drag).shiftKey).toBe(false);
// A move that says Shift is held, with no key heard: the keys were pressed in another window.
const { x, y } = onMove.mock.lastCall![0] as Drag;
pad.dispatchEvent(
  new PointerEvent("pointermove", {
    pointerId: pointer.id,
    bubbles: true,
    clientX: x + 5,
    clientY: y,
    shiftKey: true,
  }),
);
expect(onMove.mock.lastCall![0]).toMatchObject({ shiftKey: true, altKey: false });
await commands.mouseMoveBy(10, 0, 1);
expect((onMove.mock.lastCall![0] as Drag).shiftKey).toBe(false);
await commands.mouseUp();
A modifier pressed or released while the pointer rests is reported at once
const onMove = vi.fn();
await render(<Pad onMove={onMove} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(10, 0, 1);
onMove.mockClear();
await commands.keyDown("Alt");
expect(onMove).toHaveBeenCalledOnce();
expect(onMove.mock.lastCall![0]).toMatchObject({ altKey: true, dx: expect.closeTo(10, 1), dy: 0 });
await commands.keyUp("Alt");
expect(onMove.mock.lastCall![0]).toMatchObject({ altKey: false, dx: expect.closeTo(10, 1) });
await commands.mouseUp();
A drag does not end as a click
const onClick = vi.fn();
await render(
  // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- hears the click a drag would swallow
  <div onClick={onClick}>
    <Pad />
  </div>,
);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(10, 0, 2);
await commands.mouseUp();
expect(onClick).not.toHaveBeenCalled();
// And the click after that one is heard again.
await commands.clickSelector(PAD);
expect(onClick).toHaveBeenCalledOnce();
After a drag that no click follows, the next click is still heard
const log: string[] = [];
const onClick = vi.fn();
await render(
  // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- hears the click a drag would swallow
  <div onClick={onClick}>
    <Pad log={log} />
  </div>,
);
const pad = document.querySelector<HTMLElement>(PAD)!;
// A drag made of events alone, as a finger's is when the system sends no click after it.
const finger = { pointerId: 41, bubbles: true, pointerType: "touch", clientX: 20, clientY: 20 };
pad.dispatchEvent(new PointerEvent("pointerdown", finger));
pad.dispatchEvent(new PointerEvent("pointermove", { ...finger, clientX: 60 }));
pad.dispatchEvent(new PointerEvent("pointerup", { ...finger, clientX: 60 }));
expect(log.at(-1)).toBe("end 40,0");
await new Promise((resolve) => setTimeout(resolve, 20));
await commands.clickSelector(PAD);
expect(onClick).toHaveBeenCalledOnce();
Text is not selected by a drag across it
await render(<Pad />);
await commands.mouseDownAt(PAD, { x: 0.02, y: 0.08 });
await commands.mouseMoveBy(150, 0, 6);
await commands.mouseUp();
expect(getSelection()?.toString()).toBe("");
The browser's own drag of an image or a link does not start
const log: string[] = [];
function Link() {
  const drag = useDrag({ onEnd: (event) => log.push(line("end", event)) });
  return (
    <div data-testid="pad" {...drag.props} style={{ touchAction: "none" }}>
      <a href="#somewhere" style={{ display: "block", width: 200, height: 60 }}>
        A link
      </a>
    </div>
  );
}
await render(<Link />);
// What an engine sends before it begins a drag of its own; refused, it begins none.
const begins = () => {
  const event = new DragEvent("dragstart", { bubbles: true, cancelable: true });
  document.querySelector("a")!.dispatchEvent(event);
  return !event.defaultPrevented;
};
await commands.mouseDownAt(PAD);
expect(begins()).toBe(false);
await commands.mouseMoveBy(40, 30, 6);
expect(begins()).toBe(false);
await commands.mouseUp();
expect(log).toEqual(["end 40,30"]);
// Once the press is over, the link is a link again.
expect(begins()).toBe(true);

Cancelling

Escape cancels the drag, which reports no distance: where to put things back
const log: string[] = [];
await render(<Pad log={log} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 10, 2);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
expect(log.at(-1)).toBe("cancel 0,0");
// What the pointer does afterwards is no longer a drag.
await commands.mouseMoveBy(30, 10, 2);
await commands.mouseUp();
expect(log.at(-1)).toBe("cancel 0,0");
The release that follows a cancelled drag is not a click; the next press's is
const onClick = vi.fn();
await render(
  // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- hears the click a drag would swallow
  <div onClick={onClick}>
    <Pad />
  </div>,
);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(10, 0, 2);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
await commands.mouseUp();
expect(onClick).not.toHaveBeenCalled();
await commands.clickSelector(PAD);
expect(onClick).toHaveBeenCalledOnce();
// Cancelled by the system, no release comes: the click after the next press is heard.
const pad = document.querySelector<HTMLElement>(PAD)!;
const finger = { pointerId: 43, bubbles: true, pointerType: "touch", clientX: 20, clientY: 20 };
pad.dispatchEvent(new PointerEvent("pointerdown", finger));
pad.dispatchEvent(new PointerEvent("pointermove", { ...finger, clientX: 60 }));
pad.dispatchEvent(new PointerEvent("pointercancel", finger));
onClick.mockClear();
await commands.clickSelector(PAD);
expect(onClick).toHaveBeenCalledOnce();
The Escape that cancels a drag is not heard by anything else
// Stands for a dialog around the element, which Escape would close.
const onKeyDown = vi.fn();
const stop = new AbortController();
document.addEventListener("keydown", onKeyDown, { signal: stop.signal });
await render(<Pad />);
await commands.clickSelector(PAD);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
expect(onKeyDown).toHaveBeenCalledOnce();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 0, 2);
await commands.keyDown("Escape");
await commands.keyUp("Escape");
expect(onKeyDown).toHaveBeenCalledOnce();
await commands.mouseUp();
stop.abort();
The system taking the pointer away cancels the drag
const log: string[] = [];
await render(<Pad log={log} />);
const { pad, pointer } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 0, 2);
pad.dispatchEvent(new PointerEvent("pointercancel", { pointerId: pointer.id, bubbles: true }));
expect(log.at(-1)).toBe("cancel 0,0");
await commands.mouseUp();
Losing the capture cancels the drag
const log: string[] = [];
await render(<Pad log={log} />);
const { pad, pointer } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 0, 2);
pad.releasePointerCapture(pointer.id);
// The capture is given up when the pointer is next heard from.
await commands.mouseMoveBy(1, 0, 1);
await expect.poll(() => log.at(-1)).toBe("cancel 0,0");
await commands.mouseUp();
A context menu cancels the drag
const log: string[] = [];
await render(<Pad log={log} />);
const { pad } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 0, 2);
pad.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
expect(log.at(-1)).toBe("cancel 0,0");
await commands.mouseUp();
A context menu that is refused opens nothing, and cancels nothing
const log: string[] = [];
await render(<Pad log={log} />);
const { pad } = watch();
// As where a finger's hold means something of the element's own.
pad.addEventListener("contextmenu", (event) => event.preventDefault());
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(30, 0, 2);
pad.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
await commands.mouseMoveBy(10, 0, 1);
await commands.mouseUp();
expect(log.slice(-2)).toEqual(["move 40,0", "end 40,0"]);

Pointers and buttons

A second pointer does not take over a drag in progress
const log: string[] = [];
await render(<Pad log={log} />);
const { pad, pointer } = watch();
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(20, 0, 1);
const first = pointer.id;
const other = { pointerId: first + 7, bubbles: true, pointerType: "touch", clientX: 5, clientY: 5 };
pad.dispatchEvent(new PointerEvent("pointerdown", other));
pad.dispatchEvent(new PointerEvent("pointermove", { ...other, clientX: 400, clientY: 400 }));
pad.dispatchEvent(new PointerEvent("pointerup", { ...other, clientX: 400, clientY: 400 }));
expect(log.at(-1)).toBe("move 20,0");
await commands.mouseMoveBy(5, 0, 1);
await commands.mouseUp();
expect(log.slice(-2)).toEqual(["move 25,0", "end 25,0"]);
The secondary button never drags
const log: string[] = [];
await render(<Pad log={log} buttons={[0, 1, 2]} />);
// Kept from the document: where a menu opens on release, nothing else would stop the drag before it.
document.querySelector(PAD)!.addEventListener(
  "contextmenu",
  (event) => {
    event.preventDefault();
    event.stopPropagation();
  },
  { once: true },
);
await commands.mouseDownAt(PAD, { button: "right" });
await commands.mouseMoveBy(40, 0, 3);
await commands.mouseUp("right");
expect(log).toEqual([]);
The middle button drags only where it is asked to
const log: string[] = [];
const screen = await render(<Pad log={log} />);
await commands.mouseDownAt(PAD, { button: "middle" });
await commands.mouseMoveBy(40, 0, 3);
await commands.mouseUp("middle");
expect(log).toEqual([]);
await screen.rerender(<Pad log={log} buttons={[1]} />);
await commands.mouseDownAt(PAD, { button: "middle" });
await commands.mouseMoveBy(40, 0, 3);
await commands.mouseUp("middle");
expect(log.at(-1)).toBe("end 40,0");

API

onPress hears every press that could drag, and declines one by returning false
const log: string[] = [];
const onPress = vi.fn(() => false);
await render(<Pad log={log} onPress={onPress} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(40, 0, 3);
await commands.mouseUp();
expect(onPress).toHaveBeenCalledOnce();
expect(onPress.mock.calls[0]).toMatchObject([{ button: 0, pointerType: "mouse", shiftKey: false }]);
expect(log).toEqual([]);
disabled leaves every press alone
const log: string[] = [];
await render(<Pad log={log} disabled />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(40, 0, 3);
await commands.mouseUp();
expect(log).toEqual([]);
threshold sets how far a press travels before it drags
const log: string[] = [];
await render(<Pad log={log} threshold={30} />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(20, 0, 2);
expect(log).toEqual([]);
await commands.mouseMoveBy(20, 0, 1);
expect(log[0]).toBe("start 40,0");
await commands.mouseUp();
cancel abandons the drag in progress
const log: string[] = [];
const held = { cancel: () => {} };
function Cancellable() {
  const drag = useDrag({ onCancel: (event) => log.push(line("cancel", event)) });
  useEffect(() => {
    held.cancel = drag.cancel;
  });
  return (
    <div data-testid="pad" {...drag.props} style={{ width: 200, height: 100, touchAction: "none" }} />
  );
}
await render(<Cancellable />);
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(20, 0, 2);
held.cancel();
expect(log).toEqual(["cancel 0,0"]);
await commands.mouseUp();
Unmounting in the middle of a drag leaves no listener behind, and reports nothing more
const log: string[] = [];
const screen = await render(<Pad log={log} />);
const selecting = () => {
  const event = new Event("selectstart", { bubbles: true, cancelable: true });
  document.dispatchEvent(event);
  return event.defaultPrevented;
};
await commands.mouseDownAt(PAD);
await commands.mouseMoveBy(20, 0, 1);
expect(selecting()).toBe(true);
await screen.unmount();
const before = log.length;
expect(selecting()).toBe(false);
await commands.mouseMoveBy(20, 0, 2);
await commands.mouseUp();
expect(log.length).toBe(before);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/drag.json
Source

src/primitives/drag.ts

/**
 * The drag every direct manipulation shares: moving a thing, resizing it,
 * drawing a marquee, pulling a view about. A press becomes a drag only once it
 * has travelled past a threshold, so that a click stays a click.
 *
 * - The threshold is a distance on the screen, finer for a mouse (3 pixels)
 *   than for a pen (6) or a finger (10), which cannot be put down as exactly.
 * - Once a drag begins the element captures the pointer: the drag goes on
 *   wherever the pointer travels, and ends wherever it is released.
 * - Each report carries the whole distance travelled since the press, and the
 *   modifier keys as they are at that moment. A modifier pressed or released
 *   while the pointer rests is reported at once, as a move of no distance.
 * - Escape cancels, and so do the system taking the pointer away, the element
 *   losing its capture, and a context menu opening. A cancelled drag is reported with
 *   no distance: where to put things back.
 * - One pointer drags. A second pointer pressed meanwhile is not heard, and
 *   the secondary button never drags.
 * - While the press lasts, text is not selected and the browser's own drag of
 *   an image or a link does not start; and a drag does not end as a click,
 *   whether it was finished or cancelled.
 *
 * The element must have `touch-action: none` (or `pan-x` / `pan-y` to keep one
 * axis for scrolling): without it a browser takes a finger's movement for
 * itself, as a scroll, and cancels the drag.
 */
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";

/** How far a press travels, in screen pixels, before it is a drag. */
export const DRAG_THRESHOLD = { mouse: 3, pen: 6, touch: 10 } as const;

/** The threshold for a kind of pointer; one that is not known is held to the coarsest. */
export function dragThreshold(pointerType: string): number {
  return pointerType === "mouse" || pointerType === "pen"
    ? DRAG_THRESHOLD[pointerType]
    : DRAG_THRESHOLD.touch;
}

/** How far a pointer is from where it was pressed, in a straight line. */
export function dragDistance(dx: number, dy: number): number {
  return Math.hypot(dx, dy);
}

/** Whether a press that has travelled this far is a drag: only once it is past the threshold. */
export function pastThreshold(dx: number, dy: number, threshold: number): boolean {
  return dx * dx + dy * dy > threshold * threshold;
}

export interface DragModifiers {
  readonly shiftKey: boolean;
  readonly altKey: boolean;
  readonly ctrlKey: boolean;
  readonly metaKey: boolean;
}

/** A press that may become a drag. */
export interface DragPress extends DragModifiers {
  /** Where the press is, in the viewport's coordinates. */
  readonly x: number;
  readonly y: number;
  readonly pointerType: string;
  readonly button: number;
  /** What was pressed, and the element whose drag this is. */
  readonly target: EventTarget | null;
  readonly currentTarget: HTMLElement;
}

/** A drag, at one moment of it. */
export interface Drag extends DragModifiers {
  /** Where the pointer is, in the viewport's coordinates. */
  readonly x: number;
  readonly y: number;
  readonly startX: number;
  readonly startY: number;
  /** The whole distance from the press, on the screen. */
  readonly dx: number;
  readonly dy: number;
  readonly pointerType: string;
  readonly button: number;
}

export interface DragOptions {
  readonly disabled?: boolean;
  /** Screen pixels before a press is a drag; by default, what the pointer's kind asks for. */
  readonly threshold?: number | ((pointerType: string) => number);
  /** The buttons that drag: the primary one by default. The secondary button never does. */
  readonly buttons?: readonly number[];
  /** A press that could become a drag. Return `false` to have nothing to do with it. */
  readonly onPress?: (press: DragPress) => boolean | void;
  readonly onStart?: (drag: Drag) => void;
  readonly onMove?: (drag: Drag) => void;
  readonly onEnd?: (drag: Drag) => void;
  /** The drag was abandoned: `dx` and `dy` are 0, and `x` and `y` where it began. */
  readonly onCancel?: (drag: Drag) => void;
}

export interface DragProps {
  readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
  readonly onLostPointerCapture: (event: ReactPointerEvent<HTMLElement>) => void;
}

export interface DragResult {
  /** To spread on the element that is dragged from. */
  readonly props: DragProps;
  /** Abandon the press or the drag in progress, if there is one. */
  readonly cancel: () => void;
  /** Whether a drag — not merely a press — is in progress. */
  readonly dragging: () => boolean;
}

interface Gesture {
  readonly pointerId: number;
  readonly element: HTMLElement;
  readonly startX: number;
  readonly startY: number;
  readonly pointerType: string;
  readonly button: number;
  readonly threshold: number;
  x: number;
  y: number;
  modifiers: DragModifiers;
  dragging: boolean;
  /** Takes the document's listeners away. */
  detach: () => void;
}

const PRIMARY: readonly number[] = [0];
const SECONDARY = 2;
const MODIFIER_KEYS = new Set(["Shift", "Alt", "Control", "Meta"]);

function modifiersOf(event: DragModifiers): DragModifiers {
  return { shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey };
}

function report(gesture: Gesture): Drag {
  return {
    ...gesture.modifiers,
    x: gesture.x,
    y: gesture.y,
    startX: gesture.startX,
    startY: gesture.startY,
    dx: gesture.x - gesture.startX,
    dy: gesture.y - gesture.startY,
    pointerType: gesture.pointerType,
    button: gesture.button,
  };
}

/** The click that a browser sends after a release is not let through: the drag was the gesture. */
function swallow(event: MouseEvent): void {
  event.stopPropagation();
  event.preventDefault();
}

function swallowClick(document: Document): void {
  document.addEventListener("click", swallow, { capture: true, once: true });
  // The click, if one comes, comes before any timer.
  setTimeout(() => document.removeEventListener("click", swallow, { capture: true }), 0);
}

export function useDrag(options: DragOptions): DragResult {
  // Read when an event arrives, so handlers written inline are always the latest.
  const latest = useRef(options);
  useEffect(() => {
    latest.current = options;
  });
  const gesture = useRef<Gesture | null>(null);

  const finish = useCallback((how: "end" | "cancel") => {
    const current = gesture.current;
    if (current === null) return;
    gesture.current = null;
    current.detach();
    if (!current.dragging) return;
    if (current.element.hasPointerCapture(current.pointerId)) {
      current.element.releasePointerCapture(current.pointerId);
    }
    if (how === "end") {
      swallowClick(current.element.ownerDocument);
      latest.current.onEnd?.(report(current));
      return;
    }
    // The button may still be down: the click its release sends is no more a click than the drag's would be.
    const document = current.element.ownerDocument;
    const stop = new AbortController();
    document.addEventListener(
      "click",
      (event) => {
        swallow(event);
        stop.abort();
      },
      { capture: true, signal: stop.signal },
    );
    // A click after a new press is that press's own.
    document.addEventListener("pointerdown", () => stop.abort(), { capture: true, signal: stop.signal });
    current.x = current.startX;
    current.y = current.startY;
    latest.current.onCancel?.(report(current));
  }, []);

  // Unmounted in the middle of a drag: nothing is reported to what is gone, and no listener stays.
  useEffect(
    () => () => {
      gesture.current?.detach();
      gesture.current = null;
    },
    [],
  );

  const onPointerDown = useCallback(
    (event: ReactPointerEvent<HTMLElement>) => {
      const { disabled = false, buttons = PRIMARY, threshold, onPress } = latest.current;
      // A second pointer does not take over the first one's drag.
      if (gesture.current !== null || disabled) return;
      if (event.button === SECONDARY || !buttons.includes(event.button)) return;
      const element = event.currentTarget;
      const press: DragPress = {
        ...modifiersOf(event),
        x: event.clientX,
        y: event.clientY,
        pointerType: event.pointerType,
        button: event.button,
        target: event.target,
        currentTarget: element,
      };
      if (onPress?.(press) === false) return;

      const document = element.ownerDocument;
      const { pointerId } = event;
      const controller = new AbortController();
      const current: Gesture = {
        pointerId,
        element,
        startX: event.clientX,
        startY: event.clientY,
        pointerType: event.pointerType,
        button: event.button,
        threshold:
          typeof threshold === "function"
            ? threshold(event.pointerType)
            : (threshold ?? dragThreshold(event.pointerType)),
        x: event.clientX,
        y: event.clientY,
        modifiers: modifiersOf(event),
        dragging: false,
        detach: () => controller.abort(),
      };
      gesture.current = current;

      const listen = <K extends keyof DocumentEventMap>(
        type: K,
        listener: (event: DocumentEventMap[K]) => void,
        capture = false,
      ) => document.addEventListener(type, listener, { capture, signal: controller.signal });

      // On the document, not the element: before the drag begins nothing is
      // captured, and a press near an edge leaves the element within the threshold.
      listen("pointermove", (move) => {
        if (move.pointerId !== pointerId) return;
        current.x = move.clientX;
        current.y = move.clientY;
        current.modifiers = modifiersOf(move);
        if (!current.dragging) {
          if (!pastThreshold(current.x - current.startX, current.y - current.startY, current.threshold))
            return;
          current.dragging = true;
          // A pointer made by a script, or already gone, cannot be captured; the document still hears it.
          try {
            element.setPointerCapture(pointerId);
          } catch {
            // Nothing to do: the drag goes on without the capture.
          }
          latest.current.onStart?.(report(current));
        }
        latest.current.onMove?.(report(current));
      });
      listen("pointerup", (up) => {
        if (up.pointerId !== pointerId) return;
        current.x = up.clientX;
        current.y = up.clientY;
        current.modifiers = modifiersOf(up);
        finish("end");
      });
      listen("pointercancel", (cancel) => {
        if (cancel.pointerId === pointerId) finish("cancel");
      });
      // A menu that was refused — a finger's hold, where the hold means something else — opens nothing, and ends nothing.
      listen("contextmenu", (menu) => {
        if (!menu.defaultPrevented) finish("cancel");
      });
      const onKey = (key: KeyboardEvent) => {
        if (key.type === "keydown" && key.key === "Escape") {
          // Escape is the drag's own while there is one: it does not also close or clear what is around it.
          if (current.dragging) {
            key.preventDefault();
            key.stopPropagation();
          }
          finish("cancel");
          return;
        }
        if (!current.dragging || !MODIFIER_KEYS.has(key.key)) return;
        current.modifiers = modifiersOf(key);
        latest.current.onMove?.(report(current));
      };
      listen("keydown", onKey, true);
      listen("keyup", onKey, true);
      listen("selectstart", (select) => select.preventDefault());
      listen("dragstart", (native) => native.preventDefault());
    },
    [finish],
  );

  const onLostPointerCapture = useCallback(
    (event: ReactPointerEvent<HTMLElement>) => {
      // After a release the gesture is already over, and this is only the capture going with it.
      if (gesture.current?.pointerId === event.pointerId && gesture.current.dragging) finish("cancel");
    },
    [finish],
  );

  return useMemo(
    () => ({
      props: { onPointerDown, onLostPointerCapture },
      cancel: () => finish("cancel"),
      dragging: () => gesture.current?.dragging === true,
    }),
    [onPointerDown, onLostPointerCapture, finish],
  );
}