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.
- package/.claude/SKILLS.md +7 -0
- package/.claude/skills/sdn-plugin-abi-compliance/SKILL.md +56 -0
- package/.claude/todo/001-js-host-startup-and-deno.md +85 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/bin/sdn-flow-host.js +169 -0
- package/docs/.nojekyll +0 -0
- package/docs/ARCHITECTURE.md +200 -0
- package/docs/HOST_CAPABILITY_MODEL.md +317 -0
- package/docs/PLUGIN_ARCHITECTURE.md +145 -0
- package/docs/PLUGIN_COMPATIBILITY.md +61 -0
- package/docs/PLUGIN_COMPLIANCE_CHECKS.md +82 -0
- package/docs/PLUGIN_MANIFEST.md +94 -0
- package/docs/css/style.css +465 -0
- package/docs/index.html +218 -0
- package/docs/js/app.mjs +751 -0
- package/docs/js/editor-panel.mjs +203 -0
- package/docs/js/flow-canvas.mjs +515 -0
- package/docs/js/flow-model.mjs +391 -0
- package/docs/js/workers/emception.worker.js +146 -0
- package/docs/js/workers/pyodide.worker.js +134 -0
- package/native/flow_source_generator.cpp +1958 -0
- package/package.json +67 -0
- package/schemas/FlowRuntimeAbi.fbs +91 -0
- package/src/auth/canonicalize.js +5 -0
- package/src/auth/index.js +11 -0
- package/src/auth/permissions.js +8 -0
- package/src/compiler/CppFlowSourceGenerator.js +475 -0
- package/src/compiler/EmceptionCompilerAdapter.js +244 -0
- package/src/compiler/SignedArtifactCatalog.js +152 -0
- package/src/compiler/index.js +8 -0
- package/src/compiler/nativeFlowSourceGeneratorTool.js +144 -0
- package/src/compliance/index.js +13 -0
- package/src/compliance/pluginCompliance.js +11 -0
- package/src/deploy/FlowDeploymentClient.js +532 -0
- package/src/deploy/index.js +8 -0
- package/src/designer/FlowDesignerSession.js +158 -0
- package/src/designer/index.js +2 -0
- package/src/designer/requirements.js +184 -0
- package/src/generated/runtimeAbiLayouts.js +544 -0
- package/src/host/appHost.js +105 -0
- package/src/host/autoHost.js +113 -0
- package/src/host/browserHostAdapters.js +108 -0
- package/src/host/compiledFlowRuntimeHost.js +703 -0
- package/src/host/constants.js +55 -0
- package/src/host/dependencyRuntime.js +227 -0
- package/src/host/descriptorAbi.js +351 -0
- package/src/host/fetchService.js +237 -0
- package/src/host/httpHostAdapters.js +280 -0
- package/src/host/index.js +91 -0
- package/src/host/installedFlowHost.js +885 -0
- package/src/host/invocationAbi.js +440 -0
- package/src/host/normalize.js +372 -0
- package/src/host/packageManagers.js +369 -0
- package/src/host/profile.js +134 -0
- package/src/host/runtimeAbi.js +106 -0
- package/src/host/workspace.js +895 -0
- package/src/index.js +8 -0
- package/src/runtime/FlowRuntime.js +273 -0
- package/src/runtime/MethodRegistry.js +295 -0
- package/src/runtime/constants.js +44 -0
- package/src/runtime/index.js +19 -0
- package/src/runtime/normalize.js +377 -0
- package/src/transport/index.js +7 -0
- package/src/transport/pki.js +7 -0
- package/src/utils/crypto.js +7 -0
- package/src/utils/encoding.js +65 -0
- package/src/utils/wasmCrypto.js +69 -0
- 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
|
+
}
|