* feat: expose accepted canvas node selections * feat(editor): replace bulk delete alert with dialog * fix(editor): allow immediate selection after opening placement * feat(spawn): preview model during placement * feat(editor): add cross-project selection clipboard * fix(editor): harden placement and clipboard previews * fix(editor): preserve carried clipboard state * fix(editor): replace active paste drafts safely
3.8 KiB
Events
Typed event bus — emitting and listening to node and grid events.
Applies to: packages/core/src/events/**, packages/viewer/**, apps/editor/**.
The event bus (emitter) is a global mitt instance typed with EditorEvents. It decouples renderers (which emit) from selection managers and tools (which listen).
Source: packages/core/src/events/bus.ts
Event Key Format
<nodeType>:<suffix>
Example keys: wall:click, item:enter, door:double-click, grid:pointerdown
Node Types
wall item site building level zone slab ceiling roof window door
Suffixes
'click' | 'move' | 'enter' | 'leave' | 'pointerdown' | 'pointerup' | 'context-menu' | 'double-click'
The grid:* events fire when the user interacts with empty space (no node hit). They are not emitted by a mesh — useGridEvents(gridY) (apps/editor/hooks/use-grid-events.ts) manually raycasts against a ground plane and calls emitter.emit('grid:click', …). Mount it in any tool or editor component that needs empty-space interactions.
NodeEvent Shape
interface NodeEvent<T extends AnyNode = AnyNode> {
node: T // typed node that triggered the event
position: [number, number, number] // world-space hit position
localPosition: [number, number, number] // object-local hit position
normal?: [number, number, number] // face normal, if available
stopPropagation: () => void
nativeEvent: ThreeEvent<PointerEvent>
}
Grid events only carry position and nativeEvent (no node).
Selection Intent Events
selection:canvas-node-click fires after the editor accepts a 2D or 3D node
click and resolves the node that selection actually targets. Hosts can use it
for contextual navigation without reacting to programmatic setSelection
calls. The payload is the resolved AnyNode.
selection:find-node is the explicit reveal intent emitted by the node action
menu. Hosts and plugins that own catalogs or panels listen to it and reveal the
node's related controls or presets.
Emitting
Renderers emit via useNodeEvents — never call emitter.emit directly in a renderer:
// packages/viewer/src/hooks/use-node-events.ts
const events = useNodeEvents(node, 'wall')
return <mesh ref={ref} {...events} />
useNodeEvents converts R3F ThreeEvent into a NodeEvent and emits wall:click, wall:enter, etc. It suppresses events while the camera is dragging.
Listening
Listen in a useEffect. Always clean up with emitter.off using the same function reference:
// Single event
useEffect(() => {
const handler = (e: WallEvent) => { /* … */ }
emitter.on('wall:click', handler)
return () => emitter.off('wall:click', handler)
}, [])
// Multiple node types, same handler
useEffect(() => {
const types = ['wall', 'slab', 'door'] as const
const handler = (e: NodeEvent) => { /* … */ }
types.forEach(t => emitter.on(`${t}:click`, handler as any))
return () => types.forEach(t => emitter.off(`${t}:click`, handler as any))
}, [])
See apps/editor/components/editor/selection-manager.tsx for a full multi-type listener example.
Rules
- Renderers only emit, never listen. Listening belongs in selection managers, tools, or systems.
- Always clean up. Forgetting
emitter.offcauses duplicate handlers and memory leaks. - Use the same function reference for
onandoff. Anonymous functions insideuseEffectare fine as long as the ref is captured in the same scope. - Don't use emitter for state. It's for one-shot interaction events. Persistent state goes in
useScene,useViewer, oruseEditor. stopPropagationprevents the event from being handled by overlapping listeners (e.g. a door on a wall). Call it when a handler should be the final consumer.