@elicdavis/node-flow 0.1.20 → 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 (53) hide show
  1. package/dist/index.d.mts +386 -227
  2. package/dist/index.d.ts +386 -227
  3. package/dist/index.js +1866 -1059
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +1849 -1058
  6. package/dist/index.mjs.map +1 -1
  7. package/index.html +1134 -329
  8. package/package.json +1 -1
  9. package/src/connection.ts +30 -5
  10. package/src/contextMenu.ts +66 -34
  11. package/src/graph.ts +12 -10
  12. package/src/index.ts +26 -12
  13. package/src/markdown/entry.ts +11 -15
  14. package/src/markdown/syntaxParser.ts +2 -1
  15. package/src/node.ts +160 -101
  16. package/src/nodes/subsystem.ts +113 -29
  17. package/src/notes/note.ts +9 -1
  18. package/src/organize.ts +540 -111
  19. package/src/popup.ts +4 -2
  20. package/src/port.ts +83 -6
  21. package/src/styles/bevel.ts +31 -0
  22. package/src/styles/cursor.ts +1 -0
  23. package/src/styles/text.ts +4 -4
  24. package/src/theme.ts +179 -92
  25. package/src/types/text.ts +5 -1
  26. package/src/utils/color.ts +1 -1
  27. package/src/widgets/button.ts +52 -107
  28. package/src/widgets/color.ts +91 -150
  29. package/src/widgets/factory.ts +89 -46
  30. package/src/widgets/field.ts +99 -0
  31. package/src/widgets/image.ts +107 -165
  32. package/src/widgets/inplace.ts +84 -0
  33. package/src/widgets/number.ts +122 -154
  34. package/src/widgets/row.ts +138 -0
  35. package/src/widgets/slider.ts +101 -216
  36. package/src/widgets/string.ts +39 -130
  37. package/src/widgets/text.ts +81 -92
  38. package/src/widgets/toggle.ts +62 -207
  39. package/src/widgets/value.ts +127 -0
  40. package/src/widgets/widget.ts +180 -34
  41. package/test/context_menu.test.ts +82 -0
  42. package/test/cursor.test.ts +17 -0
  43. package/test/hidden_port.test.ts +125 -0
  44. package/test/markdown.test.ts +2 -2
  45. package/test/markdown_code_block.test.ts +58 -0
  46. package/test/multi_input.test.ts +124 -0
  47. package/test/organize.test.ts +117 -1
  48. package/test/support.ts +175 -0
  49. package/test/theme.test.ts +86 -0
  50. package/test/widget_click.test.ts +111 -97
  51. package/test/widget_construction.test.ts +5 -4
  52. package/test/widgets.test.ts +333 -0
  53. package/test/wire_follows_camera.test.ts +57 -0
