Skip to content

feat(events)!: pointer capture + reactive hasPointerCapture + object/currentObject event API#69

Merged
bigmistqke merged 32 commits into
solidjs-community:next-cleanupfrom
bigmistqke:next-capture
Jun 7, 2026
Merged

feat(events)!: pointer capture + reactive hasPointerCapture + object/currentObject event API#69
bigmistqke merged 32 commits into
solidjs-community:next-cleanupfrom
bigmistqke:next-capture

Conversation

@bigmistqke

@bigmistqke bigmistqke commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

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/squeeze capture glue rides with #65.

Pointer capture

  • event.setPointerCapture({ object?, normal? }) on onPointerDown/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.
  • No-arg captures the firing currentObject (sync); pass object to start a capture later (after an await/timer); pass normal to orient the drag plane (e.g. { normal: Vector3(0, 1, 0) } for ground sliding) — default is the hit surface, else camera-facing.
  • While captured, hover is frozen and the intersection is reprojected onto the drag plane through the grab point, so event.intersection.point keeps tracking.
  • Auto-releases on pointerup/pointercancel and 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)

  • A context-free reactive predicate (top-level export) — whether an object currently holds a capture — for declarative drag visuals without a parallel dragging signal. Backed by a refcounted ReactiveMap (new dep @solid-primitives/map); the framework-agnostic Pointer stays Solid-free via an injected registry. Distinct from the event's imperative event.hasPointerCapture().

Event-object API (breaking)

  • Rename event.elementevent.currentObject (the node a bubbled handler is firing on — the 3D analogue of DOM currentTarget, cleared after dispatch) and add event.object (the closest hit, intersections[0].object — the analogue of target, stable after dispatch). target/currentTarget stay DOM-only on nativeEvent.

Dispatch fixes + refactor (behavior changes)

  • bubble() helper — the parent-chain walk deduped into one shared helper.
  • fix: a shared ancestor fires once for onPointerDown/Up/Wheel (matching move/click).
  • fix: move honors stopPropagation across hits (matching click/dispatch/R3F). Both had zero coverage; both now have regression tests.

Raycaster

  • aim() factored out of cast() and ray exposed on PointerRaycaster, so capture reprojects the live ray without a full registry cast.

Docs

  • Events API overview + README event section: drop the removed onMouse* handlers, document the full event object, setPointerCapture({ object, normal }), and reactive hasPointerCapture.
  • Tour chapter 04: a worked drag demo, drag state derived from hasPointerCapture.
  • CONTRIBUTING: the create*-factory vs class convention.

Test plan

  • pnpm lint + pnpm build green.
  • Browser suite green — 214 passed, incl. capture lifecycle, exclusive/bubbling delivery, reprojection, unmount release, drag-cancels-click, async + normal-plane capture, reactive hasPointerCapture, and the dispatch-fix regressions.
  • Drag demo verified in-browser; site build green.

@pkg-pr-new

pkg-pr-new Bot commented Jun 6, 2026

Copy link
Copy Markdown

commit: fb4d22e

bigmistqke added 28 commits June 7, 2026 01:58
…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.
@bigmistqke bigmistqke changed the title feat(events)!: pointer capture (grab-and-hold drags) + dispatch consistency fixes feat(events)!: pointer capture + reactive hasPointerCapture + object/currentObject event API Jun 7, 2026
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 bigmistqke merged commit 2f321ab into solidjs-community:next-cleanup Jun 7, 2026
2 checks passed
@bigmistqke bigmistqke deleted the next-capture branch June 7, 2026 01:20
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant