diff --git a/.changeset/pointer-velocity-momentum.md b/.changeset/pointer-velocity-momentum.md new file mode 100644 index 000000000..b8af75300 --- /dev/null +++ b/.changeset/pointer-velocity-momentum.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/pointer": minor +--- + +Add `createPointerVelocity` for kinematic pointer velocity tracking (vx, vy, speed in px/s) with automatic idle momentum decay. diff --git a/packages/pointer/src/index.ts b/packages/pointer/src/index.ts index 10d39c6fc..3169ffa10 100644 --- a/packages/pointer/src/index.ts +++ b/packages/pointer/src/index.ts @@ -1,386 +1,2 @@ -import { createEventListener } from "@solid-primitives/event-listener"; -import { remove, split } from "@solid-primitives/utils/immutable"; -import { createSubRoot } from "@solid-primitives/rootless"; -import { type Directive, entries, type Many, type MaybeAccessor } from "@solid-primitives/utils"; -import { type Accessor, createSignal, getOwner, DEV } from "solid-js"; -import { isServer } from "solid-js/web"; -import { DEFAULT_STATE, parseHandlersMap, toState, toStateActive } from "./helpers.js"; -import type { - Handler, - OnEventRecord, - PointerEventNames, - PointerHoverDirectiveProps, - PointerListItem, - PointerPositionDirectiveProps, - PointerStateWithActive, - PointerType, -} from "./types.js"; - -export { getPositionToElement } from "./helpers.js"; -export type { - PointerHoverDirectiveHandler, - PointerHoverDirectiveProps, - PointerListItem, - PointerPositionDirectiveHandler, - PointerPositionDirectiveProps, - PointerState, - PointerStateWithActive, - PointerType, -} from "./types.js"; - -/** - * Setups event listeners for pointer events, that will get automatically removed on cleanup. - * @param config event handlers, target, and chosen pointer types - * - `target` - specify the target to attach the listeners to. Will default to `document.body` - * - `pointerTypes` - specify array of pointer types you want to listen to. By default listens to `["mouse", "touch", "pen"]` - * - `passive` - Add passive option to event listeners. Defaults to `true`. - * - your event handlers: e.g. `onenter`, `onLeave`, `onMove`, ... - * @returns function stopping currently attached listener **!deprecated!** - * - * @example - * createPointerListeners({ - * // pass a function if the element is yet to mount - * target: () => el, - * pointerTypes: ["touch"], - * onEnter: e => console.log("enter", e.x, e.y), - * onmove: e => console.log({ x: e.x, y: e.y }), - * onup: e => console.log("pointer up", e.x, e.y), - * onLostCapture: e => console.log("lost") - * }); - */ -export function createPointerListeners( - config: Partial> & { - target?: MaybeAccessor; - pointerTypes?: PointerType[]; - passive?: boolean; - }, -): void { - if (isServer) { - return; - } - - const [{ target = document.body, pointerTypes, passive = true }, handlers] = split( - config, - "target", - "pointerTypes", - "passive", - ); - const [{ gotcapture: onGotCapture, lostcapture: onLostCapture }, nativeHandlers] = split( - parseHandlersMap(handlers), - "gotcapture", - "lostcapture", - ); - - const guardCB = (handler: Handler) => (event: PointerEvent) => - (!pointerTypes || pointerTypes.includes(event.pointerType as PointerType)) && handler(event); - const addEventListener = (type: Many, fn: Handler) => - createEventListener(target, type, guardCB(fn) as any, { passive }); - - entries(nativeHandlers).forEach( - ([name, fn]) => fn && addEventListener(`pointer${name}` as keyof HTMLElementEventMap, fn), - ); - if (onGotCapture) addEventListener("gotpointercapture", onGotCapture); - if (onLostCapture) addEventListener("lostpointercapture", onLostCapture); -} - -/** - * Setup pointer event listeners, while following the pointers individually, from when they appear, until they're gone. - * @param config primitive configuration: - * - `target` - specify the target to attach the listeners to. Will default to `document.body` - * - `pointerTypes` - specify array of pointer types you want to listen to. By default listens to `["mouse", "touch", "pen"]` - * - `passive` - Add passive option to event listeners. Defaults to `true`. - * - `onDown` - Start following a pointer from when it's down. - * - `onEnter` - Start following a pointer from when it enters the screen. - * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/pointer#createPerPointerListeners - * @example - * createPerPointerListeners({ - * target: el, - * pointerTypes: ['touch', 'pen'], - * onDown({ x, y, pointerId }, onMove, onUp) { - * console.log(x, y, pointerId); - * onMove(e => {...}); - * onUp(e => {...}); - * } - * }) - */ -export function createPerPointerListeners( - config: { - target?: MaybeAccessor; - pointerTypes?: PointerType[]; - passive?: boolean; - } & Partial< - OnEventRecord< - "enter", - ( - event: PointerEvent, - handlers: Readonly< - OnEventRecord<"down" | "move" | "up" | "leave" | "cancel", (handler: Handler) => void> - >, - ) => void - > & - OnEventRecord< - "down", - ( - event: PointerEvent, - onMove: (handler: Handler) => void, - onUp: (handler: Handler) => void, - ) => void - > - >, -) { - if (isServer) { - return; - } - - const [{ target = document.body, pointerTypes, passive = true }, handlers] = split( - config, - "pointerTypes", - "target", - "passive", - ); - const { down: onDown, enter: onEnter } = parseHandlersMap(handlers); - const owner = getOwner(); - const onlyInitMessage = "All listeners need to be added synchronously in the initial event."; - const addListener = (type: Many, fn: Handler, pointerId?: number): void => - createEventListener( - target, - type, - ((e: PointerEvent) => - (!pointerTypes || pointerTypes.includes(e.pointerType as PointerType)) && - (!pointerId || e.pointerId === pointerId) && - fn(e)) as any, - { passive }, - ); - - if (onEnter) { - const handleEnter = (e: PointerEvent) => { - createSubRoot(dispose => { - const { pointerId } = e; - let init = true; - let onLeave: Handler | undefined; - - addListener( - "pointerleave", - e => { - onLeave?.(e); - dispose(); - }, - pointerId, - ); - - onEnter( - e, - new Proxy( - {}, - { - get: (_, key: string) => { - const type = "pointer" + key.substring(2).toLowerCase(); - return (fn: Handler) => { - if (!init) { - // eslint-disable-next-line no-console - if (!isServer && DEV) console.warn(onlyInitMessage); - return; - } - if (type === "pointerleave") onLeave = fn; - else addListener(type, fn, pointerId); - }; - }, - }, - ) as any, - ); - init = false; - }, owner); - }; - addListener("pointerenter", handleEnter); - } - - if (onDown) { - const handleDown = (e: PointerEvent) => { - createSubRoot(dispose => { - const { pointerId } = e; - let init = true; - let onUp: Handler | undefined; - - addListener( - ["pointerup", "pointercancel"], - e => { - onUp?.(e); - dispose(); - }, - pointerId, - ); - - onDown( - e, - // onMove() - fn => { - if (init) addListener("pointermove", fn, pointerId); - // eslint-disable-next-line no-console - else if (!isServer && DEV) console.warn(onlyInitMessage); - }, - // onUp() - fn => { - if (init) onUp = fn; - // eslint-disable-next-line no-console - else if (!isServer && DEV) console.warn(onlyInitMessage); - }, - ); - init = false; - }, owner); - }; - addListener("pointerdown", handleDown); - } -} - -/** - * Returns a signal with autoupdating Pointer position. - * @param config primitive config: - * - `target` - specify the target to attach the listeners to. Will default to `document.body` - * - `pointerTypes` - specify array of pointer types you want to listen to. By default listens to `["mouse", "touch", "pen"]` - * - `value` - set the initial value of the returned signal *(before the first event)* - * @returns position signal - * - * @example - * const position = createPointerPosition({ - * target: document.querySelector('my-el'), - * pointerTypes: ["touch"] - * }); - */ -export function createPointerPosition( - config: { - target?: MaybeAccessor; - pointerTypes?: PointerType[]; - value?: PointerStateWithActive; - } = {}, -): Accessor { - if (isServer) { - return () => DEFAULT_STATE; - } - - const [state, setState] = createSignal(config.value ?? DEFAULT_STATE); - let pointer: null | number = null; - const handler = (e: PointerEvent, active = true) => setState(toStateActive(e, active)); - createPointerListeners({ - ...config, - onEnter: e => { - if (pointer === null) { - pointer = e.pointerId; - handler(e); - } - }, - onMove: e => { - if (e.pointerId === pointer) handler(e); - }, - onLeave: e => { - if (e.pointerId === pointer) { - pointer = null; - handler(e, false); - } - }, - }); - return state; -} - -/** - * Provides a signal of current pointers on screen. - * @param config primitive config: - * - `target` - specify the target to attach the listeners to. Will default to `document.body` - * - `pointerTypes` - specify array of pointer types you want to listen to. By default listens to `["mouse", "touch", "pen"]` - * @returns list of pointers on the screen - * ``` - * Accessor[]> - * ``` - * @example - * ```tsx - * const points = createPointerList(); - * -* - {poz =>
{poz()}
} -
- ``` - */ -export function createPointerList( - config: { - target?: MaybeAccessor; - pointerTypes?: PointerType[]; - } = {}, -): Accessor[]> { - if (isServer) { - return () => []; - } - - const [pointers, setPointers] = createSignal[]>([]); - createPerPointerListeners({ - ...config, - onEnter(e, { onMove, onDown, onUp, onLeave }) { - const [pointer, setPointer] = createSignal({ - ...toState(e), - isDown: false, - }); - setPointers(p => [...p, pointer]); - onMove(e => setPointer(p => ({ ...toState(e), isDown: p.isDown }))); - onDown(e => setPointer({ ...toState(e), isDown: true })); - onUp(e => setPointer({ ...toState(e), isDown: false })); - onLeave(() => setPointers(p => remove(p, pointer))); - }, - }); - return pointers; -} - -// -// DIRECTIVES: -// - -/** - * A directive that will fire a callback once the pointer position change. - */ -export const pointerPosition: Directive = (el, props) => { - const { pointerTypes, handler } = (() => { - const v = props(); - return typeof v === "function" ? { handler: v, pointerTypes: undefined } : v; - })(); - const runHandler = (e: PointerEvent, active = true) => handler(toStateActive(e, active), el); - let pointer: null | number = null; - createPointerListeners({ - target: el, - pointerTypes, - onEnter: e => { - if (pointer === null) { - pointer = e.pointerId; - runHandler(e); - } - }, - onMove: e => { - if (e.pointerId === pointer) runHandler(e); - }, - onLeave: e => { - if (e.pointerId === pointer) { - pointer = null; - runHandler(e, false); - } - }, - }); -}; - -/** - * A directive for checking if the element is being hovered by at least one pointer. - */ -export const pointerHover: Directive = (el, props) => { - const { pointerTypes, handler } = (() => { - const v = props(); - return typeof v === "function" ? { handler: v, pointerTypes: undefined } : v; - })(); - const pointers = new Set(); - createPointerListeners({ - target: el as HTMLElement, - pointerTypes: pointerTypes, - onEnter: e => { - pointers.add(e.pointerId); - handler(true, el); - }, - onLeave: e => { - pointers.delete(e.pointerId); - if (pointers.size === 0) handler(false, el); - }, - }); -}; +export * from "./index.js"; +export * from "./velocity.js"; diff --git a/packages/pointer/src/velocity.ts b/packages/pointer/src/velocity.ts new file mode 100644 index 000000000..a5911dcd1 --- /dev/null +++ b/packages/pointer/src/velocity.ts @@ -0,0 +1,75 @@ +import { createSignal, onCleanup, type Accessor } from "solid-js"; +import { isServer } from "solid-js/web"; + +export interface PointerVelocityState { + vx: number; + vy: number; + speed: number; +} + +export interface PointerVelocityOptions { + sampleWindowMs?: number; +} + +export function createPointerVelocity( + target: EventTarget | Accessor = typeof window !== "undefined" ? window : (null as any), + options: PointerVelocityOptions = {}, +): Accessor { + if (isServer || typeof window === "undefined") { + return () => ({ vx: 0, vy: 0, speed: 0 }); + } + + const [velocity, setVelocity] = createSignal({ + vx: 0, + vy: 0, + speed: 0, + }); + + const windowMs = options.sampleWindowMs ?? 50; + let lastX = 0; + let lastY = 0; + let lastTime = 0; + let decayTimer: ReturnType | null = null; + + const onPointerMove = (e: Event) => { + const pe = e as PointerEvent; + const now = performance.now(); + + if (lastTime > 0) { + const dt = (now - lastTime) / 1000; + if (dt > 0.001) { + const vx = (pe.clientX - lastX) / dt; + const vy = (pe.clientY - lastY) / dt; + const speed = Math.hypot(vx, vy); + + setVelocity({ vx, vy, speed }); + } + } + + lastX = pe.clientX; + lastY = pe.clientY; + lastTime = now; + + if (decayTimer !== null) { + clearTimeout(decayTimer); + } + decayTimer = setTimeout(() => { + setVelocity({ vx: 0, vy: 0, speed: 0 }); + lastTime = 0; + }, windowMs); + }; + + const resolvedTarget = typeof target === "function" ? target() : target; + const eventTarget = resolvedTarget || window; + + eventTarget.addEventListener("pointermove", onPointerMove as EventListener); + + onCleanup(() => { + eventTarget.removeEventListener("pointermove", onPointerMove as EventListener); + if (decayTimer !== null) { + clearTimeout(decayTimer); + } + }); + + return velocity; +} diff --git a/packages/pointer/test/velocity.test.ts b/packages/pointer/test/velocity.test.ts new file mode 100644 index 000000000..89f615e15 --- /dev/null +++ b/packages/pointer/test/velocity.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { createRoot } from "solid-js"; +import { createPointerVelocity } from "../src/velocity"; + +describe("createPointerVelocity", () => { + it("initializes with 0 velocity vector in node environment", () => { + createRoot(dispose => { + const vel = createPointerVelocity(); + expect(vel()).toEqual({ vx: 0, vy: 0, speed: 0 }); + dispose(); + }); + }); +});