package/index.html CHANGED
@@ -1,329 +1,1134 @@
1
- <html>
2
-
3
- <head>
4
- <meta http-equiv="content-type" content="text/html; charset=utf-8" />
5
- <title>Node Flow Demo</title>
6
- <link rel="shortcut icon" href="#" />
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
- <link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap"
10
- rel="stylesheet">
11
- <style>
12
- * {
13
- margin: 0;
14
- padding: 0;
15
- }
16
-
17
- html,
18
- body {
19
- width: 100%;
20
- height: 100%;
21
- }
22
-
23
- canvas {
24
- width: 100%;
25
- height: 100%;
26
- display: block;
27
- }
28
- </style>
29
-
30
- <script src="./dist/web/NodeFlow.js" type="module"></script>
31
- </head>
32
-
33
- <body>
34
- <canvas id="canvas"></canvas>
35
- <script type="module">
36
- import { Theme, FlowNode, NodeFlowGraph, FlowNote } from "./dist/web/NodeFlow.js";
37
-
38
- const canvas = document.getElementById("canvas");
39
- Theme.FontFamily = "Source Code Pro";
40
-
41
- const node3 = new FlowNode({
42
- title: "Node 3",
43
- position: { x: 850, y: 150 },
44
- inputs: [
45
- { name: "test input 1", type: "int" }
46
- ],
47
- outputs: [
48
- { name: "float", type: "float32" }
49
- ],
50
- widgets: [
51
- {
52
- type: "slider",
53
- config: {
54
- min: 0,
55
- max: 5,
56
- value: 2.5,
57
- snapIncrement: .25
58
- }
59
- }
60
- ]
61
- });
62
-
63
- const graph = new NodeFlowGraph(canvas, {
64
- nodes: {
65
- publishers: {
66
- "example": {
67
- name: "Example",
68
- description: "Bunch of example nodes",
69
- version: "v1.0.0",
70
- nodes: {
71
- "empty": {
72
- title: "Empty Node",
73
- canEditTitle: true,
74
- canEditPorts: true,
75
- canEditInfo: true
76
- },
77
- "widgets/slider": {
78
- title: "Slider Node",
79
- widgets: [
80
- {
81
- type: "slider",
82
- config: {
83
- value: 0.5,
84
- }
85
- }
86
- ],
87
- outputs: [
88
- {
89
- name: "out",
90
- type: "float32"
91
- }
92
- ]
93
- },
94
- "widgets/color": {
95
- title: "Color Node",
96
- widgets: [
97
- {
98
- type: "color",
99
- config: {
100
- }
101
- }
102
- ],
103
- outputs: [
104
- {
105
- name: "out",
106
- type: "color"
107
- }
108
- ]
109
- },
110
- "widgets/text": {
111
- title: "Text Node",
112
- widgets: [
113
- {
114
- type: "text",
115
- config: {
116
- value: "Arbitrary Text"
117
- }
118
- }
119
- ],
120
- outputs: [
121
- {
122
- name: "out",
123
- type: "color"
124
- }
125
- ]
126
- }
127
- }
128
- },
129
- },
130
- },
131
- board: {
132
- notes: [
133
- {
134
- text: `
135
- # Notes
136
-
137
- This is an example of a *note* written in **markdown**.
138
-
139
- You can place notes around your graph to help explain your graph to other users.
140
-
141
- ## Editting
142
-
143
- *Right-click* on this note and select "Edit Note" to put your text here!
144
-
145
- ## Support
146
-
147
- This is a minimal implementation with graph-specific functionality. Currently implemented features are:
148
-
149
- * H1, H2, and H3
150
- * Bold and Italic
151
- * Unordered Lists
152
- * Code blocks
153
- `,
154
- position: {
155
- x: 2300,
156
- y: 20
157
- },
158
- locked: true,
159
- },
160
- {
161
- position: {
162
- x: 20,
163
- y: 20
164
- },
165
- locked: true,
166
- text: `
167
- # Node Flow
168
-
169
- Node Flow is a javascript library that enables developers to build node based tools similar to Unreal Blueprints or Blender Nodes.
170
-
171
- ## Example
172
-
173
- Scattered across this graph contains code snippets on how to accomplish different things within the library.
174
-
175
- \`\`\`
176
- // Create a canvas to render our graph to
177
- let canvas = document.createElement("canvas");
178
-
179
- // Create our Node Flow Graph
180
- let graph = new NodeFlowGraph(canvas)
181
- \`\`\`
182
- `
183
- },
184
-
185
- ]
186
- }
187
- });
188
-
189
- graph.addNote(new FlowNote({
190
- position: { x: 800, y: 20 },
191
- locked: true,
192
- text: `
193
- ## Inputs and Outputs
194
-
195
- Create a Add node that takes two numbers and outputs a single number
196
-
197
- \`\`\`
198
- let node = new FlowNode({
199
- title: "Add",
200
- info: "I add two numbers",
201
- inputs: [
202
- { name: "a", type: "float32" },
203
- { name: "b", type: "float32" }
204
- ],
205
- outputs: [
206
- {
207
- name: "sum",
208
- type: "float32",
209
- description: "The sum of a and b"
210
- }
211
- ],
212
- });
213
- graph.addNode(node);
214
- \`\`\`
215
-
216
- You can also add additional inputs and outputs to the node after it's been created
217
-
218
- \`\`\`
219
- node.addInput({ name: "c", type: "float32" })
220
- node.addOutput({ name: "sum", type: "float32" })
221
- \`\`\`
222
- ` }));
223
-
224
-
225
-
226
- var sumNode = new FlowNode({
227
- position: {
228
- x: 750,
229
- y: 700
230
- },
231
- title: "Add",
232
- info: "I add two numbers",
233
- inputs: [
234
- { name: "a", type: "float32" },
235
- { name: "b", type: "float32" }
236
- ],
237
- outputs: [
238
- { name: "sum", type: "float32", description: "The sum of a and b" }
239
- ],
240
- });
241
-
242
-
243
- var aNode = new FlowNode({
244
- position: {
245
- x: 450,
246
- y: 500
247
- },
248
- title: "Number",
249
- info: "I'm a node with a number widget",
250
- outputs: [
251
- { name: "value", type: "float32" }
252
- ],
253
- widgets: [
254
- {
255
- type: "number",
256
- config: { value: 1 }
257
- }
258
- ]
259
- });
260
-
261
- var bNode = new FlowNode({
262
- position: {
263
- x: 450,
264
- y: 650
265
- },
266
- title: "Number",
267
- info: "I'm a node with both a number and image widget on it",
268
- outputs: [
269
- { name: "value", type: "float32" }
270
- ],
271
- widgets: [
272
- {
273
- type: "number",
274
- config: { value: 2 }
275
- },
276
- {
277
- type: "image",
278
- config: {
279
- image: "https://i.imgur.com/qRZgWC0.jpeg"
280
- }
281
- }
282
- ]
283
- })
284
-
285
- const arrNode = new FlowNode({
286
- position: {
287
- x: 950,
288
- y: 700
289
- },
290
- title: "Add",
291
- subTitle: "Array Inputs",
292
- info: "This node contains a port that can take multiple input nodes at once, which can be useful for operations that can operate on [0,n] data sizes",
293
- inputs: [
294
- { name: "numbers", type: "float32", array: true, description: "All numbers to add together" },
295
- ],
296
- outputs: [
297
- { name: "sum", type: "float32", description: "The sum of all connected nodes to our 'numbers' port" }
298
- ],
299
- messages: [
300
- {
301
- message: "I am a message attatched to the node",
302
- alwaysShow: true,
303
- },
304
- {
305
- message: "I am a message that only shows when you're hovering over the node!",
306
- },
307
- {
308
- message: "I am another message attatched to the node with type \"error\"",
309
- type: "error",
310
- alwaysShow: true,
311
- }
312
- ],
313
- });
314
-
315
- graph.addNode(sumNode)
316
- graph.addNode(aNode)
317
- graph.addNode(bNode)
318
- graph.addNode(arrNode)
319
-
320
- graph.connectNodes(aNode, 0, sumNode, 0)
321
- graph.connectNodes(bNode, 0, sumNode, 1)
322
-
323
- graph.connectNodes(aNode, 0, arrNode, 0)
324
- graph.connectNodes(bNode, 0, arrNode, 0)
325
-
326
- </script>
327
- </body>
328
-
329
- </html>
1
+ <!doctype html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <title>Node Flow Demo</title>
8
+ <link rel="shortcut icon" href="#" />
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap"
12
+ rel="stylesheet">
13
+ <style>
14
+ * {
15
+ margin: 0;
16
+ padding: 0;
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ :root {
21
+ --page: #07212a;
22
+ --font: "Source Code Pro", monospace;
23
+ --node-bg: #0c2b35;
24
+ --node-border: #1c1c1c;
25
+ --node-radius: 15px;
26
+ --node-bevel: none;
27
+ --title-bg: #154050;
28
+ --title-fg: #afb9bb;
29
+ --text: #afb9bb;
30
+ --dim: #7f9497;
31
+ --widget-bg: #0f313c;
32
+ --widget-fg: #afb9bb;
33
+ --widget-border: 2px solid #154050;
34
+ --widget-radius: 2px;
35
+ --widget-hover: #195366;
36
+ --widget-active: #1f637a;
37
+ --widget-bevel: none;
38
+ --widget-bevel-pressed: none;
39
+ }
40
+
41
+ html,
42
+ body {
43
+ width: 100%;
44
+ height: 100%;
45
+ overflow: hidden;
46
+ background: var(--page);
47
+ font-family: var(--font);
48
+ }
49
+
50
+ #stage {
51
+ position: absolute;
52
+ inset: 0;
53
+ }
54
+
55
+ canvas {
56
+ width: 100%;
57
+ height: 100%;
58
+ display: block;
59
+ }
60
+
61
+ .node {
62
+ position: absolute;
63
+ left: 12px;
64
+ display: flex;
65
+ flex-direction: column;
66
+ max-width: calc(100% - 24px);
67
+ background: var(--node-bg);
68
+ border: 1px solid var(--node-border);
69
+ border-radius: var(--node-radius);
70
+ box-shadow: var(--node-bevel);
71
+ overflow: hidden;
72
+ color: var(--text);
73
+ font-size: 14px;
74
+ }
75
+
76
+ .node-title {
77
+ padding: 5px 15px 8px;
78
+ background: var(--title-bg);
79
+ color: var(--title-fg);
80
+ text-align: center;
81
+ font-weight: 700;
82
+ font-size: 16px;
83
+ cursor: pointer;
84
+ user-select: none;
85
+ }
86
+
87
+ .node-title small {
88
+ display: block;
89
+ font-size: 10px;
90
+ }
91
+
92
+ .node-body {
93
+ display: flex;
94
+ flex-direction: column;
95
+ gap: 8px;
96
+ padding: 15px;
97
+ min-height: 0;
98
+ }
99
+
100
+ .node.collapsed .node-body {
101
+ display: none;
102
+ }
103
+
104
+ .widget {
105
+ height: 25px;
106
+ padding: 0 10px;
107
+ font: inherit;
108
+ color: var(--widget-fg);
109
+ background: var(--widget-bg);
110
+ border: var(--widget-border);
111
+ border-radius: var(--widget-radius);
112
+ box-shadow: var(--widget-bevel);
113
+ cursor: pointer;
114
+ white-space: nowrap;
115
+ }
116
+
117
+ .widget:hover {
118
+ background: var(--widget-hover);
119
+ }
120
+
121
+ .widget:active {
122
+ background: var(--widget-active);
123
+ box-shadow: var(--widget-bevel-pressed);
124
+ }
125
+
126
+ #toolbar {
127
+ top: 12px;
128
+ left: auto;
129
+ right: 12px;
130
+ width: 420px;
131
+ }
132
+
133
+ #toolbar .node-body {
134
+ display: grid;
135
+ grid-template-columns: repeat(3, 1fr);
136
+ }
137
+
138
+ #log {
139
+ bottom: 12px;
140
+ width: 360px;
141
+ max-height: 34vh;
142
+ }
143
+
144
+ #log-list {
145
+ list-style: none;
146
+ overflow-y: auto;
147
+ min-height: 0;
148
+ flex: 1;
149
+ }
150
+
151
+ #log-list li {
152
+ line-height: 22px;
153
+ white-space: nowrap;
154
+ overflow: hidden;
155
+ text-overflow: ellipsis;
156
+ }
157
+
158
+ #log-list li span {
159
+ color: var(--dim);
160
+ }
161
+ </style>
162
+ </head>
163
+
164
+ <body>
165
+ <div id="stage"><canvas id="canvas"></canvas></div>
166
+
167
+ <div id="toolbar" class="node">
168
+ <div class="node-title"><small>NodeFlowGraph</small>Controls</div>
169
+ <div class="node-body">
170
+ <button class="widget" data-act="center" title="graph.centerOnGraph()">Center</button>
171
+ <button class="widget" data-act="reset" title="graph.resetCamera()">Reset</button>
172
+ <button class="widget" data-act="minimap" title="graph.minimap().setEnabled(...)">Minimap</button>
173
+ <button class="widget" data-act="zoom-in" title="graph.zoom(1)">Zoom in</button>
174
+ <button class="widget" data-act="zoom-out" title="graph.zoom(-1)">Zoom out</button>
175
+ <button class="widget" data-act="organize" title="graph.organize()">Organize</button>
176
+ <button class="widget" data-act="select-all" title="node.select()">Select all</button>
177
+ <button class="widget" data-act="delete" title="graph.removeSelectedNodes()">Delete</button>
178
+ <button class="widget" data-act="continuous" title="graph.renderContinuously()">Animate</button>
179
+ <button class="widget" data-act="theme" title="setTheme(...)" style="grid-column: 1 / -1">Theme: Windows 95</button>
180
+ </div>
181
+ </div>
182
+
183
+ <div id="log" class="node">
184
+ <div class="node-title"><small>Listeners</small>Events</div>
185
+ <div class="node-body">
186
+ <ol id="log-list"></ol>
187
+ <button class="widget" id="log-clear">Clear</button>
188
+ </div>
189
+ </div>
190
+
191
+ <script type="module">
192
+ import {
193
+ Theme, FlowNode, FlowNote, NodeFlowGraph, Publisher,
194
+ MessageType, ContextMenuItemState, DefaultConnectionRenderer,
195
+ WidgetBase, ValueWidget, drawFieldBox, widgetFont,
196
+ NumberWidget, StringWidget, ToggleWidget, ButtonWidget, TextWidget, ImageWidget, RowWidget,
197
+ setTheme, onThemeChange,
198
+ } from "./dist/web/NodeFlow.js";
199
+
200
+ const win95Font = "Tahoma, 'Microsoft Sans Serif', 'MS Sans Serif', sans-serif";
201
+ const win95Bevel = { Light: "#ffffff", Dark: "#808080" };
202
+ const themes = {
203
+ "Default": {
204
+ FontFamily: "Source Code Pro",
205
+ },
206
+ "Windows 95": {
207
+ FontFamily: win95Font,
208
+ Graph: { BackgroundColor: "#008080", DotColor: "#006a6a" },
209
+ Node: {
210
+ FontColor: "#000000",
211
+ BackgroundColor: "#c0c0c0",
212
+ Title: { Color: "#000080", FontColor: "#ffffff" },
213
+ BorderRadius: 0,
214
+ Border: { Idle: "#000000", MouseOver: "#000000", Grabbed: "#000000", Selected: "#ffffff" },
215
+ Bevel: win95Bevel,
216
+ Port: { FontColor: "#000000" },
217
+ Message: { InfoColor: "#ffffff", WarnColor: "#ffff00", ErrorColor: "#ff4040" },
218
+ },
219
+ BoxSelect: { Color: "#ffffff", Radius: 0 },
220
+ Widget: {
221
+ FontColor: "#000000",
222
+ LabelColor: "#000000",
223
+ FontSize: 13,
224
+ BackgroundColor: "#c0c0c0",
225
+ Border: { Color: "#000000", Size: 0, Radius: 0 },
226
+ Bevel: win95Bevel,
227
+ Hover: { BackgroundColor: "#c0c0c0" },
228
+ Input: { BackgroundColor: "#ffffff", HoverBackgroundColor: "#ffffff", FontColor: "#000000" },
229
+ Slider: { FillColor: "#a6caf0" },
230
+ Button: { Click: { BackgroundColor: "#c0c0c0" } },
231
+ },
232
+ ContextMenu: {
233
+ BackgroundColor: "#c0c0c0",
234
+ HighlightColor: "#000080",
235
+ HighlightFontColor: "#ffffff",
236
+ FontColor: "#000000",
237
+ DisabledFontColor: "#808080",
238
+ BorderRadius: 0,
239
+ },
240
+ Minimap: {
241
+ BackgroundColor: "#c0c0c0",
242
+ BorderColor: "#000000",
243
+ BorderRadius: 0,
244
+ NodeColor: "#000080",
245
+ ViewportColor: "rgba(0, 0, 0, 0.2)",
246
+ },
247
+ Popup: {
248
+ BackgroundColor: "#c0c0c0",
249
+ FontColor: "#000000",
250
+ FontFamily: win95Font,
251
+ BorderRadius: 0,
252
+ Backdrop: "rgba(0, 0, 0, 0.25)",
253
+ },
254
+ Note: {
255
+ FontColor: "#ffffff",
256
+ CodeBlock: { BackgroundColor: "#000000", FontColor: "#c0c0c0", BorderRadius: 0 },
257
+ },
258
+ },
259
+ };
260
+
261
+ function bevelShadow(bevel, sunken) {
262
+ if (!bevel) {
263
+ return "none";
264
+ }
265
+ const [topLeft, bottomRight] = sunken ? [bevel.Dark, bevel.Light] : [bevel.Light, bevel.Dark];
266
+ return `inset 2px 2px 0 ${topLeft}, inset -2px -2px 0 ${bottomRight}`;
267
+ }
268
+
269
+ function syncPageToTheme() {
270
+ const css = document.documentElement.style;
271
+ const set = (name, value) => css.setProperty(name, value);
272
+ set("--page", Theme.Graph.BackgroundColor);
273
+ set("--font", Theme.FontFamily);
274
+ set("--node-bg", Theme.Node.BackgroundColor);
275
+ set("--node-border", Theme.Node.Border.Idle);
276
+ set("--node-radius", `${Theme.Node.BorderRadius}px`);
277
+ set("--node-bevel", bevelShadow(Theme.Node.Bevel, false));
278
+ set("--title-bg", Theme.Node.Title.Color);
279
+ set("--title-fg", Theme.Node.Title.FontColor);
280
+ set("--text", Theme.Node.Port.FontColor);
281
+ set("--dim", Theme.Widget.LabelColor);
282
+ set("--widget-bg", Theme.Widget.BackgroundColor);
283
+ set("--widget-fg", Theme.Widget.FontColor);
284
+ set("--widget-border", Theme.Widget.Border.Size > 0 ? `${Theme.Widget.Border.Size}px solid ${Theme.Widget.Border.Color}` : "0");
285
+ set("--widget-radius", `${Theme.Widget.Border.Radius}px`);
286
+ set("--widget-hover", Theme.Widget.Hover.BackgroundColor);
287
+ set("--widget-active", Theme.Widget.Button.Click.BackgroundColor);
288
+ set("--widget-bevel", bevelShadow(Theme.Widget.Bevel, false));
289
+ set("--widget-bevel-pressed", bevelShadow(Theme.Widget.Bevel, true));
290
+ }
291
+
292
+ onThemeChange(syncPageToTheme);
293
+ setTheme(themes["Default"]);
294
+
295
+ const logList = document.getElementById("log-list");
296
+ let logging = false;
297
+ function log(what, detail = "") {
298
+ if (!logging) {
299
+ return;
300
+ }
301
+ const li = document.createElement("li");
302
+ const detailSpan = document.createElement("span");
303
+ detailSpan.textContent = detail;
304
+ li.append(what + " ", detailSpan);
305
+ logList.prepend(li);
306
+ while (logList.children.length > 80) {
307
+ logList.lastChild.remove();
308
+ }
309
+ }
310
+ document.getElementById("log-clear").onclick = () => logList.replaceChildren();
311
+ document.querySelectorAll(".node-title").forEach((title) => {
312
+ title.onclick = () => title.parentElement.classList.toggle("collapsed");
313
+ });
314
+
315
+ const fmt = (v) => typeof v === "number" ? String(parseFloat(v.toPrecision(5))) : JSON.stringify(v);
316
+
317
+ class StarRatingWidget extends ValueWidget {
318
+ #mouse = undefined;
319
+
320
+ constructor(node, config) {
321
+ super(node, config, 0);
322
+ }
323
+
324
+ Size() {
325
+ return { x: 150, y: 25 };
326
+ }
327
+
328
+ Draw(ctx, box, scale, mouse) {
329
+ this.#mouse = mouse;
330
+ super.Draw(ctx, box, scale, mouse);
331
+ }
332
+
333
+ #starAt(x) {
334
+ const box = this.box();
335
+ return Math.min(5, Math.max(1, Math.ceil(((x - box.Position.x) / box.Size.x) * 5)));
336
+ }
337
+
338
+ render(ctx, box, scale) {
339
+ drawFieldBox(ctx, box, scale, { hovered: this.hovered(), value: "" });
340
+ const showing = this.hovered() && this.#mouse ? this.#starAt(this.#mouse.x) : this.value();
341
+ ctx.font = widgetFont(scale * 1.3);
342
+ ctx.textAlign = "center";
343
+ ctx.textBaseline = "middle";
344
+ for (let i = 0; i < 5; i++) {
345
+ ctx.fillStyle = i < showing ? "#ffc95c" : "#2b4a55";
346
+ ctx.fillText("★", box.Position.x + (box.Size.x * (i + 0.5) / 5), box.Position.y + box.Size.y / 2);
347
+ }
348
+ }
349
+
350
+ onClick(pointer) {
351
+ this.commit(this.#starAt(pointer.position.x), this.value());
352
+ }
353
+ }
354
+
355
+ class MorePortsWidget extends WidgetBase {
356
+ #ports;
357
+ #open = false;
358
+
359
+ constructor(ports) {
360
+ super();
361
+ this.#ports = ports;
362
+ }
363
+
364
+ Size() {
365
+ return { x: 150, y: 16 };
366
+ }
367
+
368
+ render(ctx, box, scale) {
369
+ const waiting = this.#ports.filter((p) => p.connections().length === 0).length;
370
+ const label = this.#open ? "▴ less" : `▾ ${waiting} more`;
371
+ const y = box.Position.y + box.Size.y / 2;
372
+ const cx = box.Position.x + box.Size.x / 2;
373
+ ctx.font = widgetFont(scale * 0.8);
374
+ const half = ctx.measureText(label).width / 2 + 6 * scale;
375
+ ctx.globalAlpha = this.hovered() ? 1 : 0.55;
376
+ ctx.strokeStyle = ctx.fillStyle = Theme.Widget.FontColor;
377
+ ctx.lineWidth = Math.max(1, scale);
378
+ ctx.beginPath();
379
+ ctx.moveTo(box.Position.x, y);
380
+ ctx.lineTo(cx - half, y);
381
+ ctx.moveTo(cx + half, y);
382
+ ctx.lineTo(box.Position.x + box.Size.x, y);
383
+ ctx.stroke();
384
+ ctx.textAlign = "center";
385
+ ctx.textBaseline = "middle";
386
+ ctx.fillText(label, cx, y);
387
+ }
388
+
389
+ onClick() {
390
+ this.#open = !this.#open;
391
+ this.#ports.forEach((p) => p.setHidden(!this.#open));
392
+ }
393
+ }
394
+
395
+ const mathPublisher = new Publisher({
396
+ name: "Math",
397
+ description: "Nodes the live example evaluates",
398
+ version: "v1.0.0",
399
+ nodes: {
400
+ "Number": {
401
+ title: "Number",
402
+ metadata: { op: "number" },
403
+ outputs: [{ name: "value", type: "float32" }],
404
+ widgets: [{ type: "number", config: { property: "value", value: 1, label: "Value" } }],
405
+ },
406
+ "Slider": {
407
+ title: "Slider",
408
+ metadata: { op: "number" },
409
+ outputs: [{ name: "value", type: "float32" }],
410
+ widgets: [{ type: "slider", config: { property: "value", min: 0, max: 10, value: 5, label: "0 – 10" } }],
411
+ },
412
+ "Add": {
413
+ title: "Add",
414
+ info: "Adds up everything wired into **numbers**.",
415
+ metadata: { op: "add" },
416
+ inputs: [{ name: "numbers", type: "float32", array: true, description: "Any number of values" }],
417
+ outputs: [{ name: "sum", type: "float32" }],
418
+ },
419
+ "Multiply": {
420
+ title: "Multiply",
421
+ metadata: { op: "multiply" },
422
+ inputs: [{ name: "a", type: "float32" }, { name: "b", type: "float32" }],
423
+ outputs: [{ name: "product", type: "float32" }],
424
+ },
425
+ "Display": {
426
+ title: "Display",
427
+ metadata: { op: "display" },
428
+ inputs: [{ name: "value", type: "float32" }],
429
+ widgets: [{ type: "text", config: { property: "text", value: "—", textBoxStyle: { size: 28, color: "#ffc95c" } } }],
430
+ },
431
+ },
432
+ });
433
+
434
+ const examplePublisher = new Publisher({
435
+ name: "Examples",
436
+ description: "One node for each kind of widget",
437
+ version: "v1.0.0",
438
+ nodes: {
439
+ "Empty": { title: "Empty Node", canEditTitle: true, canEditPorts: true, canEditInfo: true },
440
+ "widgets/Number": { title: "Number", widgets: [{ type: "number", config: { label: "Value" } }] },
441
+ "widgets/Slider": { title: "Slider", widgets: [{ type: "slider", config: { value: 0.5 } }] },
442
+ "widgets/String": { title: "String", widgets: [{ type: "string", config: { label: "Text", value: "hello" } }] },
443
+ "widgets/Color": { title: "Color", widgets: [{ type: "color", config: { value: "#3fa7d6" } }] },
444
+ "widgets/Toggle": { title: "Toggle", widgets: [{ type: "toggle", config: { label: "On" } }] },
445
+ "widgets/Vector": {
446
+ title: "Vector",
447
+ outputs: [{ name: "out", type: "vector3" }],
448
+ widgets: [{
449
+ type: "row", config: {
450
+ widgets: ["X", "Y", "Z"].map((axis) => ({ type: "number", config: { property: axis.toLowerCase(), label: axis, width: 72 } })),
451
+ },
452
+ }],
453
+ },
454
+ },
455
+ });
456
+
457
+ const baseWire = DefaultConnectionRenderer(2, undefined, 4, undefined);
458
+
459
+ const graph = new NodeFlowGraph(document.getElementById("canvas"), {
460
+ minimap: { enabled: true },
461
+ idleConnection: {
462
+ renderer: (params) => {
463
+ const text = params.outPort?.getDataType() === "string" || params.inPort?.getDataType() === "string";
464
+ if (text) {
465
+ params.ctx.setLineDash([6 * params.graphScale, 5 * params.graphScale]);
466
+ }
467
+ baseWire(params);
468
+ params.ctx.setLineDash([]);
469
+ },
470
+ },
471
+ contextMenu: {
472
+ items: [
473
+ { name: "Center on graph", callback: () => graph.centerOnGraph() },
474
+ { name: "Organize", callback: () => graph.organize() },
475
+ ],
476
+ subMenus: [{
477
+ name: "Demo",
478
+ items: [
479
+ { name: "Log hello", callback: () => log("Context menu", "Demo › Log hello") },
480
+ { name: "A disabled item", enabled: () => ContextMenuItemState.Disabled },
481
+ ],
482
+ }],
483
+ },
484
+ nodes: {
485
+ onNodeCreated: (publisher, nodeType) => log("Node created", `${publisher} › ${nodeType}`),
486
+ },
487
+ board: {
488
+ onNoteAdded: () => log("Note added"),
489
+ onNoteRemoved: () => log("Note removed"),
490
+ onNoteDragStop: () => log("Note moved"),
491
+ },
492
+ });
493
+
494
+ graph.addPublisher("math", mathPublisher);
495
+ graph.addPublisher("examples", examplePublisher);
496
+
497
+ function evaluate(node, visiting = new Set()) {
498
+ const op = node.metadata()?.op;
499
+ if (op === undefined || visiting.has(node)) {
500
+ return NaN;
501
+ }
502
+ visiting.add(node);
503
+ const wired = (index) => node.inputPort(index).connections()
504
+ .map((c) => c.outNode())
505
+ .filter((n) => n !== null)
506
+ .map((n) => evaluate(n, new Set(visiting)));
507
+
508
+ switch (op) {
509
+ case "number": return Number(node.getProperty("value") ?? 0);
510
+ case "add": return wired(0).reduce((sum, v) => sum + v, 0);
511
+ case "multiply": return (wired(0)[0] ?? NaN) * (wired(1)[0] ?? NaN);
512
+ case "display": return wired(0)[0] ?? NaN;
513
+ }
514
+ return NaN;
515
+ }
516
+
517
+ let refreshQueued = false;
518
+ function refresh() {
519
+ if (refreshQueued) {
520
+ return;
521
+ }
522
+ refreshQueued = true;
523
+ queueMicrotask(() => {
524
+ refreshQueued = false;
525
+ for (const node of graph.getNodes()) {
526
+ if (node.metadata()?.op !== "display") {
527
+ continue;
528
+ }
529
+ const v = evaluate(node);
530
+ node.setProperty("text", Number.isFinite(v) ? fmt(v) : "—");
531
+ }
532
+ });
533
+ }
534
+
535
+ graph.addOnNodeAddedListener((node) => {
536
+ if (node.metadata()?.op === undefined) {
537
+ return;
538
+ }
539
+ node.addAnyPropertyChangeListener((name) => {
540
+ if (name !== "text") {
541
+ refresh();
542
+ }
543
+ });
544
+ for (let i = 0; i < node.inputs(); i++) {
545
+ node.inputPort(i).addConnectionAddedListener(refresh);
546
+ node.inputPort(i).addConnectionRemovedListener(refresh);
547
+ }
548
+ refresh();
549
+ });
550
+ graph.addOnNodeRemovedListener((node) => {
551
+ log("Node removed", node.title());
552
+ refresh();
553
+ });
554
+
555
+ const recipes = new WeakMap();
556
+ graph.addOnNodeCreatedListener((publisher, nodeType, node) => recipes.set(node, { publisher, nodeType }));
557
+ const publishers = { math: mathPublisher, examples: examplePublisher };
558
+
559
+ let clipboard = null;
560
+ graph.addCopyListener((nodes, connections) => {
561
+ const copyable = nodes.filter((n) => recipes.has(n));
562
+ clipboard = {
563
+ nodes: copyable.map((n) => ({ ...recipes.get(n), position: { ...n.getPosition() }, value: n.getProperty("value") })),
564
+ connections: connections.filter((c) => recipes.has(nodes[c.fromNode]) && recipes.has(nodes[c.toNode]))
565
+ .map((c) => ({ ...c, fromNode: copyable.indexOf(nodes[c.fromNode]), toNode: copyable.indexOf(nodes[c.toNode]) })),
566
+ };
567
+ log("Copied", `${copyable.length} of ${nodes.length} selected (menu-made nodes copy)`);
568
+ });
569
+ graph.addPasteListener((at) => {
570
+ if (!clipboard || clipboard.nodes.length === 0) {
571
+ return;
572
+ }
573
+ const origin = clipboard.nodes[0].position;
574
+ graph.unselectAllNodes();
575
+ const made = clipboard.nodes.map((n) => {
576
+ const node = publishers[n.publisher].create(n.nodeType);
577
+ recipes.set(node, { publisher: n.publisher, nodeType: n.nodeType });
578
+ node.setPosition({ x: at.x + n.position.x - origin.x, y: at.y + n.position.y - origin.y });
579
+ if (n.value !== undefined) {
580
+ node.setProperty("value", n.value);
581
+ }
582
+ graph.addNode(node);
583
+ node.select();
584
+ return node;
585
+ });
586
+ for (const c of clipboard.connections) {
587
+ graph.connectNodes(made[c.fromNode], c.fromPort, made[c.toNode], c.toPort);
588
+ }
589
+ log("Pasted", `${made.length} nodes`);
590
+ });
591
+
592
+ const COL = 1100;
593
+ const ROW = 1250;
594
+ const at = (col, row, dx = 0, dy = 0) => ({ x: col * COL + dx, y: row * ROW + dy });
595
+
596
+ function note(position, text, width = 480, locked = true) {
597
+ graph.addNote(new FlowNote({ position, text, width, locked }));
598
+ }
599
+
600
+ function add(config) {
601
+ const node = new FlowNode(config);
602
+ graph.addNode(node);
603
+ return node;
604
+ }
605
+
606
+ function fromPublisher(publisher, type, position, value) {
607
+ const node = publishers[publisher].create(type);
608
+ recipes.set(node, { publisher, nodeType: type });
609
+ node.setPosition(position);
610
+ if (value !== undefined) {
611
+ node.setProperty("value", value);
612
+ }
613
+ graph.addNode(node);
614
+ return node;
615
+ }
616
+
617
+ function logEdits(node, ...properties) {
618
+ for (const p of properties) {
619
+ node.addPropertyEditListener(p, (v) => log(`${node.title()} edited`, `${p} = ${fmt(v)}`));
620
+ }
621
+ }
622
+
623
+ note(at(0, 0), `
624
+ # Node Flow
625
+
626
+ Node Flow is a library for building node based tools, in the spirit of Unreal Blueprints and Blender's node editors. Everything on this canvas is live, so try it out.
627
+
628
+ Each area of the canvas shows one part of the library, with a note explaining it and the code to do it.
629
+
630
+ \`\`\`
631
+ const graph = new NodeFlowGraph(canvas);
632
+ graph.addNode(new FlowNode({ title: "Hello" }));
633
+ \`\`\`
634
+ `, 520);
635
+
636
+ note(at(0, 0, 0, 470), `
637
+ ## Getting around
638
+
639
+ * **Drag** empty canvas to pan, **scroll** to zoom
640
+ * **Drag** a node by its body; **Ctrl+drag** on empty canvas to box select
641
+ * **Right-click** for menus: on the canvas, a node, or a note
642
+ * **Space** opens a searchable new node menu at the cursor
643
+ * **Drag a wire** from a port and let go on empty canvas to get a menu of nodes that fit
644
+ * **Delete** removes selected nodes
645
+ * **Ctrl+C**, **Ctrl+V**, **Ctrl+D** copy, paste and duplicate
646
+ * **Drop a file** on a node that accepts files
647
+
648
+ **Controls** (top right) calls the graph's methods, and **Events** (bottom left) logs what the graph reports. Click either title to fold it away.
649
+ `, 520);
650
+
651
+ note(at(1, 0), `
652
+ # Ports and types
653
+
654
+ Each port has a type, and a wire only joins ports whose types match. Hover a port to see its type and description. Try wiring **Text** into **Add**: it is refused.
655
+
656
+ A port can also accept **any** type, or a **list** of types.
657
+
658
+ \`\`\`
659
+ inputs: [
660
+ { name: "a", type: "float32",
661
+ description: "First number" },
662
+ { name: "value", type: "T", anyType: true },
663
+ { name: "x", type: "float32",
664
+ acceptedTypes: ["float32", "int"] },
665
+ ]
666
+ \`\`\`
667
+
668
+ Wires carrying \`string\` are dashed here by a custom connection renderer.
669
+ `);
670
+
671
+ const numberA = add({
672
+ title: "Number", position: at(1, 0, 0, 560),
673
+ outputs: [{ name: "value", type: "float32", description: "A number" }],
674
+ widgets: [{ type: "number", config: { value: 4, label: "Value" } }],
675
+ });
676
+ const text = add({
677
+ title: "Text", position: at(1, 0, 0, 720),
678
+ outputs: [{ name: "text", type: "string" }],
679
+ widgets: [{ type: "string", config: { value: "hello" } }],
680
+ });
681
+ const intSource = add({
682
+ title: "Whole number", position: at(1, 0, 0, 880),
683
+ outputs: [{ name: "value", type: "int" }],
684
+ widgets: [{ type: "number", config: { value: 3, integer: true, label: "Value" } }],
685
+ });
686
+ const addTyped = add({
687
+ title: "Add", subTitle: "Typed ports", position: at(1, 0, 330, 560),
688
+ info: "Takes two **float32** numbers.",
689
+ inputs: [
690
+ { name: "a", type: "float32", description: "First number" },
691
+ { name: "b", type: "float32", description: "Second number" },
692
+ ],
693
+ outputs: [{ name: "sum", type: "float32", description: "a + b" }],
694
+ });
695
+ const anyNode = add({
696
+ title: "Print", subTitle: "Any type", position: at(1, 0, 330, 720),
697
+ inputs: [{ name: "value", type: "T", anyType: true, description: "Takes anything" }],
698
+ });
699
+ add({
700
+ title: "Round", subTitle: "Accepts a list of types", position: at(1, 0, 330, 860),
701
+ inputs: [{ name: "x", type: "float32", acceptedTypes: ["float32", "int"], description: "float32 or int" }],
702
+ outputs: [{ name: "rounded", type: "int" }],
703
+ });
704
+ add({
705
+ title: "Editable ports", position: at(1, 0, 660, 560),
706
+ info: "Right-click this node to add or remove ports.",
707
+ canEditPorts: true, canEditTitle: true, canEditInfo: true,
708
+ inputs: [{ name: "in", type: "float32" }],
709
+ outputs: [{ name: "out", type: "float32" }],
710
+ });
711
+ graph.connectNodes(numberA, 0, addTyped, 0);
712
+ graph.connectNodes(text, 0, anyNode, 0);
713
+
714
+ note(at(2, 0), `
715
+ # Inputs that take many wires
716
+
717
+ An **array** input takes any number of wires. It grows a slot per wire, in order.
718
+
719
+ * Press a **slot** to pick up just that wire
720
+ * Drop a wire **between slots** to put it there; a white bar marks the spot
721
+ * Pick up a wire and drop it in another gap to **reorder**
722
+
723
+ \`\`\`
724
+ inputs: [
725
+ { name: "numbers", type: "float32",
726
+ array: true },
727
+ ]
728
+ \`\`\`
729
+ `);
730
+ const one = add({ title: "One", position: at(2, 0, 0, 470), outputs: [{ name: "value", type: "float32" }] });
731
+ const two = add({ title: "Two", position: at(2, 0, 0, 590), outputs: [{ name: "value", type: "float32" }] });
732
+ const three = add({ title: "Three", position: at(2, 0, 0, 710), outputs: [{ name: "value", type: "float32" }] });
733
+ add({ title: "Four", subTitle: "Not wired yet", position: at(2, 0, 0, 830), outputs: [{ name: "value", type: "float32" }] });
734
+ const many = add({
735
+ title: "Sum", subTitle: "Array input", position: at(2, 0, 380, 560),
736
+ inputs: [{ name: "numbers", type: "float32", array: true, description: "Wire in as many as you like" }],
737
+ outputs: [{ name: "sum", type: "float32" }],
738
+ });
739
+ graph.connectNodes(one, 0, many, 0);
740
+ graph.connectNodes(two, 0, many, 0);
741
+ graph.connectNodes(three, 0, many, 0);
742
+
743
+ note(at(3, 0), `
744
+ # Hidden ports
745
+
746
+ A port can start **hidden**, to keep busy nodes small. A hidden port still shows while something is wired to it, so no wire ever points at nothing.
747
+
748
+ The "more" line is a custom widget that shows and hides them.
749
+
750
+ \`\`\`
751
+ inputs: [
752
+ { name: "Sheen", type: "sheen",
753
+ hidden: true },
754
+ ]
755
+ port.setHidden(false);
756
+ \`\`\`
757
+ `);
758
+ const sheen = add({ title: "Sheen", position: at(3, 0, 0, 560), outputs: [{ name: "sheen", type: "sheen" }] });
759
+ const material = add({
760
+ title: "Material", subTitle: "Hidden ports", position: at(3, 0, 330, 520),
761
+ inputs: [
762
+ { name: "Color", type: "color" },
763
+ { name: "Roughness", type: "float32" },
764
+ { name: "Metallic", type: "float32" },
765
+ { name: "Alpha", type: "float32", hidden: true },
766
+ { name: "Clearcoat", type: "clearcoat", hidden: true },
767
+ { name: "Sheen", type: "sheen", hidden: true },
768
+ { name: "Specular", type: "specular", hidden: true },
769
+ { name: "Unlit", type: "bool", hidden: true },
770
+ ],
771
+ outputs: [{ name: "material", type: "material" }],
772
+ });
773
+ const hiddenPorts = [];
774
+ for (let i = 0; i < material.inputs(); i++) {
775
+ if (["Alpha", "Clearcoat", "Sheen", "Specular", "Unlit"].includes(material.inputPort(i).getDisplayName())) {
776
+ hiddenPorts.push(material.inputPort(i));
777
+ }
778
+ }
779
+ material.insertWidget(0, new MorePortsWidget(hiddenPorts));
780
+ graph.connectNodes(sheen, 0, material, 5);
781
+
782
+ note(at(0, 1), `
783
+ # Widgets
784
+
785
+ Widgets sit under a node's ports and fill its width.
786
+
787
+ * **Numbers**: drag sideways to change, or click to type. Enter keeps it, Escape cancels
788
+ * **Sliders** drag, and keep following the pointer until you let go
789
+ * **Text** and **colours** open in place
790
+ * A **row** puts widgets side by side
791
+
792
+ Each value widget can be bound to a node **property**. Setting the property from code updates the widget quietly; a person's finished edit fires **edit listeners**. Watch the event log as you edit.
793
+
794
+ \`\`\`
795
+ widgets: [
796
+ { type: "number", config: {
797
+ property: "radius", label: "Radius",
798
+ step: 0.05, min: 0 } },
799
+ { type: "row", config: { widgets: [
800
+ { type: "number", config:
801
+ { property: "x", label: "X", width: 72 } },
802
+ ...
803
+ ] } },
804
+ ]
805
+
806
+ node.addPropertyEditListener("radius",
807
+ (v) => save(v));
808
+ \`\`\`
809
+ `, 560);
810
+
811
+ const numbers = add({
812
+ title: "Numbers", position: at(0, 1, 640, 0),
813
+ widgets: [
814
+ { type: "number", config: { property: "radius", label: "Radius", value: 1.25, min: 0 } },
815
+ { type: "number", config: { property: "count", label: "Count", value: 3, integer: true, min: 0, max: 10 } },
816
+ { type: "number", config: { property: "fine", label: "Fine", value: 0.5, step: 0.001 } },
817
+ ],
818
+ });
819
+ logEdits(numbers, "radius", "count", "fine");
820
+
821
+ const sliders = add({
822
+ title: "Sliders", position: at(0, 1, 640, 230),
823
+ widgets: [
824
+ { type: "slider", config: { property: "amount", label: "Amount", value: 0.4 } },
825
+ { type: "slider", config: { property: "snapped", label: "Snapped", min: 0, max: 5, value: 2.5, snapIncrement: 0.25 } },
826
+ ],
827
+ });
828
+ logEdits(sliders, "amount", "snapped");
829
+
830
+ const vector = add({
831
+ title: "Vector", subTitle: "Row of labelled numbers", position: at(0, 1, 640, 420),
832
+ outputs: [{ name: "position", type: "vector3" }],
833
+ widgets: [{
834
+ type: "row", config: {
835
+ widgets: ["X", "Y", "Z"].map((axis, i) => ({
836
+ type: "number", config: { property: axis.toLowerCase(), label: axis, width: 72, value: [1, 2.5, -3][i] },
837
+ })),
838
+ },
839
+ }],
840
+ });
841
+ logEdits(vector, "x", "y", "z");
842
+
843
+ const strings = add({
844
+ title: "Text and colour", position: at(0, 1, 1020, 0),
845
+ widgets: [
846
+ { type: "string", config: { property: "name", label: "Name", value: "Node Flow" } },
847
+ { type: "color", config: { property: "tint", label: "Tint", value: "#e0605080" } },
848
+ { type: "color", config: { property: "paper", value: "#f2e8cf" } },
849
+ ],
850
+ });
851
+ logEdits(strings, "name", "tint", "paper");
852
+
853
+ const toggles = add({ title: "Toggle, disable and hide", position: at(0, 1, 1020, 300) });
854
+ const gatedNumber = new NumberWidget(toggles, { label: "Gated", value: 7 });
855
+ const gatedText = new StringWidget(toggles, { label: "Also gated", value: "edit me" });
856
+ const detailRow = new RowWidget([
857
+ new NumberWidget(toggles, { label: "W", width: 72, value: 2 }),
858
+ new NumberWidget(toggles, { label: "H", width: 72, value: 1 }),
859
+ ]);
860
+ toggles.addWidget(new ToggleWidget(toggles, {
861
+ label: "Editable", value: true,
862
+ callback: (on) => {
863
+ gatedNumber.setDisabled(!on);
864
+ gatedText.setDisabled(!on);
865
+ log("Toggle", `Editable = ${on}`);
866
+ },
867
+ }));
868
+ toggles.addWidget(gatedNumber);
869
+ toggles.addWidget(gatedText);
870
+ toggles.addWidget(new ToggleWidget(toggles, {
871
+ label: "Show size", value: false, onColor: "#ffc95c", offColor: "#4a3a10",
872
+ callback: (on) => detailRow.setHidden(!on),
873
+ }));
874
+ detailRow.setHidden(true);
875
+ toggles.addWidget(detailRow);
876
+
877
+ const buttons = add({ title: "Button and text", position: at(0, 1, 1020, 600) });
878
+ let clicks = 0;
879
+ const counter = new ButtonWidget({
880
+ text: "Click me",
881
+ callback: () => {
882
+ clicks++;
883
+ counter.setText(`Clicked ${clicks} time${clicks === 1 ? "" : "s"}`);
884
+ log("Button", `clicked ${clicks}`);
885
+ },
886
+ });
887
+ buttons.addWidget(counter);
888
+ buttons.addWidget(new TextWidget(buttons, { property: "caption", value: "Drag the number:" }));
889
+ buttons.addWidget(new NumberWidget(buttons, {
890
+ label: "Live", value: 10,
891
+ onInput: (v) => buttons.setProperty("caption", `Dragging… ${fmt(v)}`),
892
+ callback: (v) => buttons.setProperty("caption", `Kept ${fmt(v)}`),
893
+ }));
894
+
895
+ add({
896
+ title: "Image", position: at(0, 1, 1400, 0),
897
+ widgets: [{ type: "image", config: { image: "https://i.imgur.com/qRZgWC0.jpeg", maxWidth: 220, maxHeight: 220 } }],
898
+ });
899
+
900
+ note(at(2, 1), `
901
+ # Custom widgets
902
+
903
+ Extend **WidgetBase** for anything interactive, or **ValueWidget** for something that holds a value. The base class tracks hover, press, disabled and hidden for you, and passes on pointer events. Once pressed, a widget keeps getting them until the pointer is let go, even off the node.
904
+
905
+ \`\`\`
906
+ class Stars extends ValueWidget {
907
+ Size() { return { x: 150, y: 25 }; }
908
+ render(ctx, box, scale) { ... }
909
+ onClick(pointer) {
910
+ this.commit(starAt(pointer.position.x),
911
+ this.value());
912
+ }
913
+ }
914
+ node.addWidget(new Stars(node,
915
+ { property: "rating" }));
916
+ \`\`\`
917
+
918
+ Use \`drawField\`, \`drawFieldBox\` and \`widgetFont\` to match the built-in look.
919
+ `);
920
+ const rating = add({ title: "Review", subTitle: "Custom widget", position: at(2, 1, 0, 660) });
921
+ rating.addWidget(new StarRatingWidget(rating, { property: "rating", value: 3 }));
922
+ rating.addWidget(new TextWidget(rating, { property: "ratingText", value: "3 of 5" }));
923
+ rating.addPropertyChangeListener("rating", (_, v) => rating.setProperty("ratingText", `${v} of 5`));
924
+ logEdits(rating, "rating");
925
+
926
+ note(at(3, 1), `
927
+ # Node options
928
+
929
+ * **Messages** hang under a node; some only show on hover
930
+ * **Styles** recolour a node's title and body
931
+ * A **locked** node cannot be dragged
932
+ * **Editable** nodes let people retitle them and edit their info
933
+ * A node can add its own **right-click menu**, with groups, submenus, and items that are disabled or hidden
934
+ * **onFileDrop** receives files dropped on the node
935
+ * Callbacks report **select**, **grab**, **release** and **drag stop**
936
+
937
+ \`\`\`
938
+ new FlowNode({
939
+ locked: true,
940
+ messages: [{ message: "Hi",
941
+ type: MessageType.Warning,
942
+ alwaysShow: true }],
943
+ style: { title: { color: "#6b2d5c" } },
944
+ onFileDrop: (file) => ...,
945
+ });
946
+ \`\`\`
947
+ `, 500);
948
+ add({
949
+ title: "Messages", position: at(3, 1, 0, 620),
950
+ messages: [
951
+ { message: "Always shown", alwaysShow: true },
952
+ { message: "Only on hover" },
953
+ { message: "A warning", type: MessageType.Warning, alwaysShow: true },
954
+ { message: "An error", type: MessageType.Error, alwaysShow: true },
955
+ ],
956
+ });
957
+ add({
958
+ title: "Styled", subTitle: "Custom colours", position: at(3, 1, 330, 620),
959
+ style: {
960
+ title: { color: "#6b2d5c" },
961
+ idle: { color: "#2a1630" },
962
+ mouseOver: { color: "#3a1f42" },
963
+ grabbed: { color: "#4a2853" },
964
+ selected: { color: "#4a2853" },
965
+ },
966
+ outputs: [{ name: "out", type: "float32" }],
967
+ });
968
+ add({ title: "Locked", subTitle: "Can't be dragged", position: at(3, 1, 330, 760), locked: true });
969
+ add({
970
+ title: "Rename me", position: at(3, 1, 660, 620), canEditTitle: true, canEditInfo: true,
971
+ info: "Right-click › **Edit** to change my title or this text.",
972
+ onTitleChange: (_, before, after) => log("Title changed", `${before} → ${after}`),
973
+ });
974
+ add({
975
+ title: "Custom menu", subTitle: "Right-click me", position: at(3, 1, 660, 760),
976
+ contextMenu: {
977
+ items: [
978
+ { name: "Say hello", callback: () => log("Menu", "hello from a node") },
979
+ { name: "Disabled item", enabled: () => ContextMenuItemState.Disabled },
980
+ { name: "Hidden item", enabled: () => ContextMenuItemState.Hidden },
981
+ { name: "Grouped A", group: "group", callback: () => log("Menu", "grouped A") },
982
+ { name: "Grouped B", group: "group", callback: () => log("Menu", "grouped B") },
983
+ ],
984
+ subMenus: [{ name: "More", items: [{ name: "Nested", callback: () => log("Menu", "nested item") }] }],
985
+ },
986
+ });
987
+ const dropTarget = add({
988
+ title: "Drop a file here", subTitle: "onFileDrop", position: at(3, 1, 0, 900),
989
+ onSelect: () => log("Selected", "Drop a file here"),
990
+ onUnselect: () => log("Unselected", "Drop a file here"),
991
+ onDragStop: () => log("Drag stopped", "Drop a file here"),
992
+ onFileDrop: (file) => {
993
+ log("File dropped", `${file.name} (${file.size} bytes)`);
994
+ dropTarget.clearWidgets();
995
+ if (file.type.startsWith("image/")) {
996
+ dropTarget.addWidget(new ImageWidget({ blob: file, maxWidth: 200, maxHeight: 200 }));
997
+ } else {
998
+ dropTarget.addWidget(new TextWidget(dropTarget, { value: file.name }));
999
+ }
1000
+ },
1001
+ });
1002
+
1003
+ note(at(0, 2), `
1004
+ # A live graph
1005
+
1006
+ Node Flow draws and edits the graph; your app decides what it means. These **Math** nodes come from a **Publisher**, and this page recalculates them whenever a value or a wire changes.
1007
+
1008
+ Drag the numbers, rewire them, or add more from **right-click › New Node › Math**.
1009
+
1010
+ \`\`\`
1011
+ const math = new Publisher({ name: "Math",
1012
+ nodes: { "Add": { title: "Add", ... } } });
1013
+ graph.addPublisher("math", math);
1014
+
1015
+ node.addAnyPropertyChangeListener(recalc);
1016
+ port.addConnectionAddedListener(recalc);
1017
+ \`\`\`
1018
+ `, 520);
1019
+ const n1 = fromPublisher("math", "Number", at(0, 2, 600, 0), 2);
1020
+ const n2 = fromPublisher("math", "Slider", at(0, 2, 600, 180), 5);
1021
+ const n3 = fromPublisher("math", "Number", at(0, 2, 600, 360), 3);
1022
+ const sum = fromPublisher("math", "Add", at(0, 2, 940, 60));
1023
+ const product = fromPublisher("math", "Multiply", at(0, 2, 1220, 200));
1024
+ const display = fromPublisher("math", "Display", at(0, 2, 1500, 200));
1025
+ graph.connectNodes(n1, 0, sum, 0);
1026
+ graph.connectNodes(n2, 0, sum, 0);
1027
+ graph.connectNodes(sum, 0, product, 0);
1028
+ graph.connectNodes(n3, 0, product, 1);
1029
+ graph.connectNodes(product, 0, display, 0);
1030
+
1031
+ note(at(2, 2), `
1032
+ # Notes
1033
+
1034
+ Notes are **markdown**, drawn on the canvas.
1035
+
1036
+ * Headers, **bold**, *italic*
1037
+ * Lists
1038
+ * Code blocks
1039
+
1040
+ \`\`\`
1041
+ graph.addNote(new FlowNote({
1042
+ text: "# Hello", position, width: 400,
1043
+ }));
1044
+ \`\`\`
1045
+
1046
+ Every note on this page is **locked**. The one beside this is not: drag it, pull its edges to resize it, or right-click it to edit.
1047
+ `);
1048
+ graph.addNote(new FlowNote({
1049
+ position: at(2, 2, 540, 0),
1050
+ width: 360,
1051
+ locked: false,
1052
+ text: `
1053
+ ## Edit me
1054
+
1055
+ Right-click › **Edit Note**, then click away to save.
1056
+
1057
+ Drag the left or right edge to change the width.
1058
+ `,
1059
+ onContentChange: (_, text) => log("Note edited", text.trim().split("\n")[0]),
1060
+ onWidthChange: (_, w) => log("Note resized", `${Math.round(w)} wide`),
1061
+ }));
1062
+
1063
+ note(at(3, 2), `
1064
+ # Graph options
1065
+
1066
+ \`\`\`
1067
+ new NodeFlowGraph(canvas, {
1068
+ backgroundColor: "#07212a",
1069
+ minimap: { enabled: true },
1070
+ idleConnection: { renderer: (p) => ... },
1071
+ contextMenu: { items: [...] },
1072
+ nodes: {
1073
+ publishers: { ... },
1074
+ onNodeCreated: (pub, type, node) => ...,
1075
+ },
1076
+ board: { notes: [...], onNoteAdded },
1077
+ });
1078
+
1079
+ setTheme({ FontFamily: "Tahoma",
1080
+ Node: { BackgroundColor: "#c0c0c0" } });
1081
+ \`\`\`
1082
+
1083
+ **setTheme** lays your values over the default theme, and everything already on screen redraws in it. Try **Theme: Windows 95** in Controls.
1084
+
1085
+ Right-click empty canvas to see this page's own menu items next to the built-in ones. The toolbar calls \`centerOnGraph\`, \`resetCamera\`, \`zoom\`, \`organize\`, \`minimap().setEnabled\` and \`renderContinuously\`.
1086
+
1087
+ \`organize\` lays every node out again, so it will rearrange this tour.
1088
+ `, 520);
1089
+
1090
+ let stopContinuous = null;
1091
+ let themeName = "Default";
1092
+ const actions = {
1093
+ "theme": (button) => {
1094
+ const next = themeName === "Default" ? "Windows 95" : "Default";
1095
+ button.textContent = `Theme: ${themeName}`;
1096
+ themeName = next;
1097
+ setTheme(themes[next]);
1098
+ log("Theme", next);
1099
+ },
1100
+ "center": () => graph.centerOnGraph(),
1101
+ "reset": () => graph.resetCamera(),
1102
+ "zoom-in": () => graph.zoom(1),
1103
+ "zoom-out": () => graph.zoom(-1),
1104
+ "minimap": () => graph.minimap().setEnabled(!graph.minimap().enabled()),
1105
+ "organize": () => graph.organize(),
1106
+ "select-all": () => graph.getNodes().forEach((n) => n.select()),
1107
+ "delete": () => log("Deleted", `${graph.removeSelectedNodes().length} nodes`),
1108
+ "continuous": (button) => {
1109
+ if (stopContinuous) {
1110
+ stopContinuous();
1111
+ stopContinuous = null;
1112
+ button.textContent = "Animate";
1113
+ } else {
1114
+ stopContinuous = graph.renderContinuously();
1115
+ button.textContent = "Stop";
1116
+ }
1117
+ },
1118
+ };
1119
+ document.getElementById("toolbar").addEventListener("click", (e) => {
1120
+ const button = e.target.closest("button");
1121
+ if (button) {
1122
+ actions[button.dataset.act]?.(button);
1123
+ graph.requestRender();
1124
+ }
1125
+ });
1126
+
1127
+ graph.setCamera({ position: { x: 40, y: 90 }, zoom: 0.55 });
1128
+ window.graph = graph;
1129
+ logging = true;
1130
+ log("Ready", "try dragging a number; `graph` is on window");
1131
+ </script>
1132
+ </body>
1133
+
1134
+ </html>