|
| 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