@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
516 lines (482 loc) • 14.3 kB
text/typescript
import { useCallback, useEffect, useRef } from 'react';
import {
createCurrentWritable as currentWritable,
type CurrentReadable
} from '../core/current-value.js';
import {
createInitialPointerState,
getPointerCoordinates,
getPointerNowSeconds,
normalizePointerKind,
resolvePointerFrameRequestMode,
type PointerClick,
type PointerFrameRequestMode,
type PointerState,
type PointerVec2
} from '../core/pointer.js';
import { useMotionGPU } from './motiongpu-context.js';
export type {
PointerClick,
PointerFrameRequestMode,
PointerKind,
PointerPoint,
PointerState
} from '../core/pointer.js';
/**
* Configuration for pointer input handling in `usePointer`.
*/
export interface UsePointerOptions {
/**
* Enables pointer listeners.
*
* @default true
*/
enabled?: boolean;
/**
* Frame wake-up strategy for pointer-driven state changes.
*
* @default 'auto'
*/
requestFrame?: PointerFrameRequestMode;
/**
* Requests pointer capture on pointer down.
*
* @default true
*/
capturePointer?: boolean;
/**
* Tracks pointer move/up outside canvas while pointer is pressed.
*
* @default true
*/
trackWhilePressedOutsideCanvas?: boolean;
/**
* Enables click/tap synthesis on pointer up.
*
* @default true
*/
clickEnabled?: boolean;
/**
* Maximum press duration to consider pointer up a click (milliseconds).
*
* @default 350
*/
clickMaxDurationMs?: number;
/**
* Maximum pointer travel from down to up to consider pointer up a click (pixels).
*
* @default 8
*/
clickMaxMovePx?: number;
/**
* Allowed pointer buttons for click synthesis.
*
* @default [0]
*/
clickButtons?: number[];
/**
* Called after pointer move state update.
*/
onMove?: (state: PointerState, event: PointerEvent) => void;
/**
* Called after pointer down state update.
*/
onDown?: (state: PointerState, event: PointerEvent) => void;
/**
* Called after pointer up/cancel state update.
*/
onUp?: (state: PointerState, event: PointerEvent) => void;
/**
* Called when click/tap is synthesized.
*/
onClick?: (click: PointerClick, state: PointerState, event: PointerEvent) => void;
}
/**
* Reactive state returned by `usePointer`.
*/
export interface UsePointerResult {
/**
* Current pointer state.
*/
state: CurrentReadable<PointerState>;
/**
* Last synthesized click/tap event.
*/
lastClick: CurrentReadable<PointerClick | null>;
/**
* Clears last click snapshot.
*/
resetClick: () => void;
}
interface PointerDownSnapshot {
button: number;
inside: boolean;
pointerId: number;
pointerType: 'mouse' | 'pen' | 'touch';
px: PointerVec2;
timeMs: number;
uv: PointerVec2;
}
/**
* Resolves a valid click duration threshold in milliseconds.
*/
function resolveClickMaxDurationMs(value: number | undefined): number {
if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) {
return 350;
}
return value;
}
/**
* Resolves a valid click travel threshold in pixels.
*/
function resolveClickMaxMovePx(value: number | undefined): number {
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
return 8;
}
return value;
}
/**
* Normalizes click button configuration with a primary-button fallback.
*/
function normalizeClickButtons(buttons: number[] | undefined): Set<number> {
const source = buttons && buttons.length > 0 ? buttons : [0];
return new Set(source);
}
/**
* Tracks normalized pointer coordinates and click/tap snapshots for the active `FragCanvas`.
*/
export function usePointer(options: UsePointerOptions = {}): UsePointerResult {
const motiongpu = useMotionGPU();
const stateRef = useRef(currentWritable<PointerState>(createInitialPointerState()));
const clickRef = useRef(currentWritable<PointerClick | null>(null));
const optionsRef = useRef(options);
const activePointerIdRef = useRef<number | null>(null);
const downSnapshotRef = useRef<PointerDownSnapshot | null>(null);
const clickCounterRef = useRef(0);
const previousPxRef = useRef<PointerVec2 | null>(null);
const previousUvRef = useRef<PointerVec2 | null>(null);
const previousTimeSecondsRef = useRef(0);
optionsRef.current = options;
const requestFrame = useCallback((): void => {
const mode = resolvePointerFrameRequestMode(
optionsRef.current.requestFrame ?? 'auto',
motiongpu.renderMode.current
);
if (mode === 'invalidate') {
motiongpu.invalidate();
return;
}
if (mode === 'advance') {
motiongpu.advance();
}
}, [motiongpu]);
/**
* Commits a full pointer state snapshot with computed delta and velocity vectors.
*/
const updatePointerState = useCallback(
(input: {
button: number | null;
buttons: number;
downPx: PointerVec2 | null;
downUv: PointerVec2 | null;
dragging: boolean;
inside: boolean;
pointerId: number | null;
pointerType: 'mouse' | 'pen' | 'touch' | null;
pressed: boolean;
point: {
ndc: PointerVec2;
px: PointerVec2;
uv: PointerVec2;
};
resetDelta?: boolean;
}): PointerState => {
const nowSeconds = getPointerNowSeconds();
const previousTimeSeconds = previousTimeSecondsRef.current;
const dt = previousTimeSeconds > 0 ? Math.max(nowSeconds - previousTimeSeconds, 1e-6) : 0;
const previousPx = previousPxRef.current;
const previousUv = previousUvRef.current;
const deltaPx: PointerVec2 =
input.resetDelta || !previousPx
? [0, 0]
: [input.point.px[0] - previousPx[0], input.point.px[1] - previousPx[1]];
const deltaUv: PointerVec2 =
input.resetDelta || !previousUv
? [0, 0]
: [input.point.uv[0] - previousUv[0], input.point.uv[1] - previousUv[1]];
const velocityPx: PointerVec2 = dt > 0 ? [deltaPx[0] / dt, deltaPx[1] / dt] : [0, 0];
const velocityUv: PointerVec2 = dt > 0 ? [deltaUv[0] / dt, deltaUv[1] / dt] : [0, 0];
const nextState: PointerState = {
px: input.point.px,
uv: input.point.uv,
ndc: input.point.ndc,
inside: input.inside,
pressed: input.pressed,
dragging: input.dragging,
pointerType: input.pointerType,
pointerId: input.pointerId,
button: input.button,
buttons: input.buttons,
time: nowSeconds,
downPx: input.downPx,
downUv: input.downUv,
deltaPx,
deltaUv,
velocityPx,
velocityUv
};
stateRef.current.set(nextState);
previousPxRef.current = input.point.px;
previousUvRef.current = input.point.uv;
previousTimeSecondsRef.current = nowSeconds;
requestFrame();
return nextState;
},
[requestFrame]
);
useEffect(() => {
const enabled = optionsRef.current.enabled ?? true;
if (!enabled) {
return;
}
const canvas = motiongpu.canvas;
if (!canvas) {
return;
}
const isTrackedPointer = (event: PointerEvent): boolean =>
activePointerIdRef.current === null || event.pointerId === activePointerIdRef.current;
const handlePointerDown = (event: PointerEvent): void => {
const point = getPointerCoordinates(
event.clientX,
event.clientY,
canvas.getBoundingClientRect()
);
const pointerType = normalizePointerKind(event.pointerType);
activePointerIdRef.current = event.pointerId;
downSnapshotRef.current = {
pointerId: event.pointerId,
pointerType,
button: event.button,
timeMs: getPointerNowSeconds() * 1000,
px: point.px,
uv: point.uv,
inside: point.inside
};
if (optionsRef.current.capturePointer ?? true) {
try {
canvas.setPointerCapture(event.pointerId);
} catch {
// Browser rejected capture (e.g. unsupported pointer state).
}
}
const nextState = updatePointerState({
point,
inside: point.inside,
pressed: true,
dragging: false,
pointerType,
pointerId: event.pointerId,
button: event.button,
buttons: event.buttons,
downPx: point.px,
downUv: point.uv,
resetDelta: true
});
optionsRef.current.onDown?.(nextState, event);
};
const handleMove = (event: PointerEvent): void => {
if (!isTrackedPointer(event)) {
return;
}
const point = getPointerCoordinates(
event.clientX,
event.clientY,
canvas.getBoundingClientRect()
);
const pressed =
activePointerIdRef.current !== null && event.pointerId === activePointerIdRef.current;
const downPx = pressed ? (downSnapshotRef.current?.px ?? point.px) : null;
const downUv = pressed ? (downSnapshotRef.current?.uv ?? point.uv) : null;
let dragging = false;
if (pressed && downPx) {
const dx = point.px[0] - downPx[0];
const dy = point.px[1] - downPx[1];
dragging = Math.hypot(dx, dy) > 0;
}
const nextState = updatePointerState({
point,
inside: point.inside,
pressed,
dragging,
pointerType: normalizePointerKind(event.pointerType),
pointerId: event.pointerId,
button: pressed ? (downSnapshotRef.current?.button ?? event.button) : null,
buttons: event.buttons,
downPx,
downUv
});
optionsRef.current.onMove?.(nextState, event);
};
const handleWindowMove = (event: PointerEvent): void => {
if (
!(optionsRef.current.trackWhilePressedOutsideCanvas ?? true) ||
activePointerIdRef.current === null ||
event.pointerId !== activePointerIdRef.current
) {
return;
}
const point = getPointerCoordinates(
event.clientX,
event.clientY,
canvas.getBoundingClientRect()
);
if (point.inside) {
return;
}
const downPx = downSnapshotRef.current?.px ?? point.px;
const downUv = downSnapshotRef.current?.uv ?? point.uv;
const dx = point.px[0] - downPx[0];
const dy = point.px[1] - downPx[1];
const nextState = updatePointerState({
point,
inside: false,
pressed: true,
dragging: Math.hypot(dx, dy) > 0,
pointerType:
downSnapshotRef.current?.pointerType ?? normalizePointerKind(event.pointerType),
pointerId: event.pointerId,
button: downSnapshotRef.current?.button ?? event.button,
buttons: event.buttons,
downPx,
downUv
});
optionsRef.current.onMove?.(nextState, event);
};
const releasePointer = (event: PointerEvent, emitClick: boolean): void => {
if (activePointerIdRef.current === null || event.pointerId !== activePointerIdRef.current) {
return;
}
const point = getPointerCoordinates(
event.clientX,
event.clientY,
canvas.getBoundingClientRect()
);
const previous = downSnapshotRef.current;
const pointerType = previous?.pointerType ?? normalizePointerKind(event.pointerType);
const nextState = updatePointerState({
point,
inside: point.inside,
pressed: false,
dragging: false,
pointerType,
pointerId: null,
button: null,
buttons: event.buttons,
downPx: null,
downUv: null
});
optionsRef.current.onUp?.(nextState, event);
if (
(optionsRef.current.capturePointer ?? true) &&
canvas.hasPointerCapture(event.pointerId)
) {
try {
canvas.releasePointerCapture(event.pointerId);
} catch {
// Browser rejected release for this pointer id.
}
}
if (emitClick && (optionsRef.current.clickEnabled ?? true) && previous) {
const allowedButtons = normalizeClickButtons(optionsRef.current.clickButtons);
if (allowedButtons.has(previous.button)) {
const clickMaxDurationMs = resolveClickMaxDurationMs(
optionsRef.current.clickMaxDurationMs
);
const clickMaxMovePx = resolveClickMaxMovePx(optionsRef.current.clickMaxMovePx);
const durationMs = getPointerNowSeconds() * 1000 - previous.timeMs;
const dx = point.px[0] - previous.px[0];
const dy = point.px[1] - previous.px[1];
const moveDistance = Math.hypot(dx, dy);
if (
previous.inside &&
point.inside &&
durationMs <= clickMaxDurationMs &&
moveDistance <= clickMaxMovePx
) {
clickCounterRef.current += 1;
const click: PointerClick = {
id: clickCounterRef.current,
time: getPointerNowSeconds(),
pointerType,
pointerId: event.pointerId,
button: previous.button,
modifiers: {
alt: event.altKey,
ctrl: event.ctrlKey,
shift: event.shiftKey,
meta: event.metaKey
},
px: point.px,
uv: point.uv,
ndc: point.ndc
};
clickRef.current.set(click);
optionsRef.current.onClick?.(click, nextState, event);
requestFrame();
}
}
}
activePointerIdRef.current = null;
downSnapshotRef.current = null;
};
const handlePointerUp = (event: PointerEvent): void => {
releasePointer(event, true);
};
const handlePointerCancel = (event: PointerEvent): void => {
releasePointer(event, false);
};
const handlePointerLeave = (): void => {
if (activePointerIdRef.current !== null) {
return;
}
const current = stateRef.current.current;
stateRef.current.set({
...current,
inside: false,
time: getPointerNowSeconds(),
deltaPx: [0, 0],
deltaUv: [0, 0],
velocityPx: [0, 0],
velocityUv: [0, 0]
});
requestFrame();
};
canvas.addEventListener('pointerdown', handlePointerDown);
canvas.addEventListener('pointermove', handleMove);
canvas.addEventListener('pointerup', handlePointerUp);
canvas.addEventListener('pointercancel', handlePointerCancel);
canvas.addEventListener('pointerleave', handlePointerLeave);
if (optionsRef.current.trackWhilePressedOutsideCanvas ?? true) {
window.addEventListener('pointermove', handleWindowMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerCancel);
}
return () => {
canvas.removeEventListener('pointerdown', handlePointerDown);
canvas.removeEventListener('pointermove', handleMove);
canvas.removeEventListener('pointerup', handlePointerUp);
canvas.removeEventListener('pointercancel', handlePointerCancel);
canvas.removeEventListener('pointerleave', handlePointerLeave);
window.removeEventListener('pointermove', handleWindowMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerCancel);
};
}, [motiongpu, requestFrame, updatePointerState]);
return {
state: stateRef.current,
lastClick: clickRef.current,
resetClick: useCallback(() => {
clickRef.current.set(null);
}, [])
};
}