Skip to content

Mouse ​

Mouse tracks buttons, canvas-local position, movement, wheel input, double-clicks, and pointer lock. Input owns one instance and mirrors primary touch input into it.

ts
import {
  Mouse,
  MouseEventButton
} from "@jolly-pixel/controls";

const canvas = document.querySelector("canvas");
if (!canvas) {
  throw new Error("No canvas element found");
}

const mouse = new Mouse({ canvas });
mouse.connect();

function gameLoop() {
  mouse.update();

  if (mouse.wasJustPressed(MouseEventButton.left)) {
    console.log("Left click!");
  }
  if (mouse.isScrolling()) {
    console.log("Scroll:", mouse.scroll);
  }

  requestAnimationFrame(gameLoop);
}

gameLoop();

Constructor ​

ts
interface MouseOptions {
  canvas: CanvasAdapter;
  documentAdapter?: DocumentAdapter;
}

new Mouse(options: MouseOptions)

An HTMLCanvasElement satisfies CanvasAdapter. documentAdapter defaults to BrowserDocumentAdapter. The adapter types are referenced by the public options but are not exported from the package root.

Types ​

ts
const MouseEventButton = {
  left: 0,
  middle: 1,
  right: 2,
  back: 3,
  forward: 4,
  scrollUp: 5,
  scrollDown: 6
} as const;

type MouseAction = keyof typeof MouseEventButton;
type InputMouseAction = number | MouseAction | "ANY" | "NONE";
type MouseLockState = "locked" | "unlocked";

interface MouseButtonState {
  isDown: boolean;
  doubleClicked: boolean;
  wasJustPressed: boolean;
  wasJustReleased: boolean;
}

scrollUp and scrollDown are virtual buttons created from the wheel's Y direction. Each sample whose summed wheel Y moved one way presses and releases that button, and they are down until the reader's next publish. Wheel up then down across two samples reports both buttons while scroll.y sums to 0. They discard wheel magnitude. Use scroll when magnitude or horizontal input matters.

Frame state ​

ts
interface Mouse {
  update(): void;
  sample(): void;
  publish(reader: InputReader): void;
  reset(): void;

  readonly wasActive: boolean;
  readonly hovering: boolean;
  isMoving(): boolean;
  isScrolling(): boolean;
}

DOM and touch events write live input state; isDown() follows them at once. update() samples that state and publishes button transitions, double-clicks, wheel input, and movement for the current input step. A complete press and release between two updates still publishes both edges.

update() is sample() then publish("step"). A fixed-step engine calls sample() and publish(reader) separately; see Input.

sample() returns early while the mouse is idle. reset() clears button, wheel, position, and delta state.

hovering is whether the pointer sits over the canvas, from mouseenter and mouseleave plus any canvas mousemove, so it recovers when the window regains focus with the pointer already inside and no enter fires. A primary touch sets it while it holds the canvas. reset() clears it, which is how Input drops it when the window loses focus.

Read it before trusting position outside a drag: leaving the canvas does not clear the position, it freezes it at the last on-canvas value. During a drag the position keeps following the pointer even though hovering is false, so a consumer wanting "the position is live" checks hovering || isDown("ANY").

Button queries ​

ts
interface Mouse {
  isDown(action: InputMouseAction): boolean;
  wasJustPressed(action: InputMouseAction): boolean;
  wasJustReleased(action: InputMouseAction): boolean;
  buttonState(
    action: number | MouseAction
  ): Readonly<MouseButtonState>;
}

The direct queries accept a button name, numeric index, "ANY", or "NONE". An index outside 0 through 6 behaves as an unpressed button.

buttonState() returns a new snapshot containing the four state flags. Use the direct queries on per-frame paths when only one flag is needed.

doubleClicked is published by the update after a dblclick event and clears on the following update.

Position and movement ​

ts
interface Mouse {
  readonly position: { x: number; y: number };
  readonly delta: { x: number; y: number };
  readonly viewportPosition: Vector2Like;
  readonly worldPosition: Vector2Like;

  viewportDelta(
    normalizeWithSize?: boolean
  ): Vector2Like;

  positionTo<T extends Vector2Like>(out: T): T;
  deltaTo<T extends Vector2Like>(out: T): T;
  viewportPositionTo<T extends Vector2Like>(out: T): T;
  worldPositionTo<T extends Vector2Like>(out: T): T;
  viewportDeltaTo<T extends Vector2Like>(
    out: T,
    normalizeWithSize?: boolean
  ): T;
}

