@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
419 lines (418 loc) • 13.8 kB
JavaScript
import { createCurrentWritable } from "./current-value.js";
//#region src/lib/core/pointer.ts
/**
* Returns a monotonic timestamp in seconds.
*/
function getPointerNowSeconds() {
if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now() / 1e3;
return Date.now() / 1e3;
}
/**
* Creates the initial pointer state snapshot.
*/
function createInitialPointerState() {
return {
px: [0, 0],
uv: [0, 0],
ndc: [-1, -1],
inside: false,
pressed: false,
dragging: false,
pointerType: null,
pointerId: null,
button: null,
buttons: 0,
time: getPointerNowSeconds(),
downPx: null,
downUv: null,
deltaPx: [0, 0],
deltaUv: [0, 0],
velocityPx: [0, 0],
velocityUv: [0, 0]
};
}
/**
* Converts client coordinates to canvas-relative pointer coordinates.
*/
function getPointerCoordinates(clientX, clientY, rect) {
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const nx = (clientX - rect.left) / width;
const ny = (clientY - rect.top) / height;
const pxX = clientX - rect.left;
const pxY = clientY - rect.top;
const uvX = nx;
const uvY = 1 - ny;
return {
px: [pxX, pxY],
uv: [uvX, uvY],
ndc: [nx * 2 - 1, uvY * 2 - 1],
inside: nx >= 0 && nx <= 1 && ny >= 0 && ny <= 1
};
}
/**
* Resolves frame wake-up strategy for pointer-driven updates.
*/
function resolvePointerFrameRequestMode(mode, renderMode) {
if (mode !== "auto") return mode;
if (renderMode === "manual") return "advance";
if (renderMode === "on-demand") return "invalidate";
return "none";
}
/**
* Normalizes unknown pointer kind values to the public `PointerKind`.
*/
function normalizePointerKind(pointerType) {
if (pointerType === "mouse" || pointerType === "pen" || pointerType === "touch") return pointerType;
return "mouse";
}
function resolveClickMaxDurationMs(value) {
if (typeof value !== "number" || Number.isNaN(value) || value <= 0) return 350;
return value;
}
function resolveClickMaxMovePx(value) {
if (typeof value !== "number" || Number.isNaN(value) || value < 0) return 8;
return value;
}
function normalizeClickButtons(buttons) {
const source = buttons && buttons.length > 0 ? buttons : [0];
return new Set(source);
}
/**
* Creates the shared pointer controller used by every framework adapter.
*/
function createPointerController(runtime, initialOptions = {}) {
const state = createCurrentWritable(createInitialPointerState());
const lastClick = createCurrentWritable(null);
let options = initialOptions;
let canvas = null;
let windowTarget = null;
let canvasListenersAttached = false;
let windowMoveListenerAttached = false;
let windowReleaseListenersAttached = false;
let activePointerId = null;
let capturedPointerId = null;
let downSnapshot = null;
let clickCounter = 0;
let previousPx = null;
let previousUv = null;
let previousTimeSeconds = 0;
const handledCanvasMoveEvents = /* @__PURE__ */ new WeakSet();
const isEnabled = () => options.enabled ?? true;
const shouldTrackOutside = () => options.trackWhilePressedOutsideCanvas ?? true;
const requestFrame = () => {
const mode = resolvePointerFrameRequestMode(options.requestFrame ?? "auto", runtime.getRenderMode());
if (mode === "invalidate") {
runtime.invalidate();
return;
}
if (mode === "advance") runtime.advance();
};
const updatePointerState = (input) => {
const nowSeconds = getPointerNowSeconds();
const dt = previousTimeSeconds > 0 ? Math.max(nowSeconds - previousTimeSeconds, 1e-6) : 0;
const deltaPx = input.resetDelta || !previousPx ? [0, 0] : [input.point.px[0] - previousPx[0], input.point.px[1] - previousPx[1]];
const deltaUv = input.resetDelta || !previousUv ? [0, 0] : [input.point.uv[0] - previousUv[0], input.point.uv[1] - previousUv[1]];
const velocityPx = dt > 0 ? [deltaPx[0] / dt, deltaPx[1] / dt] : [0, 0];
const velocityUv = dt > 0 ? [deltaUv[0] / dt, deltaUv[1] / dt] : [0, 0];
const nextState = {
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
};
state.set(nextState);
previousPx = input.point.px;
previousUv = input.point.uv;
previousTimeSeconds = nowSeconds;
requestFrame();
return nextState;
};
const updateInsideState = (inside) => {
const current = state.current;
state.set({
...current,
inside,
time: getPointerNowSeconds(),
deltaPx: [0, 0],
deltaUv: [0, 0],
velocityPx: [0, 0],
velocityUv: [0, 0]
});
requestFrame();
};
const releaseCapture = () => {
if (!canvas || capturedPointerId === null) {
capturedPointerId = null;
return;
}
try {
if (typeof canvas.hasPointerCapture !== "function" || canvas.hasPointerCapture(capturedPointerId)) canvas.releasePointerCapture(capturedPointerId);
} catch {}
capturedPointerId = null;
};
const resetTracking = (emitFrame) => {
releaseCapture();
activePointerId = null;
downSnapshot = null;
previousPx = null;
previousUv = null;
previousTimeSeconds = 0;
const current = state.current;
if (!(current.inside || current.pressed || current.dragging || current.pointerId !== null || current.button !== null || current.buttons !== 0 || current.downPx !== null || current.downUv !== null)) return;
state.set({
...current,
inside: false,
pressed: false,
dragging: false,
pointerId: null,
button: null,
buttons: 0,
time: getPointerNowSeconds(),
downPx: null,
downUv: null,
deltaPx: [0, 0],
deltaUv: [0, 0],
velocityPx: [0, 0],
velocityUv: [0, 0]
});
if (emitFrame) requestFrame();
};
const handlePointerDown = (event) => {
if (!canvas || activePointerId !== null) return;
const point = getPointerCoordinates(event.clientX, event.clientY, canvas.getBoundingClientRect());
const pointerType = normalizePointerKind(event.pointerType);
activePointerId = event.pointerId;
downSnapshot = {
pointerId: event.pointerId,
pointerType,
button: event.button,
timeMs: getPointerNowSeconds() * 1e3,
px: point.px,
uv: point.uv,
inside: point.inside
};
if (options.capturePointer ?? true) try {
canvas.setPointerCapture(event.pointerId);
capturedPointerId = event.pointerId;
} catch {}
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
});
options.onDown?.(nextState, event);
};
const handlePointerMove = (event) => {
if (!canvas || activePointerId !== null && event.pointerId !== activePointerId) return;
handledCanvasMoveEvents.add(event);
const point = getPointerCoordinates(event.clientX, event.clientY, canvas.getBoundingClientRect());
const pressed = activePointerId !== null && event.pointerId === activePointerId;
const downPx = pressed ? downSnapshot?.px ?? point.px : null;
const downUv = pressed ? downSnapshot?.uv ?? point.uv : null;
const dragging = pressed && downPx ? Math.hypot(point.px[0] - downPx[0], point.px[1] - downPx[1]) > 0 : false;
const nextState = updatePointerState({
point,
inside: point.inside,
pressed,
dragging,
pointerType: normalizePointerKind(event.pointerType),
pointerId: event.pointerId,
button: pressed ? downSnapshot?.button ?? event.button : null,
buttons: event.buttons,
downPx,
downUv
});
options.onMove?.(nextState, event);
};
const handleWindowPointerMove = (event) => {
if (handledCanvasMoveEvents.has(event) || !canvas || activePointerId === null || event.pointerId !== activePointerId) return;
const point = getPointerCoordinates(event.clientX, event.clientY, canvas.getBoundingClientRect());
if (point.inside) return;
const downPx = downSnapshot?.px ?? point.px;
const downUv = downSnapshot?.uv ?? point.uv;
const nextState = updatePointerState({
point,
inside: false,
pressed: true,
dragging: Math.hypot(point.px[0] - downPx[0], point.px[1] - downPx[1]) > 0,
pointerType: downSnapshot?.pointerType ?? normalizePointerKind(event.pointerType),
pointerId: event.pointerId,
button: downSnapshot?.button ?? event.button,
buttons: event.buttons,
downPx,
downUv
});
options.onMove?.(nextState, event);
};
const releasePointer = (event, emitClick) => {
if (!canvas || activePointerId === null || event.pointerId !== activePointerId) return;
const point = getPointerCoordinates(event.clientX, event.clientY, canvas.getBoundingClientRect());
const previous = downSnapshot;
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
});
options.onUp?.(nextState, event);
releaseCapture();
if (emitClick && (options.clickEnabled ?? true) && previous) {
if (normalizeClickButtons(options.clickButtons).has(previous.button)) {
const durationMs = getPointerNowSeconds() * 1e3 - previous.timeMs;
const moveDistance = Math.hypot(point.px[0] - previous.px[0], point.px[1] - previous.px[1]);
if (previous.inside && point.inside && durationMs <= resolveClickMaxDurationMs(options.clickMaxDurationMs) && moveDistance <= resolveClickMaxMovePx(options.clickMaxMovePx)) {
clickCounter += 1;
const click = {
id: clickCounter,
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
};
lastClick.set(click);
options.onClick?.(click, nextState, event);
requestFrame();
}
}
}
activePointerId = null;
downSnapshot = null;
};
const handlePointerUp = (event) => {
releasePointer(event, true);
};
const handlePointerCancel = (event) => {
releasePointer(event, false);
};
const handlePointerLeave = () => {
if (activePointerId === null) updateInsideState(false);
};
const attachWindowMoveListener = () => {
if (windowMoveListenerAttached || !windowTarget || !shouldTrackOutside()) return;
windowTarget.addEventListener("pointermove", handleWindowPointerMove);
windowMoveListenerAttached = true;
};
const detachWindowMoveListener = () => {
if (!windowMoveListenerAttached || !windowTarget) return;
windowTarget.removeEventListener("pointermove", handleWindowPointerMove);
windowMoveListenerAttached = false;
};
const attachWindowReleaseListeners = () => {
if (windowReleaseListenersAttached || !windowTarget) return;
windowTarget.addEventListener("pointerup", handlePointerUp);
windowTarget.addEventListener("pointercancel", handlePointerCancel);
windowReleaseListenersAttached = true;
};
const detachWindowReleaseListeners = () => {
if (!windowReleaseListenersAttached || !windowTarget) return;
windowTarget.removeEventListener("pointerup", handlePointerUp);
windowTarget.removeEventListener("pointercancel", handlePointerCancel);
windowReleaseListenersAttached = false;
};
const attachCanvasListeners = () => {
if (canvasListenersAttached || !canvas || !isEnabled()) return;
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerUp);
canvas.addEventListener("pointercancel", handlePointerCancel);
canvas.addEventListener("pointerleave", handlePointerLeave);
canvasListenersAttached = true;
attachWindowReleaseListeners();
attachWindowMoveListener();
};
const detachCanvasListeners = () => {
detachWindowMoveListener();
detachWindowReleaseListeners();
if (!canvasListenersAttached || !canvas) return;
canvas.removeEventListener("pointerdown", handlePointerDown);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerup", handlePointerUp);
canvas.removeEventListener("pointercancel", handlePointerCancel);
canvas.removeEventListener("pointerleave", handlePointerLeave);
canvasListenersAttached = false;
};
const destroy = () => {
detachCanvasListeners();
resetTracking(false);
canvas = null;
windowTarget = null;
};
return {
state,
lastClick,
resetClick() {
lastClick.set(null);
},
mount(nextCanvas) {
if (canvas === nextCanvas) {
attachCanvasListeners();
return;
}
destroy();
canvas = nextCanvas;
windowTarget = nextCanvas.ownerDocument.defaultView;
attachCanvasListeners();
},
updateOptions(nextOptions) {
const wasEnabled = isEnabled();
const wasTrackingOutside = shouldTrackOutside();
options = nextOptions;
const enabled = isEnabled();
const trackingOutside = shouldTrackOutside();
if (wasEnabled && !enabled) {
detachCanvasListeners();
resetTracking(true);
return;
}
if (!wasEnabled && enabled) {
attachCanvasListeners();
return;
}
if (!enabled || wasTrackingOutside === trackingOutside) return;
if (trackingOutside) attachWindowMoveListener();
else detachWindowMoveListener();
},
destroy
};
}
//#endregion
export { createInitialPointerState, createPointerController, getPointerCoordinates, getPointerNowSeconds, normalizePointerKind, resolvePointerFrameRequestMode };
//# sourceMappingURL=pointer.js.map