sdn-flow 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.claude/SKILLS.md +7 -0
  2. package/.claude/skills/sdn-plugin-abi-compliance/SKILL.md +56 -0
  3. package/.claude/todo/001-js-host-startup-and-deno.md +85 -0
  4. package/LICENSE +21 -0
  5. package/README.md +223 -0
  6. package/bin/sdn-flow-host.js +169 -0
  7. package/docs/.nojekyll +0 -0
  8. package/docs/ARCHITECTURE.md +200 -0
  9. package/docs/HOST_CAPABILITY_MODEL.md +317 -0
  10. package/docs/PLUGIN_ARCHITECTURE.md +145 -0
  11. package/docs/PLUGIN_COMPATIBILITY.md +61 -0
  12. package/docs/PLUGIN_COMPLIANCE_CHECKS.md +82 -0
  13. package/docs/PLUGIN_MANIFEST.md +94 -0
  14. package/docs/css/style.css +465 -0
  15. package/docs/index.html +218 -0
  16. package/docs/js/app.mjs +751 -0
  17. package/docs/js/editor-panel.mjs +203 -0
  18. package/docs/js/flow-canvas.mjs +515 -0
  19. package/docs/js/flow-model.mjs +391 -0
  20. package/docs/js/workers/emception.worker.js +146 -0
  21. package/docs/js/workers/pyodide.worker.js +134 -0
  22. package/native/flow_source_generator.cpp +1958 -0
  23. package/package.json +67 -0
  24. package/schemas/FlowRuntimeAbi.fbs +91 -0
  25. package/src/auth/canonicalize.js +5 -0
  26. package/src/auth/index.js +11 -0
  27. package/src/auth/permissions.js +8 -0
  28. package/src/compiler/CppFlowSourceGenerator.js +475 -0
  29. package/src/compiler/EmceptionCompilerAdapter.js +244 -0
  30. package/src/compiler/SignedArtifactCatalog.js +152 -0
  31. package/src/compiler/index.js +8 -0
  32. package/src/compiler/nativeFlowSourceGeneratorTool.js +144 -0
  33. package/src/compliance/index.js +13 -0
  34. package/src/compliance/pluginCompliance.js +11 -0
  35. package/src/deploy/FlowDeploymentClient.js +532 -0
  36. package/src/deploy/index.js +8 -0
  37. package/src/designer/FlowDesignerSession.js +158 -0
  38. package/src/designer/index.js +2 -0
  39. package/src/designer/requirements.js +184 -0
  40. package/src/generated/runtimeAbiLayouts.js +544 -0
  41. package/src/host/appHost.js +105 -0
  42. package/src/host/autoHost.js +113 -0
  43. package/src/host/browserHostAdapters.js +108 -0
  44. package/src/host/compiledFlowRuntimeHost.js +703 -0
  45. package/src/host/constants.js +55 -0
  46. package/src/host/dependencyRuntime.js +227 -0
  47. package/src/host/descriptorAbi.js +351 -0
  48. package/src/host/fetchService.js +237 -0
  49. package/src/host/httpHostAdapters.js +280 -0
  50. package/src/host/index.js +91 -0
  51. package/src/host/installedFlowHost.js +885 -0
  52. package/src/host/invocationAbi.js +440 -0
  53. package/src/host/normalize.js +372 -0
  54. package/src/host/packageManagers.js +369 -0
  55. package/src/host/profile.js +134 -0
  56. package/src/host/runtimeAbi.js +106 -0
  57. package/src/host/workspace.js +895 -0
  58. package/src/index.js +8 -0
  59. package/src/runtime/FlowRuntime.js +273 -0
  60. package/src/runtime/MethodRegistry.js +295 -0
  61. package/src/runtime/constants.js +44 -0
  62. package/src/runtime/index.js +19 -0
  63. package/src/runtime/normalize.js +377 -0
  64. package/src/transport/index.js +7 -0
  65. package/src/transport/pki.js +7 -0
  66. package/src/utils/crypto.js +7 -0
  67. package/src/utils/encoding.js +65 -0
  68. package/src/utils/wasmCrypto.js +69 -0
  69. package/tools/run-plugin-compliance-check.mjs +153 -0