position uses canvas-local CSS pixels. Mousemove reads offsetX and offsetY when present, then falls back to getBoundingClientRect(). A drag continues to update when the pointer leaves the canvas and releases when the document receives mouseup.

delta is the change in canvas pixels published by the latest update. viewportPosition normalizes position into [-1, 1] on both axes and flips Y. worldPosition scales that value by half the canvas size, producing centered pixel coordinates.

viewportDelta() flips Y. Passing true also divides X and Y by half of the matching canvas dimension. The property getters and viewportDelta() return new objects. The *To() methods write into a caller-owned object such as a THREE.Vector2 and return the same object.

Vector2Like is the structural { x: number; y: number } shape used by these methods. It is exported from the package root.

Wheel input ​

ts
interface Mouse {
  readonly scroll: { x: number; y: number };
  readonly scrollUp: boolean;
  readonly scrollDown: boolean;

  scrollTo<T extends Vector2Like>(out: T): T;
}

type LegacyWheelEvent = WheelEvent & {
  readonly wheelDeltaX?: number;
  readonly wheelDeltaY?: number;
};

declare class Mouse {
  static wheelDelta(
    event: LegacyWheelEvent
  ): [number, number];
}

scroll contains signed wheel notches for the current update. Positive Y means scrolling away from the user. Several wheel events in one update are summed, including horizontal input and magnitude. scrollTo() writes the same state into a caller-owned object.

wheelDelta() normalizes browser wheel events into [x, y] notches. Outside Apple platforms it reads the non-standard wheelDeltaX and wheelDeltaY fields, which LegacyWheelEvent declares; a missing field counts as 0. Any WheelEvent, including the wheel listener argument, can be passed as is.

Wheel source ​

ts
type ScrollSource = "wheel" | "touchpad" | "zoom";
type ScrollDevice = "auto" | "wheel" | "touchpad";

interface Mouse {
  readonly scrollSource: ScrollSource | null;
  scrollDevice: ScrollDevice;
}

Touchpad swipes and pinches still land in scroll. scrollSource tells which one produced it, and is null when nothing scrolled. A two-finger swipe is "touchpad" and a pinch is "zoom".

Browsers report a pinch as a wheel event with ctrlKey set, the same as Ctrl+wheel, so both read as "zoom". To tell them apart, check whether the keyboard holds Control. Safari sends pinches as gesture events and never produces "zoom" for them.

With scrollDevice set to "auto" (the default), a gesture starts as "wheel" and switches to "touchpad" on the first event carrying a partial notch, or a horizontal delta without Shift. It stays "touchpad" until wheel events pause for 200 ms. Line- and page-mode events always read as "wheel".

The detection is a heuristic. Smooth or free-spinning wheels, and accelerated wheels on macOS, can read as "touchpad". Setting scrollDevice to "wheel" or "touchpad" skips detection; Ctrl-flagged events still read as "zoom". reset() keeps the setting.

Pointer lock and visibility ​

ts
interface Mouse {
  readonly locked: boolean;
  visible: boolean;

  lock(): void;
  unlock(): void;
}

lock() records pointer-lock intent. The canvas requests pointer lock on the next mouse down; calling lock() does not request it immediately. Movement then comes from movementX and movementY while lock is active.

unlock() clears pending intent and exits pointer lock when the canvas owns it. visible = false sets the canvas cursor to "none"; true sets it to "auto".

Touch ​

The first touch on the canvas drives the left button and the position until it ends or is cancelled. Touches that start while it is held are ignored. Touch handlers prevent their browser defaults, so the browser does not send compatibility mouse events.

Events ​

ts
type MouseEvents = {
  lockStateChange: (state: MouseLockState) => void;
  down: (event: MouseEvent) => void;
  up: (event: MouseEvent) => void;
  move: (event: MouseEvent) => void;
  wheel: (event: WheelEvent) => void;
  enter: () => void;
  leave: () => void;
};

Down, move, double-click, and wheel handlers prevent their browser defaults. Mouse down also focuses the canvas. A held drag continues to emit move and up from document events outside the canvas.

enter and leave fire only when hovering changes, so a re-entry without an intervening leave emits nothing.

lockStateChange fires only when the tracked lock state changes or an active lock reports an error.

Lifecycle ​

ts
connect(): void
disconnect(): void

connect() registers canvas mouse, wheel, and touch listeners, and document listeners. disconnect() removes them. It does not reset the published state.