diff --git a/CONTEXT.md b/CONTEXT.md index c34e038c..d59f09d3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -94,8 +94,12 @@ _Avoid_: target / currentTarget for the 3D sense — those stay DOM-only, on `na **Intersection** (intersections): A raycast hit — three.js `Intersection` (`object`, `point`, `distance`, `face`, `uv`, `normal`). `event.intersections` is nearest-first; `event.intersection` is the nearest. -**Missed event**: -`onClickMissed` / `onDoubleClickMissed` / `onContextMenuMissed` — fires on a registered **object** when the interaction did _not_ hit it or its descendants. +**Void event**: +A canvas-level handler — `onVoidClick`, `onVoidDoubleClick`, `onVoidContextMenu`, `onVoidWheel`, `onVoidPointerDown`, `onVoidPointerUp` — fired when a gesture runs no object-level handler for it, reaching the **backdrop** instead. Exclusive with the canvas-level positive handler (a click runs `onClick` or `onVoidClick`, never both); judged per gesture (a hover-only object doesn't suppress `onVoidClick`). +_Avoid_: missed (the inverted, per-object framing it replaces). + +**Backdrop**: +The conceptual surface behind every object — a gesture no object handles "reaches the backdrop", where its **void event** fires. Not a real mesh; the name for the "no object handler ran" branch of dispatch. **raycast propagation**: The first dispatch phase — the handler fires on each hit **object** nearest-first along the ray. diff --git a/README.md b/README.md index 906c89cb..940c89c7 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ - [Event Object](#event-object) - [Pointer Capture](#pointer-capture) - [Event Propagation](#event-propagation) - - [Missed Events](#missed-events) + - [Void Events](#void-events) - [Hover Events](#hover-events) 7. [Performance Optimization](#performance-optimization) 8. [Contributing](#contributing) @@ -119,7 +119,7 @@ The `Canvas` component initializes the `three.js` rendering context and acts as - **style**: Custom CSS styles for the canvas container. - **class**: CSS class names for the canvas container. - **ref**: Receives the scene's [`Context`](#usethree) once the renderer is created, so code outside `` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts — [`createXR`](#createxr) uses this. -- **Event handlers**: All event handlers are supported on the Canvas component, allowing you to handle events that bubble through the entire scene (e.g., `onClick`, `onPointerMove`, `onClickMissed`, etc.) +- **Event handlers**: All event handlers are supported on the Canvas component, allowing you to handle events that bubble through the entire scene (e.g., `onClick`, `onPointerMove`, `onVoidClick`, etc.)
Typescript Interface @@ -163,7 +163,7 @@ interface CanvasProps { frameloop="always" style={{ background: "black" }} onClick={e => console.log("Canvas clicked")} - onClickMissed={e => console.log("Clicked empty space")} + onVoidClick={() => console.log("Clicked the backdrop")} onPointerMove={e => console.log("Pointer moved on canvas")} > {/* Your 3D scene */} @@ -1179,9 +1179,9 @@ const MyTest = () => { - `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: +**Void events** — fire on `` when the gesture reaches the backdrop (no object handler ran): -- `onClickMissed`, `onDoubleClickMissed`, `onContextMenuMissed` +- `onVoidClick`, `onVoidDoubleClick`, `onVoidContextMenu`, `onVoidWheel`, `onVoidPointerDown`, `onVoidPointerUp` ### Event Object @@ -1303,7 +1303,6 @@ 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 - `onPointerEnter` - Enter events always fire [Hover Events](#hover-events-entermoveleave) - `onPointerLeave` - Leave events always fire [Hover Events](#hover-events-entermoveleave) @@ -1311,87 +1310,31 @@ Not all events in solid-three can be stopped with `stopPropagation()`. This desi - All other events (`onClick`, `onPointerMove`, `onPointerDown`, etc.) can be stopped with `stopPropagation()` -### Missed Events +### Void Events -The "missed" events (`onClickMissed`, `onDoubleClickMissed`, `onContextMenuMissed`) fire when an object has registered a missed event handler and the click/interaction didn't hit the object or any of its descendants. +Not every gesture is aimed at an object — a click to deselect, a right-click for a background menu, a drag to orbit the camera. A void event fires for each. -**When missed events fire:** +A backdrop sits behind the scene, and `onVoidClick` is its `onClick`. A click runs the `onClick` of each object along its path — the ones it intersects and their ancestors — reaching the backdrop only when none of them has one. -1. **Click outside object**: The click didn't intersect with the object or any of its descendants -2. **Blocked by stopPropagation**: Another object called `stopPropagation()` preventing the event from reaching this object +There's a `` handler like it for each of these gestures: -**Clicking outside the mesh** +`onVoidClick` · `onVoidDoubleClick` · `onVoidContextMenu` · `onVoidWheel` · `onVoidPointerDown` · `onVoidPointerUp` -```tsx -const ClickOutside = () => { - return ( - - console.log("Missed - clicked outside this mesh")}> - - - - - ) -} -``` - - -**Blocked by stopPropagation** - -```tsx -const TreePropagation = () => { - return ( - - console.log("Group missed - child stopped propagation")}> - { - e.stopPropagation() - console.log("Child clicked") - }} - > - - - - - - ) -} -``` +A click that runs an `onClick` bubbles it up to the canvas-level `onClick`; a click that runs none reaches `onVoidClick`. The two never fire for the same click. ```tsx -const RayPropagation = () => { - return ( - - { - e.stopPropagation() - console.log("Front mesh clicked") - }} - > - - - - console.log("Back mesh missed - front mesh blocked it")} - > - - - - - ) -} + console.log("ran an onClick")} + onVoidClick={() => console.log("reached the backdrop")} +> + console.log("the box's onClick")}> + + + + ``` - -This is useful for: - -- Deselecting objects when clicking outside them -- Creating UI layers where front objects can block interactions with objects behind -- Handling complex interaction patterns where parent containers need to know when their children intercepted events +Each gesture has its own backdrop. A mesh with `onPointerMove` but no `onClick` takes part in pointer-moves but not clicks: a click on it runs no `onClick`, so it reaches the click-backdrop and fires `onVoidClick`. ### Hover Events (Enter/Move/Leave) diff --git a/site/src/routes/api/components/canvas.mdx b/site/src/routes/api/components/canvas.mdx index 0ab6d46d..4fac1353 100644 --- a/site/src/routes/api/components/canvas.mdx +++ b/site/src/routes/api/components/canvas.mdx @@ -8,22 +8,22 @@ title: Canvas ## Props -| Prop | Type | Default | Description | -| -------------- | ------------------------------------------------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `camera` | `Partial> \| Camera` | `new PerspectiveCamera()` | The scene camera — partial props for the default camera, or an instance you built. | -| `gl` | `object \| ((canvas: HTMLCanvasElement) => Renderer) \| Renderer` | `new WebGLRenderer({ alpha: true })` | How the renderer is built. See [The `gl` prop](#the-gl-prop). | -| `scene` | `Partial> \| Scene` | `new Scene()` | Settings for the scene, or an existing `Scene`. | -| `raycaster` | `Partial> \| EventRaycaster \| Raycaster` | `new CursorRaycaster()` | The [raycaster](/api/utilities/raycasters) used for pointer events. | -| `shadows` | `boolean \| "basic" \| "percentage" \| "soft" \| "variance" \| WebGLRenderer["shadowMap"]` | off | Enables shadows. See [Rendering defaults](#rendering-defaults) for the string mapping. | -| `orthographic` | `boolean` | `false` | Use an `OrthographicCamera` for the default camera. | -| `linear` | `boolean` | `false` | Use a linear output color space instead of sRGB. | -| `flat` | `boolean` | `false` | Disable tone mapping (`NoToneMapping`). | -| `frameloop` | `"always" \| "demand" \| "never"` | `"always"` | When to render: every frame, only on request, or never. | -| `fallback` | `JSX.Element` | — | Shown while content loads asynchronously. | -| `style` | `JSX.CSSProperties` | — | CSS for the canvas container. | -| `class` | `string` | — | CSS class for the canvas container. | -| `ref` | `RefWithCleanup` | — | Receives the [`Context`](/api/hooks/use-three) once the renderer is created, so code outside `` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts; [`createXR`](/api/hooks/create-xr) uses this. | -| event handlers | `Partial` | — | Any [event handler](/api/events/overview), firing after the event bubbles through the whole scene (e.g. `onClick`, `onClickMissed`). | +| Prop | Type | Default | Description | +| -------------- | ------------------------------------------------------------------------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `camera` | `Partial> \| Camera` | `new PerspectiveCamera()` | The scene camera — partial props for the default camera, or an instance you built. | +| `gl` | `object \| ((canvas: HTMLCanvasElement) => Renderer) \| Renderer` | `new WebGLRenderer({ alpha: true })` | How the renderer is built. See [The `gl` prop](#the-gl-prop). | +| `scene` | `Partial> \| Scene` | `new Scene()` | Settings for the scene, or an existing `Scene`. | +| `raycaster` | `Partial> \| EventRaycaster \| Raycaster` | `new CursorRaycaster()` | The [raycaster](/api/utilities/raycasters) used for pointer events. | +| `shadows` | `boolean \| "basic" \| "percentage" \| "soft" \| "variance" \| WebGLRenderer["shadowMap"]` | off | Enables shadows. See [Rendering defaults](#rendering-defaults) for the string mapping. | +| `orthographic` | `boolean` | `false` | Use an `OrthographicCamera` for the default camera. | +| `linear` | `boolean` | `false` | Use a linear output color space instead of sRGB. | +| `flat` | `boolean` | `false` | Disable tone mapping (`NoToneMapping`). | +| `frameloop` | `"always" \| "demand" \| "never"` | `"always"` | When to render: every frame, only on request, or never. | +| `fallback` | `JSX.Element` | — | Shown while content loads asynchronously. | +| `style` | `JSX.CSSProperties` | — | CSS for the canvas container. | +| `class` | `string` | — | CSS class for the canvas container. | +| `ref` | `RefWithCleanup` | — | Receives the [`Context`](/api/hooks/use-three) once the renderer is created, so code outside `` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts; [`createXR`](/api/hooks/create-xr) uses this. | +| event handlers | `Partial` | — | Any [event handler](/api/events/overview), firing after the event bubbles through the whole scene (e.g. `onClick`, `onVoidClick`, `onVoidDoubleClick`, `onVoidContextMenu`, `onVoidWheel`, `onVoidPointerDown`, `onVoidPointerUp`). The `onVoid*` handlers fire when the gesture runs no handler on any object (reaches the backdrop). |
Exact type @@ -65,7 +65,7 @@ interface CanvasProps extends ParentProps> { camera={{ position: [0, 0, 5], fov: 75 }} shadows="soft" gl={{ antialias: true }} - onClickMissed={() => console.log("Clicked empty space")} + onVoidClick={() => console.log("Reached the backdrop")} > {/* Your 3D scene */} diff --git a/site/src/routes/api/events/overview.mdx b/site/src/routes/api/events/overview.mdx index 4f217e2d..612ba05c 100644 --- a/site/src/routes/api/events/overview.mdx +++ b/site/src/routes/api/events/overview.mdx @@ -6,17 +6,17 @@ title: Events overview `solid-three` has its own pointer-event system, inspired by [`react-three-fiber`](https://github.com/pmndrs/react-three-fiber). Events are dispatched by raycasting against the scene, then propagate twice: along the ray (through every object the ray hit) and up the scene graph (from the hit object to its ancestors). -You attach handlers as props on any [``](/api/components/t) or [``](/api/components/entity) component. The same handler names also work on [``](/api/components/canvas), where they fire after tree propagation finishes — handy for catching clicks that land on empty space (see [Missed events](#missed-events)). +You attach handlers as props on any [``](/api/components/t) or [``](/api/components/entity) component. The same handler names also work on [``](/api/components/canvas), where they fire after tree propagation finishes — handy for catching clicks that land on empty space (see [Void events](#void-events)). ## Supported events -### Click events (missable) +### Click events -These fire on the object that was hit, or — when nothing handles them — as a paired `*Missed` event: +These fire on the object that was hit: -- `onClick` / `onClickMissed` -- `onContextMenu` / `onContextMenuMissed` — right-click -- `onDoubleClick` / `onDoubleClickMissed` +- `onClick` +- `onContextMenu` — right-click +- `onDoubleClick` ### Hover events @@ -46,11 +46,11 @@ Every handler receives one event argument that combines the original DOM event w Not every handler receives every field — what you get depends on the event: -| Handler | Receives | -| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `onClick`, `onContextMenu`, `onDoubleClick`, the `*Move` / `*Down` / `*Up` handlers, `onWheel` | `nativeEvent`, the intersections, and `stopPropagation` | -| `onPointerEnter`, `onPointerLeave` | `nativeEvent` and the intersections, but no `stopPropagation` — these can't be stopped | -| `onClickMissed`, `onContextMenuMissed`, `onDoubleClickMissed` | only `nativeEvent` — missed events don't raycast | +| Handler | Receives | +| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `onClick`, `onContextMenu`, `onDoubleClick`, the `*Move` / `*Down` / `*Up` handlers, `onWheel` | `nativeEvent`, the intersections, and `stopPropagation` | +| `onPointerEnter`, `onPointerLeave` | `nativeEvent` and the intersections, but no `stopPropagation` — these can't be stopped | +| `onVoidClick`, `onVoidContextMenu`, `onVoidDoubleClick`, `onVoidWheel`, `onVoidPointerDown`, `onVoidPointerUp` | only `nativeEvent` — void events don't raycast |
Exact type @@ -154,54 +154,34 @@ Not every event accepts `stopPropagation()`. The split mirrors the DOM: **Non-stoppable** — these always fire for every registered handler, regardless of order: -- `onClickMissed`, `onContextMenuMissed`, `onDoubleClickMissed` — a [missed event](#missed-events) is by definition the "nothing else handled it" signal. - `onPointerEnter` — enter has to reach the newly-hovered subtree. - `onPointerLeave` — leave has to reach the previously-hovered subtree. -## Missed events +## Void events -A `*Missed` variant fires on a handler-bearing object when the click, double-click, or context-menu did **not** reach it. Two cases: +Not every gesture is aimed at an object — a click to deselect, a right-click for a background menu, a drag to orbit the camera. A void event fires for each. -1. **Clicked outside** — the ray missed the object and all its descendants. -2. **Blocked by `stopPropagation()`** — another object handled the event first and stopped it. +A backdrop sits behind the scene, and `onVoidClick` is its `onClick`. A click runs the `onClick` of each object along its path — the ones it intersects and their ancestors — reaching the backdrop only when none of them has one. -```tsx - console.log("Missed — clicked outside this mesh")}> - - - -``` - -```tsx - console.log("Group missed — child stopped propagation")}> - { - event.stopPropagation() - console.log("Child clicked") - }} - > - - - - -``` - -Common uses: deselecting on a background click, blocking interaction with objects behind a UI layer, or telling a parent container that a child intercepted the event. +There's a `` handler like it for each of these gestures: -### Missed events on `` +`onVoidClick` · `onVoidDoubleClick` · `onVoidContextMenu` · `onVoidWheel` · `onVoidPointerDown` · `onVoidPointerUp` -The same handlers work on [``](/api/components/canvas), where they fire when **no object handler at all** consumed the ray: +A click that runs an `onClick` bubbles it up to the canvas-level `onClick`; a click that runs none reaches `onVoidClick`. The two never fire for the same click. ```tsx - deselect()}> - select()}> + console.log("ran an onClick")} + onVoidClick={() => console.log("reached the backdrop")} +> + console.log("the box's onClick")}> - + ``` -Use this for canvas-wide "click on empty space" logic without attaching a sentinel mesh. +Each gesture has its own backdrop. A mesh with `onPointerMove` but no `onClick` takes part in pointer-moves but not clicks: a click on it runs no `onClick`, so it reaches the click-backdrop and fires `onVoidClick`. ## Hover events @@ -289,4 +269,4 @@ To keep an object from being hit while it still receives events that bubble up f - [Pointer events](/tour/04-pointer-events) — the tutorial chapter, with a worked walk-through. - [`raycastable`](/api/events/raycastable) — opt an object out of hit-testing while keeping bubbled events. -- [``](/api/components/canvas) — where canvas-level handlers and `onClickMissed` fire. +- [``](/api/components/canvas) — where canvas-level handlers and `onVoidClick` fire. diff --git a/site/src/routes/tour/04-pointer-events.mdx b/site/src/routes/tour/04-pointer-events.mdx index 0e5b8737..3b3a6224 100644 --- a/site/src/routes/tour/04-pointer-events.mdx +++ b/site/src/routes/tour/04-pointer-events.mdx @@ -6,8 +6,10 @@ import clickSnippet from "../../snippets/04-click.tsx?raw" import clickUrl from "../../snippets/04-click.tsx?importChunkUrl" import hoverSnippet from "../../snippets/04-hover.tsx?raw" import hoverUrl from "../../snippets/04-hover.tsx?importChunkUrl" -import clickMissedSnippet from "../../snippets/04-click-missed.tsx?raw" -import clickMissedUrl from "../../snippets/04-click-missed.tsx?importChunkUrl" +import voidClickSnippet from "../../snippets/04-void-click.tsx?raw" +import voidClickUrl from "../../snippets/04-void-click.tsx?importChunkUrl" +import voidWheelSnippet from "../../snippets/04-void-wheel.tsx?raw" +import voidWheelUrl from "../../snippets/04-void-wheel.tsx?importChunkUrl" import stopPropagationSnippet from "../../snippets/04-stop-propagation.tsx?raw" import stopPropagationUrl from "../../snippets/04-stop-propagation.tsx?importChunkUrl" import dragSnippet from "../../snippets/04-drag.tsx?raw" @@ -41,22 +43,32 @@ to react to hover. Move the pointer over the cube — it grows. Leave — it shrinks. The signal flips; `scale` reads it; one property assignment runs. -## Clicking nothing +## Void events -Sometimes the interesting event is when the user clicks but _doesn't_ hit any -mesh — to deselect the current selection, dismiss a tooltip, that sort of -thing. [``](/api/components/canvas) exposes `onClickMissed` for exactly that case: +Sometimes the event you want is a click that hits no mesh — to deselect the +current thing, or dismiss a tooltip. Picture a backdrop behind the whole +scene: a click that misses every mesh reaches the backdrop, and `onVoidClick` +on the [``](/api/components/canvas) is the backdrop's `onClick`. - + -Click the cube to select it; click anywhere else in the canvas to deselect. -The two handlers cover the full "did you hit something?" question between -them. +Click the cube to select it; click anywhere else and the click reaches the +backdrop, which deselects. Every click is one or the other — a mesh, or the +backdrop. + +The backdrop catches more than clicks — every gesture that can miss has one. +Here it's the wheel: + + + +Wheel over the cube and `onWheel` resizes it; wheel over the empty backdrop +and `onVoidWheel` zooms the camera. That covers the everyday cases. There's a [longer list of pointer events](/api/events/overview) (`onPointerMove`, `onPointerDown`, `onPointerUp`, `onWheel`, ...) — all -following the same convention. +following the same convention, and the ones that can miss have a void twin +too (`onVoidPointerDown`, `onVoidWheel`, ...). ## Stopping events diff --git a/site/src/snippets/04-click-missed.tsx b/site/src/snippets/04-void-click.tsx similarity index 85% rename from site/src/snippets/04-click-missed.tsx rename to site/src/snippets/04-void-click.tsx index 731bcd25..0eb18508 100644 --- a/site/src/snippets/04-click-missed.tsx +++ b/site/src/snippets/04-void-click.tsx @@ -8,7 +8,7 @@ export default () => { const [selected, setSelected] = createSignal(false) return ( - setSelected(false)}> + setSelected(false)}> setSelected(true)}> diff --git a/site/src/snippets/04-void-wheel.tsx b/site/src/snippets/04-void-wheel.tsx new file mode 100644 index 00000000..97371841 --- /dev/null +++ b/site/src/snippets/04-void-wheel.tsx @@ -0,0 +1,28 @@ +import * as THREE from "three" +import { createSignal } from "solid-js" +import { Canvas, createT } from "solid-three" + +const T = createT(THREE) +const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)) + +export default () => { + const [size, setSize] = createSignal(1) + const [distance, setDistance] = createSignal(5) + + return ( + setDistance(d => clamp(d + event.nativeEvent.deltaY * 0.005, 3, 9))} + > + setSize(s => clamp(s - event.nativeEvent.deltaY * 0.001, 0.5, 2))} + > + + + + + + + ) +} diff --git a/src/canvas.tsx b/src/canvas.tsx index d3df1951..c178482a 100644 --- a/src/canvas.tsx +++ b/src/canvas.tsx @@ -17,12 +17,15 @@ import type { Context, RefWithCleanup, ResolvedRenderer, + VoidEventHandlers, } from "./types.ts" /** * Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene. */ -export interface CanvasProps extends ParentProps> { +export interface CanvasProps extends ParentProps< + Partial & Partial +> { ref?: RefWithCleanup class?: string /** Configuration for the camera used in the scene. */ diff --git a/src/index.ts b/src/index.ts index aa2a9ec8..931ef989 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,5 +14,11 @@ export * as S3 from "./types.ts" // Direct re-exports of types that users commonly need at the top level. // `Register` is augmentable from `declare module "solid-three"` (see its // JSDoc); `SupportedRenderer` and `ResolvedRenderer` show up in advanced typings. -export type { Plugin, Register, ResolvedRenderer, SupportedRenderer } from "./types.ts" +export type { + Plugin, + Register, + ResolvedRenderer, + SupportedRenderer, + VoidEventHandlers, +} from "./types.ts" export { autodispose, getMeta, hasMeta, load, meta } from "./utils.ts" diff --git a/src/pointers.ts b/src/pointers.ts index 7d51b1ba..2573ec7f 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -25,14 +25,12 @@ export type DispatchEvent = { /** * The slice of an `EventRaycaster` a `Pointer` needs: cast its current ray against - * a registry, (for the click-missed phase) re-cast a single object, and — for - * pointer capture — `aim` the live `ray` without casting (to reproject onto the - * captured object's plane). The real `EventRaycaster` (which extends three's - * `Raycaster`) satisfies this structurally. + * a registry, and — for pointer capture — `aim` the live `ray` without casting (to + * reproject onto the captured object's plane). The real `EventRaycaster` (which + * extends three's `Raycaster`) satisfies this structurally. */ export type PointerRaycaster = { cast(registry: Object3D[], context: Context): Intersection>[] - intersectObject(object: Object3D, recursive?: boolean): Intersection[] aim(context: Context): void ray: Ray } @@ -109,11 +107,6 @@ interface Captured { * and calls these gesture methods; the `Pointer` raycasts the context's single * `eventRegistry` and bubbles to the `onPointer*` / `onClick` / … handlers, * tracking its own hover state so multiple pointers stay independent. - * - * Dispatch logic is ported verbatim from the previous per-kind registries - * (`createHoverEventRegistry` / `createMissableEventRegistry` / - * `createDefaultEventRegistry`); the only changes are per-pointer instance state - * and the single `onPointer*` family (the redundant `onMouse*` family is gone). */ export class Pointer { private hovered = new Set() @@ -278,6 +271,7 @@ export class Pointer { intersection.object, ]), ) + this.canvasLevel(moveEvent, "onPointerMove") // Phase #3 — Leave (objects hovered last time but not now). const leaveEvent = createThreeEvent(nativeEvent, { stoppable: false, intersections }) @@ -308,42 +302,68 @@ export class Pointer { this.dispatch("onWheel", nativeEvent) } + /** Non-voidable canvas-level (hover, captured drags): fire on `!stopped`, no void fallback. */ + private canvasLevel(event: DispatchEvent, handler: string) { + if (!event.stopped) (this.context.props as Record)[handler]?.(event) + } + + /** Voidable tail (exclusive): an object-level handler ran → the canvas handler; none ran → canvas `onVoid`. */ + private finishVoidable(event: DispatchEvent, handler: string, firedOnObject: boolean) { + if (event.stopped) return + const props = this.context.props as Record + if (firedOnObject) { + props[handler]?.(event) + } else { + const voidEvent = createThreeEvent(event.nativeEvent, { stoppable: false }) + props[`onVoid${handler.slice(2)}`]?.(voidEvent) + } + } + /** * Propagate `handler` across the roots (nearest-first — raycast propagation) and up * each root's parent chain (tree propagation) — setting `event.currentIntersection` * for the chain and `event.currentObject` for each node it fires on — honoring - * `stopPropagation`, then fire the canvas-level handler if nothing stopped it. Each - * `[intersection, root]` pairs the starting node (`root`) with the intersection to - * expose while walking it: the captured path passes a single pair rooted at the - * captured object, the normal path one pair per hit. A node shared by several hits - * fires once (the closest hit's chain reaches it first), matching `move`/`click`. + * `stopPropagation`. Each `[intersection, root]` pairs the starting node (`root`) with + * the intersection to expose while walking it: the captured path passes a single pair + * rooted at the captured object, the normal path one pair per hit. A node shared by + * several hits fires once (the closest hit's chain reaches it first), matching + * `move`/`click`. Returns whether any object-level handler fired. */ - private propagate(event: DispatchEvent, handler: string, roots: Array<[Intersection, Object3D]>) { + private propagate( + event: DispatchEvent, + handler: string, + roots: Array<[Intersection, Object3D]>, + ): boolean { const visited = new Set() + let firedOnObject = false for (const [intersection, root] of roots) { event.currentIntersection = intersection let node: Object3D | null = root while (node && !event.stopped && !visited.has(node)) { visited.add(node) event.currentObject = node - ;(getMeta(node)?.props as any)?.[handler]?.(event) + const handle = (getMeta(node)?.props as any)?.[handler] + if (handle) { + firedOnObject = true + handle(event) + } node = node.parent } if (event.stopped) break } - if (!event.stopped) { - delete event.currentIntersection - event.currentObject = undefined - ;(this.context.props as Record)[handler]?.(event) - } + delete event.currentIntersection + event.currentObject = undefined + return firedOnObject } /** * Dispatch a "default"-style gesture to an arbitrary handler name (plugin-extensible: * the built-in sources fire `onPointerDown`/`onPointerUp`/`onWheel`; a plugin source * can fire its own names, e.g. `onXRSelect`). Propagates along the hit chain honoring - * `stopPropagation`, then fires canvas-level if unstopped. `extra` is merged onto the - * event (plugin sources use it for rich fields, e.g. the XR controller payload), and + * `stopPropagation`, then, for the non-captured path, fires the canvas handler if an + * object-level handler ran (otherwise the canvas-level `onVoid`); the captured + * path always fires the canvas-level handler. `extra` is merged onto the event (plugin + * sources use it for rich fields, e.g. the XR controller payload), and * `event.currentObject` exposes the node a handler is firing on. When this pointer * holds a capture, delivery is exclusive to the captured object's chain (the * registry is not raycast) but still bubbles to the canvas-level handler; the @@ -363,13 +383,14 @@ export class Pointer { const event = createThreeEvent(nativeEvent, { intersections: [intersection] }, extra) if (capturable) this.attachCapture(event) this.propagate(event, handler, [[intersection, captured.object]]) + this.canvasLevel(event, handler) return } const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) const event = createThreeEvent(nativeEvent, { intersections }, extra) if (capturable) this.attachCapture(event) - this.propagate( + const firedOnObject = this.propagate( event, handler, intersections.map((intersection): [Intersection, Object3D] => [ @@ -377,54 +398,24 @@ export class Pointer { intersection.object, ]), ) + this.finishVoidable(event, handler, firedOnObject) } - /** Missable gesture: bubbled `onClick`/`onDoubleClick`/`onContextMenu` + `-Missed`. */ + /** Void-family gesture: bubbled onClick/onDoubleClick/onContextMenu + canvas-level onVoid. */ click(kind: "onClick" | "onDoubleClick" | "onContextMenu", nativeEvent: Event) { - const missedType = `${kind}Missed` as const - const registry = this.context.eventRegistry const props = this.context.props as Record - if (registry.length === 0 && !props[kind] && !props[missedType]) return - - const missed = new Set(registry) - const visited = new Set() + const registry = this.context.eventRegistry + if (registry.length === 0 && !props[kind] && !props[`onVoid${kind.slice(2)}`]) return const intersections = this.raycaster.cast(registry, this.context) const event = createThreeEvent(nativeEvent, { intersections }) - - // Phase #1 — fire the handler, bubbling down the hit chain. - for (const intersection of intersections) { - event.currentIntersection = intersection - let node: Object3D | null = intersection.object - while (node && !event.stopped && !visited.has(node)) { - missed.delete(node) - visited.add(node) - event.currentObject = node - ;(getMeta(node)?.props as any)?.[kind]?.(event) - node = node.parent - } - } - if (!event.stopped) { - delete event.currentIntersection - event.currentObject = undefined - props[kind]?.(event) - } - - // Phase #2 — re-raycast remaining objects to mark any genuinely under the ray as hit. - for (const remaining of missed) { - const hits = this.raycaster.intersectObject(remaining, true) - for (const { object } of hits) { - let node: Object3D | null = object - while (node && !visited.has(node)) { - missed.delete(node) - visited.add(node) - node = node.parent - } - } - } - - // Phase #3 — fire `-Missed` on the truly-missed objects, and canvas-level on a total miss. - const missedEvent = createThreeEvent(nativeEvent, { stoppable: false }) - for (const object of missed) (getMeta(object)?.props as any)?.[missedType]?.(missedEvent) - if (intersections.length === 0) props[missedType]?.(missedEvent) + const firedOnObject = this.propagate( + event, + kind, + intersections.map((intersection): [Intersection, Object3D] => [ + intersection, + intersection.object, + ]), + ) + this.finishVoidable(event, kind, firedOnObject) } } diff --git a/src/types.ts b/src/types.ts index 34347ff7..84dfe8b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -439,11 +439,8 @@ export type PointerCapture = { type EventHandlersMap = { onClick: Prettify> - onClickMissed: Prettify> onDoubleClick: Prettify> - onDoubleClickMissed: Prettify> onContextMenu: Prettify> - onContextMenuMissed: Prettify> onPointerUp: Prettify & PointerCapture> onPointerDown: Prettify & PointerCapture> onPointerMove: Prettify & PointerCapture> @@ -465,6 +462,20 @@ export type CanvasEventHandlers = { /** The names of all `EventHandlers` */ export type EventName = keyof EventHandlersMap +type VoidEventHandlersMap = { + onVoidClick: Prettify> + onVoidDoubleClick: Prettify> + onVoidContextMenu: Prettify> + onVoidWheel: Prettify> + onVoidPointerDown: Prettify> + onVoidPointerUp: Prettify> +} + +/** Canvas-level handlers fired when a gesture hits no interactive object (the void). */ +export type VoidEventHandlers = { + [TKey in keyof VoidEventHandlersMap]: (event: VoidEventHandlersMap[TKey]) => void +} + /**********************************************************************************/ /* */ /* Solid Three Representation */ diff --git a/tests/core/canvas-events.test.tsx b/tests/core/canvas-events.test.tsx index 30f070f8..4ab1cee9 100644 --- a/tests/core/canvas-events.test.tsx +++ b/tests/core/canvas-events.test.tsx @@ -59,22 +59,24 @@ const ListeningMesh = (props: { eventType: string; handler?: (e: any) => void }) /**********************************************************************************/ /* */ -/* Missable Events */ +/* Click-Family Events */ /* */ /**********************************************************************************/ -describe("canvas missable events", () => { +describe("canvas click-family events", () => { // // onClick // describe("onClick", () => { - it("fires when canvas is clicked and no meshes are in the scene", () => { + it("does NOT fire when no meshes are in the scene; onVoidClick fires instead", () => { const handleClick = vi.fn() - const { canvas } = test(() => null, { onClick: handleClick }) + const onVoidClick = vi.fn() + const { canvas } = test(() => null, { onClick: handleClick, onVoidClick }) fireEvent(canvas, hitEvent("click")) - expect(handleClick).toHaveBeenCalledTimes(1) + expect(onVoidClick).toHaveBeenCalledTimes(1) + expect(handleClick).not.toHaveBeenCalled() }) it("fires when click propagates through a mesh that does not stop it", () => { @@ -86,72 +88,32 @@ describe("canvas missable events", () => { expect(handleClick).toHaveBeenCalledTimes(1) }) - it("fires when click misses all meshes", () => { + it("does NOT fire when click misses all meshes; onVoidClick fires instead", () => { const handleClick = vi.fn() - const { canvas } = test(() => , { onClick: handleClick }) - - fireEvent(canvas, missEvent("click")) - - expect(handleClick).toHaveBeenCalledTimes(1) - }) - - it("does not fire when a mesh stops propagation", () => { - const handleClick = vi.fn() - const { canvas } = test(() => , { onClick: handleClick }) - - fireEvent(canvas, hitEvent("click")) - - expect(handleClick).not.toHaveBeenCalled() - }) - }) - - // - // onClickMissed - // - describe("onClickMissed", () => { - it("fires when click misses all registered meshes", () => { - const handleClickMissed = vi.fn() + const onVoidClick = vi.fn() const { canvas } = test(() => , { - onClickMissed: handleClickMissed, + onClick: handleClick, + onVoidClick, }) fireEvent(canvas, missEvent("click")) - expect(handleClickMissed).toHaveBeenCalledTimes(1) - }) - - it("fires when canvas is clicked with no meshes in the scene", () => { - const handleClickMissed = vi.fn() - const { canvas } = test(() => null, { onClickMissed: handleClickMissed }) - - fireEvent(canvas, hitEvent("click")) - - expect(handleClickMissed).toHaveBeenCalledTimes(1) - }) - - it("does not fire when click hits a registered mesh", () => { - const handleClickMissed = vi.fn() - const { canvas } = test(() => , { - onClickMissed: handleClickMissed, - }) - - fireEvent(canvas, hitEvent("click")) - - expect(handleClickMissed).not.toHaveBeenCalled() + expect(onVoidClick).toHaveBeenCalledTimes(1) + expect(handleClick).not.toHaveBeenCalled() }) - it("does not fire when onClick is also registered and click hits a mesh", () => { + it("does not fire when a mesh stops propagation", () => { const handleClick = vi.fn() - const handleClickMissed = vi.fn() - const { canvas } = test(() => , { + const onVoidClick = vi.fn() + const { canvas } = test(() => , { onClick: handleClick, - onClickMissed: handleClickMissed, + onVoidClick, }) fireEvent(canvas, hitEvent("click")) - expect(handleClick).toHaveBeenCalledTimes(1) - expect(handleClickMissed).not.toHaveBeenCalled() + expect(handleClick).not.toHaveBeenCalled() + expect(onVoidClick).not.toHaveBeenCalled() }) }) @@ -159,13 +121,15 @@ describe("canvas missable events", () => { // onDoubleClick // describe("onDoubleClick", () => { - it("fires when canvas is double-clicked and no meshes are in the scene", () => { + it("does NOT fire when no meshes are in the scene; onVoidDoubleClick fires instead", () => { const handleDoubleClick = vi.fn() - const { canvas } = test(() => null, { onDoubleClick: handleDoubleClick }) + const onVoidDoubleClick = vi.fn() + const { canvas } = test(() => null, { onDoubleClick: handleDoubleClick, onVoidDoubleClick }) fireEvent(canvas, hitEvent("dblclick")) - expect(handleDoubleClick).toHaveBeenCalledTimes(1) + expect(onVoidDoubleClick).toHaveBeenCalledTimes(1) + expect(handleDoubleClick).not.toHaveBeenCalled() }) it("fires when double-click propagates through a mesh that does not stop it", () => { @@ -179,62 +143,32 @@ describe("canvas missable events", () => { expect(handleDoubleClick).toHaveBeenCalledTimes(1) }) - it("fires when double-click misses all meshes", () => { + it("does NOT fire when double-click misses all meshes; onVoidDoubleClick fires instead", () => { const handleDoubleClick = vi.fn() + const onVoidDoubleClick = vi.fn() const { canvas } = test(() => , { onDoubleClick: handleDoubleClick, + onVoidDoubleClick, }) fireEvent(canvas, missEvent("dblclick")) - expect(handleDoubleClick).toHaveBeenCalledTimes(1) + expect(onVoidDoubleClick).toHaveBeenCalledTimes(1) + expect(handleDoubleClick).not.toHaveBeenCalled() }) it("does not fire when a mesh stops propagation", () => { const handleDoubleClick = vi.fn() + const onVoidDoubleClick = vi.fn() const { canvas } = test(() => , { onDoubleClick: handleDoubleClick, + onVoidDoubleClick, }) fireEvent(canvas, hitEvent("dblclick")) expect(handleDoubleClick).not.toHaveBeenCalled() - }) - }) - - // - // onDoubleClickMissed - // - describe("onDoubleClickMissed", () => { - it("fires when double-click misses all registered meshes", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => , { - onDoubleClickMissed: handleMissed, - }) - - fireEvent(canvas, missEvent("dblclick")) - - expect(handleMissed).toHaveBeenCalledTimes(1) - }) - - it("fires when canvas is double-clicked with no meshes in the scene", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => null, { onDoubleClickMissed: handleMissed }) - - fireEvent(canvas, hitEvent("dblclick")) - - expect(handleMissed).toHaveBeenCalledTimes(1) - }) - - it("does not fire when double-click hits a registered mesh", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => , { - onDoubleClickMissed: handleMissed, - }) - - fireEvent(canvas, hitEvent("dblclick")) - - expect(handleMissed).not.toHaveBeenCalled() + expect(onVoidDoubleClick).not.toHaveBeenCalled() }) }) @@ -242,13 +176,15 @@ describe("canvas missable events", () => { // onContextMenu // describe("onContextMenu", () => { - it("fires when canvas receives contextmenu and no meshes are in the scene", () => { + it("does NOT fire when no meshes are in the scene; onVoidContextMenu fires instead", () => { const handleContextMenu = vi.fn() - const { canvas } = test(() => null, { onContextMenu: handleContextMenu }) + const onVoidContextMenu = vi.fn() + const { canvas } = test(() => null, { onContextMenu: handleContextMenu, onVoidContextMenu }) fireEvent(canvas, hitEvent("contextmenu")) - expect(handleContextMenu).toHaveBeenCalledTimes(1) + expect(onVoidContextMenu).toHaveBeenCalledTimes(1) + expect(handleContextMenu).not.toHaveBeenCalled() }) it("fires when contextmenu propagates through a mesh that does not stop it", () => { @@ -262,62 +198,32 @@ describe("canvas missable events", () => { expect(handleContextMenu).toHaveBeenCalledTimes(1) }) - it("fires when contextmenu misses all meshes", () => { + it("does NOT fire when contextmenu misses all meshes; onVoidContextMenu fires instead", () => { const handleContextMenu = vi.fn() + const onVoidContextMenu = vi.fn() const { canvas } = test(() => , { onContextMenu: handleContextMenu, + onVoidContextMenu, }) fireEvent(canvas, missEvent("contextmenu")) - expect(handleContextMenu).toHaveBeenCalledTimes(1) + expect(onVoidContextMenu).toHaveBeenCalledTimes(1) + expect(handleContextMenu).not.toHaveBeenCalled() }) it("does not fire when a mesh stops propagation", () => { const handleContextMenu = vi.fn() + const onVoidContextMenu = vi.fn() const { canvas } = test(() => , { onContextMenu: handleContextMenu, + onVoidContextMenu, }) fireEvent(canvas, hitEvent("contextmenu")) expect(handleContextMenu).not.toHaveBeenCalled() - }) - }) - - // - // onContextMenuMissed - // - describe("onContextMenuMissed", () => { - it("fires when contextmenu misses all registered meshes", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => , { - onContextMenuMissed: handleMissed, - }) - - fireEvent(canvas, missEvent("contextmenu")) - - expect(handleMissed).toHaveBeenCalledTimes(1) - }) - - it("fires when canvas receives contextmenu with no meshes in the scene", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => null, { onContextMenuMissed: handleMissed }) - - fireEvent(canvas, hitEvent("contextmenu")) - - expect(handleMissed).toHaveBeenCalledTimes(1) - }) - - it("does not fire when contextmenu hits a registered mesh", () => { - const handleMissed = vi.fn() - const { canvas } = test(() => , { - onContextMenuMissed: handleMissed, - }) - - fireEvent(canvas, hitEvent("contextmenu")) - - expect(handleMissed).not.toHaveBeenCalled() + expect(onVoidContextMenu).not.toHaveBeenCalled() }) }) }) @@ -333,35 +239,43 @@ describe("canvas default events", () => { // onPointerDown // describe("onPointerDown", () => { - it("fires when pointerdown occurs with no meshes in the scene", () => { + it("does NOT fire when no meshes are in the scene; onVoidPointerDown fires instead", () => { const handlePointerDown = vi.fn() - const { canvas } = test(() => null, { onPointerDown: handlePointerDown }) + const onVoidPointerDown = vi.fn() + const { canvas } = test(() => null, { onPointerDown: handlePointerDown, onVoidPointerDown }) fireEvent(canvas, hitEvent("pointerdown")) - expect(handlePointerDown).toHaveBeenCalledTimes(1) + expect(onVoidPointerDown).toHaveBeenCalledTimes(1) + expect(handlePointerDown).not.toHaveBeenCalled() }) it("fires when pointerdown propagates through a mesh that does not stop it", () => { const handlePointerDown = vi.fn() + const onVoidPointerDown = vi.fn() const { canvas } = test(() => , { onPointerDown: handlePointerDown, + onVoidPointerDown, }) fireEvent(canvas, hitEvent("pointerdown")) expect(handlePointerDown).toHaveBeenCalledTimes(1) + expect(onVoidPointerDown).not.toHaveBeenCalled() }) it("does not fire when a mesh stops propagation", () => { const handlePointerDown = vi.fn() + const onVoidPointerDown = vi.fn() const { canvas } = test(() => , { onPointerDown: handlePointerDown, + onVoidPointerDown, }) fireEvent(canvas, hitEvent("pointerdown")) expect(handlePointerDown).not.toHaveBeenCalled() + expect(onVoidPointerDown).not.toHaveBeenCalled() }) }) @@ -369,35 +283,43 @@ describe("canvas default events", () => { // onPointerUp // describe("onPointerUp", () => { - it("fires when pointerup occurs with no meshes in the scene", () => { + it("does NOT fire when no meshes are in the scene; onVoidPointerUp fires instead", () => { const handlePointerUp = vi.fn() - const { canvas } = test(() => null, { onPointerUp: handlePointerUp }) + const onVoidPointerUp = vi.fn() + const { canvas } = test(() => null, { onPointerUp: handlePointerUp, onVoidPointerUp }) fireEvent(canvas, hitEvent("pointerup")) - expect(handlePointerUp).toHaveBeenCalledTimes(1) + expect(onVoidPointerUp).toHaveBeenCalledTimes(1) + expect(handlePointerUp).not.toHaveBeenCalled() }) it("fires when pointerup propagates through a mesh that does not stop it", () => { const handlePointerUp = vi.fn() + const onVoidPointerUp = vi.fn() const { canvas } = test(() => , { onPointerUp: handlePointerUp, + onVoidPointerUp, }) fireEvent(canvas, hitEvent("pointerup")) expect(handlePointerUp).toHaveBeenCalledTimes(1) + expect(onVoidPointerUp).not.toHaveBeenCalled() }) it("does not fire when a mesh stops propagation", () => { const handlePointerUp = vi.fn() + const onVoidPointerUp = vi.fn() const { canvas } = test(() => , { onPointerUp: handlePointerUp, + onVoidPointerUp, }) fireEvent(canvas, hitEvent("pointerup")) expect(handlePointerUp).not.toHaveBeenCalled() + expect(onVoidPointerUp).not.toHaveBeenCalled() }) }) @@ -405,21 +327,27 @@ describe("canvas default events", () => { // onWheel // describe("onWheel", () => { - it("fires when wheel event occurs with no meshes in the scene", () => { + it("does NOT fire when no meshes are in the scene; onVoidWheel fires instead", () => { const handleWheel = vi.fn() - const { canvas } = test(() => null, { onWheel: handleWheel }) + const onVoidWheel = vi.fn() + const { canvas } = test(() => null, { onWheel: handleWheel, onVoidWheel }) fireEvent( canvas, new WheelEvent("wheel", { deltaY: 100, clientX: HIT_X, clientY: HIT_Y, bubbles: true }), ) - expect(handleWheel).toHaveBeenCalledTimes(1) + expect(onVoidWheel).toHaveBeenCalledTimes(1) + expect(handleWheel).not.toHaveBeenCalled() }) it("fires when wheel event propagates through a mesh that does not stop it", () => { const handleWheel = vi.fn() - const { canvas } = test(() => , { onWheel: handleWheel }) + const onVoidWheel = vi.fn() + const { canvas } = test(() => , { + onWheel: handleWheel, + onVoidWheel, + }) fireEvent( canvas, @@ -427,11 +355,16 @@ describe("canvas default events", () => { ) expect(handleWheel).toHaveBeenCalledTimes(1) + expect(onVoidWheel).not.toHaveBeenCalled() }) it("does not fire when a mesh stops propagation", () => { const handleWheel = vi.fn() - const { canvas } = test(() => , { onWheel: handleWheel }) + const onVoidWheel = vi.fn() + const { canvas } = test(() => , { + onWheel: handleWheel, + onVoidWheel, + }) fireEvent( canvas, @@ -439,6 +372,327 @@ describe("canvas default events", () => { ) expect(handleWheel).not.toHaveBeenCalled() + expect(onVoidWheel).not.toHaveBeenCalled() + }) + }) +}) + +/**********************************************************************************/ +/* */ +/* Void Events */ +/* */ +/**********************************************************************************/ + +describe("void events", () => { + // + // onVoidClick + // + describe("onVoidClick", () => { + it("fires when click misses all registered meshes", () => { + const handleVoidClick = vi.fn() + const { canvas } = test(() => , { + onVoidClick: handleVoidClick, + }) + + fireEvent(canvas, missEvent("click")) + + expect(handleVoidClick).toHaveBeenCalledTimes(1) + }) + + it("fires when canvas is clicked with no meshes in the scene", () => { + const handleVoidClick = vi.fn() + const { canvas } = test(() => null, { onVoidClick: handleVoidClick }) + + fireEvent(canvas, hitEvent("click")) + + expect(handleVoidClick).toHaveBeenCalledTimes(1) + }) + + it("does not fire when click hits a registered mesh", () => { + const handleVoidClick = vi.fn() + const { canvas } = test(() => , { + onVoidClick: handleVoidClick, + }) + + fireEvent(canvas, hitEvent("click")) + + expect(handleVoidClick).not.toHaveBeenCalled() + }) + + it("does not fire when onClick is also registered and click hits a mesh", () => { + const handleClick = vi.fn() + const handleVoidClick = vi.fn() + const { canvas } = test(() => , { + onClick: handleClick, + onVoidClick: handleVoidClick, + }) + + fireEvent(canvas, hitEvent("click")) + + expect(handleClick).toHaveBeenCalledTimes(1) + expect(handleVoidClick).not.toHaveBeenCalled() + }) + }) + + // + // onVoidClick (gesture-scoped) + // + describe("onVoidClick (gesture-scoped)", () => { + it("fires when the click lands on a hover-only mesh (no onClick)", () => { + const onVoidClick = vi.fn() + const { canvas } = test(() => , { onVoidClick }) + fireEvent(canvas, hitEvent("click")) + expect(onVoidClick).toHaveBeenCalledTimes(1) + }) + + it("does NOT fire when the click hits a mesh with onClick; canvas onClick fires instead", () => { + const onVoidClick = vi.fn() + const onClick = vi.fn() + const { canvas } = test(() => , { onVoidClick, onClick }) + fireEvent(canvas, hitEvent("click")) + expect(onClick).toHaveBeenCalledTimes(1) + expect(onVoidClick).not.toHaveBeenCalled() + }) + + it("fires on empty space; canvas onClick does NOT (exclusive)", () => { + const onVoidClick = vi.fn() + const onClick = vi.fn() + const { canvas } = test(() => , { onVoidClick, onClick }) + fireEvent(canvas, missEvent("click")) + expect(onVoidClick).toHaveBeenCalledTimes(1) + expect(onClick).not.toHaveBeenCalled() + }) + }) + + // + // onVoidDoubleClick + // + describe("onVoidDoubleClick", () => { + it("fires when double-click misses all registered meshes", () => { + const handleVoid = vi.fn() + const { canvas } = test(() => , { + onVoidDoubleClick: handleVoid, + }) + + fireEvent(canvas, missEvent("dblclick")) + + expect(handleVoid).toHaveBeenCalledTimes(1) + }) + + it("fires when canvas is double-clicked with no meshes in the scene", () => { + const handleVoid = vi.fn() + const { canvas } = test(() => null, { onVoidDoubleClick: handleVoid }) + + fireEvent(canvas, hitEvent("dblclick")) + + expect(handleVoid).toHaveBeenCalledTimes(1) + }) + + it("does not fire when double-click hits a registered mesh", () => { + const handleVoid = vi.fn() + const { canvas } = test(() => , { + onVoidDoubleClick: handleVoid, + }) + + fireEvent(canvas, hitEvent("dblclick")) + + expect(handleVoid).not.toHaveBeenCalled() + }) + }) + + // + // onVoidContextMenu + // + describe("onVoidContextMenu", () => { + it("fires when contextmenu misses all registered meshes", () => { + const handleVoid = vi.fn() + const { canvas } = test(() => , { + onVoidContextMenu: handleVoid, + }) + + fireEvent(canvas, missEvent("contextmenu")) + + expect(handleVoid).toHaveBeenCalledTimes(1) + }) + + it("fires when canvas receives contextmenu with no meshes in the scene", () => { + const handleVoid = vi.fn() + const { canvas } = test(() => null, { onVoidContextMenu: handleVoid }) + + fireEvent(canvas, hitEvent("contextmenu")) + + expect(handleVoid).toHaveBeenCalledTimes(1) + }) + + it("does not fire when contextmenu hits a registered mesh", () => { + const handleVoid = vi.fn() + const { canvas } = test(() => , { + onVoidContextMenu: handleVoid, + }) + + fireEvent(canvas, hitEvent("contextmenu")) + + expect(handleVoid).not.toHaveBeenCalled() + }) + }) + + // + // onVoid family — wheel / pointer down / up + // + describe("onVoid family — wheel / pointer down / up", () => { + it("onVoidWheel fires on an empty-space wheel", () => { + const onVoidWheel = vi.fn() + const { canvas } = test(() => , { onVoidWheel }) + + fireEvent( + canvas, + new WheelEvent("wheel", { deltaY: 100, clientX: MISS_X, clientY: MISS_Y, bubbles: true }), + ) + + expect(onVoidWheel).toHaveBeenCalledTimes(1) + }) + + it("onVoidPointerDown fires on an empty-space pointerdown", () => { + const onVoidPointerDown = vi.fn() + const { canvas } = test(() => , { + onVoidPointerDown, + }) + + fireEvent(canvas, missEvent("pointerdown")) + + expect(onVoidPointerDown).toHaveBeenCalledTimes(1) + }) + + it("onVoidPointerUp fires on an empty-space pointerup", () => { + const onVoidPointerUp = vi.fn() + const { canvas } = test(() => , { + onVoidPointerUp, + }) + + fireEvent(canvas, missEvent("pointerup")) + + expect(onVoidPointerUp).toHaveBeenCalledTimes(1) + }) + }) + + // + // onVoid* — gesture-scoped parity (hover-only mesh is transparent to the void family) + // + describe("onVoid* — gesture-scoped (hover-only object is transparent)", () => { + it("onVoidClick fires when click hits a hover-only mesh; canvas onClick does NOT", () => { + const onClick = vi.fn() + const onVoidClick = vi.fn() + const { canvas } = test(() => , { + onClick, + onVoidClick, + }) + + fireEvent(canvas, hitEvent("click")) + + expect(onVoidClick).toHaveBeenCalledTimes(1) + expect(onClick).not.toHaveBeenCalled() + }) + + it("onVoidDoubleClick fires when double-click hits a hover-only mesh; canvas onDoubleClick does NOT", () => { + const onDoubleClick = vi.fn() + const onVoidDoubleClick = vi.fn() + const { canvas } = test(() => , { + onDoubleClick, + onVoidDoubleClick, + }) + + fireEvent(canvas, hitEvent("dblclick")) + + expect(onVoidDoubleClick).toHaveBeenCalledTimes(1) + expect(onDoubleClick).not.toHaveBeenCalled() + }) + + it("onVoidContextMenu fires when contextmenu hits a hover-only mesh; canvas onContextMenu does NOT", () => { + const onContextMenu = vi.fn() + const onVoidContextMenu = vi.fn() + const { canvas } = test(() => , { + onContextMenu, + onVoidContextMenu, + }) + + fireEvent(canvas, hitEvent("contextmenu")) + + expect(onVoidContextMenu).toHaveBeenCalledTimes(1) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("onVoidWheel fires when wheel hits a hover-only mesh; canvas onWheel does NOT", () => { + const onWheel = vi.fn() + const onVoidWheel = vi.fn() + const { canvas } = test(() => , { + onWheel, + onVoidWheel, + }) + + fireEvent( + canvas, + new WheelEvent("wheel", { deltaY: 100, clientX: HIT_X, clientY: HIT_Y, bubbles: true }), + ) + + expect(onVoidWheel).toHaveBeenCalledTimes(1) + expect(onWheel).not.toHaveBeenCalled() + }) + + it("onVoidPointerDown fires when pointerdown hits a hover-only mesh; canvas onPointerDown does NOT", () => { + const onPointerDown = vi.fn() + const onVoidPointerDown = vi.fn() + const { canvas } = test(() => , { + onPointerDown, + onVoidPointerDown, + }) + + fireEvent( + canvas, + new PointerEvent("pointerdown", { clientX: HIT_X, clientY: HIT_Y, bubbles: true }), + ) + + expect(onVoidPointerDown).toHaveBeenCalledTimes(1) + expect(onPointerDown).not.toHaveBeenCalled() + }) + + it("onVoidPointerUp fires when pointerup hits a hover-only mesh; canvas onPointerUp does NOT", () => { + const onPointerUp = vi.fn() + const onVoidPointerUp = vi.fn() + const { canvas } = test(() => , { + onPointerUp, + onVoidPointerUp, + }) + + fireEvent( + canvas, + new PointerEvent("pointerup", { clientX: HIT_X, clientY: HIT_Y, bubbles: true }), + ) + + expect(onVoidPointerUp).toHaveBeenCalledTimes(1) + expect(onPointerUp).not.toHaveBeenCalled() + }) + }) + + // + // chain-aware: ancestor carries the handler — not a void + // + describe("onVoid* — chain-aware (ancestor handler via bubbling)", () => { + it("a group's onClick fires via bubbling from a hover-only child; onVoidClick does NOT", () => { + const groupClick = vi.fn() + const onVoidClick = vi.fn() + const { canvas } = test( + () => ( + + + + ), + { onVoidClick }, + ) + + fireEvent(canvas, hitEvent("click")) + + expect(groupClick).toHaveBeenCalledTimes(1) + expect(onVoidClick).not.toHaveBeenCalled() }) }) }) diff --git a/tests/core/events.test.tsx b/tests/core/events.test.tsx index 310cad68..c90747f3 100644 --- a/tests/core/events.test.tsx +++ b/tests/core/events.test.tsx @@ -26,119 +26,6 @@ describe("events", () => { expect(handlePointerDown).toHaveBeenCalled() }) - // TODO: implement onPointerMissed-api - // NOTE: unsure if/how we should implement onPointerMissed - // the heuristics are unclear imo - - // it("can handle onPointerMissed", async () => { - // const handleClick = vi.fn(); - // const handleMissed = vi.fn(); - - // const { canvas } = test(() => ( - // - // - // - // - // )); - - // const evt = new MouseEvent("click"); - // Object.defineProperty(evt, "offsetX", { get: () => 0 }); - // Object.defineProperty(evt, "offsetY", { get: () => 0 }); - - // fireEvent(canvas, evt); - - // expect(handleClick).not.toHaveBeenCalled(); - // expect(handleMissed).toHaveBeenCalledWith(evt); - // }); - - // TODO: implement onPointerMissed-api - - // it("should not fire onPointerMissed when same element is clicked", async () => { - // const handleClick = vi.fn(); - // const handleMissed = vi.fn(); - - // const { canvas } = test(() => ( - // - // - // - // - // )); - - // const down = new Event("pointerdown"); - // Object.defineProperty(down, "offsetX", { get: () => 577 }); - // Object.defineProperty(down, "offsetY", { get: () => 480 }); - - // fireEvent(canvas, down); - - // const up = new Event("pointerup"); - // Object.defineProperty(up, "offsetX", { get: () => 577 }); - // Object.defineProperty(up, "offsetY", { get: () => 480 }); - - // const evt = new MouseEvent("click"); - // Object.defineProperty(evt, "offsetX", { get: () => 577 }); - // Object.defineProperty(evt, "offsetY", { get: () => 480 }); - - // fireEvent(canvas, evt); - - // expect(handleClick).toHaveBeenCalled(); - // expect(handleMissed).not.toHaveBeenCalled(); - // }); - - // TODO: implement onPointerMissed-api - - // it("should not fire onPointerMissed on parent when child element is clicked", async () => { - // const handleClick = vi.fn(); - // const handleMissed = vi.fn(); - - // const { canvas } = test(() => ( - // - // - // - // - // - // - // )); - - // const down = new Event("pointerdown"); - // Object.defineProperty(down, "offsetX", { get: () => 577 }); - // Object.defineProperty(down, "offsetY", { get: () => 480 }); - - // fireEvent(canvas, down); - - // const up = new Event("pointerup"); - // Object.defineProperty(up, "offsetX", { get: () => 577 }); - // Object.defineProperty(up, "offsetY", { get: () => 480 }); - - // const evt = new MouseEvent("click"); - // Object.defineProperty(evt, "offsetX", { get: () => 577 }); - // Object.defineProperty(evt, "offsetY", { get: () => 480 }); - - // fireEvent(canvas, evt); - - // expect(handleClick).toHaveBeenCalled(); - // expect(handleMissed).not.toHaveBeenCalled(); - // }); - - // TODO: implement onPointerMissed-api - - // it("can handle onPointerMissed on Canvas", async () => { - // const handleMissed = vi.fn(); - - // const { canvas } = test(() => ( - // - // - // - // - // )); - - // const evt = new MouseEvent("click"); - // Object.defineProperty(evt, "offsetX", { get: () => 0 }); - // Object.defineProperty(evt, "offsetY", { get: () => 0 }); - - // fireEvent(canvas, evt); - // expect(handleMissed).toHaveBeenCalledWith(evt); - // }); - it("can handle onPointerMove", async () => { const handlePointerMove = vi.fn() const handlePointerEnter = vi.fn() @@ -227,7 +114,7 @@ describe("events", () => { /**********************************************************************************/ /* */ -/* Mesh-level onClickMissed */ +/* Bubbling past a stopping mesh */ /* */ /**********************************************************************************/ @@ -241,77 +128,23 @@ function makeClickAt(clientX: number, clientY: number) { return new MouseEvent("click", { clientX, clientY, bubbles: true }) } -describe("mesh onClickMissed", () => { - it("fires when a click misses the mesh", async () => { - const handleClickMissed = vi.fn() - - const { canvas } = await test(() => ( - - - - - )) +/** A 2×2 mesh at origin whose handler stops propagation. */ +const StoppingMesh = (props: { eventType: string }) => { + const handlerProp = { [props.eventType]: (e: any) => e.stopPropagation() } + return ( + + + + + ) +} +describe("click bubbling", () => { + it("a click that misses a stopping mesh fires the canvas onVoidClick instead", () => { + const onVoidClick = vi.fn() + const { canvas } = test(() => , { onVoidClick }) fireEvent(canvas, makeClickAt(MISS_X, MISS_Y)) - - expect(handleClickMissed).toHaveBeenCalledTimes(1) - }) - - it("does not fire when the mesh itself is clicked", async () => { - const handleClickMissed = vi.fn() - - const { canvas } = await test(() => ( - - - - - )) - - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) - - expect(handleClickMissed).not.toHaveBeenCalled() - }) - - it("does not fire when a different mesh in the scene is clicked", async () => { - const handleClickMissed = vi.fn() - - // Mesh A: off-center (far right), has onClickMissed - // Mesh B: at origin (center of screen), gets clicked - const { canvas } = await test(() => ( - <> - - - - - - - - - - )) - - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) - - expect(handleClickMissed).not.toHaveBeenCalled() - }) - - it("does not fire on a parent when its child is clicked", async () => { - const handleParentClickMissed = vi.fn() - const handleChildClick = vi.fn() - - const { canvas } = await test(() => ( - - - - - - - )) - - fireEvent(canvas, makeClickAt(HIT_X, HIT_Y)) - - expect(handleChildClick).toHaveBeenCalledTimes(1) - expect(handleParentClickMissed).not.toHaveBeenCalled() + expect(onVoidClick).toHaveBeenCalledTimes(1) }) }) diff --git a/tests/core/pointer.test.tsx b/tests/core/pointer.test.tsx index 53964d35..a95af789 100644 --- a/tests/core/pointer.test.tsx +++ b/tests/core/pointer.test.tsx @@ -13,8 +13,7 @@ function ctx(eventRegistry: Object3D[], props: Record = {}) { type RayState = { target?: Object3D; point?: Vector3; normal?: Vector3 } // Fake raycaster: `cast` hits whatever `state.target` is (with a point+face so -// capture() can build a plane); phase-2 re-cast finds nothing; `aim` is a no-op -// (tests position `ray` directly). +// capture() can build a plane); `aim` is a no-op (tests position `ray` directly). function fakeRaycaster(state: RayState): PointerRaycaster { return { cast: () => @@ -28,7 +27,6 @@ function fakeRaycaster(state: RayState): PointerRaycaster { } as any, ] : [], - intersectObject: () => [], aim: () => {}, ray: new Ray(), } @@ -94,15 +92,26 @@ describe("Pointer dispatch", () => { expect(seen[1]).toBe(parent) // bubbled handler on parent sees parent }) - it("fires onClickMissed (mesh-level + canvas-level) when the click hits nothing", () => { - const meshMissed = vi.fn() - const canvasMissed = vi.fn() - const mesh = eventful({ onClickMissed: meshMissed }) - const pointer = new Pointer(ctx([mesh], { onClickMissed: canvasMissed }), fakeRaycaster({})) + it("fires onVoidClick on the canvas when the click hits nothing", () => { + const canvasVoid = vi.fn() + const pointer = new Pointer(ctx([], { onVoidClick: canvasVoid }), fakeRaycaster({})) pointer.click("onClick", new MouseEvent("click")) - expect(meshMissed).toHaveBeenCalledTimes(1) - expect(canvasMissed).toHaveBeenCalledTimes(1) + expect(canvasVoid).toHaveBeenCalledTimes(1) + }) + + it("does not fire onVoidClick when the click hits a mesh with onClick", () => { + const objectClick = vi.fn() + const canvasVoid = vi.fn() + const mesh = eventful({ onClick: objectClick }) + const pointer = new Pointer( + ctx([mesh], { onVoidClick: canvasVoid }), + fakeRaycaster({ target: mesh }), + ) + + pointer.click("onClick", new MouseEvent("click")) + expect(objectClick).toHaveBeenCalledTimes(1) + expect(canvasVoid).not.toHaveBeenCalled() }) it("dispatch sets event.currentObject to the bubbling node and merges extra fields", () => { @@ -147,7 +156,6 @@ describe("Pointer dispatch", () => { face: { normal: new Vector3(0, 0, 1) }, }, ], - intersectObject: () => [], aim: () => {}, ray: new Ray(), } as any as PointerRaycaster @@ -175,7 +183,6 @@ describe("Pointer dispatch", () => { }, { object: back, distance: 2, point: new Vector3(), face: { normal: new Vector3(0, 0, 1) } }, ], - intersectObject: () => [], aim: () => {}, ray: new Ray(), } as any as PointerRaycaster @@ -185,6 +192,57 @@ describe("Pointer dispatch", () => { expect(frontMove).toHaveBeenCalledTimes(1) expect(backMove).not.toHaveBeenCalled() // stop halts the deeper hit too }) + + it("firedOnObject — far object with onClick is not a void even when the near object lacks it", () => { + // Near object has no onClick; far object does. Any hit-chain carrying the handler + // means firedOnObject=true → onVoidClick must NOT fire. + const farClick = vi.fn() + const onVoidClick = vi.fn() + const nearObject = eventful({}) // no onClick — handler-less but in the registry + const farObject = eventful({ onClick: farClick }) + const raycaster = { + cast: () => [ + { + object: nearObject, + distance: 1, + point: new Vector3(), + face: { normal: new Vector3(0, 0, 1) }, + }, + { + object: farObject, + distance: 2, + point: new Vector3(), + face: { normal: new Vector3(0, 0, 1) }, + }, + ], + aim: () => {}, + ray: new Ray(), + } as any as PointerRaycaster + const pointer = new Pointer(ctx([nearObject, farObject], { onVoidClick }), raycaster) + + pointer.click("onClick", new MouseEvent("click")) + + expect(farClick).toHaveBeenCalledTimes(1) + expect(onVoidClick).not.toHaveBeenCalled() + }) + + it("void-event payload: object/intersection/currentObject are undefined and stopPropagation is absent", () => { + const nativeEvent = new MouseEvent("click") + let capturedVoidEvent: any + const onVoidClick = vi.fn((event: any) => { + capturedVoidEvent = event + }) + const pointer = new Pointer(ctx([], { onVoidClick }), fakeRaycaster({})) + + pointer.click("onClick", nativeEvent) + + expect(onVoidClick).toHaveBeenCalledTimes(1) + expect(capturedVoidEvent.object).toBeUndefined() + expect(capturedVoidEvent.intersection).toBeUndefined() + expect(capturedVoidEvent.currentObject).toBeUndefined() + expect(capturedVoidEvent.stopPropagation).toBeUndefined() + expect(capturedVoidEvent.nativeEvent).toBe(nativeEvent) + }) }) // A minimal capture sink that records calls. @@ -264,7 +322,6 @@ describe("Pointer capture lifecycle", () => { hitLeaf.updateMatrixWorld() const raycaster: PointerRaycaster = { cast: () => [], - intersectObject: () => [], aim: () => {}, ray: new Ray(new Vector3(2, 1, 0), new Vector3(-1, 0, 0)), // toward -x, offset +1 in y } @@ -289,7 +346,6 @@ describe("Pointer capture lifecycle", () => { const mesh = eventful({ onPointerMove: (e: any) => (point = e.intersection.point) }) const raycaster: PointerRaycaster = { cast: () => [], - intersectObject: () => [], aim: () => {}, ray: new Ray(new Vector3(2, 1, 0), new Vector3(-1, 0, 0)), // toward -x, offset +1 in y }