html-overlay-node 0.1.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/src/index.js ADDED
@@ -0,0 +1,1030 @@
1
+ import { Registry } from "./core/Registry.js";
2
+ import { createHooks } from "./core/Hooks.js";
3
+ import { Graph } from "./core/Graph.js";
4
+ import { CanvasRenderer } from "./render/CanvasRenderer.js";
5
+ import { Controller } from "./interact/Controller.js";
6
+ import { ContextMenu } from "./interact/ContextMenu.js";
7
+ import { Runner } from "./core/Runner.js";
8
+
9
+ import { HtmlOverlay } from "./render/HtmlOverlay.js";
10
+ import { RemoveNodeCmd, ChangeGroupColorCmd } from "./core/commands.js";
11
+ import { Minimap } from "./minimap/Minimap.js";
12
+ import { PropertyPanel } from "./ui/PropertyPanel.js";
13
+
14
+
15
+
16
+ export function createGraphEditor(
17
+ target,
18
+ {
19
+ theme,
20
+ hooks: customHooks,
21
+ autorun = true,
22
+ showMinimap = true,
23
+ enablePropertyPanel = true,
24
+ propertyPanelContainer = null,
25
+ } = {}
26
+ ) {
27
+ let canvas;
28
+ let container;
29
+
30
+ if (typeof target === "string") {
31
+ target = document.querySelector(target);
32
+ }
33
+
34
+ if (!target) {
35
+ throw new Error("createGraphEditor: target element not found");
36
+ }
37
+
38
+ if (target instanceof HTMLCanvasElement) {
39
+ canvas = target;
40
+ container = canvas.parentElement;
41
+ } else {
42
+ container = target;
43
+ canvas = container.querySelector("canvas");
44
+ if (!canvas) {
45
+ canvas = document.createElement("canvas");
46
+ canvas.style.display = "block";
47
+ canvas.style.width = "100%";
48
+ canvas.style.height = "100%";
49
+ container.appendChild(canvas);
50
+ }
51
+ }
52
+
53
+ // Ensure container has relative positioning for overlays
54
+ if (getComputedStyle(container).position === "static") {
55
+ container.style.position = "relative";
56
+ }
57
+ const hooks =
58
+ customHooks ??
59
+ createHooks([
60
+ // essential hooks
61
+ "node:create",
62
+ "node:move",
63
+ "node:click",
64
+ "node:dblclick",
65
+ "edge:create",
66
+ "edge:delete",
67
+ "graph:serialize",
68
+ "graph:deserialize",
69
+ "error",
70
+ "runner:tick",
71
+ "runner:start",
72
+ "runner:stop",
73
+ "node:resize",
74
+ "group:change",
75
+ "node:updated",
76
+ ]);
77
+ const registry = new Registry();
78
+ const graph = new Graph({ hooks, registry });
79
+ const renderer = new CanvasRenderer(canvas, { theme, registry });
80
+ // HTML Overlay
81
+ const htmlOverlay = new HtmlOverlay(canvas.parentElement, renderer, registry);
82
+
83
+ // Port Canvas (above HTML overlay)
84
+ const portCanvas = document.createElement("canvas");
85
+ portCanvas.id = "port-canvas";
86
+ Object.assign(portCanvas.style, {
87
+ position: "absolute",
88
+ top: "0",
89
+ left: "0",
90
+ pointerEvents: "none", // Pass through clicks
91
+ zIndex: "20", // Above HTML overlay (z-index 10)
92
+ });
93
+ canvas.parentElement.appendChild(portCanvas);
94
+
95
+ // Create port renderer (shares transform with main renderer)
96
+ const portRenderer = new CanvasRenderer(portCanvas, { theme, registry });
97
+ portRenderer.setTransform = renderer.setTransform.bind(renderer);
98
+ portRenderer.scale = renderer.scale;
99
+ portRenderer.offsetX = renderer.offsetX;
100
+ portRenderer.offsetY = renderer.offsetY;
101
+
102
+ const controller = new Controller({ graph, renderer, hooks, htmlOverlay, portRenderer });
103
+
104
+ // Create context menu after controller (needs commandStack)
105
+ const contextMenu = new ContextMenu({
106
+ graph,
107
+ hooks,
108
+ renderer,
109
+ commandStack: controller.stack,
110
+ });
111
+
112
+ // Connect context menu to controller
113
+ controller.contextMenu = contextMenu;
114
+
115
+ // Create minimap if enabled
116
+ let minimap = null;
117
+ if (showMinimap) {
118
+ minimap = new Minimap(container, { graph, renderer });
119
+ }
120
+
121
+ // Initialize Property Panel if enabled
122
+ let propertyPanel = null;
123
+ if (enablePropertyPanel) {
124
+ propertyPanel = new PropertyPanel(propertyPanelContainer || container, {
125
+ graph,
126
+ hooks,
127
+ registry,
128
+ render: () => controller.render(),
129
+ });
130
+
131
+ // Handle node double-click to open property panel
132
+ hooks.on("node:dblclick", (node) => {
133
+ propertyPanel.open(node);
134
+ });
135
+ }
136
+
137
+ const runner = new Runner({ graph, registry, hooks });
138
+
139
+ hooks.on("runner:tick", ({ time, dt }) => {
140
+ renderer.draw(graph, {
141
+ selection: controller.selection,
142
+ tempEdge: controller.connecting ? controller.renderTempEdge() : null, // 필요시 helper
143
+ running: true,
144
+ time,
145
+ dt,
146
+ });
147
+ htmlOverlay.draw(graph, controller.selection);
148
+ });
149
+ hooks.on("runner:start", () => {
150
+ // 첫 프레임 즉시 렌더
151
+ renderer.draw(graph, {
152
+ selection: controller.selection,
153
+ tempEdge: controller.connecting ? controller.renderTempEdge() : null,
154
+ running: true,
155
+ time: performance.now(),
156
+ dt: 0,
157
+ });
158
+ htmlOverlay.draw(graph, controller.selection);
159
+ });
160
+ hooks.on("runner:stop", () => {
161
+ // 정지 프레임
162
+ renderer.draw(graph, {
163
+ selection: controller.selection,
164
+ tempEdge: controller.connecting ? controller.renderTempEdge() : null,
165
+ running: false,
166
+ time: performance.now(),
167
+ dt: 0,
168
+ });
169
+ htmlOverlay.draw(graph, controller.selection);
170
+ });
171
+
172
+ hooks.on("node:updated", () => {
173
+ controller.render();
174
+ });
175
+
176
+ // default node
177
+ registry.register("core/Note", {
178
+ title: "Note",
179
+ size: { w: 180, h: 80 },
180
+ inputs: [{ name: "in", datatype: "any" }],
181
+ outputs: [{ name: "out", datatype: "any" }],
182
+ onCreate(node) {
183
+ node.state.text = "hello";
184
+ },
185
+ onExecute(node, { dt, getInput, setOutput }) {
186
+ // Simple passthrough with uppercase and a heartbeat value
187
+ const incoming = getInput("in");
188
+ const out = (incoming ?? node.state.text ?? "").toString().toUpperCase();
189
+ setOutput(
190
+ "out",
191
+ out + ` · ${Math.floor((performance.now() / 1000) % 100)}`
192
+ );
193
+ },
194
+ onDraw(node, { ctx, theme }) {
195
+ const pr = 8;
196
+ const { x, y } = node.pos;
197
+ const { width: w } = node.size;
198
+ const lx = x + pr; // 월드 x
199
+ const ly = y + 24 + 6; // 타이틀 바(24) 아래 여백 6
200
+ // renderer._drawScreenText(node.state.text ?? "hello", lx, ly, {
201
+ // fontPx: 11,
202
+ // color: theme.text,
203
+ // baseline: "top",
204
+ // align: "left",
205
+ // });
206
+ },
207
+ });
208
+
209
+ // HTML Custom Node Example
210
+ registry.register("core/HtmlNote", {
211
+ title: "HTML Note",
212
+ size: { w: 200, h: 150 },
213
+ inputs: [{ name: "in", datatype: "any" }],
214
+ outputs: [{ name: "out", datatype: "any" }],
215
+
216
+ // HTML Overlay Configuration
217
+ html: {
218
+ // 초기화: 헤더/바디 구성
219
+ init(node, el, { header, body }) {
220
+ el.style.backgroundColor = "#222";
221
+ el.style.borderRadius = "8px";
222
+ el.style.border = "1px solid #444";
223
+ el.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)";
224
+
225
+ // Header
226
+ header.style.backgroundColor = "#333";
227
+ header.style.borderBottom = "1px solid #444";
228
+ header.style.color = "#eee";
229
+ header.style.fontSize = "12px";
230
+ header.style.fontWeight = "bold";
231
+ header.textContent = "My HTML Node";
232
+
233
+ // Body
234
+ body.style.padding = "8px";
235
+ body.style.color = "#ccc";
236
+ body.style.fontSize = "12px";
237
+
238
+ const contentDiv = document.createElement("div");
239
+ contentDiv.textContent = "Event Name";
240
+ body.appendChild(contentDiv);
241
+
242
+ // Add some interactive content
243
+ const input = document.createElement("input");
244
+ Object.assign(input.style, {
245
+ marginTop: "4px",
246
+ padding: "4px",
247
+ background: "#111",
248
+ border: "1px solid #555",
249
+ color: "#fff",
250
+ borderRadius: "4px",
251
+ pointerEvents: "auto",
252
+ });
253
+ input.placeholder = "Type here...";
254
+ input.addEventListener("input", (e) => {
255
+ node.state.text = e.target.value;
256
+ });
257
+ input.addEventListener("mousedown", (e) => e.stopPropagation()); // 캔버스 드래그 방지
258
+
259
+ body.appendChild(input);
260
+
261
+ // Store input ref for updates
262
+ el._input = input;
263
+ },
264
+
265
+ // 매 프레임(또는 필요시) 업데이트
266
+ update(node, el, { header, body, selected }) {
267
+ el.style.borderColor = selected ? "#6cf" : "#444";
268
+ header.style.backgroundColor = selected ? "#3a4a5a" : "#333";
269
+
270
+ // 상태 동기화 (외부에서 변경되었을 경우)
271
+ if (el._input.value !== (node.state.text || "")) {
272
+ el._input.value = node.state.text || "";
273
+ }
274
+ }
275
+ },
276
+
277
+ onCreate(node) {
278
+ node.state.text = "";
279
+ },
280
+ onExecute(node, { getInput, setOutput }) {
281
+ const incoming = getInput("in");
282
+ setOutput("out", incoming);
283
+ },
284
+ // onDraw는 생략 가능 (HTML이 덮으니까)
285
+ // 하지만 포트 등은 그려야 할 수도 있음.
286
+ // 현재 구조상 CanvasRenderer가 기본 노드를 그리므로,
287
+ // 투명하게 하거나 겹쳐서 그릴 수 있음.
288
+ });
289
+
290
+ // Todo List Node Example (HTML Overlay)
291
+ registry.register("core/TodoNode", {
292
+ title: "Todo List",
293
+ size: { w: 240, h: 300 },
294
+ inputs: [{ name: "in", datatype: "any" }],
295
+ outputs: [{ name: "out", datatype: "any" }],
296
+ html: {
297
+ init(node, el, { header, body }) {
298
+ el.style.backgroundColor = "#1e1e24";
299
+ el.style.borderRadius = "8px";
300
+ el.style.boxShadow = "0 4px 12px rgba(0,0,0,0.5)";
301
+ el.style.border = "1px solid #333";
302
+
303
+ header.style.backgroundColor = "#2a2a31";
304
+ header.style.padding = "8px";
305
+ header.style.fontWeight = "bold";
306
+ header.style.color = "#e9e9ef";
307
+ header.textContent = node.title;
308
+
309
+ body.style.display = "flex";
310
+ body.style.flexDirection = "column";
311
+ body.style.padding = "8px";
312
+ body.style.color = "#e9e9ef";
313
+
314
+ // Input Area
315
+ const inputRow = document.createElement("div");
316
+ Object.assign(inputRow.style, { display: "flex", gap: "4px", marginBottom: "8px" });
317
+
318
+ const input = document.createElement("input");
319
+ Object.assign(input.style, {
320
+ flex: "1", padding: "6px", borderRadius: "4px",
321
+ border: "1px solid #444", background: "#141417", color: "#fff",
322
+ pointerEvents: "auto",
323
+ });
324
+ input.placeholder = "Add task...";
325
+
326
+ const addBtn = document.createElement("button");
327
+ addBtn.textContent = "+";
328
+ Object.assign(addBtn.style, {
329
+ padding: "0 12px", cursor: "pointer", background: "#4f5b66",
330
+ color: "#fff", border: "none", borderRadius: "4px",
331
+ pointerEvents: "auto",
332
+ });
333
+
334
+ inputRow.append(input, addBtn);
335
+
336
+ // List Area
337
+ const list = document.createElement("ul");
338
+ Object.assign(list.style, {
339
+ listStyle: "none", padding: "0", margin: "0",
340
+ overflow: "hidden", flex: "1"
341
+ });
342
+
343
+ body.append(inputRow, list);
344
+
345
+ // Logic
346
+ const addTodo = () => {
347
+ const text = input.value.trim();
348
+ if (!text) return;
349
+ const todos = node.state.todos || [];
350
+ node.state.todos = [...todos, { id: Date.now(), text, done: false }];
351
+ input.value = "";
352
+ hooks.emit("node:updated", node);
353
+ };
354
+
355
+ addBtn.onclick = addTodo;
356
+ input.onkeydown = (e) => {
357
+ if (e.key === "Enter") addTodo();
358
+ e.stopPropagation();
359
+ };
360
+ input.onmousedown = (e) => e.stopPropagation(); // prevent drag
361
+
362
+ el._refs = { list };
363
+ },
364
+ update(node, el, { selected }) {
365
+ el.style.borderColor = selected ? "#6cf" : "#333";
366
+
367
+ const { list } = el._refs;
368
+ const todos = node.state.todos || [];
369
+
370
+ // Re-render list (simple approach)
371
+ list.innerHTML = "";
372
+ todos.forEach((todo) => {
373
+ const li = document.createElement("li");
374
+ Object.assign(li.style, {
375
+ display: "flex", alignItems: "center", padding: "6px 0",
376
+ borderBottom: "1px solid #2a2a31"
377
+ });
378
+
379
+ const chk = document.createElement("input");
380
+ chk.type = "checkbox";
381
+ chk.checked = todo.done;
382
+ chk.style.marginRight = "8px";
383
+ chk.style.pointerEvents = "auto";
384
+ chk.onchange = () => {
385
+ todo.done = chk.checked;
386
+ hooks.emit("node:updated", node);
387
+ };
388
+ chk.onmousedown = (e) => e.stopPropagation();
389
+
390
+ const span = document.createElement("span");
391
+ span.textContent = todo.text;
392
+ span.style.flex = "1";
393
+ span.style.textDecoration = todo.done ? "line-through" : "none";
394
+ span.style.color = todo.done ? "#777" : "#eee";
395
+
396
+ const del = document.createElement("button");
397
+ del.textContent = "×";
398
+ Object.assign(del.style, {
399
+ background: "none", border: "none", color: "#f44",
400
+ cursor: "pointer", fontSize: "16px",
401
+ pointerEvents: "auto",
402
+ });
403
+ del.onclick = () => {
404
+ node.state.todos = node.state.todos.filter((t) => t.id !== todo.id);
405
+ hooks.emit("node:updated", node);
406
+ };
407
+ del.onmousedown = (e) => e.stopPropagation();
408
+
409
+ li.append(chk, span, del);
410
+ list.appendChild(li);
411
+ });
412
+ }
413
+ },
414
+ onCreate(node) {
415
+ node.state.todos = [
416
+ { id: 1, text: "Welcome to Free Node", done: false },
417
+ { id: 2, text: "Try adding a task", done: true },
418
+ ];
419
+ },
420
+ });
421
+
422
+ // ===== MATH NODES =====
423
+ registry.register("math/Add", {
424
+ title: "Add",
425
+ size: { w: 140, h: 100 },
426
+ inputs: [
427
+ { name: "exec", portType: "exec" },
428
+ { name: "a", portType: "data", datatype: "number" },
429
+ { name: "b", portType: "data", datatype: "number" },
430
+ ],
431
+ outputs: [
432
+ { name: "exec", portType: "exec" },
433
+ { name: "result", portType: "data", datatype: "number" },
434
+ ],
435
+ onCreate(node) {
436
+ node.state.a = 0;
437
+ node.state.b = 0;
438
+ },
439
+ onExecute(node, { getInput, setOutput }) {
440
+ const a = getInput("a") ?? 0;
441
+ const b = getInput("b") ?? 0;
442
+ const result = a + b;
443
+ console.log("[Add] a:", a, "b:", b, "result:", result);
444
+ setOutput("result", result);
445
+ },
446
+ });
447
+
448
+ registry.register("math/Subtract", {
449
+ title: "Subtract",
450
+ size: { w: 140, h: 80 },
451
+ inputs: [
452
+ { name: "a", datatype: "number" },
453
+ { name: "b", datatype: "number" },
454
+ ],
455
+ outputs: [{ name: "result", datatype: "number" }],
456
+ onExecute(node, { getInput, setOutput }) {
457
+ const a = getInput("a") ?? 0;
458
+ const b = getInput("b") ?? 0;
459
+ setOutput("result", a - b);
460
+ },
461
+ });
462
+
463
+ registry.register("math/Multiply", {
464
+ title: "Multiply",
465
+ size: { w: 140, h: 100 },
466
+ inputs: [
467
+ { name: "exec", portType: "exec" },
468
+ { name: "a", portType: "data", datatype: "number" },
469
+ { name: "b", portType: "data", datatype: "number" },
470
+ ],
471
+ outputs: [
472
+ { name: "exec", portType: "exec" },
473
+ { name: "result", portType: "data", datatype: "number" },
474
+ ],
475
+ onExecute(node, { getInput, setOutput }) {
476
+ const a = getInput("a") ?? 0;
477
+ const b = getInput("b") ?? 0;
478
+ const result = a * b;
479
+ console.log("[Multiply] a:", a, "b:", b, "result:", result);
480
+ setOutput("result", result);
481
+ },
482
+ });
483
+
484
+ registry.register("math/Divide", {
485
+ title: "Divide",
486
+ size: { w: 140, h: 80 },
487
+ inputs: [
488
+ { name: "a", datatype: "number" },
489
+ { name: "b", datatype: "number" },
490
+ ],
491
+ outputs: [{ name: "result", datatype: "number" }],
492
+ onExecute(node, { getInput, setOutput }) {
493
+ const a = getInput("a") ?? 0;
494
+ const b = getInput("b") ?? 1;
495
+ setOutput("result", b !== 0 ? a / b : 0);
496
+ },
497
+ });
498
+
499
+ // ===== LOGIC NODES =====
500
+ registry.register("logic/AND", {
501
+ title: "AND",
502
+ size: { w: 120, h: 100 },
503
+ inputs: [
504
+ { name: "exec", portType: "exec" },
505
+ { name: "a", portType: "data", datatype: "boolean" },
506
+ { name: "b", portType: "data", datatype: "boolean" },
507
+ ],
508
+ outputs: [
509
+ { name: "exec", portType: "exec" },
510
+ { name: "result", portType: "data", datatype: "boolean" },
511
+ ],
512
+ onExecute(node, { getInput, setOutput }) {
513
+ const a = getInput("a") ?? false;
514
+ const b = getInput("b") ?? false;
515
+ console.log("[AND] Inputs - a:", a, "b:", b);
516
+ const result = a && b;
517
+ console.log("[AND] Result:", result);
518
+ setOutput("result", result);
519
+ },
520
+ });
521
+
522
+ registry.register("logic/OR", {
523
+ title: "OR",
524
+ size: { w: 120, h: 80 },
525
+ inputs: [
526
+ { name: "a", datatype: "boolean" },
527
+ { name: "b", datatype: "boolean" },
528
+ ],
529
+ outputs: [{ name: "result", datatype: "boolean" }],
530
+ onExecute(node, { getInput, setOutput }) {
531
+ const a = getInput("a") ?? false;
532
+ const b = getInput("b") ?? false;
533
+ setOutput("result", a || b);
534
+ },
535
+ });
536
+
537
+ registry.register("logic/NOT", {
538
+ title: "NOT",
539
+ size: { w: 120, h: 70 },
540
+ inputs: [{ name: "in", datatype: "boolean" }],
541
+ outputs: [{ name: "out", datatype: "boolean" }],
542
+ onExecute(node, { getInput, setOutput }) {
543
+ const val = getInput("in") ?? false;
544
+ setOutput("out", !val);
545
+ },
546
+ });
547
+
548
+ // ===== VALUE NODES =====
549
+ registry.register("value/Number", {
550
+ title: "Number",
551
+ size: { w: 140, h: 60 },
552
+ outputs: [{ name: "value", portType: "data", datatype: "number" }],
553
+ onCreate(node) {
554
+ node.state.value = 0;
555
+ },
556
+ onExecute(node, { setOutput }) {
557
+ console.log("[Number] Outputting value:", node.state.value ?? 0);
558
+ setOutput("value", node.state.value ?? 0);
559
+ },
560
+ html: {
561
+ init(node, el, { header, body }) {
562
+ el.style.backgroundColor = "#1e1e24";
563
+ el.style.border = "1px solid #444";
564
+ el.style.borderRadius = "8px";
565
+
566
+ header.style.backgroundColor = "#2a2a31";
567
+ header.style.borderBottom = "1px solid #444";
568
+ header.style.color = "#eee";
569
+ header.style.fontSize = "12px";
570
+ header.textContent = "Number";
571
+
572
+ body.style.padding = "12px";
573
+ body.style.display = "flex";
574
+ body.style.alignItems = "center";
575
+ body.style.justifyContent = "center";
576
+
577
+ const input = document.createElement("input");
578
+ input.type = "number";
579
+ input.value = node.state.value ?? 0;
580
+ Object.assign(input.style, {
581
+ width: "100%",
582
+ padding: "6px",
583
+ background: "#141417",
584
+ border: "1px solid #444",
585
+ borderRadius: "4px",
586
+ color: "#fff",
587
+ fontSize: "14px",
588
+ textAlign: "center",
589
+ pointerEvents: "auto",
590
+ });
591
+
592
+ input.addEventListener("change", (e) => {
593
+ node.state.value = parseFloat(e.target.value) || 0;
594
+ });
595
+
596
+ input.addEventListener("mousedown", (e) => e.stopPropagation());
597
+ input.addEventListener("keydown", (e) => e.stopPropagation());
598
+
599
+ body.appendChild(input);
600
+ },
601
+ update(node, el, { header, body, selected }) {
602
+ el.style.borderColor = selected ? "#6cf" : "#444";
603
+ header.style.backgroundColor = selected ? "#3a4a5a" : "#2a2a31";
604
+ },
605
+ },
606
+ onDraw(node, { ctx, theme }) {
607
+ const { x, y } = node.computed;
608
+ ctx.fillStyle = "#8f8";
609
+ ctx.font = "14px sans-serif";
610
+ ctx.textAlign = "center";
611
+ ctx.fillText(String(node.state.value ?? 0), x + 70, y + 42);
612
+ },
613
+ });
614
+
615
+ registry.register("value/String", {
616
+ title: "String",
617
+ size: { w: 160, h: 60 },
618
+ outputs: [{ name: "value", datatype: "string" }],
619
+ onCreate(node) {
620
+ node.state.value = "Hello";
621
+ },
622
+ onExecute(node, { setOutput }) {
623
+ setOutput("value", node.state.value ?? "");
624
+ },
625
+ onDraw(node, { ctx, theme }) {
626
+ const { x, y } = node.computed;
627
+ ctx.fillStyle = "#8f8";
628
+ ctx.font = "12px sans-serif";
629
+ ctx.textAlign = "center";
630
+ const text = String(node.state.value ?? "");
631
+ const displayText = text.length > 15 ? text.substring(0, 15) + "..." : text;
632
+ ctx.fillText(displayText, x + 80, y + 42);
633
+ },
634
+ });
635
+
636
+ registry.register("value/Boolean", {
637
+ title: "Boolean",
638
+ size: { w: 140, h: 60 },
639
+ outputs: [{ name: "value", portType: "data", datatype: "boolean" }],
640
+ onCreate(node) {
641
+ node.state.value = true;
642
+ },
643
+ onExecute(node, { setOutput }) {
644
+ console.log("[Boolean] Outputting value:", node.state.value ?? false);
645
+ setOutput("value", node.state.value ?? false);
646
+ },
647
+ onDraw(node, { ctx, theme }) {
648
+ const { x, y } = node.computed;
649
+ ctx.fillStyle = node.state.value ? "#8f8" : "#f88";
650
+ ctx.font = "14px sans-serif";
651
+ ctx.textAlign = "center";
652
+ ctx.fillText(String(node.state.value), x + 70, y + 42);
653
+ },
654
+ });
655
+
656
+ // ===== UTILITY NODES =====
657
+ registry.register("util/Print", {
658
+ title: "Print",
659
+ size: { w: 140, h: 80 },
660
+ inputs: [
661
+ { name: "exec", portType: "exec" },
662
+ { name: "value", portType: "data", datatype: "any" },
663
+ ],
664
+ onCreate(node) {
665
+ node.state.lastValue = null;
666
+ },
667
+ onExecute(node, { getInput }) {
668
+ const val = getInput("value");
669
+ if (val !== node.state.lastValue) {
670
+ console.log("[Print]", val);
671
+ node.state.lastValue = val;
672
+ }
673
+ },
674
+ });
675
+
676
+ registry.register("util/Watch", {
677
+ title: "Watch",
678
+ size: { w: 180, h: 110 },
679
+ inputs: [
680
+ { name: "exec", portType: "exec" },
681
+ { name: "value", portType: "data", datatype: "any" },
682
+ ],
683
+ outputs: [
684
+ { name: "exec", portType: "exec" },
685
+ { name: "value", portType: "data", datatype: "any" },
686
+ ],
687
+ onCreate(node) {
688
+ node.state.displayValue = "---";
689
+ },
690
+ onExecute(node, { getInput, setOutput }) {
691
+ const val = getInput("value");
692
+ console.log("[Watch] onExecute called, value:", val);
693
+ node.state.displayValue = String(val ?? "---");
694
+ setOutput("value", val);
695
+ },
696
+ onDraw(node, { ctx, theme }) {
697
+ const { x, y } = node.computed;
698
+ ctx.fillStyle = "#fa3";
699
+ ctx.font = "11px monospace";
700
+ ctx.textAlign = "left";
701
+ const text = String(node.state.displayValue ?? "---");
702
+ const displayText = text.length > 20 ? text.substring(0, 20) + "..." : text;
703
+ ctx.fillText(displayText, x + 8, y + 50);
704
+ },
705
+ });
706
+
707
+ registry.register("util/Timer", {
708
+ title: "Timer",
709
+ size: { w: 140, h: 60 },
710
+ outputs: [{ name: "time", datatype: "number" }],
711
+ onCreate(node) {
712
+ node.state.startTime = performance.now();
713
+ },
714
+ onExecute(node, { setOutput }) {
715
+ const elapsed = (performance.now() - (node.state.startTime ?? 0)) / 1000;
716
+ setOutput("time", elapsed.toFixed(2));
717
+ },
718
+ });
719
+
720
+ // Trigger Node with Button (HTML Overlay)
721
+ registry.register("util/Trigger", {
722
+ title: "Trigger",
723
+ size: { w: 140, h: 80 },
724
+ outputs: [{ name: "exec", portType: "exec" }], // Changed to exec port
725
+
726
+ html: {
727
+ init(node, el, { header, body }) {
728
+ el.style.backgroundColor = "#1e1e24";
729
+ el.style.border = "1px solid #444";
730
+ el.style.borderRadius = "8px";
731
+
732
+ header.style.backgroundColor = "#2a2a31";
733
+ header.style.borderBottom = "1px solid #444";
734
+ header.style.color = "#eee";
735
+ header.style.fontSize = "12px";
736
+ header.textContent = "Trigger";
737
+
738
+ body.style.padding = "12px";
739
+ body.style.display = "flex";
740
+ body.style.alignItems = "center";
741
+ body.style.justifyContent = "center";
742
+
743
+ const button = document.createElement("button");
744
+ button.textContent = "Fire!";
745
+ Object.assign(button.style, {
746
+ padding: "8px 16px",
747
+ background: "#4a9eff",
748
+ border: "none",
749
+ borderRadius: "4px",
750
+ color: "#fff",
751
+ fontWeight: "bold",
752
+ cursor: "pointer",
753
+ pointerEvents: "auto",
754
+ transition: "background 0.2s",
755
+ });
756
+
757
+ button.addEventListener("mousedown", (e) => {
758
+ e.stopPropagation();
759
+ button.style.background = "#2a7ede";
760
+ });
761
+
762
+ button.addEventListener("mouseup", () => {
763
+ button.style.background = "#4a9eff";
764
+ });
765
+
766
+ button.addEventListener("click", (e) => {
767
+ e.stopPropagation();
768
+ node.state.triggered = true;
769
+ console.log("[Trigger] Button clicked!");
770
+
771
+ // Use runner.runOnce for connected node execution
772
+ if (node.__runnerRef && node.__controllerRef) {
773
+ console.log("[Trigger] Runner and controller found");
774
+ const runner = node.__runnerRef;
775
+ const controller = node.__controllerRef;
776
+ const graph = controller.graph;
777
+ console.log("[Trigger] Calling runner.runOnce with node.id:", node.id);
778
+
779
+ // Execute connected nodes using runner
780
+ const result = runner.runOnce(node.id, 0);
781
+ const connectedEdges = result.connectedEdges;
782
+
783
+
784
+
785
+ // Show animation with manual rendering
786
+ const startTime = performance.now();
787
+ const animationDuration = 500;
788
+
789
+ const animate = () => {
790
+ const elapsed = performance.now() - startTime;
791
+ if (elapsed < animationDuration) {
792
+ controller.renderer.draw(graph, {
793
+ selection: controller.selection,
794
+ tempEdge: null,
795
+ running: true,
796
+ time: performance.now(),
797
+ dt: 0,
798
+ activeEdges: connectedEdges, // Only animate connected edges
799
+ });
800
+ controller.htmlOverlay?.draw(graph, controller.selection);
801
+ requestAnimationFrame(animate);
802
+ } else {
803
+ controller.render();
804
+ node.state.triggered = false;
805
+ }
806
+ };
807
+
808
+ animate();
809
+ }
810
+ });
811
+
812
+ body.appendChild(button);
813
+ },
814
+
815
+ update(node, el, { header, body, selected }) {
816
+ el.style.borderColor = selected ? "#6cf" : "#444";
817
+ header.style.backgroundColor = selected ? "#3a4a5a" : "#2a2a31";
818
+ },
819
+ },
820
+
821
+ onCreate(node) {
822
+ node.state.triggered = false;
823
+ },
824
+
825
+ onExecute(node, { setOutput }) {
826
+ console.log("[Trigger] Outputting triggered:", node.state.triggered);
827
+ setOutput("triggered", node.state.triggered);
828
+ },
829
+ });
830
+
831
+ // Group Node
832
+ registry.register("core/Group", {
833
+ title: "Group",
834
+ size: { w: 240, h: 160 },
835
+ onDraw(node, { ctx, theme }) {
836
+ const { x, y, w, h } = node.computed;
837
+ const headerH = 24;
838
+ const color = node.state.color || "#39424e";
839
+ const bgAlpha = 0.5;
840
+ const textColor = theme.text || "#e9e9ef";
841
+
842
+ // Helper for rgba
843
+ const rgba = (hex, a) => {
844
+ const c = hex.replace("#", "");
845
+ const n = parseInt(
846
+ c.length === 3
847
+ ? c
848
+ .split("")
849
+ .map((x) => x + x)
850
+ .join("")
851
+ : c,
852
+ 16
853
+ );
854
+ const r = (n >> 16) & 255,
855
+ g = (n >> 8) & 255,
856
+ b = n & 255;
857
+ return `rgba(${r},${g},${b},${a})`;
858
+ };
859
+
860
+ // Helper for roundRect
861
+ const roundRect = (ctx, x, y, w, h, r) => {
862
+ if (w < 2 * r) r = w / 2;
863
+ if (h < 2 * r) r = h / 2;
864
+ ctx.beginPath();
865
+ ctx.moveTo(x + r, y);
866
+ ctx.arcTo(x + w, y, x + w, y + h, r);
867
+ ctx.arcTo(x + w, y + h, x, y + h, r);
868
+ ctx.arcTo(x, y + h, x, y, r);
869
+ ctx.arcTo(x, y, x + w, y, r);
870
+ ctx.closePath();
871
+ };
872
+
873
+ // Body
874
+ ctx.fillStyle = rgba(color, bgAlpha);
875
+ roundRect(ctx, x, y, w, h, 10);
876
+ ctx.fill();
877
+
878
+ // Header bar (subtle)
879
+ ctx.fillStyle = rgba(color, 0.3);
880
+ ctx.beginPath();
881
+ ctx.roundRect(x, y, w, headerH, [10, 10, 0, 0]);
882
+ ctx.fill();
883
+
884
+ // Title - top left with better styling
885
+ ctx.fillStyle = textColor;
886
+ ctx.font = "600 13px system-ui";
887
+ ctx.textBaseline = "top";
888
+ ctx.fillText(node.title, x + 12, y + 6);
889
+ },
890
+ });
891
+
892
+ /**
893
+ * Setup default context menu items
894
+ * This function can be customized or replaced by users
895
+ */
896
+ function setupDefaultContextMenu(contextMenu, { controller, graph, hooks }) {
897
+ // Add Node submenu (canvas background only)
898
+ const nodeTypes = [];
899
+ for (const [key, typeDef] of graph.registry.types.entries()) {
900
+ nodeTypes.push({
901
+ id: `add-${key}`,
902
+ label: typeDef.title || key,
903
+ action: () => {
904
+ // Get world position from context menu
905
+ const worldPos = contextMenu.worldPosition || { x: 100, y: 100 };
906
+
907
+ // Add node at click position
908
+ const node = graph.addNode(key, {
909
+ x: worldPos.x,
910
+ y: worldPos.y,
911
+ });
912
+
913
+ hooks?.emit("node:updated", node);
914
+ controller.render(); // Update minimap and canvas
915
+ },
916
+ });
917
+ }
918
+
919
+ contextMenu.addItem("add-node", "Add Node", {
920
+ condition: (target) => !target,
921
+ submenu: nodeTypes,
922
+ order: 5,
923
+ });
924
+
925
+ // Delete Node (for all nodes except groups)
926
+ contextMenu.addItem("delete-node", "Delete Node", {
927
+ condition: (target) => target && target.type !== "core/Group",
928
+ action: (target) => {
929
+ const cmd = RemoveNodeCmd(graph, target);
930
+ controller.stack.exec(cmd);
931
+ hooks?.emit("node:updated", target);
932
+ },
933
+ order: 10,
934
+ });
935
+
936
+ // Change Group Color (for groups only) - with submenu
937
+ const colors = [
938
+ { name: "Default", color: "#39424e" },
939
+ { name: "Slate", color: "#4a5568" },
940
+ { name: "Gray", color: "#2d3748" },
941
+ { name: "Blue", color: "#1a365d" },
942
+ { name: "Green", color: "#22543d" },
943
+ { name: "Red", color: "#742a2a" },
944
+ { name: "Purple", color: "#44337a" },
945
+ ];
946
+
947
+ contextMenu.addItem("change-group-color", "Change Color", {
948
+ condition: (target) => target && target.type === "core/Group",
949
+ submenu: colors.map((colorInfo) => ({
950
+ id: `color-${colorInfo.color}`,
951
+ label: colorInfo.name,
952
+ color: colorInfo.color,
953
+ action: (target) => {
954
+ const currentColor = target.state.color || "#39424e";
955
+ const cmd = ChangeGroupColorCmd(target, currentColor, colorInfo.color);
956
+ controller.stack.exec(cmd);
957
+ hooks?.emit("node:updated", target);
958
+ },
959
+ })),
960
+ order: 20,
961
+ });
962
+ contextMenu.addItem("delete-group", "Delete Group", {
963
+ condition: (target) => target && target.type === "core/Group",
964
+ action: (target) => {
965
+ const cmd = RemoveNodeCmd(graph, target);
966
+ controller.stack.exec(cmd);
967
+ hooks?.emit("node:updated", target);
968
+ },
969
+ order: 20,
970
+ });
971
+ }
972
+
973
+
974
+ // Setup default context menu items
975
+ // Users can easily override, remove, or add items here
976
+ setupDefaultContextMenu(contextMenu, { controller, graph, hooks });
977
+
978
+ // initial render & resize
979
+ renderer.resize(canvas.clientWidth, canvas.clientHeight);
980
+ portRenderer.resize(canvas.clientWidth, canvas.clientHeight);
981
+ controller.render();
982
+
983
+ const ro = new ResizeObserver(() => {
984
+ renderer.resize(canvas.clientWidth, canvas.clientHeight);
985
+ portRenderer.resize(canvas.clientWidth, canvas.clientHeight);
986
+ controller.render();
987
+ });
988
+ ro.observe(canvas);
989
+
990
+ // Wrap controller.render to update minimap
991
+ const originalRender = controller.render.bind(controller);
992
+ controller.render = function () {
993
+ originalRender();
994
+ if (minimap) {
995
+ minimap.render();
996
+ }
997
+ };
998
+
999
+ const api = {
1000
+ addGroup: (args = {}) => {
1001
+ controller.graph.groupManager.addGroup(args);
1002
+ controller.render();
1003
+ },
1004
+ graph,
1005
+ renderer,
1006
+ controller, // Expose controller for snap-to-grid access
1007
+ runner, // Expose runner for trigger
1008
+ minimap, // Expose minimap
1009
+ contextMenu,
1010
+ hooks, // Expose hooks for event handling
1011
+ registry, // Expose registry for node types
1012
+ htmlOverlay, // Expose htmlOverlay for clearing/resetting
1013
+ propertyPanel, // Expose propertyPanel
1014
+ render: () => controller.render(),
1015
+ start: () => runner.start(),
1016
+ stop: () => runner.stop(),
1017
+ destroy: () => {
1018
+ runner.stop();
1019
+ ro.disconnect();
1020
+ controller.destroy();
1021
+ htmlOverlay.destroy();
1022
+ contextMenu.destroy();
1023
+ if (propertyPanel) propertyPanel.destroy();
1024
+ if (minimap) minimap.destroy();
1025
+ },
1026
+ };
1027
+
1028
+ if (autorun) runner.start();
1029
+ return api;
1030
+ }