This is a strictly technical document that details the event model implementation, based on GraphLayer and EventedComponent. It explains capturing choices, click emulation, and subtle behaviors that affect reliability and performance. The document also covers how layers with CSS pointer-events other than none impact event delivery.
See also: docs/system/events.md for a high-level overview.
- Graph — central event emitter at the graph level, with
emitand event types defined insrc/graphEvents.ts. - GraphLayer — the layer that:
- Subscribes to DOM events on the graph
rootelement. - Performs hit‑testing in camera space to determine the target component.
- Emits graph‑level events (
graph.emit) and delegates DOM events into the canvas component tree.
- Subscribes to DOM events on the graph
- EventedComponent — base component class that supports subscriptions and dispatch with custom bubbling over the component tree.
- A DOM event arrives at
root. GraphLayerintercepts it (some events via capturing), recomputes the current target from camera‑space coordinates, and updates gesture state (e.g., whether a button is pressed).GraphLayermay first emit a graph event viagraph.emit(eventName, { target, sourceEvent, pointerPressed }).- If any graph handler calls
event.preventDefault(), delegation into components is aborted (seedispatchNativeEvent).
- If any graph handler calls
- If not prevented,
GraphLayerdelegates the original or a synthetic event into the canvas component tree viatarget.dispatchEvent(event). EventedComponentprocesses the event, including custom bubbling to parents untilstopPropagationis invoked.
sequenceDiagram
participant U as User
participant R as root (DOM)
participant L as GraphLayer
participant G as Graph
participant T as Target Component
participant P as Parent Components
U->>R: mousedown (capture)
R->>L: mousedown (capture)
L->>G: emit("mousedown", detail)
alt prevented
L-->>R: preventDefault / stop delegation
else not prevented
L->>T: mousedown
T-->>P: bubble (if not stopped)
end
U->>R: mouseup (capture)
R->>L: mouseup (capture)
L->>G: emit("mouseup", detail)
L->>T: mouseup
U->>R: click (bubble)
R->>L: click (bubble)
L->>G: emit("click", detail)
alt prevented
L-->>R: stop delegation
else not prevented
L->>T: synthetic click to pointerStartTarget
T-->>P: bubble (if not stopped)
end
Key points from GraphLayer:
- Registers listeners on
root:- In bubbling phase:
mousedown,touchstart,mouseup,touchend,click,dblclick,contextmenu, andmousemove. - In capturing phase:
mousedown,touchstart,mouseup,touchend.
- In bubbling phase:
- Tracks
targetComponentandprevTargetComponent. - On
mousemove, when target changes, synthesizes hover semantics:- For the previous target:
mouseleave(non‑bubbling) andmouseout(bubbling). - For the new target:
mouseenter(non‑bubbling) andmouseover(bubbling).
- For the previous target:
- Tracks
pointerPressedand supports explicit capture viacaptureEvents/releaseCapture. - Emulates click and double click (
tryEmulateClick) to deliver them to the start target.
[mousemove]
target changed?
yes ->
prevTarget <- mouseleave (no bubble)
prevTarget <- mouseout (bubble)
newTarget <- mouseenter (no bubble)
newTarget <- mouseover (bubble)
mousedown/touchstart and mouseup/touchend are critical for gestures (drag, selection) and consistent state (pointerPressed). Capturing them provides:
- Guarantees seeing gesture start/end even with overlaying HTML in the same
root. - Ability to compute and lock the start target before other handlers mutate DOM, focus, or selection.
- Fewer races and target flicker on fast pointer motion.
click/dblclick/contextmenu are intentionally not captured (see the section on event selection).
EventedComponent implements a lightweight event model with custom bubbling up the component tree:
- Each component keeps a map of listeners by event type.
dispatchEvent(event):- If the component is not interactive (
interactive: false), it immediately starts bubbling without local delivery. - If local listeners exist for
event.type, they run, then bubbling continues unlessstopPropagationwas called. - If there are no local listeners but
event.bubbles === true, bubbling proceeds.
- If the component is not interactive (
- Bubbling is a linear walk to
parentuntil the overriddenstopPropagationis invoked.
Bottom line: local handlers do not consume events automatically; call stopPropagation explicitly to stop further propagation.
EventedComponent supports evented areas — sub-regions within a component that respond to events independently. During render(), a component calls eventedArea(fn, params) to register a rectangular area with its own event handlers and optional hit-test logic. When _fireEvent dispatches an event, it also checks all registered evented areas on the target component: if the last known hit-test coordinates fall within an area (via its onHitBox callback or a default AABB check), the area's matching handler is invoked.
Areas are cleared and rebuilt on every render cycle (willRender), so they always reflect the current visual state. See Canvas GraphComponent — Evented Areas for API details and examples.
Scenario for a typical left‑click where press and release occur nearly at the same spot:
mousedown(capturing):GraphLayersetspointerPressed = true.- Computes
targetComponent(or uses the captured target ifcaptureEventswas used). - Stores
pointerStartTargetand the DOM event aspointerStartEvent. - Proxies the native
mousedownto the target (after graph emit and default prevention check).
mouseup(capturing):GraphLayersetspointerPressed = false.- Verifies that:
- Current target equals the start target OR both are
BlockConnection(connections can be very close), and - Pointer delta
getEventDelta(pointerStartEvent, mouseup) < 3.
- Current target equals the start target OR both are
- If satisfied, marks
canEmulateClick = true. - Proxies
mouseupto the target.
click(bubbling):- If
canEmulateClick === true,GraphLayercreates a newMouseEvent("click")and DELIVERS it topointerStartTarget(not the current target!). - First, a graph event
clickis emitted; if any handler callspreventDefault(), delegation into components is skipped. - Otherwise, the event is dispatched into components (
EventedComponent.dispatchEvent).
- If
dblclickfollows the same logic and is delivered to the start target.
The click is intentionally bound to the start target, not to the current pointer target, to avoid misses from tiny pointer drift and to keep BlockConnection interactions reliable.
Down (capture) Up (capture) Click (bubble)
| | |
v v v
mark start ----> mark canEmulateClick? --> deliver synthetic click
\________ same target OR both BlockConnection _______/
AND getEventDelta(start, up) < 3
By default, the canvas layer is marked with no-pointer-events (pointer-events: none) to avoid becoming a DOM target that blocks other layers. What if a top layer uses pointer-events: auto?
mousedown/touchstartandmouseup/touchendwill still be observed byGraphLayerdue to capturing onroot. Even if the DOM target is an HTML overlay, the event traversesrootduring capture.click/dblclick/contextmenuare subscribed in bubbling. If a top layer callsstopPropagation()during capture or bubble,GraphLayermay never receive them and thus won’t delegate them into canvas components. Gestures still work (down/up are captured), but component clicks may be lost.- If a top layer uses portals with handlers outside the graph
root, events might never enter therootsubtree — thenGraphLayerwon't see them.
Recommendations:
- For overlays, keep
pointer-events: noneby default and enablepointer-events: autoonly where DOM interactivity is required. - Avoid unconditional
stopPropagation()on top layers if the graph must continue receivingclick/dblclick/contextmenu. - If you need to block clicks for the graph, do it deliberately and locally.
Captured in GraphLayer: mousedown, touchstart, mouseup, touchend.
Rationale:
- Gesture lifecycle — press and release bound the majority of interactions (drag, selection). They must be observed early and reliably.
- State consistency — both
pointerPressedand the start target must be established before third‑party handlers run. - Performance and semantics —
mousemoveis high‑frequency; capturing it brings little value. Target changes and hover semantics are already handled correctly with a regularmousemovelistener. - Click/dblclick are not captured because:
- Clicks are emulated (
tryEmulateClick) from the already captured down/up. - It is often desirable for top overlays to handle clicks first and optionally block them for the graph.
- Clicks are emulated (
Before delegating a DOM event into components, GraphLayer emits a graph event (e.g., click, mousedown, mouseenter). If any graph handler calls preventDefault(), GraphLayer calls event.preventDefault() on the original event and does NOT delegate into components. This enables centralized control at the app level (e.g., disable clicks, intercept drag start).
- Use
Graphhandlers for cross‑component logic (selection, global hotkeys, cancellations). - Use
EventedComponenthandlers for local component behavior. - Stop propagation deliberately: at the
Graphlevel viapreventDefault(to block delegation), at components viastopPropagation. - When designing HTML overlays, validate how
pointer-eventsandstopPropagationaffectclick/dblclick/contextmenudelivery.
- Capture only
mousedown/touchstartandmouseup/touchendto ensure gesture boundaries and consistent state. - Click is emulated and delivered to the start target (
pointerStartTarget) if pointer drift is small. Graph.emitcan prevent further delegation into components viapreventDefault().- Top layers with
pointer-events: autocan swallowclick/dblclick/contextmenu; avoid unconditionalstopPropagationor allow events to reach the graphroot.