|
| 1 | +## Event Model in @gravity-ui/graph |
| 2 | + |
| 3 | +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. |
| 4 | + |
| 5 | +See also: `docs/system/events.md` for a high-level overview. |
| 6 | + |
| 7 | +### Terms and Actors |
| 8 | + |
| 9 | +- **Graph** — central event emitter at the graph level, with `emit` and event types defined in `src/graphEvents.ts`. |
| 10 | +- **GraphLayer** — the layer that: |
| 11 | + - Subscribes to DOM events on the graph `root` element. |
| 12 | + - Performs hit‑testing in camera space to determine the target component. |
| 13 | + - Emits graph‑level events (`graph.emit`) and delegates DOM events into the canvas component tree. |
| 14 | +- **EventedComponent** — base component class that supports subscriptions and dispatch with custom bubbling over the component tree. |
| 15 | + |
| 16 | +### Event Flow (high level) |
| 17 | + |
| 18 | +1. A DOM event arrives at `root`. |
| 19 | +2. `GraphLayer` intercepts it (some events via capturing), recomputes the current target from camera‑space coordinates, and updates gesture state (e.g., whether a button is pressed). |
| 20 | +3. `GraphLayer` may first emit a graph event via `graph.emit(eventName, { target, sourceEvent, pointerPressed })`. |
| 21 | + - If any graph handler calls `event.preventDefault()`, delegation into components is aborted (see `dispatchNativeEvent`). |
| 22 | +4. If not prevented, `GraphLayer` delegates the original or a synthetic event into the canvas component tree via `target.dispatchEvent(event)`. |
| 23 | +5. `EventedComponent` processes the event, including custom bubbling to parents until `stopPropagation` is invoked. |
| 24 | + |
| 25 | +```mermaid |
| 26 | +sequenceDiagram |
| 27 | + participant U as User |
| 28 | + participant R as root (DOM) |
| 29 | + participant L as GraphLayer |
| 30 | + participant G as Graph |
| 31 | + participant T as Target Component |
| 32 | + participant P as Parent Components |
| 33 | +
|
| 34 | + U->>R: mousedown (capture) |
| 35 | + R->>L: mousedown (capture) |
| 36 | + L->>G: emit("mousedown", detail) |
| 37 | + alt prevented |
| 38 | + L-->>R: preventDefault / stop delegation |
| 39 | + else not prevented |
| 40 | + L->>T: mousedown |
| 41 | + T-->>P: bubble (if not stopped) |
| 42 | + end |
| 43 | +
|
| 44 | + U->>R: mouseup (capture) |
| 45 | + R->>L: mouseup (capture) |
| 46 | + L->>G: emit("mouseup", detail) |
| 47 | + L->>T: mouseup |
| 48 | +
|
| 49 | + U->>R: click (bubble) |
| 50 | + R->>L: click (bubble) |
| 51 | + L->>G: emit("click", detail) |
| 52 | + alt prevented |
| 53 | + L-->>R: stop delegation |
| 54 | + else not prevented |
| 55 | + L->>T: synthetic click to pointerStartTarget |
| 56 | + T-->>P: bubble (if not stopped) |
| 57 | + end |
| 58 | +``` |
| 59 | + |
| 60 | +### Role of GraphLayer: what the layer actually does |
| 61 | + |
| 62 | +Key points from `GraphLayer`: |
| 63 | + |
| 64 | +- Registers listeners on `root`: |
| 65 | + - In bubbling phase: `mousedown`, `touchstart`, `mouseup`, `touchend`, `click`, `dblclick`, `contextmenu`, and `mousemove`. |
| 66 | + - In capturing phase: `mousedown`, `touchstart`, `mouseup`, `touchend`. |
| 67 | +- Tracks `targetComponent` and `prevTargetComponent`. |
| 68 | +- On `mousemove`, when target changes, synthesizes hover semantics: |
| 69 | + - For the previous target: `mouseleave` (non‑bubbling) and `mouseout` (bubbling). |
| 70 | + - For the new target: `mouseenter` (non‑bubbling) and `mouseover` (bubbling). |
| 71 | +- Tracks `pointerPressed` and supports explicit capture via `captureEvents/releaseCapture`. |
| 72 | +- Emulates click and double click (`tryEmulateClick`) to deliver them to the start target. |
| 73 | + |
| 74 | +``` |
| 75 | +[mousemove] |
| 76 | + target changed? |
| 77 | + yes -> |
| 78 | + prevTarget <- mouseleave (no bubble) |
| 79 | + prevTarget <- mouseout (bubble) |
| 80 | + newTarget <- mouseenter (no bubble) |
| 81 | + newTarget <- mouseover (bubble) |
| 82 | +``` |
| 83 | + |
| 84 | +#### Why capturing is needed in GraphLayer |
| 85 | + |
| 86 | +`mousedown/touchstart` and `mouseup/touchend` are critical for gestures (drag, selection) and consistent state (`pointerPressed`). Capturing them provides: |
| 87 | + |
| 88 | +- Guarantees seeing gesture start/end even with overlaying HTML in the same `root`. |
| 89 | +- Ability to compute and lock the start target before other handlers mutate DOM, focus, or selection. |
| 90 | +- Fewer races and target flicker on fast pointer motion. |
| 91 | + |
| 92 | +`click/dblclick/contextmenu` are intentionally not captured (see the section on event selection). |
| 93 | + |
| 94 | +### EventedComponent model: internal dispatch |
| 95 | + |
| 96 | +`EventedComponent` implements a lightweight event model with custom bubbling up the component tree: |
| 97 | + |
| 98 | +- Each component keeps a map of listeners by event type. |
| 99 | +- `dispatchEvent(event)`: |
| 100 | + - If the component is not interactive (`interactive: false`), it immediately starts bubbling without local delivery. |
| 101 | + - If local listeners exist for `event.type`, they run, then bubbling continues unless `stopPropagation` was called. |
| 102 | + - If there are no local listeners but `event.bubbles === true`, bubbling proceeds. |
| 103 | +- Bubbling is a linear walk to `parent` until the overridden `stopPropagation` is invoked. |
| 104 | + |
| 105 | +Bottom line: local handlers do not consume events automatically; call `stopPropagation` explicitly to stop further propagation. |
| 106 | + |
| 107 | +### How a click on an element works (step by step) |
| 108 | + |
| 109 | +Scenario for a typical left‑click where press and release occur nearly at the same spot: |
| 110 | + |
| 111 | +1. `mousedown` (capturing): |
| 112 | + - `GraphLayer` sets `pointerPressed = true`. |
| 113 | + - Computes `targetComponent` (or uses the captured target if `captureEvents` was used). |
| 114 | + - Stores `pointerStartTarget` and the DOM event as `pointerStartEvent`. |
| 115 | + - Proxies the native `mousedown` to the target (after graph emit and default prevention check). |
| 116 | +2. `mouseup` (capturing): |
| 117 | + - `GraphLayer` sets `pointerPressed = false`. |
| 118 | + - Verifies that: |
| 119 | + - Current target equals the start target OR both are `BlockConnection` (connections can be very close), and |
| 120 | + - Pointer delta `getEventDelta(pointerStartEvent, mouseup) < 3`. |
| 121 | + - If satisfied, marks `canEmulateClick = true`. |
| 122 | + - Proxies `mouseup` to the target. |
| 123 | +3. `click` (bubbling): |
| 124 | + - If `canEmulateClick === true`, `GraphLayer` creates a new `MouseEvent("click")` and DELIVERS it to `pointerStartTarget` (not the current target!). |
| 125 | + - First, a graph event `click` is emitted; if any handler calls `preventDefault()`, delegation into components is skipped. |
| 126 | + - Otherwise, the event is dispatched into components (`EventedComponent.dispatchEvent`). |
| 127 | +4. `dblclick` follows the same logic and is delivered to the start target. |
| 128 | + |
| 129 | +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. |
| 130 | + |
| 131 | +``` |
| 132 | +Down (capture) Up (capture) Click (bubble) |
| 133 | + | | | |
| 134 | + v v v |
| 135 | + mark start ----> mark canEmulateClick? --> deliver synthetic click |
| 136 | + \________ same target OR both BlockConnection _______/ |
| 137 | + AND getEventDelta(start, up) < 3 |
| 138 | +``` |
| 139 | + |
| 140 | +### Impact of layers with `pointer-events` ≠ `none` |
| 141 | + |
| 142 | +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`? |
| 143 | + |
| 144 | +- `mousedown/touchstart` and `mouseup/touchend` will still be observed by `GraphLayer` due to capturing on `root`. Even if the DOM target is an HTML overlay, the event traverses `root` during capture. |
| 145 | +- `click/dblclick/contextmenu` are subscribed in bubbling. If a top layer calls `stopPropagation()` during capture or bubble, `GraphLayer` may 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. |
| 146 | +- If a top layer uses portals with handlers outside the graph `root`, events might never enter the `root` subtree — then `GraphLayer` won't see them. |
| 147 | + |
| 148 | +Recommendations: |
| 149 | + |
| 150 | +- For overlays, keep `pointer-events: none` by default and enable `pointer-events: auto` only where DOM interactivity is required. |
| 151 | +- Avoid unconditional `stopPropagation()` on top layers if the graph must continue receiving `click/dblclick/contextmenu`. |
| 152 | +- If you need to block clicks for the graph, do it deliberately and locally. |
| 153 | + |
| 154 | +### Why these events are captured (and others are not) |
| 155 | + |
| 156 | +Captured in `GraphLayer`: `mousedown`, `touchstart`, `mouseup`, `touchend`. |
| 157 | + |
| 158 | +Rationale: |
| 159 | + |
| 160 | +- **Gesture lifecycle** — press and release bound the majority of interactions (drag, selection). They must be observed early and reliably. |
| 161 | +- **State consistency** — both `pointerPressed` and the start target must be established before third‑party handlers run. |
| 162 | +- **Performance and semantics** — `mousemove` is high‑frequency; capturing it brings little value. Target changes and hover semantics are already handled correctly with a regular `mousemove` listener. |
| 163 | +- **Click/dblclick** are not captured because: |
| 164 | + - Clicks are emulated (`tryEmulateClick`) from the already captured down/up. |
| 165 | + - It is often desirable for top overlays to handle clicks first and optionally block them for the graph. |
| 166 | + |
| 167 | +### Interaction between graph events and component delegation |
| 168 | + |
| 169 | +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). |
| 170 | + |
| 171 | +### Practical guidelines |
| 172 | + |
| 173 | +- Use `Graph` handlers for cross‑component logic (selection, global hotkeys, cancellations). |
| 174 | +- Use `EventedComponent` handlers for local component behavior. |
| 175 | +- Stop propagation deliberately: at the `Graph` level via `preventDefault` (to block delegation), at components via `stopPropagation`. |
| 176 | +- When designing HTML overlays, validate how `pointer-events` and `stopPropagation` affect `click/dblclick/contextmenu` delivery. |
| 177 | + |
| 178 | +### TL;DR |
| 179 | + |
| 180 | +- Capture only `mousedown/touchstart` and `mouseup/touchend` to ensure gesture boundaries and consistent state. |
| 181 | +- Click is emulated and delivered to the start target (`pointerStartTarget`) if pointer drift is small. |
| 182 | +- `Graph.emit` can prevent further delegation into components via `preventDefault()`. |
| 183 | +- Top layers with `pointer-events: auto` can swallow `click/dblclick/contextmenu`; avoid unconditional `stopPropagation` or allow events to reach the graph `root`. |
| 184 | + |
| 185 | + |
| 186 | + |
0 commit comments