feat(events)!: pointer capture + reactive hasPointerCapture + object/currentObject event API#69
Merged
bigmistqke merged 32 commits intoJun 7, 2026
Conversation
commit: |
…anvas move still fires
…guard, sink-throw rollback
Addresses code-review findings:
- collapse move()'s captured branch into dispatch("onPointerMove", …, capturable)
— removes a duplicate bubble loop; hover stays frozen since dispatch never
touches this.hovered
- widen capture()'s param to Object3D|null|undefined so the no-op guard is a real
narrowing (was statically dead under the non-null type)
- roll back captured state if the OS sink's setPointerCapture throws
- name the Captured shape; note why the touch path needs no explicit release
…dispose - createEvents now registers the DOMPointerManager disconnect via onCleanup, so canvas listeners (incl. lostpointercapture) are removed when the Canvas owner disposes (was leaking across mount/unmount cycles) - when an object leaves the event registry (unmount / last handler removed), the manager releases any pointer capturing it, so a drag stops dispatching to a detached node instead of waiting for pointerup
…both dispatch paths The captured and normal `dispatch` branches each rebuilt the same parent-chain walk — set `event.currentIntersection`/`event.element`, fire the handler honoring `stopPropagation`, then fire canvas-level on `!stopped`. Extract `bubble(event, handler, roots)` so that logic, including the canvas-level `event.element = undefined` reset, lives in one place instead of being mirrored across the two branches. `move()`'s Phase-2 loop is intentionally left separate: it dedups visited nodes via a Set (a shared parent fires `onPointerMove` once) whereas dispatch deliberately does not, so folding it in would change behavior. Pure refactor — dispatch semantics unchanged.
`dispatch` (onPointerDown/onPointerUp/onWheel and plugin gestures) walked each hit's parent chain without deduping, so an ancestor reachable from two hits along the ray fired the handler twice — while `move` and `click` already dedup via a visited Set. Give `bubble` the same Set so a shared ancestor fires once, on the closest hit's chain. Distinct hit leaves still each fire.
move()'s Phase-2 deduped shared ancestors but, unlike click and dispatch, lacked the cross-hit `!stopped` guard: a `stopPropagation()` from a closer object's `onPointerMove` halted that chain but a deeper hit along the ray still fired. Route Phase-2 through `bubble`, which already stops all further hits once stopped — so a closer stop now suppresses deeper hits, matching click/dispatch (and R3F). This also removes the last hand-rolled copy of the bubble-walk: enter/move/down/up/wheel/click now share `bubble` (move keeps its own enter/leave phases).
Capture the rule that `create*` functions are the Solid-facing reactive glue while classes are the framework-agnostic, unit-testable core — so it doesn't have to be reverse-engineered from the code.
Pointer capture shipped on this branch but was undocumented. Add an `## Pointer capture` section to the events overview (setPointerCapture/releasePointerCapture/hasPointerCapture, exclusive delivery, element-scoping, plane reprojection, auto-release), add the missing `event.element` row to the event-object table, and add a runnable drag demo to the pointer-events tour chapter — finally delivering on the "drag-to-rotate" the chapter already teased.
Drop the "two details" aside: the reprojection explanation was TMI for a tour (it lives in the events API reference), and the stopPropagation note described meshes stacked behind the cube — which the single-cube demo doesn't have. Also drop the now-unused stopPropagation() call from the demo so the shown code stays minimal. setPointerCapture is the single takeaway.
…ler re-register
`releaseCaptured` fired on any registry refcount→0, so a reactive handler (e.g. `onPointerMove={dragging() ? a : b}`) that's an object's only listener would tear down a live capture mid-drag: SolidJS re-runs the prop effect cleanup-then-body, dropping the count to 0 (→ `canvas.releasePointerCapture`) before re-registering. Defer the release to a microtask and run it only if the object is still gone, so a same-tick re-registration keeps the capture while a real unmount still releases. Microtasks drain before the next pointer event, so unmount timing is unaffected in practice.
…d OS-sink captures Two capture fixes in `Pointer`, plus a clarifying rename: - The drag-plane normal was transformed by `event.element`'s matrix, but `intersection.face.normal` lives in the hit leaf's local space. When a handler on an ancestor captures, that tilts the plane. Orient it with `intersection.object.matrixWorld` instead. - `capture()` re-threw a failing `sink.capture()`, so calling `setPointerCapture()` from a no-button hover-move surfaced `InvalidStateError` into the user's handler and aborted dispatch. Roll back and swallow it — a failed OS capture just means capture didn't engage. - Rename `Captured.object` → `element` (and the `capture()` param) so the capture target reads distinctly from the hit leaf (`intersection.object`); `Captured` is now an interface with per-field docs. Adds regression tests for the plane transform, the swallowed sink error, and that no `onPointerLeave` fires while captured.
The two `it.todo` stubs in `events.test.tsx` were ported from react-three-fiber (they call `target.setPointerCapture(pointerId)`, not our `event.setPointerCapture()`, and assert synchronous unmount release). Capture is now implemented and covered by the current-idiom suites — release-on-unmount and lostpointercapture in `canvas-events.test.tsx`, exclusive delivery / reprojection / no-leave-while-captured in `pointer.test.tsx` — so the stubs are superseded.
`click()`'s bubble loop set `event.currentIntersection` but never `event.element`, so `onClick`/`onDoubleClick`/`onContextMenu` handlers reading the documented `event.element` always got `undefined` — unlike the `onPointer*` handlers, which go through `bubble()` and do set it. Set it per node (and clear it for the canvas-level dispatch), mirroring `bubble()`.
The browser synthesizes a click/dblclick/contextmenu after every press+release, including at the end of a drag — and since these route through the capture-less `primary` pointer, a drag was firing a spurious click. Track pointers that moved while captured (a drag) and swallow the gesture's synthesized click/dblclick/contextmenu, re-arming on the next pointerdown. A captured press that doesn't move still clicks (it's a tap, not a drag). Adds `Pointer.capturing` so the manager can tell, per move, whether the pointer is captured.
…c capture) Add a "Reading the event" note to the events overview: the event is one object reused across the bubble chain (like a DOM event), so its data (intersection, …) is shared scene data to be treated as read-only, and setPointerCapture() must be called synchronously in the handler because it captures event.element, which is cleared after dispatch.
… async capture Rename event.element to event.currentObject (the 3D analogue of a DOM event's currentTarget — the node a bubbled handler is firing on, cleared after dispatch) and add event.object (= intersections[0].object, the closest hit, stable after dispatch like a DOM target). Make setPointerCapture accept an optional target: the no-arg form still captures currentObject synchronously, while passing a target starts a capture later (after an await or timer), synthesizing a camera-facing drag plane when there's no live hit.
Update the Events API overview and refresh the README Event Handling section: drop the removed onMouse* handlers, document the full event object (intersections, object, currentObject, capture methods), and add a Pointer Capture section.
…signal The onPointerMove guard now reads event.hasPointerCapture() — true only while the mesh holds the capture — instead of a separate dragging() check, reinforcing that the capture itself is the drag state. The signal stays for the reactive scale/color visuals, which can't read capture state.
… state Add a global, context-free reactive predicate — hasPointerCapture(object) — so visuals can react to a capture without maintaining a parallel dragging signal. Backed by a refcounted ReactiveMap (one object can be captured by more than one pointer at once), keyed by Object3D identity, which is unique across canvases. The core Pointer stays framework-agnostic: it records captures through an injected PointerCaptureRegistry, wired to the global reactive map in create-events. The predicate is nullish-tolerant, so a not-yet-mounted ref reads false.
Drop the demo's dragging signal: scale and color now read hasPointerCapture(mesh()) directly, with a signal ref so the child material binding re-subscribes once the mesh mounts.
Now that the renderer assigns an element's ref before mounting its children, a plain `let` ref read by a child binding resolves correctly — so the drag demo, the JSDoc example, and the integration test drop the signal ref. The test now mirrors the demo exactly: a child material binding keyed off the parent mesh's capture.
Add the top-level hasPointerCapture(object) reactive predicate to the README capture section and the Events API overview: a context-free reactive read of an object's capture status, distinct from the event's imperative hasPointerCapture().
…normal })
setPointerCapture now takes a single options object. `object` selects what to capture (default: the firing currentObject; pass it explicitly to capture async), and `normal` orients the drag plane through the grab point — to constrain a drag, e.g. { normal: new Vector3(0, 1, 0) } for ground-plane sliding. The default plane (hit surface, else camera-facing) is unchanged.
Also rename the capture internals from `element` to `object`, so the 3D vocabulary (object / currentObject) is consistent throughout and `target` / `currentTarget` stay reserved for the DOM event.
Update the README and Events API overview for setPointerCapture({ object, normal }): the options object and the normal that constrains the drag plane through the grab point.
The helper iterates the hit roots nearest-first (raycast propagation) and walks each one's ancestor chain (tree propagation), then fires canvas-level — it runs the whole propagation, not just the tree-phase bubble. "propagate" is the accurate umbrella term (and pairs with stopPropagation); "bubble" stays the verb for the tree phase in prose.
bigmistqke
added a commit
to bigmistqke/solid-three
that referenced
this pull request
Jun 7, 2026
…re for select/squeeze Rebased onto next-cleanup (post-solidjs-community#69): the superseded event.element/extra dispatch primitive is dropped (it's in core now as currentObject + extra), and the XR event field follows the rename (element → currentObject). Adds pointer capture to the controller source: start events (onXRSelectStart/onXRSqueezeStart) are capturable — a handler may call setPointerCapture() to grab the hit object — and the paired end event delivers to that captured object (the live ray reprojected onto the drag plane), then releases it, since XR has no OS lostpointercapture sink. Captures register in the global captureRegistry, so reactive hasPointerCapture() works for XR too.
Open
2 tasks
bigmistqke
added a commit
to bigmistqke/solid-three
that referenced
this pull request
Jun 7, 2026
…re for select/squeeze Rebased onto next-cleanup (post-solidjs-community#69): the superseded event.element/extra dispatch primitive is dropped (it's in core now as currentObject + extra), and the XR event field follows the rename (element → currentObject). Adds pointer capture to the controller source: start events (onXRSelectStart/onXRSqueezeStart) are capturable — a handler may call setPointerCapture() to grab the hit object — and the paired end event delivers to that captured object (the live ray reprojected onto the drag plane), then releases it, since XR has no OS lostpointercapture sink. Captures register in the global captureRegistry, so reactive hasPointerCapture() works for XR too.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings grab-and-hold pointer dragging to the core event system, plus the event-object API it motivated and a few dispatch-consistency fixes surfaced along the way. Core-only; the XR
select/squeezecapture glue rides with #65.Pointer capture
event.setPointerCapture({ object?, normal? })ononPointerDown/Up/Move: subsequent move/up deliver exclusively to the captured object's chain (still bubbling to canvas-level) — on-ray or off, and off-canvas for the DOM source, via an OS-capture sink.currentObject(sync); passobjectto start a capture later (after anawait/timer); passnormalto orient the drag plane (e.g.{ normal: Vector3(0, 1, 0) }for ground sliding) — default is the hit surface, else camera-facing.event.intersection.pointkeeps tracking.pointerup/pointercanceland when the captured object unmounts mid-drag; idempotent; a failed OS capture rolls back. A captured drag that moved suppresses its trailing click.Reactive
hasPointerCapture(object)draggingsignal. Backed by a refcountedReactiveMap(new dep@solid-primitives/map); the framework-agnosticPointerstays Solid-free via an injected registry. Distinct from the event's imperativeevent.hasPointerCapture().Event-object API (breaking)
event.element→event.currentObject(the node a bubbled handler is firing on — the 3D analogue of DOMcurrentTarget, cleared after dispatch) and addevent.object(the closest hit,intersections[0].object— the analogue oftarget, stable after dispatch).target/currentTargetstay DOM-only onnativeEvent.Dispatch fixes + refactor (behavior changes)
onPointerDown/Up/Wheel(matchingmove/click).movehonorsstopPropagationacross hits (matchingclick/dispatch/R3F). Both had zero coverage; both now have regression tests.Raycaster
aim()factored out ofcast()andrayexposed onPointerRaycaster, so capture reprojects the live ray without a full registry cast.Docs
onMouse*handlers, document the full event object,setPointerCapture({ object, normal }), and reactivehasPointerCapture.hasPointerCapture.create*-factory vs class convention.Test plan
pnpm lint+pnpm buildgreen.hasPointerCapture, and the dispatch-fix regressions.