fix(ui): keep paper host out of React-managed DOM; fix duplicate stroke-width in node SVGs

Paper.remove() during dispose deleted the React canvas div (StrictMode double
mount left a blank canvas). Duplicate XML attributes made 5 symbol data URIs
unparseable.

Also: README, Playwright smoke test (e2e/smoke.mjs), ES2022 lib.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:30:46 +02:00
parent 2701b4e198
commit cd937cf4ac
10 changed files with 315 additions and 13 deletions

71
README.md Normal file
View File

@ -0,0 +1,71 @@
# Pipeline Diagram Editor
Desktop editor for pipeline / utility network diagrams (water, heating, gas,
sewage, power, signal) built with **Tauri 2**, **React + TypeScript + Vite**
and **JointJS core** (SVG diagramming, MPL-2.0).
![stack](https://img.shields.io/badge/stack-Tauri%202%20·%20React%2018%20·%20JointJS%204-blue)
## Features
- **Node palette** (left): equipment grouped by category with SVG previews;
drag onto the canvas or double-click to place. Search filter included.
- **Ports & relations**: click a node to reveal its ports. Each port carries a
relation (water / heat / gas / sewage / power / signal); edges can only be
drawn between compatible ports — compatible targets highlight green while
dragging. Direction rules forbid out→out / in→in connections.
- **Grid routing**: edges are routed orthogonally along the rectangular grid
(Manhattan router). A configuration option draws an **arc (jumpover) at
line intersections**. Router, grid size, snapping — all configurable.
- **Edge styles**: solid/dashed/dotted/dash-dot lines, width, color override,
head & tail markers (arrow, circle, diamond, bar, none).
- **Property panel** (right): grouped properties of the focused node/edge —
General, Presentation (geometry, colors, label), Physics (length, diameter,
flow rate, flow type, materials…), Simulation results (read-only). Property
types: `string`, `int`, `float`, `enum`, `catalogueItem`, `catalogueItems`,
`color`, `listOfValues` — all with validation.
- **Catalogues**: pipe diameter series (DN), materials, media, insulation.
- **Color schemes**: Classic, Dark, High contrast, Monochrome — recolor ports,
edges, node symbols, canvas and grid.
- **Editing UX**: multi-select (shift-click / shift-drag rubber band), group
dragging, copy/paste/duplicate, delete with edge cleanup, undo/redo,
arrow-key nudge, select-all, zoom to fit / reset, pan (drag empty space),
wheel zoom, edge vertex editing.
- **Mock flow simulation**: sources (supply) and consumers (demand) are
balanced across the network; per-edge flow and direction are written into
read-only properties, edges animate with moving dashes whose **speed and
width are proportional to flow volume**, labels show m³/h. Insufficient
supply scales deliveries; unreachable consumers are reported.
- **Persistence**: save/load diagram JSON and export SVG via native dialogs
(Tauri) with browser-download fallback in web dev mode.
## Development
```bash
npm install # frontend deps
npm run dev # web dev server on :1420 (browser fallback mode)
npm run tauri:dev # full desktop app (requires webkit2gtk etc.)
npm test # vitest suite (unit + component + editor integration)
npm run build # typecheck + production frontend bundle
npm run tauri:build # desktop bundles (deb / AppImage)
node e2e/smoke.mjs # Playwright smoke test against the dev server
```
Linux system deps (Debian/Ubuntu): `libwebkit2gtk-4.1-dev librsvg2-dev
libayatana-appindicator3-dev libxdo-dev libssl-dev build-essential`.
## Architecture
```
src/model pure domain model (properties, catalogues, node types, graph, validation)
src/editor JointJS integration (shapes, paper controller, edge styles, color schemes)
src/sim mock flow solver + animation
src/state app store, history (undo/redo), persistence adapters
src/ui React panels (palette, property panel, toolbar, settings, canvas)
src-tauri Rust shell (dialog + fs plugins)
tests vitest suites; docs/plan/plan.md — implementation plan/checklist
```
The JointJS graph is the runtime source of truth; `EditorController`
converts it to the pure `PipelineGraph` document model for the solver,
save files and tests.

172
e2e/smoke.mjs Normal file
View File

@ -0,0 +1,172 @@
/**
* Playwright smoke test for typical editor scenarios, run against the Vite
* dev server: npm run dev node e2e/smoke.mjs
* (starts its own server if :1420 is free)
*
* Covers: app boot, palette quick-add, node selection + port display,
* property editing, port-to-port edge drawing with validation, node drag,
* simulation run with animated flow, undo, save config roundtrip absence
* of console errors.
*/
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
const URL = 'http://localhost:1420';
let devServer = null;
async function serverUp() {
try {
const res = await fetch(URL);
return res.ok;
} catch {
return false;
}
}
function fail(msg) {
console.error(`${msg}`);
process.exitCode = 1;
}
function ok(msg) {
console.log(`${msg}`);
}
async function ensureServer() {
if (await serverUp()) return;
devServer = spawn('npm', ['run', 'dev'], { stdio: 'ignore', detached: true });
for (let i = 0; i < 60; i += 1) {
if (await serverUp()) return;
await sleep(500);
}
throw new Error('dev server did not start');
}
const consoleErrors = [];
async function main() {
await ensureServer();
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1500, height: 950 } });
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text());
});
page.on('pageerror', (e) => consoleErrors.push(String(e)));
await page.goto(URL);
await page.waitForSelector('[data-testid="palette"]');
await page.waitForSelector('[data-testid="canvas"] svg');
ok('app boots: palette + canvas rendered');
// --- add nodes via palette double-click -------------------------------
await page.dblclick('[data-testid="palette-item-waterSource"]');
await page.dblclick('[data-testid="palette-item-consumer"]');
await sleep(300);
const nodeCount = await page.locator('.joint-element').count();
if (nodeCount === 2) ok('palette quick-add creates nodes');
else fail(`expected 2 nodes, got ${nodeCount}`);
// --- drag the consumer to the right so the diagram is spread out ------
const nodes = page.locator('.joint-element');
const consumerBox = await nodes.nth(1).boundingBox();
await page.mouse.move(consumerBox.x + consumerBox.width / 2, consumerBox.y + consumerBox.height / 2);
await page.mouse.down();
await page.mouse.move(consumerBox.x + 420, consumerBox.y + 60, { steps: 12 });
await page.mouse.up();
const movedBox = await nodes.nth(1).boundingBox();
if (Math.abs(movedBox.x - consumerBox.x) > 300) ok('node dragging works');
else fail(`node did not move (dx=${movedBox.x - consumerBox.x})`);
// --- select source: ports become visible ------------------------------
const srcBox = await nodes.nth(0).boundingBox();
await page.mouse.click(srcBox.x + srcBox.width / 2, srcBox.y + srcBox.height / 2);
await sleep(150);
const focused = await page.locator('.joint-element.pf-focused').count();
if (focused === 1) ok('clicking a node focuses it (ports shown)');
else fail('node focus class missing');
const panelText = await page.locator('[data-testid="property-panel"]').innerText();
if (/water source/i.test(panelText) && /physics/i.test(panelText)) {
ok('property panel shows grouped node properties');
} else fail(`property panel content unexpected: ${panelText.slice(0, 200).replace(/\n/g, ' | ')}`);
// --- edit title property ----------------------------------------------
await page.fill('[data-testid="prop-title"]', 'Main intake');
await page.press('[data-testid="prop-title"]', 'Enter');
await sleep(300);
const labels = await page.evaluate(() =>
[...document.querySelectorAll('.joint-element text')].map((t) => t.textContent),
);
if (labels.some((l) => l && l.includes('Main intake'))) ok('editing title updates canvas label');
else fail(`canvas labels: ${JSON.stringify(labels)}`);
// --- draw an edge from source out-port to consumer waterIn port --------
// Locate the actual port circles: source is focused so its ports are active.
const portRects = await page.evaluate(() => {
const rect = (el) => {
const r = el.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
};
const out = document.querySelector('.joint-element.pf-focused [port="out"]');
const elements = [...document.querySelectorAll('.joint-element')];
const consumer = elements.find((e) => e.querySelector('[port="waterIn"]'));
const waterIn = consumer?.querySelector('[port="waterIn"]');
return out && waterIn ? { out: rect(out), waterIn: rect(waterIn) } : null;
});
let linkCount = 0;
if (!portRects) {
fail('could not locate port magnets');
} else {
await page.mouse.move(portRects.out.x, portRects.out.y);
await page.mouse.down();
await page.mouse.move(portRects.waterIn.x, portRects.waterIn.y, { steps: 15 });
await sleep(150);
await page.mouse.up();
await sleep(300);
linkCount = await page.locator('.joint-link').count();
if (linkCount >= 1) ok('edge drawn between compatible ports');
else fail('no edge was created by port drag');
}
// --- simulation ---------------------------------------------------------
await page.click('[data-testid="simulate-button"]');
await sleep(400);
const summary = await page.locator('[data-testid="sim-summary"]').count();
if (linkCount >= 1 && summary === 1) {
const text = await page.locator('[data-testid="sim-summary"]').innerText();
ok(`simulation runs: ${text.trim()}`);
const dash = await page.evaluate(() => {
const path = document.querySelector('.joint-link path[joint-selector="line"]');
return path ? path.getAttribute('stroke-dasharray') : null;
});
if (dash) ok('flow animation applied to edges (dasharray set)');
else fail('no dasharray on edge during simulation');
} else if (linkCount === 0) {
console.log('… skipping simulation assertions (no edge)');
} else fail('simulation summary missing');
await page.click('[data-testid="simulate-button"]'); // stop
// --- undo ---------------------------------------------------------------
await page.keyboard.press('Control+z');
await sleep(300);
ok('undo executes without errors');
await page.screenshot({ path: 'e2e/smoke.png', fullPage: true });
ok('screenshot saved to e2e/smoke.png');
const realErrors = consoleErrors.filter((e) => !e.includes('favicon'));
if (realErrors.length === 0) ok('no console errors');
else fail(`console errors:\n${realErrors.join('\n')}`);
await browser.close();
if (devServer) process.kill(-devServer.pid);
console.log(process.exitCode ? 'SMOKE TEST FAILED' : 'SMOKE TEST PASSED');
}
main().catch((e) => {
console.error(e);
if (devServer) process.kill(-devServer.pid);
process.exit(1);
});

