Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
811ca2a
feat(events): Pointer.dispatch exposes event.element + merges extra f…
bigmistqke Jun 4, 2026
60c7e04
refactor(raycaster): factor aim() out of cast() and expose ray on Poi…
bigmistqke Jun 5, 2026
71118bc
feat(pointers): capture state + capture/release/dropCapture with an o…
bigmistqke Jun 5, 2026
9209501
feat(pointers): exclusive-but-bubbling capture dispatch with plane-re…
bigmistqke Jun 5, 2026
58ae204
feat(pointers): captured move() — frozen hover, exclusive delivery, c…
bigmistqke Jun 5, 2026
85abb43
feat(types): PointerCapture methods on onPointerDown/Up/Move
bigmistqke Jun 5, 2026
eddc31e
feat(pointer-managers): DOM OS capture sink + lostpointercapture cleanup
bigmistqke Jun 5, 2026
a76bbab
refactor(pointers): dedup captured move via dispatch, honest capture …
bigmistqke Jun 5, 2026
91ebbc7
fix(events): release capture on unmount + remove canvas listeners on …
bigmistqke Jun 5, 2026
d2cc3fc
refactor(pointers): factor the bubble-walk into one helper shared by …
bigmistqke Jun 6, 2026
5a2dd73
fix(events): dispatch fires a shared ancestor once, not once per hit
bigmistqke Jun 6, 2026
984b55a
fix(events): align move's stopPropagation with click/dispatch via bubble
bigmistqke Jun 6, 2026
d088517
docs(contributing): document the factory (create*) vs. class convention
bigmistqke Jun 6, 2026
15059b7
docs(events): document pointer capture (API reference + tour drag demo)
bigmistqke Jun 6, 2026
7e96f4d
docs(events): trim the tour drag section
bigmistqke Jun 6, 2026
9f506a0
fix(events): release pointer capture only on real removal, not a hand…
bigmistqke Jun 6, 2026
5304709
fix(events): build the capture plane from the hit leaf; swallow faile…
bigmistqke Jun 6, 2026
de7fa3b
test(events): drop stale r3f-API pointer-capture todos
bigmistqke Jun 6, 2026
034c593
fix(events): set event.element in click() so handlers can read it
bigmistqke Jun 6, 2026
cc63422
feat(events): a captured drag cancels its trailing click
bigmistqke Jun 6, 2026
6c201fe
docs(events): document event-object semantics (shared, read-only, syn…
bigmistqke Jun 6, 2026
9ff63c3
docs(events): note reproject also falls back when the plane is behind…
bigmistqke Jun 6, 2026
ea271a1
feat(events): finalize the pointer event API — object/currentObject +…
bigmistqke Jun 6, 2026
a89deb3
docs(events): document object/currentObject and async setPointerCapture
bigmistqke Jun 6, 2026
98e5320
docs(site): guard the drag demo on hasPointerCapture, not a parallel …
bigmistqke Jun 6, 2026
a626218
feat(events): reactive hasPointerCapture(object) for declarative drag…
bigmistqke Jun 6, 2026
21c9d07
docs(site): derive the drag demo's state from hasPointerCapture
bigmistqke Jun 6, 2026
11d3069
refactor(events): hasPointerCapture demo/docs use a plain ref
bigmistqke Jun 7, 2026
e099e8f
docs(events): document hasPointerCapture for reactive drag visuals
bigmistqke Jun 7, 2026
6d7bed5
feat(events): drag-plane normal option + setPointerCapture({ object, …
bigmistqke Jun 7, 2026
8b04907
docs(events): document the drag-plane normal option
bigmistqke Jun 7, 2026
fb4d22e
refactor(pointers): rename bubble() to propagate()
bigmistqke Jun 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,31 @@ if (context.gl.xr) {
Use full names: `raycaster`, not `rc`. When a name shadows an outer scope,
prefix with `_` to disambiguate (`_raycaster`).

## Architecture

### Factories (`create*`) vs. classes

The rule: `create*` functions are the Solid-facing glue; classes are the
framework-agnostic core. They never overlap — a `create*` function holds Solid
reactivity, a class holds none.

- **`create*` factories** (`createThree`, `createEvents`, `createXR`, `createT`)
follow Solid's `createSignal`/`createStore` convention: they run synchronously
inside a reactive owner and wire up `onCleanup`, effects, and context. Reach for
one whenever setup must bind to the reactive scope.
- **Classes** (`Pointer`, `DOMPointerManager`, the `*Raycaster`s, the data
structures) carry no Solid reactivity. Use a class for long-lived,
identity-bearing state behind a method API — especially when it must subclass a
three.js type (`extends Raycaster`), swap behind an interface
(`implements ScreenRaycaster`), or be `new`'d many times.

Keep the core in classes so it stays unit-testable without a reactive runtime:
tests `new Pointer(...)` with a fake raycaster — no `Canvas`, no owner. The
`create*` layer is the thin seam that instantiates those classes inside Solid.

(`createThreeEvent` is the lone lowercase "value factory": it returns a plain,
spreadable event object, not a reactive primitive.)

## Tooling

### Package Management
Expand Down
105 changes: 72 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
- [Controlling Raycasting with raycastable](#controlling-raycasting-with-raycastable)
- [Supported Events](#supported-events)
- [Event Object](#event-object)
- [Pointer Capture](#pointer-capture)
- [Event Propagation](#event-propagation)
- [Missed Events](#missed-events)
- [Hover Events](#hover-events)
Expand Down Expand Up @@ -1163,45 +1164,83 @@ const MyTest = () => {

### Supported Events

- `onClick` - Fired when clicking on an object
- `onClickMissed` - Fired when a click doesn't hit any objects with onClick handlers
- `onContextMenu` - Fired when right-clicking on an object
- `onContextMenuMissed` - Fired when a right-click doesn't hit any objects with onContextMenu handlers
- `onDoubleClick` - Fired when double-clicking on an object
- `onDoubleClickMissed` - Fired when a double-click doesn't hit any objects with onDoubleClick handlers
- `onMouseDown` - Fired when mouse button is pressed
- `onMouseEnter` - Fired when mouse enters object
- `onMouseLeave` - Fired when mouse leaves object
- `onMouseMove` - Fired when mouse moves over object
- `onMouseUp` - Fired when mouse button is released
- `onPointerDown` - Fired when pointer is pressed
- `onPointerEnter` - Fired when pointer enters object
- `onPointerLeave` - Fired when pointer leaves object
- `onPointerMove` - Fired when pointer moves
- `onPointerUp` - Fired when pointer is released
- `onWheel` - Fired on mouse wheel events
**Pointer events** — carry the [pointer-capture API](#pointer-capture):

- `onPointerDown` — pointer pressed on an object
- `onPointerUp` — pointer released
- `onPointerMove` — pointer moves over an object
- `onPointerEnter` — pointer enters an object (can't be stopped)
- `onPointerLeave` — pointer leaves an object (can't be stopped)

**Mouse-semantic events:**

- `onClick` — click on an object
- `onDoubleClick` — double-click on an object
- `onContextMenu` — right-click on an object
- `onWheel` — wheel scroll over an object

**Missed events** — fire when the interaction didn't hit the handler's object or its descendants:

- `onClickMissed`, `onDoubleClickMissed`, `onContextMenuMissed`


### Event Object

Event handlers receive an event object with the following properties:
Every handler receives one event object, created once per dispatch and reused as it bubbles up the chain — like a DOM event, treat it as read-only.

- **nativeEvent**: The original DOM event
- **stopped**: Whether propagation has been stopped (only for stoppable events)
- **stopPropagation**: Method to stop event propagation (only for stoppable events)
| Property | Type | Available on | Description |
| --- | --- | --- | --- |
| `nativeEvent` | DOM event | all events | The original DOM `PointerEvent` / `MouseEvent` / `WheelEvent`. |
| `intersections` | `Intersection[]` | raycasting events | All hits under the pointer, sorted nearest-first. |
| `intersection` | `Intersection` | raycasting events | Shorthand for `intersections[0]` — the closest hit. |
| `object` | `Object3D` | raycasting events | The closest hit object (`intersections[0].object`). Stable after dispatch — the 3D analogue of a DOM event's `target`. |
| `currentObject` | `Object3D` | inside an object handler | The object this handler is firing on; walks up the ancestor chain as the event bubbles, and is cleared after dispatch — the 3D analogue of `currentTarget`. |
| `currentIntersection` | `Intersection` | inside an object handler | The intersection for `currentObject`. Absent at the canvas level. |
| `stopped` | `boolean` | stoppable events | Whether `stopPropagation()` has been called. |
| `stopPropagation` | `() => void` | stoppable events | Stops both raycast and tree propagation. |
| `setPointerCapture` | `(options?: { object?, normal? }) => void` | pointer events | Capture the pointer (default object: `currentObject`); `normal` sets the drag-plane orientation. See [Pointer Capture](#pointer-capture). |
| `releasePointerCapture` | `() => void` | pointer events | End a capture early. |
| `hasPointerCapture` | `(target?: Object3D) => boolean` | pointer events | Whether `target` currently holds the capture. |

<details>
<summary>Typescript Interface</summary>
Each `Intersection` is a standard [three.js `Intersection`](https://threejs.org/docs/#api/en/core/Raycaster.intersectObject): `object`, `point` (world-space `Vector3`), `distance`, plus `face`, `uv`, `normal`, and `instanceId` when available.

### Pointer Capture

Inside a pointer handler, `setPointerCapture()` routes every later move — and the release — for that pointer to the captured object, even once the pointer leaves it. This is the basis for drag interactions:

```tsx
interface Event<T> {
nativeEvent: T
stopped?: boolean
stopPropagation?: () => void
}
<T.Mesh
onPointerDown={e => e.setPointerCapture()}
onPointerMove={e => {
if (e.hasPointerCapture()) {
// dragging: e.intersection.point keeps tracking on a drag plane,
// even when the pointer moves off the mesh
}
}}
>
<T.BoxGeometry args={[1, 1, 1]} />
<T.MeshBasicMaterial color="orange" />
</T.Mesh>
```

</details>
The no-arg form captures `currentObject` and must be called synchronously in the handler (`currentObject` is cleared after dispatch). To start a capture later — after an `await` or a timer — name the object: `setPointerCapture({ object: mesh })`. Capture releases automatically on pointer up / cancel; call `releasePointerCapture()` to end it early.

By default the drag plane is the hit surface (tangent), or camera-facing when there's no face — right for sliding along a surface. To constrain the drag instead, pass a world-space `normal` for the plane (built through the grab point): `setPointerCapture({ normal: new THREE.Vector3(0, 1, 0) })` slides on the ground (horizontal), regardless of where on the object you grabbed.

For reactive visuals — scaling or recoloring a mesh while it's dragged — read the capture state declaratively with `hasPointerCapture(object)` instead of tracking your own `dragging` signal:

```tsx
import { hasPointerCapture } from "solid-three"

let mesh: THREE.Mesh | undefined
<T.Mesh
ref={mesh}
scale={hasPointerCapture(mesh) ? 1.15 : 1}
onPointerDown={e => e.setPointerCapture()}
/>
```

`hasPointerCapture(object)` is a context-free reactive read — it re-runs only when that object's capture status flips, and a nullish `object` (an unmounted ref) reads `false`. It's distinct from the event's own `event.hasPointerCapture()`, which is the imperative, in-handler check.

### Event Propagation

Expand Down Expand Up @@ -1265,12 +1304,12 @@ Not all events in solid-three can be stopped with `stopPropagation()`. This desi
**Non-stoppable events:**

- `onClickMissed`, `onDoubleClickMissed`, `onContextMenuMissed` - [Missed Events](#missed-events) always fire for all registered handlers
- `onMouseEnter`, `onPointerEnter` - Enter events always fire [Hover Events](#hover-events-entermoveleave)
- `onMouseLeave`, `onPointerLeave` - Leave events always fire [Hover Events](#hover-events-entermoveleave)
- `onPointerEnter` - Enter events always fire [Hover Events](#hover-events-entermoveleave)
- `onPointerLeave` - Leave events always fire [Hover Events](#hover-events-entermoveleave)

**Stoppable events:**

- All other events (`onClick`, `onMouseMove`, `onPointerMove`, `onMouseDown`, etc.) can be stopped with `stopPropagation()`
- All other events (`onClick`, `onPointerMove`, `onPointerDown`, etc.) can be stopped with `stopPropagation()`

### Missed Events

Expand Down Expand Up @@ -1356,7 +1395,7 @@ This is useful for:

### Hover Events (Enter/Move/Leave)

solid-three handles hover events (`onMouseEnter`, `onMouseMove`, `onMouseLeave`, `onPointerEnter`, `onPointerMove`, `onPointerLeave`) with a specific scheduling approach:
solid-three handles hover events (`onPointerEnter`, `onPointerMove`, `onPointerLeave`) with a specific scheduling approach:

**Event Scheduling:**

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
},
"dependencies": {
"@bigmistqke/solid-whenever": "^0.1.0",
"@solid-primitives/map": "^0.7.3",
"@solid-primitives/resize-observer": "^2.0.25",
"debounce": "^2.1.0",
"vite-tsconfig-paths": "^5.1.4"
Expand Down
17 changes: 15 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 69 additions & 0 deletions site/src/routes/api/events/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ Every handler receives one event argument that combines the original DOM event w
| `nativeEvent` | `MouseEvent \| PointerEvent \| WheelEvent` | always | The original DOM event that triggered the handler. |
| `intersections` | `Intersection[]` | events that raycast | All hit intersections, sorted nearest-first. |
| `intersection` | `Intersection` | events that raycast | Shorthand for `intersections[0]` — the closest hit overall. |
| `object` | `Object3D` | events that raycast | The closest hit object (`intersections[0].object`) — stable after dispatch, the 3D analogue of a DOM event's `target`. |
| `currentIntersection` | `Intersection` | inside an object handler | The intersection for the current handler's object. Absent on canvas-level dispatch. |
| `currentObject` | `Object3D` | inside an object handler | The object this handler is firing on; as the event bubbles it walks up the ancestor chain (while `currentIntersection` stays on the hit) and is cleared after dispatch — the 3D analogue of a DOM event's `currentTarget`. |
| `stopped` | `boolean` | stoppable events only | Whether `stopPropagation()` has been called. |
| `stopPropagation` | `() => void` | stoppable events only | Stops both raycast and tree propagation. See [Stoppable vs non-stoppable](#stoppable-vs-non-stoppable-events). |

Expand Down Expand Up @@ -87,6 +89,13 @@ This extra detail is what lets you go beyond "did they click it?". A few of the
- `normal` — align a world-space gizmo to the surface;
- `distance` — react to how far away the hit is.

### Reading the event

The event object is created once per dispatch and **reused** as it bubbles up the chain — the same object is passed to every handler, just like a DOM event. Two things follow from that:

- **Treat it as read-only.** `intersection`, `intersections`, and the rest are shared scene data, not per-handler copies. Mutating them affects the other handlers in the same dispatch (and, while a pointer is captured, persists across moves). Read from the event; don't write to it.
- **`setPointerCapture()` captures `event.currentObject`** (the object currently firing), which is cleared after dispatch — so the no-arg form must be called synchronously in the handler. To start a capture later (after an `await`, a timer), name the object explicitly: `setPointerCapture({ object: mesh })`. (With no live hit, a deferred capture drags on a camera-facing plane through the object's center.)

## Reading the full hit stack

Most handlers care only about the nearest hit, which is what `intersection` resolves to. For x-ray tools, measure-through-walls, or click-through selection, `intersections` is sorted nearest-first:
Expand Down Expand Up @@ -212,6 +221,66 @@ solid-three's hover handling differs from react-three-fiber in three ways:
- **What can be stopped.** In solid-three, move can be stopped but enter and leave always fire (DOM-like). In react-three-fiber, every hover event can be stopped.
- **Child to parent.** In solid-three, moving from a child to its parent fires only a leave on the child (DOM-like). In react-three-fiber, the parent receives both a leave and an immediate re-enter.

## Pointer capture

By default a pointer event goes to whatever the ray currently hits. That breaks down for a drag: the moment the pointer slips off the object — or off the canvas entirely — the moves stop arriving, and the gesture dies mid-stroke.

Pointer capture fixes this. Inside an `onPointerDown`, `onPointerMove`, or `onPointerUp` handler, three methods ride on the event:

| Method | Description |
| --- | --- |
| `setPointerCapture(options?)` | Capture the pointer to `options.object` (default: the firing `currentObject`; sync-only). Pass `options.normal` to orient the drag plane. |
| `releasePointerCapture()` | Release a capture started with `setPointerCapture()`. |
| `hasPointerCapture()` | Whether this event's node currently holds the capture. |

Once captured, every subsequent `onPointerMove` and `onPointerUp` for that pointer is delivered **exclusively** to the captured node's chain — still bubbling up to the canvas-level handler, but no longer raycasting against the rest of the scene. Delivery continues even when the pointer is off the object, and (for the DOM pointer source) off the canvas. Hover is frozen for the duration: no enter/leave fires while a capture is held.

Capture is **object-scoped** — the no-arg form captures the object whose handler is running, so calling it from a canvas-level handler is a no-op (there's no `currentObject` there). Pass an explicit `target` to capture any object, including later/async.

While the pointer is off the ray, `event.intersection` is reprojected onto a plane through the original grab point, so `event.intersection.point` keeps tracking a sensible world-space position to drag toward.

The drag plane defaults to the hit surface (or camera-facing when there's no face). Pass an `options.normal` to constrain it — e.g. `{ normal: new Vector3(0, 1, 0) }` for ground-plane sliding — and it's built through the grab point so the object doesn't jump.

A capture releases when you call `releasePointerCapture()`, and automatically on `pointerup`/`pointercancel` (DOM source) or the paired end event (XR). It is also released if the captured object leaves the scene mid-drag, so a captured pointer never keeps dispatching to an unmounted node.

```tsx
let grabbing = false

<T.Mesh
onPointerDown={event => {
event.stopPropagation()
event.setPointerCapture() // grab — moves now follow this mesh
grabbing = true
}}
onPointerMove={event => {
if (!grabbing) return
// event.intersection.point tracks the drag plane, even off the mesh
}}
onPointerUp={() => {
grabbing = false // capture auto-releases on pointerup
}}
/>
```

See the [pointer events tour](/tour/04-pointer-events#dragging) for a runnable drag demo.

### Reacting to capture in your UI

For visuals that should follow a drag — scaling or recoloring the grabbed object — read the capture state declaratively with the top-level `hasPointerCapture(object)` instead of tracking your own `grabbing` flag:

```tsx
import { hasPointerCapture } from "solid-three"

let mesh: THREE.Mesh | undefined
<T.Mesh
ref={mesh}
scale={hasPointerCapture(mesh) ? 1.15 : 1}
onPointerDown={event => event.setPointerCapture()}
/>
```

`hasPointerCapture(object)` is a context-free reactive read — no `useThree`, callable anywhere — that re-runs only when that object's capture status flips. A nullish `object` (an unmounted ref) reads `false`. It's the reactive counterpart to the event's own `event.hasPointerCapture()`, which is the imperative, in-handler check.

## Filtering with `raycastable`

To keep an object from being hit while it still receives events that bubble up from its children, set `raycastable={false}`. See [`raycastable`](/api/events/raycastable) for the full prop.
Expand Down
Loading
Loading