Skip to content

Commit c5db8e2

Browse files
authored
refactor(events)!: source-agnostic pointer system (Pointer + EventRaycaster + DOMPointerManager) (#66)
**Act 1 of 3** splitting #65 into reviewable, independently-green slices that land as three squash-merged commits on `next-cleanup`. This one is self-contained; Act 2 (plugin system) and Act 3 (XR as a plugin) follow once this merges. ## Summary Replaces the DOM-coupled per-kind event registries with a layered, source-agnostic pointer system that any source can drive: - **`Pointer`** — per-pointer, DOM-agnostic dispatch over a single `onPointer*` family (the redundant `onMouse*` family is gone), tracking its own hover state so multiple pointers stay independent. - **`EventRaycaster.cast` + `ScreenRaycaster` + `ControllerRaycaster`** — the ray strategy, aimed from a 2D cursor (screen) or an `Object3D`'s world transform (controller). - **`DOMPointerManager`** — the built-in screen source: one `Pointer` per native `pointerId` (independent multi-touch) plus a `primary` `Pointer` for the family-agnostic click/dblclick/contextmenu/wheel gestures. ## Commits (6) - `feat(events)`: Pointer — per-pointer DOM-agnostic dispatch (single onPointer* family) - `feat(events)`: EventRaycaster.cast + ScreenRaycaster + ControllerRaycaster - `feat(events)`: DOMPointerManager — screen pointer source (per-pointerId + primary) - `refactor(events)!`: drive dispatch via DOMPointerManager + Pointer; drop onMouse* - `refactor(events)`: remove dead legacy raycaster.update() - `test(events)`: multi-touch — independent hover/leave per pointerId ## Breaking change Dispatch is driven by `DOMPointerManager` + `Pointer`; the `onMouse*` handler family and the legacy `raycaster.update()` are removed. ## Test plan - [x] full suite green at this commit (`ec8df05`)
1 parent 824aea9 commit c5db8e2

12 files changed

Lines changed: 661 additions & 650 deletions

src/create-events.ts

Lines changed: 45 additions & 459 deletions
Large diffs are not rendered by default.

src/create-three.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) {
357357
},
358358
canvas,
359359
clock,
360+
eventRegistry: [],
360361
get dpr() {
361362
// Renderers without a pixel-ratio API (CSS2D/3D, SVG) didn't scale
362363
// anything — reporting `1` is honest. Users who need the device's

src/pointer-managers.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Vector2 } from "three"
2+
import { Pointer } from "./pointers.ts"
3+
import type { ScreenRaycaster } from "./raycasters.tsx"
4+
import type { Context } from "./types.ts"
5+
6+
type RayEvent = PointerEvent | MouseEvent | WheelEvent
7+
8+
/**
9+
* The built-in screen pointer source. Owns the canvas's pointer/click/wheel
10+
* listeners, one `Pointer` per native `pointerId` (so multi-touch tracks
11+
* independently), and a `primary` `Pointer` for the family-agnostic
12+
* click/dblclick/contextmenu/wheel gestures — those are `MouseEvent`s with no
13+
* `pointerId`, so they don't belong to a specific touch.
14+
*
15+
* It aims the (single, shared) screen raycaster from each event before calling
16+
* the pointer's gesture method; that's safe because `setCursor` → `cast` runs
17+
* synchronously within one event, so concurrent pointers never collide.
18+
*/
19+
export class DOMPointerManager {
20+
private pointers = new Map<number, Pointer>()
21+
private primary: Pointer
22+
23+
constructor(
24+
private context: Context,
25+
private raycaster: ScreenRaycaster,
26+
) {
27+
this.primary = new Pointer(context, raycaster)
28+
}
29+
30+
private forId(id: number): Pointer {
31+
let pointer = this.pointers.get(id)
32+
if (!pointer) {
33+
pointer = new Pointer(this.context, this.raycaster)
34+
this.pointers.set(id, pointer)
35+
}
36+
return pointer
37+
}
38+
39+
private ndc(event: RayEvent): Vector2 {
40+
const { width, height } = this.context.bounds
41+
return new Vector2((event.offsetX / width) * 2 - 1, -(event.offsetY / height) * 2 + 1)
42+
}
43+
44+
/** Attach all canvas listeners; returns a disconnect that removes them. */
45+
connect(): () => void {
46+
const canvas = this.context.canvas
47+
const aim = (event: RayEvent) => this.raycaster.setCursor(this.ndc(event))
48+
49+
const onMove = (event: PointerEvent) => {
50+
aim(event)
51+
this.forId(event.pointerId).move(event)
52+
}
53+
const onDown = (event: PointerEvent) => {
54+
aim(event)
55+
this.forId(event.pointerId).down(event)
56+
}
57+
const onUp = (event: PointerEvent) => {
58+
aim(event)
59+
this.forId(event.pointerId).up(event)
60+
// A lifted touch no longer exists — leave + drop it so it keeps no state.
61+
if (event.pointerType === "touch") {
62+
this.pointers.get(event.pointerId)?.leave(event)
63+
this.pointers.delete(event.pointerId)
64+
}
65+
}
66+
const onLeaveOrCancel = (event: PointerEvent) => {
67+
// Always fire the canvas-level leave (a fresh pointer's leave does that even
68+
// with nothing hovered), matching the old per-session leave behavior.
69+
this.forId(event.pointerId).leave(event)
70+
this.pointers.delete(event.pointerId)
71+
}
72+
const onClick = (event: MouseEvent) => {
73+
aim(event)
74+
this.primary.click("onClick", event)
75+
}
76+
const onDoubleClick = (event: MouseEvent) => {
77+
aim(event)
78+
this.primary.click("onDoubleClick", event)
79+
}
80+
const onContextMenu = (event: MouseEvent) => {
81+
aim(event)
82+
this.primary.click("onContextMenu", event)
83+
}
84+
const onWheel = (event: WheelEvent) => {
85+
aim(event)
86+
this.primary.wheel(event)
87+
}
88+
89+
canvas.addEventListener("pointermove", onMove)
90+
canvas.addEventListener("pointerdown", onDown)
91+
canvas.addEventListener("pointerup", onUp)
92+
canvas.addEventListener("pointerleave", onLeaveOrCancel)
93+
canvas.addEventListener("pointercancel", onLeaveOrCancel)
94+
canvas.addEventListener("click", onClick)
95+
canvas.addEventListener("dblclick", onDoubleClick)
96+
canvas.addEventListener("contextmenu", onContextMenu)
97+
canvas.addEventListener("wheel", onWheel, { passive: true })
98+
99+
return () => {
100+
canvas.removeEventListener("pointermove", onMove)
101+
canvas.removeEventListener("pointerdown", onDown)
102+
canvas.removeEventListener("pointerup", onUp)
103+
canvas.removeEventListener("pointerleave", onLeaveOrCancel)
104+
canvas.removeEventListener("pointercancel", onLeaveOrCancel)
105+
canvas.removeEventListener("click", onClick)
106+
canvas.removeEventListener("dblclick", onDoubleClick)
107+
canvas.removeEventListener("contextmenu", onContextMenu)
108+
canvas.removeEventListener("wheel", onWheel)
109+
}
110+
}
111+
}

