Skip to content

Commit add1fa7

Browse files
authored
fix: fix events bubbling (#157)
1 parent f4bdb08 commit add1fa7

File tree

5 files changed

+209
-70
lines changed

5 files changed

+209
-70
lines changed

docs/system/event-model.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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+

src/components/canvas/EventedComponent/EventedComponent.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,9 @@ export class EventedComponent<
9191
return this._dipping(this, event);
9292
} else if (this._hasListener(this, event.type)) {
9393
this._fireEvent(this, event);
94-
if (event.bubbles) {
95-
return this._dipping(this, event);
96-
}
97-
return false;
94+
return this._dipping(this, event);
95+
} else if (event.bubbles) {
96+
return this._dipping(this, event);
9897
}
9998
return false;
10099
}

src/components/canvas/layers/graphLayer/GraphLayer.ts

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ export class GraphLayer extends Layer<TGraphLayerProps, TGraphLayerContext> {
121121
private attachListeners() {
122122
if (!this.root) return;
123123

124-
rootBubblingEventTypes.forEach((type) => this.onRootEvent(type as keyof HTMLElementEventMap, this));
124+
rootBubblingEventTypes.forEach((type) =>
125+
this.onRootEvent(type as keyof HTMLElementEventMap, this, { capture: true })
126+
);
125127
rootCapturingEventTypes.forEach((type) =>
126128
this.onRootEvent(type as keyof HTMLElementEventMap, this, { capture: true })
127129
);
@@ -148,35 +150,22 @@ export class GraphLayer extends Layer<TGraphLayerProps, TGraphLayerContext> {
148150
this.onRootPointerMove(e as MouseEvent);
149151
return;
150152
}
151-
152-
if (e.eventPhase === Event.CAPTURING_PHASE && rootCapturingEventTypes.has(e.type)) {
153-
this.tryEmulateClick(e as MouseEvent);
154-
return;
155-
}
156-
157-
if (
158-
(e.eventPhase === Event.AT_TARGET || e.eventPhase === Event.BUBBLING_PHASE) &&
159-
rootBubblingEventTypes.has(e.type)
160-
) {
161-
switch (e.type) {
162-
case "mousedown":
163-
case "touchstart": {
164-
this.updateTargetComponent(e as MouseEvent, true);
165-
this.handleMouseDownEvent(e as MouseEvent);
166-
break;
167-
}
168-
case "mouseup":
169-
case "touchend": {
170-
this.onRootPointerEnd(e as MouseEvent);
171-
break;
172-
}
173-
case "click":
174-
case "dblclick": {
175-
this.tryEmulateClick(e as MouseEvent);
176-
break;
177-
}
153+
switch (e.type) {
154+
case "mousedown":
155+
case "touchstart": {
156+
this.updateTargetComponent(e as MouseEvent, true);
157+
this.handleMouseDownEvent(e as MouseEvent);
158+
break;
159+
}
160+
case "mouseup":
161+
case "touchend": {
162+
this.onRootPointerEnd(e as MouseEvent);
163+
break;
178164
}
179165
}
166+
this.tryEmulateClick(e as MouseEvent);
167+
168+
return;
180169
}
181170

182171
private dispatchNativeEvent(

src/react-components/Block.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
/* Creates a composite layer */
1313
backface-visibility: hidden;
1414
transform-style: preserve-3d;
15+
pointer-events: all;
1516
}
1617

1718
.graph-block-wrapper {

src/stories/Playground/GraphPlayground.tsx

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,8 @@ import { TBlock } from "../../components/canvas/blocks/Block";
1414
import { random } from "../../components/canvas/blocks/generate";
1515
import { ConnectionLayer } from "../../components/canvas/layers/connectionLayer/ConnectionLayer";
1616
import { Graph, GraphState, TGraphConfig } from "../../graph";
17-
import { TComponentState } from "../../lib/Component";
18-
import {
19-
GraphBlock,
20-
GraphCanvas,
21-
GraphLayer,
22-
GraphPortal,
23-
HookGraphParams,
24-
useGraph,
25-
useGraphEvent,
26-
useLayer,
27-
} from "../../react-components";
17+
import { GraphBlock, GraphCanvas, HookGraphParams, useGraph, useGraphEvent, useLayer } from "../../react-components";
2818
import { useFn } from "../../react-components/utils/hooks/useFn";
29-
import { Layer, LayerContext, LayerProps } from "../../services/Layer";
3019
import { ECanChangeBlockGeometry } from "../../store/settings";
3120
import { EAnchorType } from "../configurations/definitions";
3221

@@ -342,14 +331,7 @@ export function GraphPLayground() {
342331
<Toolbox graph={graph} className="graph-tools-zoom button-group" />
343332
<GraphSettings className="graph-tools-settings" graph={graph} />
344333
</Flex>
345-
<GraphCanvas graph={graph} renderBlock={renderBlockFn}>
346-
<GraphPortal className="no-pointer-events" zIndex={1} transformByCameraPosition={true}>
347-
<div>
348-
<h1>DevTools</h1>
349-
</div>
350-
</GraphPortal>
351-
<GraphLayer layer={SomeLayer} />
352-
</GraphCanvas>
334+
<GraphCanvas graph={graph} renderBlock={renderBlockFn} />
353335
</Flex>
354336
</Flex>
355337
<Flex direction="column" grow={1} className="content" gap={6}>
@@ -369,22 +351,4 @@ export function GraphPLayground() {
369351
);
370352
}
371353

372-
class SomeLayer extends Layer<LayerProps, LayerContext, TComponentState> {
373-
constructor(props: LayerProps) {
374-
super({
375-
canvas: {
376-
zIndex: 100,
377-
transformByCameraPosition: true,
378-
},
379-
...props,
380-
});
381-
}
382-
383-
protected render(): void {
384-
super.render();
385-
this.context.ctx.fillStyle = "red";
386-
this.context.ctx.fillRect(0, 0, 100, 100);
387-
}
388-
}
389-
390354
export const Default: StoryFn = () => <GraphPLayground />;

0 commit comments

Comments
 (0)