@@ -0,0 +1,515 @@
1
+ /**
2
+ * flow-canvas.mjs — SVG flow canvas with draggable nodes, port wiring,
3
+ * pan/zoom, selection, and grid snapping. Node-RED style.
4
+ */
5
+
6
+ import { KIND_COLORS } from "./flow-model.mjs";
7
+
8
+ const NODE_W = 160;
9
+ const TITLE_H = 22; // compact title bar
10
+ const PORT_ROW_H = 16; // space per port row
11
+ const NODE_PAD_BOTTOM = 4;
12
+ const PORT_R = 5;
13
+ const GRID_SNAP = 20;
14
+ const MIN_ZOOM = 0.15;
15
+ const MAX_ZOOM = 3;
16
+
17
+ function snap(v) { return Math.round(v / GRID_SNAP) * GRID_SNAP; }
18
+
19
+ function bezierWire(x1, y1, x2, y2) {
20
+ const dx = Math.abs(x2 - x1);
21
+ const cp = Math.max(50, dx * 0.4);
22
+ return `M${x1},${y1} C${x1 + cp},${y1} ${x2 - cp},${y2} ${x2},${y2}`;
23
+ }
24
+
25
+ function svgEl(tag, attrs = {}, parent = null) {
26
+ const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
27
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
28
+ if (parent) parent.appendChild(el);
29
+ return el;
30
+ }
31
+
32
+ export class FlowCanvas {
33
+ constructor(svgElement, model) {
34
+ this.svg = svgElement;
35
+ this.model = model;
36
+ this.viewport = svgElement.querySelector("#canvas-viewport");
37
+ this.wiresLayer = svgElement.querySelector("#layer-wires");
38
+ this.nodesLayer = svgElement.querySelector("#layer-nodes");
39
+ this.tempWire = svgElement.querySelector("#temp-wire");
40
+
41
+ // State
42
+ this.zoom = 1;
43
+ this.panX = 0;
44
+ this.panY = 0;
45
+ this.selectedNodes = new Set();
46
+ this.selectedEdge = null;
47
+ this.nodeElements = new Map(); // nodeId → SVG <g>
48
+ this.wireElements = new Map(); // edgeId → SVG <path>
49
+ this.portPositions = new Map(); // "nodeId:dir:portId" → {x, y}
50
+
51
+ // Drag state
52
+ this._drag = null; // { type: 'node'|'wire'|'pan'|'select', ... }
53
+ this._onNodeSelect = null; // callback
54
+ this._onEdgeSelect = null;
55
+
56
+ this._bindEvents();
57
+ this._bindModelEvents();
58
+ }
59
+
60
+ // ── Public API ──
61
+
62
+ onNodeSelect(fn) { this._onNodeSelect = fn; }
63
+ onEdgeSelect(fn) { this._onEdgeSelect = fn; }
64
+ onNodeDblClick(fn) { this._onNodeDblClick = fn; }
65
+
66
+ selectNode(nodeId, additive = false) {
67
+ if (!additive) {
68
+ this.selectedNodes.forEach(id => this.nodeElements.get(id)?.classList.remove("selected"));
69
+ this.selectedNodes.clear();
70
+ }
71
+ this.selectedNodes.add(nodeId);
72
+ this.nodeElements.get(nodeId)?.classList.add("selected");
73
+ this._clearEdgeSelection();
74
+ this._onNodeSelect?.(nodeId);
75
+ }
76
+
77
+ deselectAll() {
78
+ this.selectedNodes.forEach(id => this.nodeElements.get(id)?.classList.remove("selected"));
79
+ this.selectedNodes.clear();
80
+ this._clearEdgeSelection();
81
+ this._onNodeSelect?.(null);
82
+ }
83
+
84
+ fitView() {
85
+ const positions = Object.values(this.model.editorMeta.nodes);
86
+ if (positions.length === 0) return;
87
+ const xs = positions.map(p => p.x);
88
+ const ys = positions.map(p => p.y);
89
+ const minX = Math.min(...xs) - 100;
90
+ const minY = Math.min(...ys) - 100;
91
+ const maxX = Math.max(...xs) + NODE_W + 100;
92
+ const maxY = Math.max(...ys) + TITLE_H + 60 + 100;
93
+ const rect = this.svg.getBoundingClientRect();
94
+ const scaleX = rect.width / (maxX - minX);
95
+ const scaleY = rect.height / (maxY - minY);
96
+ this.zoom = Math.min(scaleX, scaleY, 1.5);
97
+ this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom));
98
+ this.panX = -minX * this.zoom + (rect.width - (maxX - minX) * this.zoom) / 2;
99
+ this.panY = -minY * this.zoom + (rect.height - (maxY - minY) * this.zoom) / 2;
100
+ this._applyTransform();
101
+ }
102
+
103
+ rebuild() {
104
+ this.wiresLayer.innerHTML = "";
105
+ this.nodesLayer.innerHTML = "";
106
+ this.nodeElements.clear();
107
+ this.wireElements.clear();
108
+ this.portPositions.clear();
109
+ for (const node of this.model.nodes.values()) this._renderNode(node);
110
+ for (const edge of this.model.edges.values()) this._renderWire(edge);
111
+ this._updateAllWires();
112
+ this.fitView();
113
+ }
114
+
115
+ // Convert page coords to canvas coords
116
+ pageToCanvas(px, py) {
117
+ const rect = this.svg.getBoundingClientRect();
118
+ return {
119
+ x: (px - rect.left - this.panX) / this.zoom,
120
+ y: (py - rect.top - this.panY) / this.zoom,
121
+ };
122
+ }
123
+
124
+ // ── Node Rendering ──
125
+
126
+ _renderNode(node) {
127
+ const pos = this.model.editorMeta.nodes[node.nodeId] || { x: 200, y: 200 };
128
+ const color = KIND_COLORS[node.kind] || "#569cd6";
129
+ const nInputs = node.ports?.inputs?.length || 0;
130
+ const nOutputs = node.ports?.outputs?.length || 0;
131
+ const portRows = Math.max(nInputs, nOutputs);
132
+ const bodyH = portRows > 0 ? portRows * PORT_ROW_H + NODE_PAD_BOTTOM : NODE_PAD_BOTTOM;
133
+ const totalH = TITLE_H + bodyH;
134
+
135
+ const g = svgEl("g", { class: "flow-node", "data-node-id": node.nodeId, transform: `translate(${pos.x},${pos.y})` }, this.nodesLayer);
136
+
137
+ // ── Title bar (colored background) ──
138
+ svgEl("rect", { class: "node-title-bg", x: 0, y: 0, width: NODE_W, height: TITLE_H, rx: 4, ry: 4, fill: color }, g);
139
+ // Square off bottom corners of title bar
140
+ svgEl("rect", { fill: color, x: 0, y: TITLE_H - 4, width: NODE_W, height: 4 }, g);
141
+
142
+ // Title text — small, white, truncated
143
+ const titleText = svgEl("text", { class: "node-title", x: 8, y: 15 }, g);
144
+ titleText.textContent = node.label || node.nodeId;
145
+
146
+ // Status dot in title bar
147
+ svgEl("circle", { class: "node-status", cx: NODE_W - 10, cy: 11, r: 3 }, g);
148
+
149
+ // ── Body (dark background for ports) ──
150
+ svgEl("rect", { class: "node-body", x: 0, y: TITLE_H, width: NODE_W, height: bodyH, rx: 0, ry: 0 }, g);
151
+ // Round bottom corners
152
+ svgEl("rect", { class: "node-body-bottom", x: 0, y: TITLE_H + bodyH - 4, width: NODE_W, height: 4, rx: 4, ry: 4, fill: "var(--node-bg, #1e1e1e)" }, g);
153
+
154
+ // ── Sublabel (pluginId — tiny, inside body) ──
155
+ if (node.pluginId || node.lang) {
156
+ const sub = node.pluginId || node.lang || "";
157
+ // Truncate long plugin IDs
158
+ const truncated = sub.length > 28 ? sub.slice(0, 27) + "\u2026" : sub;
159
+ svgEl("text", { class: "node-sublabel", x: NODE_W / 2, y: TITLE_H + bodyH - 2, "text-anchor": "middle" }, g).textContent = truncated;
160
+ }
161
+
162
+ // ── Input ports (left side, in body area) ──
163
+ if (node.ports?.inputs) {
164
+ node.ports.inputs.forEach((port, i) => {
165
+ const py = TITLE_H + 10 + i * PORT_ROW_H;
166
+ const circle = svgEl("circle", { class: "port port-in", cx: 0, cy: py, r: PORT_R, "data-port-id": port.id, "data-dir": "input" }, g);
167
+ circle._nodeId = node.nodeId;
168
+ circle._portId = port.id;
169
+ circle._dir = "input";
170
+ svgEl("text", { class: "port-label", x: 8, y: py + 3, "text-anchor": "start" }, g).textContent = port.label || port.id;
171
+ });
172
+ }
173
+
174
+ // ── Output ports (right side, in body area) ──
175
+ if (node.ports?.outputs) {
176
+ node.ports.outputs.forEach((port, i) => {
177
+ const py = TITLE_H + 10 + i * PORT_ROW_H;
178
+ const circle = svgEl("circle", { class: "port port-out", cx: NODE_W, cy: py, r: PORT_R, "data-port-id": port.id, "data-dir": "output" }, g);
179
+ circle._nodeId = node.nodeId;
180
+ circle._portId = port.id;
181
+ circle._dir = "output";
182
+ svgEl("text", { class: "port-label", x: NODE_W - 8, y: py + 3, "text-anchor": "end" }, g).textContent = port.label || port.id;
183
+ });
184
+ }
185
+
186
+ // ── Outer border (full node outline) ──
187
+ svgEl("rect", { class: "node-outline", x: 0, y: 0, width: NODE_W, height: totalH, rx: 4, ry: 4 }, g);
188
+
189
+ this.nodeElements.set(node.nodeId, g);
190
+ this._updatePortPositions(node.nodeId);
191
+
192
+ if (this.selectedNodes.has(node.nodeId)) g.classList.add("selected");
193
+ return g;
194
+ }
195
+
196
+ _updatePortPositions(nodeId) {
197
+ const node = this.model.nodes.get(nodeId);
198
+ const pos = this.model.editorMeta.nodes[nodeId];
199
+ if (!node || !pos) return;
200
+ if (node.ports?.inputs) {
201
+ node.ports.inputs.forEach((port, i) => {
202
+ this.portPositions.set(`${nodeId}:input:${port.id}`, { x: pos.x, y: pos.y + TITLE_H + 10 + i * PORT_ROW_H });
203
+ });
204
+ }
205
+ if (node.ports?.outputs) {
206
+ node.ports.outputs.forEach((port, i) => {
207
+ this.portPositions.set(`${nodeId}:output:${port.id}`, { x: pos.x + NODE_W, y: pos.y + TITLE_H + 10 + i * PORT_ROW_H });
208
+ });
209
+ }
210
+ }
211
+
212
+ // ── Wire Rendering ──
213
+
214
+ _renderWire(edge) {
215
+ const from = this.portPositions.get(`${edge.fromNodeId}:output:${edge.fromPortId}`);
216
+ const to = this.portPositions.get(`${edge.toNodeId}:input:${edge.toPortId}`);
217
+ if (!from || !to) return;
218
+ const path = svgEl("path", {
219
+ class: "wire",
220
+ d: bezierWire(from.x, from.y, to.x, to.y),
221
+ "data-edge-id": edge.edgeId,
222
+ }, this.wiresLayer);
223
+ this.wireElements.set(edge.edgeId, path);
224
+ }
225
+
226
+ _updateAllWires() {
227
+ for (const [edgeId, edge] of this.model.edges) {
228
+ this._updateWire(edgeId, edge);
229
+ }
230
+ }
231
+
232
+ _updateWire(edgeId, edge) {
233
+ const path = this.wireElements.get(edgeId);
234
+ if (!path) return;
235
+ const from = this.portPositions.get(`${edge.fromNodeId}:output:${edge.fromPortId}`);
236
+ const to = this.portPositions.get(`${edge.toNodeId}:input:${edge.toPortId}`);
237
+ if (from && to) {
238
+ path.setAttribute("d", bezierWire(from.x, from.y, to.x, to.y));
239
+ }
240
+ }
241
+
242
+ _updateNodeWires(nodeId) {
243
+ for (const [edgeId, edge] of this.model.edges) {
244
+ if (edge.fromNodeId === nodeId || edge.toNodeId === nodeId) {
245
+ this._updateWire(edgeId, edge);
246
+ }
247
+ }
248
+ }
249
+
250
+ _clearEdgeSelection() {
251
+ if (this.selectedEdge) {
252
+ this.wireElements.get(this.selectedEdge)?.classList.remove("selected");
253
+ this.selectedEdge = null;
254
+ }
255
+ }
256
+
257
+ // ── Transform ──
258
+
259
+ _applyTransform() {
260
+ this.viewport.setAttribute("transform", `translate(${this.panX},${this.panY}) scale(${this.zoom})`);
261
+ // Update grid pattern scale
262
+ const bg = this.svg.querySelector(".canvas-bg");
263
+ if (bg) {
264
+ bg.setAttribute("transform", `translate(${this.panX % (20 * this.zoom)},${this.panY % (20 * this.zoom)})`);
265
+ }
266
+ }
267
+
268
+ // ── Events ──
269
+
270
+ _bindEvents() {
271
+ // Wheel zoom
272
+ this.svg.addEventListener("wheel", (e) => {
273
+ e.preventDefault();
274
+ const rect = this.svg.getBoundingClientRect();
275
+ const mx = e.clientX - rect.left;
276
+ const my = e.clientY - rect.top;
277
+ const oldZoom = this.zoom;
278
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
279
+ this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * delta));
280
+ this.panX = mx - (mx - this.panX) * (this.zoom / oldZoom);
281
+ this.panY = my - (my - this.panY) * (this.zoom / oldZoom);
282
+ this._applyTransform();
283
+ }, { passive: false });
284
+
285
+ // Mouse down
286
+ this.svg.addEventListener("mousedown", (e) => {
287
+ const target = e.target;
288
+
289
+ // Port drag (wire creation)
290
+ if (target.classList.contains("port")) {
291
+ e.stopPropagation();
292
+ const nodeId = target._nodeId;
293
+ const portId = target._portId;
294
+ const dir = target._dir;
295
+ if (dir === "output") {
296
+ const pos = this.portPositions.get(`${nodeId}:output:${portId}`);
297
+ if (pos) {
298
+ this._drag = { type: "wire", fromNodeId: nodeId, fromPortId: portId, startX: pos.x, startY: pos.y };
299
+ this.tempWire.style.display = "";
300
+ target.classList.add("active");
301
+ }
302
+ }
303
+ return;
304
+ }
305
+
306
+ // Node drag
307
+ const nodeG = target.closest(".flow-node");
308
+ if (nodeG) {
309
+ const nodeId = nodeG.dataset.nodeId;
310
+ if (!e.shiftKey && !this.selectedNodes.has(nodeId)) {
311
+ this.selectNode(nodeId);
312
+ } else if (e.shiftKey) {
313
+ this.selectNode(nodeId, true);
314
+ }
315
+ const pos = this.model.editorMeta.nodes[nodeId];
316
+ if (pos) {
317
+ const canvasPos = this.pageToCanvas(e.clientX, e.clientY);
318
+ this._drag = {
319
+ type: "node",
320
+ startMouseX: canvasPos.x,
321
+ startMouseY: canvasPos.y,
322
+ startPositions: new Map([...this.selectedNodes].map(id => [id, { ...this.model.editorMeta.nodes[id] }])),
323
+ };
324
+ }
325
+ return;
326
+ }
327
+
328
+ // Wire selection
329
+ if (target.classList.contains("wire") && !target.classList.contains("temp")) {
330
+ const edgeId = target.dataset.edgeId;
331
+ this.deselectAll();
332
+ this.selectedEdge = edgeId;
333
+ target.classList.add("selected");
334
+ this._onEdgeSelect?.(edgeId);
335
+ return;
336
+ }
337
+
338
+ // Pan (middle button or canvas background)
339
+ if (e.button === 1 || (e.button === 0 && (target === this.svg || target.classList.contains("canvas-bg")))) {
340
+ if (e.button === 0 && !e.ctrlKey && !e.metaKey) {
341
+ // Selection rectangle
342
+ this.deselectAll();
343
+ this._drag = { type: "select", startX: e.clientX, startY: e.clientY };
344
+ } else {
345
+ this._drag = { type: "pan", startX: e.clientX, startY: e.clientY, startPanX: this.panX, startPanY: this.panY };
346
+ }
347
+ }
348
+ });
349
+
350
+ // Mouse move
351
+ window.addEventListener("mousemove", (e) => {
352
+ if (!this._drag) return;
353
+
354
+ if (this._drag.type === "node") {
355
+ const canvasPos = this.pageToCanvas(e.clientX, e.clientY);
356
+ const dx = canvasPos.x - this._drag.startMouseX;
357
+ const dy = canvasPos.y - this._drag.startMouseY;
358
+ for (const [nodeId, startPos] of this._drag.startPositions) {
359
+ const x = snap(startPos.x + dx);
360
+ const y = snap(startPos.y + dy);
361
+ this.model.editorMeta.nodes[nodeId] = { ...this.model.editorMeta.nodes[nodeId], x, y };
362
+ this.nodeElements.get(nodeId)?.setAttribute("transform", `translate(${x},${y})`);
363
+ this._updatePortPositions(nodeId);
364
+ this._updateNodeWires(nodeId);
365
+ }
366
+ } else if (this._drag.type === "wire") {
367
+ const canvasPos = this.pageToCanvas(e.clientX, e.clientY);
368
+ this.tempWire.setAttribute("d", bezierWire(this._drag.startX, this._drag.startY, canvasPos.x, canvasPos.y));
369
+ } else if (this._drag.type === "pan") {
370
+ this.panX = this._drag.startPanX + (e.clientX - this._drag.startX);
371
+ this.panY = this._drag.startPanY + (e.clientY - this._drag.startY);
372
+ this._applyTransform();
373
+ } else if (this._drag.type === "select") {
374
+ const selectRect = document.getElementById("select-rect");
375
+ const x1 = Math.min(this._drag.startX, e.clientX);
376
+ const y1 = Math.min(this._drag.startY, e.clientY);
377
+ const w = Math.abs(e.clientX - this._drag.startX);
378
+ const h = Math.abs(e.clientY - this._drag.startY);
379
+ selectRect.style.display = "";
380
+ selectRect.style.left = `${x1}px`;
381
+ selectRect.style.top = `${y1}px`;
382
+ selectRect.style.width = `${w}px`;
383
+ selectRect.style.height = `${h}px`;
384
+ }
385
+ });
386
+
387
+ // Mouse up
388
+ window.addEventListener("mouseup", (e) => {
389
+ if (!this._drag) return;
390
+
391
+ if (this._drag.type === "node") {
392
+ // Persist positions to model
393
+ for (const nodeId of this.selectedNodes) {
394
+ const pos = this.model.editorMeta.nodes[nodeId];
395
+ if (pos) this.model.moveNode(nodeId, pos.x, pos.y);
396
+ }
397
+ } else if (this._drag.type === "wire") {
398
+ this.tempWire.style.display = "none";
399
+ this.tempWire.setAttribute("d", "");
400
+ // Remove active class from all ports
401
+ this.svg.querySelectorAll(".port.active").forEach(p => p.classList.remove("active"));
402
+ // Find target port
403
+ const target = document.elementFromPoint(e.clientX, e.clientY);
404
+ if (target?.classList.contains("port") && target._dir === "input") {
405
+ this.model.addEdge({
406
+ fromNodeId: this._drag.fromNodeId,
407
+ fromPortId: this._drag.fromPortId,
408
+ toNodeId: target._nodeId,
409
+ toPortId: target._portId,
410
+ });
411
+ }
412
+ } else if (this._drag.type === "select") {
413
+ const selectRect = document.getElementById("select-rect");
414
+ selectRect.style.display = "none";
415
+ // Select nodes in rectangle
416
+ const rect = {
417
+ x1: Math.min(this._drag.startX, e.clientX),
418
+ y1: Math.min(this._drag.startY, e.clientY),
419
+ x2: Math.max(this._drag.startX, e.clientX),
420
+ y2: Math.max(this._drag.startY, e.clientY),
421
+ };
422
+ for (const [nodeId, el] of this.nodeElements) {
423
+ const bbox = el.getBoundingClientRect();
424
+ if (bbox.left >= rect.x1 && bbox.right <= rect.x2 && bbox.top >= rect.y1 && bbox.bottom <= rect.y2) {
425
+ this.selectNode(nodeId, true);
426
+ }
427
+ }
428
+ }
429
+
430
+ this._drag = null;
431
+ });
432
+
433
+ // Delete key
434
+ window.addEventListener("keydown", (e) => {
435
+ if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT") return;
436
+ if (e.target.closest("#editor-container")) return;
437
+ if (e.key === "Delete" || e.key === "Backspace") {
438
+ if (this.selectedEdge) {
439
+ this.model.removeEdge(this.selectedEdge);
440
+ this.selectedEdge = null;
441
+ }
442
+ if (this.selectedNodes.size > 0) {
443
+ for (const nodeId of [...this.selectedNodes]) {
444
+ this.model.removeNode(nodeId);
445
+ }
446
+ this.selectedNodes.clear();
447
+ this._onNodeSelect?.(null);
448
+ }
449
+ }
450
+ // Ctrl+A select all
451
+ if ((e.ctrlKey || e.metaKey) && e.key === "a" && !e.target.closest("#editor-container")) {
452
+ e.preventDefault();
453
+ for (const nodeId of this.model.nodes.keys()) {
454
+ this.selectNode(nodeId, true);
455
+ }
456
+ }
457
+ });
458
+
459
+ // Double-click node → load plugin metadata
460
+ this.svg.addEventListener("dblclick", (e) => {
461
+ const nodeG = e.target.closest(".flow-node");
462
+ if (nodeG) {
463
+ const nodeId = nodeG.dataset.nodeId;
464
+ this._onNodeDblClick?.(nodeId);
465
+ }
466
+ });
467
+
468
+ // Drop from palette
469
+ this.svg.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; });
470
+ this.svg.addEventListener("drop", (e) => {
471
+ e.preventDefault();
472
+ const kind = e.dataTransfer.getData("text/x-sdn-kind");
473
+ const lang = e.dataTransfer.getData("text/x-sdn-lang") || null;
474
+ if (!kind) return;
475
+ const pos = this.pageToCanvas(e.clientX, e.clientY);
476
+ const node = this.model.addNode({
477
+ kind,
478
+ lang,
479
+ x: snap(pos.x),
480
+ y: snap(pos.y),
481
+ label: lang ? `${lang}-module` : kind,
482
+ });
483
+ this.selectNode(node.nodeId);
484
+ });
485
+ }
486
+
487
+ _bindModelEvents() {
488
+ this.model.addEventListener("node-add", (e) => {
489
+ this._renderNode(e.detail.node);
490
+ });
491
+ this.model.addEventListener("node-remove", (e) => {
492
+ const el = this.nodeElements.get(e.detail.nodeId);
493
+ if (el) { el.remove(); this.nodeElements.delete(e.detail.nodeId); }
494
+ });
495
+ this.model.addEventListener("node-update", (e) => {
496
+ // Re-render node
497
+ const el = this.nodeElements.get(e.detail.node.nodeId);
498
+ if (el) {
499
+ el.remove();
500
+ this.nodeElements.delete(e.detail.node.nodeId);
501
+ }
502
+ this._renderNode(e.detail.node);
503
+ this._updateNodeWires(e.detail.node.nodeId);
504
+ });
505
+ this.model.addEventListener("edge-add", (e) => {
506
+ this._renderWire(e.detail.edge);
507
+ });
508
+ this.model.addEventListener("edge-remove", (e) => {
509
+ const el = this.wireElements.get(e.detail.edgeId);
510
+ if (el) { el.remove(); this.wireElements.delete(e.detail.edgeId); }
511
+ });
512
+ this.model.addEventListener("load", () => this.rebuild());
513
+ this.model.addEventListener("clear", () => this.rebuild());
514
+ }
515
+ }