src/pointers.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import type { Intersection, Object3D } from "three"
2+
import type { Context, Meta, Prettify, ThreeEvent } from "./types.ts"
3+
import { getMeta } from "./utils.ts"
4+
5+
/**
6+
* The slice of an `EventRaycaster` a `Pointer` needs: cast its current ray against
7+
* a registry, and (for the click-missed phase) re-cast a single object. The real
8+
* `EventRaycaster` (which extends three's `Raycaster`) satisfies this structurally.
9+
*/
10+
export type PointerRaycaster = {
11+
cast(registry: Object3D[], context: Context): Intersection<Meta<Object3D>>[]
12+
intersectObject(object: Object3D, recursive?: boolean): Intersection[]
13+
}
14+
15+
/** Creates a `ThreeEvent` (intersection excluded) from a native `MouseEvent` | `PointerEvent` | `WheelEvent`. */
16+
export function createThreeEvent<
17+
TEvent extends Event,
18+
TConfig extends { stoppable?: boolean; intersections?: Array<Intersection> },
19+
>(nativeEvent: TEvent, { stoppable = true, intersections }: TConfig = {} as TConfig) {
20+
const event: Record<string, any> = stoppable
21+
? {
22+
nativeEvent,
23+
stopped: false,
24+
stopPropagation() {
25+
event.stopped = true
26+
},
27+
}
28+
: { nativeEvent }
29+
30+
if (intersections) {
31+
event.intersections = intersections
32+
event.intersection = intersections[0]
33+
}
34+
35+
return event as Prettify<
36+
Omit<
37+
ThreeEvent<
38+
TEvent,
39+
{
40+
stoppable: TConfig["stoppable"] extends false
41+
? TConfig["stoppable"] extends true
42+
? true
43+
: false
44+
: true
45+
intersections: TConfig["intersections"] extends Intersection[] ? true : false
46+
}
47+
>,
48+
"currentIntersection"
49+
>
50+
>
51+
}
52+
53+
/**
54+
* One pointer's dispatch + per-pointer state, decoupled from the DOM. A
55+
* `*PointerManager` owns the source (canvas / XR controller) and the raycaster,
56+
* and calls these gesture methods; the `Pointer` raycasts the context's single
57+
* `eventRegistry` and bubbles to the `onPointer*` / `onClick` / … handlers,
58+
* tracking its own hover state so multiple pointers stay independent.
59+
*
60+
* Dispatch logic is ported verbatim from the previous per-kind registries
61+
* (`createHoverEventRegistry` / `createMissableEventRegistry` /
62+
* `createDefaultEventRegistry`); the only changes are per-pointer instance state
63+
* and the single `onPointer*` family (the redundant `onMouse*` family is gone).
64+
*/
65+
export class Pointer {
66+
private hovered = new Set<Object3D>()
67+
private hoveredCanvas = false
68+
69+
constructor(
70+
private context: Context,
71+
private raycaster: PointerRaycaster,
72+
) {}
73+
74+
/** Hover: enter/leave diff + bubbled `onPointerMove`, plus canvas-level. */
75+
move(nativeEvent: Event) {
76+
const intersections = this.raycaster.cast(this.context.eventRegistry, this.context)
77+
const props = this.context.props as Record<string, any>
78+
79+
// Phase #1 — Enter (bubble up; fire onPointerEnter for newly-hovered objects).
80+
const enterEvent: any = createThreeEvent(nativeEvent, { stoppable: false, intersections })
81+
const entered = new Set<Object3D>()
82+
for (const intersection of intersections) {
83+
enterEvent.currentIntersection = intersection
84+
let current: Object3D | null = intersection.object
85+
while (current && !entered.has(current)) {
86+
entered.add(current)
87+
if (!this.hovered.has(current)) (getMeta(current)?.props as any)?.onPointerEnter?.(enterEvent)
88+
current = current.parent
89+
}
90+
}
91+
if (!this.hoveredCanvas) {
92+
this.hoveredCanvas = true
93+
props.onPointerEnter?.(enterEvent)
94+
}
95+
96+
// Phase #2 — Move (bubble up, stoppable).
97+
const moveEvent: any = createThreeEvent(nativeEvent, { intersections })
98+
const moved = new Set<Object3D>()
99+
for (const intersection of intersections) {
100+
moveEvent.currentIntersection = intersection
101+
let current: Object3D | null = intersection.object
102+
while (current && !moved.has(current)) {
103+
moved.add(current)
104+
const meta = getMeta(current)
105+
if (meta) {
106+
;(meta.props as any).onPointerMove?.(moveEvent)
107+
if (moveEvent.stopped) break
108+
}
109+
current = current.parent
110+
}
111+
}
112+
if (!moveEvent.stopped) {
113+
delete moveEvent.currentIntersection
114+
props.onPointerMove?.(moveEvent)
115+
}
116+
117+
// Phase #3 — Leave (objects hovered last time but not now).
118+
const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false, intersections })
119+
const previous = this.hovered
120+
this.hovered = entered
121+
for (const object of previous) {
122+
if (entered.has(object)) continue
123+
;(getMeta(object)?.props as any)?.onPointerLeave?.(leaveEvent)
124+
}
125+
}
126+
127+
/** The pointer left the canvas/source: leave everything currently hovered. */
128+
leave(nativeEvent: Event) {
129+
const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false })
130+
;(this.context.props as Record<string, any>).onPointerLeave?.(leaveEvent)
131+
this.hoveredCanvas = false
132+
for (const object of this.hovered) (getMeta(object)?.props as any)?.onPointerLeave?.(leaveEvent)
133+
this.hovered.clear()
134+
}
135+
136+
down(nativeEvent: Event) {
137+
this.dispatchBubbled("onPointerDown", nativeEvent)
138+
}
139+
up(nativeEvent: Event) {
140+
this.dispatchBubbled("onPointerUp", nativeEvent)
141+
}
142+
wheel(nativeEvent: Event) {
143+
this.dispatchBubbled("onWheel", nativeEvent)
144+
}
145+
146+
/** Shared body for the down/up/wheel "default" gestures (bubble + canvas-level). */
147+
private dispatchBubbled(handler: "onPointerDown" | "onPointerUp" | "onWheel", nativeEvent: Event) {
148+
const intersections = this.raycaster.cast(this.context.eventRegistry, this.context)
149+
const event: any = createThreeEvent(nativeEvent, { intersections })
150+
for (const intersection of intersections) {
151+
event.currentIntersection = intersection
152+
let node: Object3D | null = intersection.object
153+
while (node && !event.stopped) {
154+
;(getMeta(node)?.props as any)?.[handler]?.(event)
155+
node = node.parent
156+
}
157+
}
158+
if (!event.stopped) {
159+
delete event.currentIntersection
160+
;(this.context.props as Record<string, any>)[handler]?.(event)
161+
}
162+
}
163+
164+
/** Missable gesture: bubbled `onClick`/`onDoubleClick`/`onContextMenu` + `-Missed`. */
165+
click(kind: "onClick" | "onDoubleClick" | "onContextMenu", nativeEvent: Event) {
166+
const missedType = `${kind}Missed` as const
167+
const registry = this.context.eventRegistry
168+
const props = this.context.props as Record<string, any>
169+
if (registry.length === 0 && !props[kind] && !props[missedType]) return
170+
171+
const missed = new Set<Object3D>(registry)
172+
const visited = new Set<Object3D>()
173+
const intersections = this.raycaster.cast(registry, this.context)
174+
const event: any = createThreeEvent(nativeEvent, { intersections })
175+
176+
// Phase #1 — fire the handler, bubbling down the hit chain.
177+
for (const intersection of intersections) {
178+
event.currentIntersection = intersection
179+
let node: Object3D | null = intersection.object
180+
while (node && !event.stopped && !visited.has(node)) {
181+
missed.delete(node)
182+
visited.add(node)
183+
;(getMeta(node)?.props as any)?.[kind]?.(event)
184+
node = node.parent
185+
}
186+
}
187+
if (!event.stopped) {
188+
delete event.currentIntersection
189+
props[kind]?.(event)
190+
}
191+
192+
// Phase #2 — re-raycast remaining objects to mark any genuinely under the ray as hit.
193+
for (const remaining of missed) {
194+
const hits = this.raycaster.intersectObject(remaining, true)
195+
for (const { object } of hits) {
196+
let node: Object3D | null = object
197+
while (node && !visited.has(node)) {
198+
missed.delete(node)
199+
visited.add(node)
200+
node = node.parent
201+
}
202+
}
203+
}
204+
205+
// Phase #3 — fire `-Missed` on the truly-missed objects, and canvas-level on a total miss.
206+
const missedEvent = createThreeEvent(nativeEvent, { stoppable: false })
207+
for (const object of missed) (getMeta(object)?.props as any)?.[missedType]?.(missedEvent)
208+
if (intersections.length === 0) props[missedType]?.(missedEvent)
209+
}
210+
}

0 commit comments

Comments
 (0)