BIN
e2e/smoke.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

48
package-lock.json generated
View File

@ -23,6 +23,7 @@
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^4.4.1",
"jsdom": "^26.1.0",
"playwright": "^1.61.1",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.1.3"
@ -2635,6 +2636,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",

View File

@ -30,6 +30,7 @@
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^4.4.1",
"jsdom": "^26.1.0",
"playwright": "^1.61.1",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.1.3"

View File

@ -85,6 +85,8 @@ const pipePhysics: PropertyDef[] = [
const S = 'stroke="currentColor" stroke-width="2.5" fill="none"';
const SF = 'stroke="currentColor" stroke-width="2.5" fill="#fff"';
/** Stroke with explicit width — S/SF must not be combined with another stroke-width (duplicate XML attributes break SVG data URIs). */
const SW = (w: number) => `stroke="currentColor" stroke-width="${w}" fill="none"`;
function def(
partial: Omit<NodeTypeDef, 'properties'> & { extraProps?: PropertyDef[]; titleDefault?: string },
@ -134,7 +136,7 @@ export const NODE_TYPES: NodeTypeDef[] = [
width: 70,
height: 70,
simRole: 'junction',
svg: `<rect x="10" y="6" width="50" height="58" rx="8" ${SF}/><path d="M35 20 c-6 9-8 13-8 17a8 8 0 0 0 16 0c0-4-2-8-8-17Z" ${S} stroke-width="2"/>`,
svg: `<rect x="10" y="6" width="50" height="58" rx="8" ${SF}/><path d="M35 20 c-6 9-8 13-8 17a8 8 0 0 0 16 0c0-4-2-8-8-17Z" ${SW(2)}/>`,
ports: [
{ id: 'gasIn', relation: 'gas', direction: 'in', x: 0, y: 0.75, label: 'Gas' },
{ id: 'heatOut', relation: 'heat', direction: 'out', x: 1, y: 0.25, label: 'Flow' },
@ -193,7 +195,7 @@ export const NODE_TYPES: NodeTypeDef[] = [
width: 70,
height: 60,
simRole: 'junction',
svg: `<rect x="6" y="10" width="58" height="40" rx="6" ${SF}/><path d="M6 30 h14 l8 -12 l12 24 l12 -24 l8 12 h4" ${S} stroke-width="2"/>`,
svg: `<rect x="6" y="10" width="58" height="40" rx="6" ${SF}/><path d="M6 30 h14 l8 -12 l12 24 l12 -24 l8 12 h4" ${SW(2)}/>`,
ports: [
{ id: 'primaryIn', relation: 'heat', direction: 'in', x: 0, y: 0.25, label: 'Primary in' },
{ id: 'primaryOut', relation: 'heat', direction: 'out', x: 0, y: 0.75, label: 'Primary out' },
@ -251,7 +253,7 @@ export const NODE_TYPES: NodeTypeDef[] = [
width: 60,
height: 60,
simRole: 'junction',
svg: `<path d="M6 30 h48 M30 30 v24" ${S} stroke-width="5"/><circle cx="30" cy="30" r="5" fill="currentColor" stroke="none"/>`,
svg: `<path d="M6 30 h48 M30 30 v24" ${SW(5)}/><circle cx="30" cy="30" r="5" fill="currentColor" stroke="none"/>`,
ports: [
{ id: 'a', relation: 'water', direction: 'any', x: 0, y: 0.5, label: 'A' },
{ id: 'b', relation: 'water', direction: 'any', x: 1, y: 0.5, label: 'B' },
@ -285,7 +287,7 @@ export const NODE_TYPES: NodeTypeDef[] = [
width: 70,
height: 80,
simRole: 'junction',
svg: `<path d="M10 14 a25 10 0 0 1 50 0 v50 a25 10 0 0 1 -50 0 Z" ${SF}/><ellipse cx="35" cy="14" rx="25" ry="10" ${S} stroke-width="2"/><path d="M14 46 h42" ${S} stroke-width="1.5" stroke-dasharray="4 3"/>`,
svg: `<path d="M10 14 a25 10 0 0 1 50 0 v50 a25 10 0 0 1 -50 0 Z" ${SF}/><ellipse cx="35" cy="14" rx="25" ry="10" ${SW(2)}/><path d="M14 46 h42" ${SW(1.5)} stroke-dasharray="4 3"/>`,
ports: [
{ id: 'in', relation: 'water', direction: 'in', x: 0, y: 0.3, label: 'Inlet' },
{ id: 'out', relation: 'water', direction: 'out', x: 1, y: 0.8, label: 'Outlet' },
@ -326,7 +328,7 @@ export const NODE_TYPES: NodeTypeDef[] = [
width: 70,
height: 50,
simRole: 'consumer',
svg: `<rect x="6" y="8" width="58" height="34" rx="4" ${SF}/><path d="M16 8 v34 M26 8 v34 M36 8 v34 M46 8 v34 M56 8 v34" ${S} stroke-width="1.5"/>`,
svg: `<rect x="6" y="8" width="58" height="34" rx="4" ${SF}/><path d="M16 8 v34 M26 8 v34 M36 8 v34 M46 8 v34 M56 8 v34" ${SW(1.5)}/>`,
ports: [
{ id: 'in', relation: 'heat', direction: 'in', x: 0, y: 0.5, label: 'Flow' },
{ id: 'out', relation: 'heat', direction: 'out', x: 1, y: 0.5, label: 'Return' },

View File

@ -19,7 +19,13 @@ export function Canvas({ onReady }: CanvasProps) {
useEffect(() => {
const host = hostRef.current;
if (!host || controllerRef.current) return;
const controller = new EditorController(host, getState().config, {
// The paper gets its own child div: Paper.remove() (called in dispose)
// removes its el from the DOM, and it must never be the React-managed one.
const paperHost = document.createElement('div');
paperHost.style.width = '100%';
paperHost.style.height = '100%';
host.appendChild(paperHost);
const controller = new EditorController(paperHost, getState().config, {
onSelectionChange: (selection) => setState({ selection }),
onGraphChange: () => bumpGraphVersion(),
onHistoryChange: (canUndo, canRedo) => setState({ canUndo, canRedo }),
@ -29,6 +35,7 @@ export function Canvas({ onReady }: CanvasProps) {
onReady(controller);
return () => {
controller.dispose();
paperHost.remove();
controllerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -95,12 +95,12 @@ describe('defaults and grouping', () => {
it('groups defs in canonical order', () => {
const defs: PropertyDef[] = [
def({ key: 'a', group: 'physics' }),
def({ key: 'b', group: 'general' }),
def({ key: 'c', group: 'presentation' }),
def({ key: 'd', group: 'physics' }),
def({ key: 'a', group: 'physics', type: 'string' }),
def({ key: 'b', group: 'general', type: 'string' }),
def({ key: 'c', group: 'presentation', type: 'string' }),
def({ key: 'd', group: 'physics', type: 'string' }),
];
const groups = groupDefs(defs.map((d) => ({ ...d, type: 'string' as const })));
const groups = groupDefs(defs);
expect(groups.map((g) => g.group.id)).toEqual(['general', 'presentation', 'physics']);
expect(groups[2].defs.map((d) => d.key)).toEqual(['a', 'd']);
});

View File

@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2021",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",

1
tsconfig.tsbuildinfo Normal file
View File

@ -0,0 +1 @@
{"root":["./src/main.tsx","./src/editor/colorSchemes.ts","./src/editor/edgeStyles.ts","./src/editor/paper.ts","./src/editor/shapes.ts","./src/model/catalog.ts","./src/model/graph.ts","./src/model/nodeTypes.ts","./src/model/properties.ts","./src/model/validation.ts","./src/sim/flow.ts","./src/state/history.ts","./src/state/persistence.ts","./src/state/store.ts","./src/ui/App.tsx","./src/ui/Canvas.tsx","./src/ui/Palette.tsx","./src/ui/PropertyPanel.tsx","./src/ui/SettingsDialog.tsx","./src/ui/Toolbar.tsx","./tests/catalog.test.ts","./tests/components.test.tsx","./tests/edgeStyles.test.ts","./tests/editor.test.ts","./tests/flow.test.ts","./tests/graph.test.ts","./tests/history.test.ts","./tests/nodeTypes.test.ts","./tests/properties.test.ts","./tests/setup.ts","./tests/validation.test.ts"],"version":"5.9.3"}