diff --git a/src/pointers.ts b/src/pointers.ts index b5525648..a3656f06 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -1,7 +1,28 @@ import { Plane, Vector3, type Intersection, type Object3D, type Ray } from "three" -import type { Context, Meta, Prettify, ThreeEvent } from "./types.ts" +import type { Context, Meta, PointerCapture, Prettify } from "./types.ts" import { getMeta } from "./utils.ts" +/** + * The dispatcher's writable view of an event while it's built and walked. Every + * field is optional because the object is assembled incrementally: stoppable adds + * `stopped`/`stopPropagation`; raycasting adds `intersection(s)`/`object`; the tree + * walk advances `currentObject`/`currentIntersection`; a capturable event gains the + * `PointerCapture` methods; a plugin source merges typed `TExtra`. Handlers receive + * the precise public `ThreeEvent` (via their prop types) — this is the internal + * shape the `Pointer` writes, so the dispatch path needs no `any`. + */ +export type DispatchEvent = { + nativeEvent: Event + stopped?: boolean + stopPropagation?: () => void + intersections?: Intersection[] + intersection?: Intersection + object?: Object3D + currentObject?: Object3D + currentIntersection?: Intersection +} & Partial & + TExtra + /** * The slice of an `EventRaycaster` a `Pointer` needs: cast its current ray against * a registry, (for the click-missed phase) re-cast a single object, and — for @@ -16,20 +37,28 @@ export type PointerRaycaster = { ray: Ray } -/** Creates a `ThreeEvent` (intersection excluded) from a native `MouseEvent` | `PointerEvent` | `WheelEvent`. */ -export function createThreeEvent< - TEvent extends Event, - TConfig extends { stoppable?: boolean; intersections?: Array }, ->(nativeEvent: TEvent, { stoppable = true, intersections }: TConfig = {} as TConfig) { - const event: Record = stoppable - ? { - nativeEvent, - stopped: false, - stopPropagation() { - event.stopped = true - }, - } - : { nativeEvent } +/** + * Build the {@link DispatchEvent} for one dispatch from a native `MouseEvent` | + * `PointerEvent` | `WheelEvent`, optionally merging a plugin source's typed `extra` + * fields (e.g. the XR controller payload). The per-node `currentObject` / + * `currentIntersection` and the capture methods are added later by the `Pointer`. + */ +export function createThreeEvent( + nativeEvent: TEvent, + { stoppable = true, intersections }: { stoppable?: boolean; intersections?: Intersection[] } = {}, + extra?: TExtra, +): Prettify> { + const event: DispatchEvent = ( + stoppable + ? { + nativeEvent, + stopped: false, + stopPropagation() { + event.stopped = true + }, + } + : { nativeEvent } + ) as DispatchEvent if (intersections) { event.intersections = intersections @@ -37,22 +66,9 @@ export function createThreeEvent< event.object = intersections[0]?.object } - return event as Prettify< - Omit< - ThreeEvent< - TEvent, - { - stoppable: TConfig["stoppable"] extends false - ? TConfig["stoppable"] extends true - ? true - : false - : true - intersections: TConfig["intersections"] extends Intersection[] ? true : false - } - >, - "currentIntersection" - > - > + if (extra) Object.assign(event, extra) + + return event as Prettify> } /** The OS-level half of pointer capture, injected per source (DOM canvas vs XR). */ @@ -194,7 +210,7 @@ export class Pointer { } /** Attach the capture methods to a capturable event (down/up/move). */ - private attachCapture(event: any) { + private attachCapture(event: DispatchEvent) { // With no `object`, captures `event.currentObject` (the firing node) — sync-only, // since it's cleared after dispatch (like a DOM event's `currentTarget`). Pass an // `object` to capture it later: the caller holds the reference, so it works async. @@ -233,7 +249,7 @@ export class Pointer { const props = this.context.props as Record // Phase #1 — Enter (bubble up; fire onPointerEnter for newly-hovered objects). - const enterEvent: any = createThreeEvent(nativeEvent, { stoppable: false, intersections }) + const enterEvent = createThreeEvent(nativeEvent, { stoppable: false, intersections }) const entered = new Set() for (const intersection of intersections) { enterEvent.currentIntersection = intersection @@ -251,7 +267,7 @@ export class Pointer { // Phase #2 — Move (bubble up, stoppable). Capturable: a handler may start a // drag by calling `setPointerCapture()` from here. - const moveEvent: any = createThreeEvent(nativeEvent, { intersections }) + const moveEvent = createThreeEvent(nativeEvent, { intersections }) this.attachCapture(moveEvent) this.propagate( moveEvent, @@ -298,7 +314,7 @@ export class Pointer { * captured object, the normal path one pair per hit. A node shared by several hits * fires once (the closest hit's chain reaches it first), matching `move`/`click`. */ - private propagate(event: any, handler: string, roots: Array<[Intersection, Object3D]>) { + private propagate(event: DispatchEvent, handler: string, roots: Array<[Intersection, Object3D]>) { const visited = new Set() for (const [intersection, root] of roots) { event.currentIntersection = intersection @@ -329,22 +345,25 @@ export class Pointer { * registry is not raycast) but still bubbles to the canvas-level handler; the * intersection is the live ray reprojected onto the captured plane. */ - dispatch(handler: string, nativeEvent: Event, extra?: Record, capturable = false) { + dispatch( + handler: string, + nativeEvent: Event, + extra?: TExtra, + capturable = false, + ) { const captured = this.captured if (captured) { // Captured: exclusive delivery to the captured object's chain, with the live // ray reprojected onto the captured plane. const intersection = this.reproject(captured) - const event: any = createThreeEvent(nativeEvent, { intersections: [intersection] }) - if (extra) Object.assign(event, extra) + const event = createThreeEvent(nativeEvent, { intersections: [intersection] }, extra) if (capturable) this.attachCapture(event) this.propagate(event, handler, [[intersection, captured.object]]) return } const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) - const event: any = createThreeEvent(nativeEvent, { intersections }) - if (extra) Object.assign(event, extra) + const event = createThreeEvent(nativeEvent, { intersections }, extra) if (capturable) this.attachCapture(event) this.propagate( event, @@ -363,7 +382,7 @@ export class Pointer { const missed = new Set(registry) const visited = new Set() const intersections = this.raycaster.cast(registry, this.context) - const event: any = createThreeEvent(nativeEvent, { intersections }) + const event = createThreeEvent(nativeEvent, { intersections }) // Phase #1 — fire the handler, bubbling down the hit chain. for (const intersection of intersections) { diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index 6c6e5429..343bd077 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from "vitest" -import { Object3D, PerspectiveCamera, Ray, Vector3 } from "three" -import { Pointer, type PointerRaycaster } from "../../src/pointers.ts" +import { assertType, describe, expect, it, vi } from "vitest" +import { type Intersection, Object3D, PerspectiveCamera, Ray, Vector3 } from "three" +import { createThreeEvent, Pointer, type PointerRaycaster } from "../../src/pointers.ts" import { meta } from "../../src/utils.ts" function eventful(handlers: Record) { @@ -441,3 +441,16 @@ describe("Pointer capture lifecycle", () => { expect(pointer.hasCaptured(mesh)).toBe(true) }) }) + +describe("createThreeEvent typing", () => { + it("merges typed extra and exposes a typed event shape — no any", () => { + const event = createThreeEvent(new MouseEvent("click"), { intersections: [] }, { controller: "c" } as const) + + assertType(event.nativeEvent) + assertType<"c">(event.controller) // typed plugin extra, not `any` + assertType(event.intersections) + + expect(event.controller).toBe("c") + expect(event.nativeEvent.type).toBe("click") + }) +})