Skip to content

Commit 474029e

Browse files
fix(library): stop a selected node's toolbar from blocking the node behind it (#791)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 43d2739 commit 474029e

3 files changed

Lines changed: 136 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/apollon": patch
3+
---
4+
5+
The edit/delete toolbar on a selected node no longer blocks clicks on a node behind it — its empty area now lets clicks through, and only the icons themselves stay clickable.

library/lib/components/toolbars/NodeToolbar.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export const NodeToolbar: FC<Props> = ({ elementId, showEdit = true }) => {
2626
position={Position.Top}
2727
align="end"
2828
offset={10}
29+
// The toolbar wrapper is larger than its two icons; left opaque to the
30+
// pointer, its empty margins and the gap between the icons swallow clicks
31+
// meant for whatever node sits beneath (the toolbar floats at the node's
32+
// top-right). Make the box transparent and re-enable only the icons, so
33+
// only the icons themselves capture — matching the edge toolbar.
34+
style={{ pointerEvents: "none" }}
2935
>
3036
<div
3137
className="nodrag nopan"
@@ -38,6 +44,7 @@ export const NodeToolbar: FC<Props> = ({ elementId, showEdit = true }) => {
3844
onClick={handleDelete}
3945
style={{
4046
cursor: "pointer",
47+
pointerEvents: "auto",
4148
width: 16,
4249
height: 16,
4350
color: "var(--apollon-primary-contrast, #000000)",
@@ -51,6 +58,7 @@ export const NodeToolbar: FC<Props> = ({ elementId, showEdit = true }) => {
5158
}}
5259
style={{
5360
cursor: "pointer",
61+
pointerEvents: "auto",
5462
width: 16,
5563
height: 16,
5664
color: "var(--apollon-primary-contrast, #000000)",
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { test, expect, type Page } from "@playwright/test"
2+
import { openFixtureInLocalEditor, waitForCanvasReady } from "../helpers/canvas"
3+
4+
/**
5+
* A selected node shows an edit/delete toolbar floating at its top-right. The
6+
* toolbar's box is larger than its two icons, so its empty margins (and the gap
7+
* between the icons) must NOT swallow clicks meant for a node sitting beneath
8+
* it — otherwise that node becomes unclickable. Only the icons themselves
9+
* should capture the pointer.
10+
*/
11+
12+
// Two class nodes: "Beta" covers "Alpha"'s top-right corner, exactly where
13+
// Alpha's toolbar appears once Alpha is selected.
14+
const OVERLAP_MODEL = {
15+
id: "e2e-toolbar-blocking",
16+
type: "ClassDiagram",
17+
version: "4.0.0",
18+
title: "toolbar blocking",
19+
assessments: {},
20+
edges: [],
21+
nodes: [
22+
{
23+
id: "alpha-0000-0000-0000-000000000001",
24+
type: "class",
25+
width: 200,
26+
height: 100,
27+
position: { x: 200, y: 400 },
28+
measured: { width: 200, height: 100 },
29+
data: { name: "Alpha", attributes: [], methods: [] },
30+
},
31+
{
32+
id: "beta-0000-0000-0000-0000000000002",
33+
type: "class",
34+
width: 300,
35+
height: 200,
36+
position: { x: 250, y: 250 },
37+
measured: { width: 300, height: 200 },
38+
data: { name: "Beta", attributes: [], methods: [] },
39+
},
40+
],
41+
}
42+
43+
const SINGLE_MODEL = {
44+
id: "e2e-toolbar-buttons",
45+
type: "ClassDiagram",
46+
version: "4.0.0",
47+
title: "toolbar buttons",
48+
assessments: {},
49+
edges: [],
50+
nodes: [
51+
{
52+
id: "solo-0000-0000-0000-0000000000001",
53+
type: "class",
54+
width: 200,
55+
height: 100,
56+
position: { x: 300, y: 300 },
57+
measured: { width: 200, height: 100 },
58+
data: { name: "Solo", attributes: [], methods: [] },
59+
},
60+
],
61+
}
62+
63+
const selectedNames = (page: Page) =>
64+
page.evaluate(() =>
65+
Array.from(document.querySelectorAll(".react-flow__node.selected")).map(
66+
(n) => (n.textContent || "").replace(/\s+/g, " ").trim().slice(0, 8)
67+
)
68+
)
69+
70+
test("the toolbar's empty area does not block the node beneath it", async ({
71+
page,
72+
}) => {
73+
await openFixtureInLocalEditor(page, OVERLAP_MODEL as Record<string, unknown>)
74+
await waitForCanvasReady(page)
75+
76+
const alpha = page.locator(".react-flow__node").filter({ hasText: "Alpha" })
77+
// Select Alpha via its bottom strip, which is clear of Beta.
78+
const aBox = await alpha.boundingBox()
79+
await page.mouse.click(aBox!.x + aBox!.width / 2, aBox!.y + aBox!.height - 8)
80+
await page.waitForTimeout(300)
81+
expect(await selectedNames(page)).toEqual(["Alpha"])
82+
83+
// A point inside the toolbar box but on no icon (its left margin), which sits
84+
// over Beta. Clicking it must reach Beta, not be eaten by the toolbar.
85+
const target = await page.evaluate(() => {
86+
const tb = document.querySelector<HTMLElement>(".react-flow__node-toolbar")!
87+
const r = tb.getBoundingClientRect()
88+
return { x: Math.round(r.x + 2), y: Math.round(r.y + r.height / 2) }
89+
})
90+
await page.mouse.click(target.x, target.y)
91+
await page.waitForTimeout(300)
92+
93+
expect(await selectedNames(page)).toEqual(["Beta"])
94+
})
95+
96+
test("the toolbar's edit and delete icons still work", async ({ page }) => {
97+
await openFixtureInLocalEditor(page, SINGLE_MODEL as Record<string, unknown>)
98+
await waitForCanvasReady(page)
99+
100+
const solo = page.locator(".react-flow__node").filter({ hasText: "Solo" })
101+
await solo.click()
102+
await page.waitForTimeout(300)
103+
expect(await selectedNames(page)).toEqual(["Solo"])
104+
105+
// The toolbar renders two lucide icons (svg): [0] = delete, [1] = edit.
106+
const icons = page.locator(".react-flow__node-toolbar svg")
107+
108+
// Edit (pencil) opens the popover.
109+
await icons.nth(1).click()
110+
await page.waitForTimeout(300)
111+
await expect(page.locator(".apollon-popover")).toHaveCount(1)
112+
113+
// Dismiss the popover, then delete (trash) removes the node.
114+
await page.keyboard.press("Escape")
115+
await page.waitForTimeout(200)
116+
await solo.click()
117+
await page.waitForTimeout(200)
118+
await icons.first().click()
119+
await page.waitForTimeout(300)
120+
await expect(
121+
page.locator(".react-flow__node").filter({ hasText: "Solo" })
122+
).toHaveCount(0)
123+
})

0 commit comments

Comments
 (0)