@pictogrammers/components 0.5.15 → 0.5.17

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 (149) hide show
  1. package/package.json +1 -1
  2. package/pg/autocompleteItem/__examples__/basic/basic.html +3 -0
  3. package/pg/autocompleteItem/autocompleteItem.css +55 -0
  4. package/pg/autocompleteItem/autocompleteItem.html +1 -0
  5. package/pg/autocompleteItem/autocompleteItem.ts +65 -0
  6. package/pg/button/button.css +5 -0
  7. package/pg/buttonMenu/buttonMenu.ts +1 -1
  8. package/pg/inputCombobox/__examples__/basic/basic.ts +33 -0
  9. package/pg/inputCombobox/inputCombobox.ts +158 -0
  10. package/pg/inputCombobox/inputCombobox.ts.tmp.44252.751480cb81cc +158 -0
  11. package/pg/inputCombobox/inputCombobox.ts.tmp.44252.dcea847c40a8 +156 -0
  12. package/pg/inputPill/inputPill.html +6 -0
  13. package/pg/inputPill/inputPill.html.tmp.26852.84ac63f4c46f +6 -0
  14. package/pg/inputPill/inputPill.ts +41 -0
  15. package/pg/inputPills/__examples__/basic/basic.html +9 -0
  16. package/pg/inputPills/__examples__/basic/basic.html.tmp.26852.4771e4cad5b7 +9 -0
  17. package/pg/inputPills/__examples__/basic/basic.ts.tmp.26852.d8fe41df18bd +0 -0
  18. package/pg/inputPixelEditor/README.md +104 -69
  19. package/pg/inputPixelEditor/README.md.tmp.42596.658de5f0a6f5 +0 -0
  20. package/pg/inputPixelEditor/__examples__/basic/basic.ts +6 -5
  21. package/pg/inputPixelEditor/inputPixelEditor.ts +95 -23
  22. package/pg/inputPixelEditor/inputPixelEditor.ts.tmp.38516.96529dd82670 +1703 -0
  23. package/pg/inputPixelEditor/inputPixelEditor.ts.tmp.42596.48ecc3ad9c72 +1724 -0
  24. package/pg/inputPixelEditor/utils/bitmapToMask.ts +113 -79
  25. package/pg/inputPixelEditor/utils/getLassoPixels.ts +51 -0
  26. package/pg/inputSelect/inputSelect.ts +5 -1
  27. package/pg/inputText/README.md +6 -6
  28. package/pg/inputText/__examples__/basic/basic.ts +1 -1
  29. package/pg/inputText/inputText.css +2 -2
  30. package/pg/inputText copy/README.md +38 -0
  31. package/pg/inputText copy/inputText.css +41 -0
  32. package/pg/inputText copy/inputText.html +1 -0
  33. package/pg/inputText copy/inputText.spec.ts +59 -0
  34. package/pg/inputText copy/inputText.ts +67 -0
  35. package/pg/inputTextarea/README.md +39 -0
  36. package/pg/inputTextarea/__examples__/basic/basic.html +9 -0
  37. package/pg/inputTextarea/__examples__/basic/basic.ts +30 -0
  38. package/pg/inputTextarea/inputTextarea.ts +121 -0
  39. package/pg/inputTextarea copy/README.md +39 -0
  40. package/pg/inputTextarea copy/inputTextarea.css +41 -0
  41. package/pg/inputTextarea copy/inputTextarea.html +1 -0
  42. package/pg/inputTextarea copy/inputTextarea.spec.ts +59 -0
  43. package/pg/inputTextarea copy/inputTextarea.ts +79 -0
  44. package/pg/inputTextareaAutocomplete/README.md +70 -0
  45. package/pg/inputTextareaAutocomplete/__examples__/basic/basic.ts +67 -0
  46. package/pg/inputTextareaAutocomplete/inputTextareaAutocomplete.ts +267 -0
  47. package/pg/node/README.md +35 -0
  48. package/pg/node/__examples__/basic/basic.ts +34 -0
  49. package/pg/node/index.ts +3 -0
  50. package/pg/node/node.css +53 -0
  51. package/pg/node/node.html +5 -0
  52. package/pg/node/node.ts +270 -0
  53. package/pg/nodeEditorLink/nodeEditorLink.css +69 -0
  54. package/pg/nodeEditorLink/nodeEditorLink.css.tmp.1408.09e961fd0dcc +69 -0
  55. package/pg/nodeEditorLink/nodeEditorLink.html +9 -0
  56. package/pg/nodeEditorNumber/nodeEditorNumber.css +59 -0
  57. package/pg/nodeEditorNumber/nodeEditorNumber.ts +57 -0
  58. package/pg/nodeEditorRange/nodeEditorRange.css +23 -0
  59. package/pg/nodeEditorRange/nodeEditorRange.ts +69 -0
  60. package/pg/nodeEditorText/__examples__/basic/basic.html +9 -0
  61. package/pg/nodeEditorText/__examples__/basic/basic.ts +30 -0
  62. package/pg/nodeEditorText/nodeEditorText.css +17 -0
  63. package/pg/nodeEditorText/nodeEditorText.html +4 -0
  64. package/pg/nodeEditorText/nodeEditorText.ts +57 -0
  65. package/pg/nodeEditorText copy/README.md +35 -0
  66. package/pg/nodeEditorText copy/nodeEditorText.css +49 -0
  67. package/pg/nodeEditorText copy/nodeEditorText.html +4 -0
  68. package/pg/nodeEditorText copy/nodeEditorText.ts +50 -0
  69. package/pg/nodeEditorTextArray/nodeEditorTextArray.css +68 -0
  70. package/pg/nodeEditorTextArray/nodeEditorTextArray.html +9 -0
  71. package/pg/nodeEditorTextArray/nodeEditorTextArray.ts +162 -0
  72. package/pg/nodeEntry/nodeEntry.css +61 -0
  73. package/pg/nodeEntry/nodeEntry.ts +164 -0
  74. package/pg/nodeEntry/nodeEntry.ts.tmp.28724.dda67762bca5 +164 -0
  75. package/pg/nodeInputText/nodeInputText.css +75 -0
  76. package/pg/nodeInputText/nodeInputText.html +4 -0
  77. package/pg/nodeInputText/nodeInputText.ts +105 -0
  78. package/pg/nodeOutput/nodeOutput.css +32 -0
  79. package/pg/nodeResize/README.md +3 -0
  80. package/pg/nodeResize/THINKING.md +81 -0
  81. package/pg/nodeResize/THINKING.md.tmp.24812.99a70ba81062 +81 -0
  82. package/pg/nodeResize/basic/basic.ts +17 -0
  83. package/pg/nodeResize/dragUtil.ts +105 -0
  84. package/pg/nodeResize/dragUtil.ts.tmp.34372.787464c73309 +75 -0
  85. package/pg/nodeResize/nodeResize.css +187 -0
  86. package/pg/nodeResize/nodeResize.html +9 -0
  87. package/pg/nodeResize/nodeResize.ts +156 -0
  88. package/pg/nodes/README.md +177 -0
  89. package/pg/nodes/__examples__/basic/basic.html +33 -0
  90. package/pg/nodes/__examples__/basic/basic.ts +716 -0
  91. package/pg/nodes/__examples__/basic/exampleNodes.ts +129 -0
  92. package/pg/nodes/__examples__/basic/exampleScripts.ts +129 -0
  93. package/pg/nodes/__examples__/basic/keystore.ts +38 -0
  94. package/pg/nodes/executeInSandbox.ts +32 -0
  95. package/pg/nodes/index.ts +3 -0
  96. package/pg/nodes/nodes.css +69 -0
  97. package/pg/nodes/nodes.html +6 -0
  98. package/pg/nodes/nodes.ts +1240 -0
  99. package/pg/nodes copy/nodes.css +41 -0
  100. package/pg/nodes copy/nodes.html +1 -0
  101. package/pg/overlayContextMenu/__examples__/filter/filter.ts +87 -0
  102. package/pg/overlayContextMenu/overlayContextMenu.css +14 -0
  103. package/pg/overlayContextMenu/overlayContextMenu.html +4 -3
  104. package/pg/overlayContextMenu/overlayContextMenu.ts +6 -2
  105. package/pg/overlayContextMenu/overlayContextMenu.ts.tmp.37088.530ff1c0a321 +0 -0
  106. package/pg/overlayMenu/overlayMenu.css +3 -1
  107. package/pg/overlayMenu/overlayMenu.ts +11 -2
  108. package/pg/overlayMenu/overlayMenu.ts.tmp.44252.9bb22fd59ede +80 -0
  109. package/pg/overlaySelectMenu/overlaySelectMenu.css +2 -0
  110. package/pg/overlaySubMenu/overlaySubMenu.css +8 -6
  111. package/pg/poco/README.md +22 -0
  112. package/pg/poco/__examples__/basic/basic.html +11 -0
  113. package/pg/poco/__examples__/basic/basic.ts +95 -0
  114. package/pg/poco/__examples__/basic/examples.ts +258 -0
  115. package/pg/poco/__examples__/basic/myFont.ts +1300 -0
  116. package/pg/poco/index.ts +3 -0
  117. package/pg/poco/mockPoco.ts +640 -0
  118. package/pg/poco/poco.css +7 -0
  119. package/pg/poco/poco.html +1 -0
  120. package/pg/poco/poco.ts +75 -0
  121. package/pg/popover/README.md +47 -0
  122. package/pg/popover/__examples__/basic/basic.html +3 -0
  123. package/pg/popover/popover.html +3 -0
  124. package/pg/popover/popover.ts +44 -0
  125. package/pg/popoverAutocomplete/__examples__/basic/basic.html +4 -0
  126. package/pg/popoverAutocomplete/__examples__/basic/basic.ts +46 -0
  127. package/pg/popoverAutocomplete/popoverAutocomplete.css +25 -0
  128. package/pg/popoverAutocomplete/popoverAutocomplete.html +1 -0
  129. package/pg/popoverAutocomplete/popoverAutocomplete.ts +134 -0
  130. package/pg/popoverAutocompleteItem/popoverAutocompleteItem.css +69 -0
  131. package/pg/popoverAutocompleteItem/popoverAutocompleteItem.ts +90 -0
  132. package/pg/table/__examples__/basic/basic.ts +1 -0
  133. package/pg/tableCellFileLocal/tableCellFileLocal.html +3 -0
  134. package/pg/tableCellFileLocal/tableCellFileLocal.html.tmp.20484.68ee2e30710e +3 -0
  135. package/pg/tableCellFileLocal/tableCellFileLocal.ts +44 -0
  136. package/pg/tableCellSelect/tableCellSelect.html +3 -0
  137. package/pg/tableCellSelect/tableCellSelect.html.tmp.41808.99c1a6f14340 +3 -0
  138. package/pg/tableCellSelect/tableCellSelect.ts +51 -0
  139. package/pg/tree/__examples__/basic/basic.ts +12 -5
  140. package/pg/tree/tree.html +1 -1
  141. package/pg/tree/tree.ts +385 -227
  142. package/pg/tree/tree.ts.tmp.38376.07d376a04f41 +343 -0
  143. package/pg/tree/tree.ts.tmp.38376.2a04599ade9f +361 -0
  144. package/pg/tree/tree.ts.tmp.38376.6cb2c663d476 +0 -0
  145. package/pg/tree/tree.ts.tmp.38376.ac5af1aff2b0 +393 -0
  146. package/pg/tree/tree.ts.tmp.38376.ac9acfc4f95f +324 -0
  147. package/pg/treeItem/treeItem.ts +536 -558
  148. package/pg/treeItem/treeItem.ts.tmp.38376.145d3121d648 +530 -0
  149. package/pg/treeItem/treeItem.ts.tmp.38376.b5224d48e5c0 +0 -0
@@ -0,0 +1,1240 @@
1
+ import { Component, Prop, Part, forEach } from '@pictogrammers/element';
2
+ import { NodeConnector } from '@pictogrammers/node-connector';
3
+
4
+ import PgNode from '../node/node';
5
+ import PgNodeEntry from '../nodeEntry/nodeEntry';
6
+ import PgOverlayContextMenu from '../overlayContextMenu/overlayContextMenu';
7
+ import PgMenuItem from '../menuItem/menuItem';
8
+
9
+ import template from './nodes.html';
10
+ import style from './nodes.css';
11
+ import PgMenuDivider from '../menuDivider/menuDivider';
12
+
13
+ type NodeState = { x: number; y: number; width: number; height: number };
14
+ type UndoTransform = { type: 'transform'; nodeId: number; before: NodeState; after: NodeState };
15
+ type UndoMultiTransform = { type: 'multi-transform'; transforms: Array<{ nodeId: number; before: NodeState; after: NodeState }> };
16
+ // incoming = connections from surviving nodes into a deleted node; recorded so
17
+ // undo can restore them (the connector strips them from the model on removal).
18
+ type DeleteEntry = { item: any; index: number; incoming: Array<{ id: number; key: string; index: number }> };
19
+ type UndoDelete = { type: 'delete'; entries: DeleteEntry[] };
20
+ type UndoItem = UndoTransform | UndoMultiTransform | UndoDelete;
21
+
22
+ @Component({
23
+ selector: 'pg-nodes',
24
+ style,
25
+ template,
26
+ })
27
+ export default class PgNodes extends HTMLElement {
28
+ @Prop() items: any = [];
29
+ @Prop() gridSize: number = 16;
30
+ @Prop() menuItems: any = [];
31
+ // Field editors
32
+ @Prop() editors: any = [];
33
+ // Node type registry
34
+ @Prop() nodes: any = [];
35
+ // Globals
36
+ @Prop() globals: any = [];
37
+
38
+ @Part() $grid: HTMLDivElement;
39
+ @Part() $svg: SVGSVGElement;
40
+ @Part() $items: HTMLDivElement;
41
+ @Part() $forceScroll: HTMLDivElement;
42
+ @Part() $selection: HTMLDivElement;
43
+ @Part() $dragPreview: HTMLDivElement;
44
+ @Part() $selectMode: HTMLDivElement;
45
+
46
+ #nextNodeId: number = 0;
47
+ #focusNext: boolean = false;
48
+ #connector: NodeConnector | null = null;
49
+ #connectionsScheduled: boolean = false;
50
+ #selected = new Set<number>();
51
+ #debug: number[] = [];
52
+ #debugPrevious: number = 0;
53
+
54
+ #undo: UndoItem[] = [];
55
+ #redo: UndoItem[] = [];
56
+ #nodeStates = new Map<number, NodeState>();
57
+
58
+ #clipboard: Array<{ item: any; state: NodeState }> | null = null;
59
+ #pasteCount: number = 0;
60
+
61
+ #dragOrigin: { x: number; y: number } | null = null;
62
+ #isDragging: boolean = false;
63
+ #wasSelectionDrag: boolean = false;
64
+ #nodeDragging: boolean = false;
65
+
66
+ // Node selection mode (Link editor): non-null while the overlay is up.
67
+ #selectionCallback: ((nodeId: number) => void) | null = null;
68
+
69
+ // connectedCallback runs again if the element is reparented; listeners on
70
+ // document must follow the connected state while everything else (connector,
71
+ // shadow DOM listeners, forEach) must only ever be set up once.
72
+ #initialized: boolean = false;
73
+ #keydownListener = (e: KeyboardEvent) => this.#handleKeyDown(e);
74
+
75
+ connectedCallback() {
76
+ document.addEventListener('keydown', this.#keydownListener);
77
+ if (this.#initialized) return;
78
+ this.#initialized = true;
79
+
80
+ const connector = new NodeConnector(this.$svg);
81
+ connector.bridgeColor = '#0a0c14';
82
+ this.#connector = connector;
83
+
84
+ connector.on('create', this.#handleConnectorCreate.bind(this));
85
+ connector.on('change', (change) => {
86
+ const sourceNodeId = parseInt(change.sourceNodeId, 10);
87
+ const targetNodeId = parseInt(change.targetNodeId, 10);
88
+ const currentItem = this.items.find((x: any) => x.id === sourceNodeId);
89
+ if (!currentItem) return;
90
+ if (change.type === 'connect') {
91
+ const nodes = currentItem.nodes ?? (currentItem.nodes = {});
92
+ const targets = Array.isArray(nodes[change.sourceKey])
93
+ ? nodes[change.sourceKey]
94
+ : (nodes[change.sourceKey] = []);
95
+ if (!targets.includes(targetNodeId)) {
96
+ targets.push(targetNodeId);
97
+ }
98
+ } else if (change.type === 'disconnect') {
99
+ const targets = currentItem.nodes?.[change.sourceKey];
100
+ if (!Array.isArray(targets)) return;
101
+ const index = targets.indexOf(targetNodeId);
102
+ // Check if the item actually exists in the array (-1 means not found)
103
+ if (index > -1) {
104
+ targets.splice(index, 1);
105
+ }
106
+ }
107
+ });
108
+
109
+ this.$grid.addEventListener('click', (e: any) => {
110
+ if (this.#selectionCallback) return;
111
+ if (this.#wasSelectionDrag) {
112
+ this.#wasSelectionDrag = false;
113
+ return;
114
+ }
115
+ if (e.target.part.contains('grid')) {
116
+ this.clearSelection();
117
+ }
118
+ });
119
+
120
+ this.$grid.addEventListener('pointerdown', (e: PointerEvent) => {
121
+ if (!e.isPrimary) return;
122
+ // Node selection mode is modal; no marquee while it is up.
123
+ if (this.#selectionCallback) return;
124
+ const ele = e.target as HTMLElement;
125
+ if (!ele.part?.contains('grid')) return;
126
+
127
+ const rect = this.$grid.getBoundingClientRect();
128
+ this.#dragOrigin = {
129
+ x: e.clientX - rect.left + this.$grid.scrollLeft,
130
+ y: e.clientY - rect.top + this.$grid.scrollTop,
131
+ };
132
+ this.#isDragging = false;
133
+
134
+ const cancelDrag = () => {
135
+ document.removeEventListener('pointermove', onMove);
136
+ document.removeEventListener('pointerup', onUp);
137
+ document.removeEventListener('pointercancel', onCancel);
138
+ document.removeEventListener('keydown', onKeyDown);
139
+ this.$grid.classList.remove('selecting');
140
+ this.$selection.classList.remove('active');
141
+ this.#dragOrigin = null;
142
+ this.#isDragging = false;
143
+ };
144
+
145
+ const onCancel = () => {
146
+ if (this.#isDragging) this.#suppressNextGridClick();
147
+ cancelDrag();
148
+ };
149
+
150
+ const onKeyDown = (ev: KeyboardEvent) => {
151
+ if (ev.key === 'Escape') onCancel();
152
+ };
153
+
154
+ const onMove = (ev: PointerEvent) => {
155
+ if (!this.#dragOrigin) return;
156
+ const r = this.$grid.getBoundingClientRect();
157
+ const curX = ev.clientX - r.left + this.$grid.scrollLeft;
158
+ const curY = ev.clientY - r.top + this.$grid.scrollTop;
159
+ const w = Math.abs(curX - this.#dragOrigin.x);
160
+ const h = Math.abs(curY - this.#dragOrigin.y);
161
+ if (w > 4 || h > 4) {
162
+ this.#isDragging = true;
163
+ this.$grid.classList.add('selecting');
164
+ this.$selection.classList.add('active');
165
+ this.$selection.style.left = `${Math.min(this.#dragOrigin.x, curX)}px`;
166
+ this.$selection.style.top = `${Math.min(this.#dragOrigin.y, curY)}px`;
167
+ this.$selection.style.width = `${w}px`;
168
+ this.$selection.style.height = `${h}px`;
169
+ }
170
+ };
171
+
172
+ const onUp = (ev: PointerEvent) => {
173
+ document.removeEventListener('pointermove', onMove);
174
+ document.removeEventListener('pointerup', onUp);
175
+ document.removeEventListener('pointercancel', onCancel);
176
+ document.removeEventListener('keydown', onKeyDown);
177
+ this.$grid.classList.remove('selecting');
178
+ this.$selection.classList.remove('active');
179
+
180
+ if (!this.#dragOrigin || !this.#isDragging) {
181
+ this.#dragOrigin = null;
182
+ this.#isDragging = false;
183
+ return;
184
+ }
185
+
186
+ const origin = this.#dragOrigin;
187
+ this.#dragOrigin = null;
188
+ this.#isDragging = false;
189
+ this.#suppressNextGridClick();
190
+
191
+ const r = this.$grid.getBoundingClientRect();
192
+ const curX = ev.clientX - r.left + this.$grid.scrollLeft;
193
+ const curY = ev.clientY - r.top + this.$grid.scrollTop;
194
+ const selX = Math.min(origin.x, curX);
195
+ const selY = Math.min(origin.y, curY);
196
+ const selW = Math.abs(curX - origin.x);
197
+ const selH = Math.abs(curY - origin.y);
198
+
199
+ if (!ev.shiftKey) {
200
+ this.clearSelection();
201
+ }
202
+
203
+ Array.from(this.$items.children).forEach((child: any) => {
204
+ const nodeId = child.itemId as number;
205
+ const nx = child.x * this.gridSize;
206
+ const ny = child.y * this.gridSize;
207
+ const nw = (child.width ?? 12) * this.gridSize;
208
+ const nh = (child.height ?? 4) * this.gridSize;
209
+ if (nx < selX + selW && nx + nw > selX && ny < selY + selH && ny + nh > selY) {
210
+ this.#selected.add(nodeId);
211
+ child.select();
212
+ }
213
+ });
214
+ };
215
+
216
+ document.addEventListener('pointermove', onMove);
217
+ document.addEventListener('pointerup', onUp);
218
+ document.addEventListener('pointercancel', onCancel);
219
+ document.addEventListener('keydown', onKeyDown);
220
+ });
221
+
222
+ this.$grid.addEventListener('contextmenu', async (e: MouseEvent) => {
223
+ e.preventDefault();
224
+ // Node selection mode is modal; no context menus while it is up.
225
+ if (this.#selectionCallback) return;
226
+ const ele = e.target as HTMLDivElement;
227
+ if (ele.part?.contains('grid')) {
228
+ const rect = this.$grid.getBoundingClientRect();
229
+ const x = Math.floor((e.clientX - rect.left + this.$grid.scrollLeft) / this.gridSize);
230
+ const y = Math.floor((e.clientY - rect.top + this.$grid.scrollTop) / this.gridSize);
231
+ const nodeMenuItems = this.#nodeTypeMenuItems();
232
+ if (this.#clipboard && this.#clipboard.length > 0) {
233
+ nodeMenuItems.unshift({ label: 'Paste', value: '__paste__', type: PgMenuItem });
234
+ }
235
+ const result = await PgOverlayContextMenu.open({
236
+ source: this.$items,
237
+ x: e.clientX,
238
+ y: e.clientY,
239
+ items: nodeMenuItems,
240
+ filter: true,
241
+ });
242
+ if (!result) { return; }
243
+ if (result.value === '__paste__') {
244
+ this.#pasteClipboard();
245
+ return;
246
+ }
247
+ this.#createNode(result.value, x, y);
248
+ } else {
249
+ // Right-clicks land on connector paths and pin circles too; only
250
+ // elements created by the forEach carry an itemId.
251
+ const nodeId = (ele as any).itemId;
252
+ if (typeof nodeId !== 'number') return;
253
+ const item = this.items.find((i: any) => i.id === nodeId);
254
+ // The entry node cannot be copied or deleted.
255
+ if (item && !item.node) return;
256
+ if (!this.#selected.has(nodeId)) {
257
+ this.clearSelection();
258
+ this.getNodeById(nodeId)?.select();
259
+ this.#selected.add(nodeId);
260
+ }
261
+ const result = await PgOverlayContextMenu.open({
262
+ source: this.$items,
263
+ x: e.clientX,
264
+ y: e.clientY,
265
+ items: [
266
+ { label: 'Copy', value: 'copyNode', type: PgMenuItem },
267
+ { type: PgMenuDivider },
268
+ { label: 'Enable Breakpoint', value: 'breakpoint', type: PgMenuItem },
269
+ { type: PgMenuDivider },
270
+ { label: 'Delete Node', value: 'deleteNode', type: PgMenuItem },
271
+ ],
272
+ });
273
+ if (result?.value === 'copyNode') {
274
+ this.#copySelected();
275
+ } else if (result?.value === 'deleteNode') {
276
+ const toDelete = Array.from(this.#selected);
277
+ this.clearSelection();
278
+ this.#deleteNodesWithUndo(toDelete);
279
+ }
280
+ }
281
+ });
282
+
283
+ forEach({
284
+ container: this.$items,
285
+ items: this.items,
286
+ type: (item) => {
287
+ item.editors = this.editors;
288
+ return !item.node ? PgNodeEntry : PgNode;
289
+ },
290
+ create: ($item: any, item) => {
291
+ const nodeId = item.id as number;
292
+ this.#nextNodeId = Math.max(nodeId, this.#nextNodeId) + 1;
293
+ connector.setNode(
294
+ String(nodeId),
295
+ item.x * this.gridSize,
296
+ item.y * this.gridSize,
297
+ (item.width ?? 12) * this.gridSize,
298
+ (item.height ?? 4) * this.gridSize
299
+ );
300
+
301
+ if (item.node) {
302
+ // Regular node: set input pin and look up output slots from type registry
303
+ connector.setInputPin(String(nodeId), 'in', this.gridSize);
304
+ const nodeType = this.nodes.find((n: any) => n.name === item.node);
305
+ if (nodeType) {
306
+ $item.label = nodeType.label;
307
+ $item.minWidth = nodeType.width ?? 6;
308
+ if (!item.width) {
309
+ $item.width = $item.getMinWidth();
310
+ }
311
+ if (nodeType.args) {
312
+ $item.fields = nodeType.args.map((arg: any) => ({
313
+ label: arg.label,
314
+ value: item.args?.[arg.key] ?? '',
315
+ itemKey: arg.key, // use itemKey, key is reserved
316
+ type: arg.editor,
317
+ }));
318
+ }
319
+ if (nodeType.nodes) {
320
+ $item.outputs = nodeType.nodes;
321
+ nodeType.nodes.forEach((slot: any, i: number) => {
322
+ connector.setOutputPin(String(nodeId), slot.key, this.gridSize + i * this.gridSize);
323
+ });
324
+ }
325
+ }
326
+ } else {
327
+ // Entry node (id 0): always has a single 'then' output
328
+ connector.setOutputPin(String(nodeId), 'then', this.gridSize);
329
+ $item.fields = [{ key: 'description', value: item.args?.description ?? '' }];
330
+ }
331
+
332
+ $item.addEventListener('registernodeoutput', this.#registerNodeOutput.bind(this));
333
+ $item.addEventListener('nodepulse', this.#handleNodePulse.bind(this));
334
+ $item.addEventListener('nodeselection', this.#handleNodeSelection.bind(this));
335
+ $item.addEventListener('select', this.#handleSelect.bind(this));
336
+ $item.addEventListener('change', this.#handleChange.bind(this));
337
+ $item.addEventListener('nodedragstart', this.#handleNodeDragStart.bind(this));
338
+ $item.addEventListener('nodedragmove', this.#handleNodeDragMove.bind(this));
339
+ $item.addEventListener('nodedragend', this.#handleNodeDragEnd.bind(this));
340
+ $item.addEventListener('input', (e: any) => {
341
+ // Re-dispatch editor input events (they don't compose past this
342
+ // shadow root); native composed input events already reach the host.
343
+ if (e.detail) {
344
+ this.dispatchEvent(new CustomEvent('input', { detail: e.detail }));
345
+ }
346
+ });
347
+ },
348
+ connect: ($item: any, item) => {
349
+ const nodeId = item.id as number;
350
+ this.#nodeStates.set(nodeId, {
351
+ x: $item.x,
352
+ y: $item.y,
353
+ width: $item.width ?? 12,
354
+ height: $item.height ?? 4,
355
+ });
356
+ connector.setNode(
357
+ String(nodeId),
358
+ $item.x * this.gridSize,
359
+ $item.y * this.gridSize,
360
+ ($item.width ?? 12) * this.gridSize,
361
+ ($item.height ?? 4) * this.gridSize
362
+ );
363
+ // Performance: only run once for many items
364
+ if (!this.#connectionsScheduled) {
365
+ this.#connectionsScheduled = true;
366
+ queueMicrotask(() => {
367
+ this.#connectionsScheduled = false;
368
+ this.items.forEach((i: any) => {
369
+ if (!i.nodes || typeof i.nodes !== 'object') return;
370
+ Object.entries(i.nodes as Record<string, number[]>).forEach(([key, targets]) => {
371
+ if (!Array.isArray(targets)) return;
372
+ targets.forEach((targetId: number) => {
373
+ connector.connect(String(i.id), key, String(targetId), 'in');
374
+ });
375
+ });
376
+ });
377
+ this.#updateScrollExtent();
378
+ });
379
+ }
380
+ if (this.#focusNext) {
381
+ $item.focus();
382
+ this.#focusNext = false;
383
+ }
384
+ },
385
+ disconnect: (_$item: any, item: any) => {
386
+ this.#connector?.removeNode(String(item.id));
387
+ // Items can also disappear through array reassignment (e.g. the json
388
+ // setter or items.filter), so interaction state is pruned here.
389
+ this.#nodeStates.delete(item.id);
390
+ this.#selected.delete(item.id);
391
+ this.#pinUpdateIds.delete(item.id);
392
+ },
393
+ });
394
+ }
395
+
396
+ disconnectedCallback() {
397
+ document.removeEventListener('keydown', this.#keydownListener);
398
+ }
399
+
400
+ getNodeById(nodeId: number): PgNode {
401
+ return Array.from(this.$items.children).find((x: any) => x.itemId === nodeId) as PgNode;
402
+ }
403
+
404
+ clearSelection() {
405
+ this.#selected.forEach((value) => {
406
+ this.getNodeById(value)?.deselect();
407
+ });
408
+ this.#selected.clear();
409
+ }
410
+
411
+ render(_changes: any) {}
412
+
413
+ #handleKeyDown(e: KeyboardEvent) {
414
+ // Node selection mode is modal: Escape cancels it and every other
415
+ // canvas shortcut is inert until it closes.
416
+ if (this.#selectionCallback) {
417
+ if (e.key === 'Escape') {
418
+ e.preventDefault();
419
+ this.#exitSelectionMode();
420
+ }
421
+ return;
422
+ }
423
+
424
+ // Never hijack keys while the user is typing in a field (the editors live
425
+ // in nested shadow roots, so composedPath is needed to see the real target).
426
+ const target = e.composedPath()[0];
427
+ if (target instanceof HTMLElement
428
+ && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))) {
429
+ return;
430
+ }
431
+
432
+ const key = e.key.toLowerCase();
433
+ const mod = e.ctrlKey || e.metaKey;
434
+
435
+ if (mod && key === 'z') {
436
+ e.preventDefault();
437
+ e.shiftKey ? this.#applyRedo() : this.#applyUndo();
438
+ return;
439
+ }
440
+
441
+ if (mod && key === 'y') {
442
+ e.preventDefault();
443
+ this.#applyRedo();
444
+ return;
445
+ }
446
+
447
+ if (mod && key === 'c') {
448
+ // Without a node selection let the browser copy whatever is selected.
449
+ if (this.#selected.size === 0) return;
450
+ e.preventDefault();
451
+ this.#copySelected();
452
+ return;
453
+ }
454
+
455
+ if (mod && key === 'v') {
456
+ if (!this.#clipboard || this.#clipboard.length === 0) return;
457
+ e.preventDefault();
458
+ this.#pasteClipboard();
459
+ return;
460
+ }
461
+
462
+ if (this.#selected.size === 0) return;
463
+
464
+ if (e.key === 'Delete') {
465
+ const toDelete = Array.from(this.#selected);
466
+ this.clearSelection();
467
+ this.#deleteNodesWithUndo(toDelete);
468
+ return;
469
+ }
470
+ if (e.key === 'Escape') {
471
+ // Active drags handle Escape themselves (cancel); keep the selection.
472
+ if (this.#nodeDragging || this.#dragOrigin) return;
473
+ this.clearSelection();
474
+ return;
475
+ }
476
+
477
+ if (mod || e.altKey) return;
478
+ let dx = 0;
479
+ let dy = 0;
480
+ switch (e.key) {
481
+ case 'ArrowUp': dy = -1; break;
482
+ case 'ArrowDown': dy = 1; break;
483
+ case 'ArrowLeft': dx = -1; break;
484
+ case 'ArrowRight': dx = 1; break;
485
+ default: return;
486
+ }
487
+ e.preventDefault();
488
+ this.#moveSelectedBy(dx, dy);
489
+ }
490
+
491
+ // Swallow only the click generated by this pointerup; if none follows
492
+ // (pointer released off-grid) the flag must not eat a later real click.
493
+ #suppressNextGridClick() {
494
+ this.#wasSelectionDrag = true;
495
+ setTimeout(() => { this.#wasSelectionDrag = false; }, 0);
496
+ }
497
+
498
+ #nodeTypeMenuItems(): any[] {
499
+ return this.nodes.map((nodeType: any) => {
500
+ return { label: nodeType.label, value: nodeType.name, type: PgMenuItem };
501
+ });
502
+ }
503
+
504
+ #createNode(name: string, x: number, y: number): any | null {
505
+ const nodeType = this.nodes.find((n: any) => n.name === name);
506
+ if (!nodeType) return null;
507
+ const args: any = {};
508
+ (nodeType.args ?? []).forEach(({ key, value }: any) => {
509
+ args[key] = value;
510
+ });
511
+ const item = {
512
+ id: this.#nextNodeId,
513
+ node: name,
514
+ x: Math.max(x, 0),
515
+ y: Math.max(y, 0),
516
+ ...(nodeType.width !== undefined && { width: nodeType.width }),
517
+ ...(nodeType.height !== undefined && { height: nodeType.height }),
518
+ args,
519
+ nodes: {},
520
+ };
521
+ this.#focusNext = true;
522
+ this.items.push(item);
523
+ return item;
524
+ }
525
+
526
+ // Dragging from an output pin into empty space offers to create the target
527
+ // node there and connects it to the dragged pin.
528
+ async #handleConnectorCreate(event: any) {
529
+ const { x, y, sourceY, sourceNodeId, sourceKey } = event;
530
+ const sourceId = parseInt(sourceNodeId, 10);
531
+ const $source = this.getNodeById(sourceId) as any;
532
+ if (!$source) return;
533
+ // The event carries cursor offsets from the source pin in SVG space.
534
+ const px = ($source.x + ($source.width ?? 12)) * this.gridSize + x;
535
+ const py = $source.y * this.gridSize + sourceY + y;
536
+ const rect = this.$grid.getBoundingClientRect();
537
+ const result = await PgOverlayContextMenu.open({
538
+ source: this.$items,
539
+ x: rect.left + px - this.$grid.scrollLeft,
540
+ y: rect.top + py - this.$grid.scrollTop,
541
+ items: this.#nodeTypeMenuItems(),
542
+ filter: true,
543
+ });
544
+ if (!result) return;
545
+ // Offset one cell up so the new node's input pin lines up with the cursor.
546
+ const item = this.#createNode(
547
+ result.value,
548
+ Math.round(px / this.gridSize),
549
+ Math.round(py / this.gridSize) - 1
550
+ );
551
+ if (!item) return;
552
+ const source = this.items.find((i: any) => i.id === sourceId);
553
+ if (!source) return; // source deleted while the menu was open
554
+ const nodes = source.nodes ?? (source.nodes = {});
555
+ const targets = Array.isArray(nodes[sourceKey])
556
+ ? nodes[sourceKey]
557
+ : (nodes[sourceKey] = []);
558
+ if (!targets.includes(item.id)) {
559
+ targets.push(item.id);
560
+ }
561
+ // The reconnect pass scheduled by the new node's connect callback draws it.
562
+ }
563
+
564
+ #registerNodeOutput(e: any) {
565
+ const { node, key, offset } = e.detail;
566
+ this.#connector?.setOutputPin(String(node), key, offset);
567
+ }
568
+
569
+ #handleNodePulse(e: any) {
570
+ const id = Number(e.detail.id);
571
+ const node = this.getNodeById(id) as any;
572
+ if (!node) {
573
+ throw new Error(`<pg-nodes> nodepulse: node ${e.detail.id} does not exist.`);
574
+ }
575
+ node.pulse();
576
+ }
577
+
578
+ #handleNodeSelection(e: any) {
579
+ const { value, callback } = e.detail;
580
+ if (typeof callback !== 'function') return;
581
+ this.#enterSelectionMode(Number(value), callback);
582
+ }
583
+
584
+ // Modal node picker for the Link editor: clickable rectangles cover every
585
+ // node while the nodes themselves ignore the pointer; Escape cancels.
586
+ #enterSelectionMode(currentId: number, callback: (nodeId: number) => void) {
587
+ this.#exitSelectionMode();
588
+ this.#selectionCallback = callback;
589
+ this.$grid.classList.add('selectMode');
590
+ this.$selectMode.replaceChildren();
591
+ Array.from(this.$items.children).forEach((child: any) => {
592
+ const $button = document.createElement('button');
593
+ $button.type = 'button';
594
+ $button.style.left = `${child.x}rem`;
595
+ $button.style.top = `${child.y}rem`;
596
+ $button.style.width = `${child.width ?? 12}rem`;
597
+ $button.style.height = `${child.height ?? 3}rem`;
598
+ if (child.itemId === currentId) {
599
+ $button.classList.add('current');
600
+ }
601
+ $button.addEventListener('click', () => {
602
+ const selected = this.#selectionCallback;
603
+ this.#exitSelectionMode();
604
+ selected?.(child.itemId);
605
+ });
606
+ this.$selectMode.appendChild($button);
607
+ });
608
+ this.$selectMode.classList.add('active');
609
+ }
610
+
611
+ #exitSelectionMode() {
612
+ if (!this.#selectionCallback) return;
613
+ this.#selectionCallback = null;
614
+ this.$selectMode.classList.remove('active');
615
+ this.$selectMode.replaceChildren();
616
+ this.$grid.classList.remove('selectMode');
617
+ }
618
+
619
+ #handleSelect(e: any) {
620
+ const nodeId = e.detail.nodeId as number;
621
+ if (e.detail.addToSelection) {
622
+ // Shift/Ctrl click toggles membership without touching the rest.
623
+ if (this.#selected.has(nodeId)) {
624
+ this.#selected.delete(nodeId);
625
+ (e.target as any).deselect();
626
+ } else {
627
+ this.#selected.add(nodeId);
628
+ e.target.select();
629
+ }
630
+ return;
631
+ }
632
+ this.#selected.forEach((value) => {
633
+ if (value !== nodeId) {
634
+ this.getNodeById(value)?.deselect();
635
+ }
636
+ });
637
+ this.#selected.clear();
638
+ this.#selected.add(nodeId);
639
+ e.target.select();
640
+ }
641
+
642
+ #handleChange(e: any) {
643
+ const { type } = e.detail;
644
+ if (type === 'transform') {
645
+ const { x, y, width, height, final } = e.detail;
646
+ if (x === undefined) return;
647
+ const nodeId = (e.target as any).itemId as number;
648
+ const before = this.#nodeStates.get(nodeId) ?? { x, y, width, height };
649
+
650
+ // A change that keeps the size is a move; moving one node of a
651
+ // multi-selection moves the whole group. Resizes always apply to the
652
+ // changed node alone.
653
+ const isMove = width === before.width && height === before.height;
654
+ if (isMove && this.#selected.has(nodeId) && this.#selected.size > 1) {
655
+ const ids = Array.from(this.#selected);
656
+ const states = new Map<number, NodeState>(ids.map((id) => [
657
+ id,
658
+ this.#nodeStates.get(id) ?? { x: 0, y: 0, width: 12, height: 4 },
659
+ ]));
660
+ let dx = x - before.x;
661
+ let dy = y - before.y;
662
+ // Clamp the group delta so no node ends up off-canvas (unreachable).
663
+ states.forEach((state) => {
664
+ dx = Math.max(dx, -state.x);
665
+ dy = Math.max(dy, -state.y);
666
+ });
667
+ const transforms = ids.map((id) => {
668
+ const state = states.get(id)!;
669
+ const after: NodeState = { x: state.x + dx, y: state.y + dy, width: state.width, height: state.height };
670
+ const $node = this.getNodeById(id) as any;
671
+ if ($node) { $node.x = after.x; $node.y = after.y; }
672
+ this.#nodeStates.set(id, after);
673
+ this.#updatePins(id);
674
+ const tItem = this.items.find((i: any) => i.id === id);
675
+ if (tItem) { tItem.x = after.x; tItem.y = after.y; }
676
+ return { nodeId: id, before: { ...state }, after };
677
+ });
678
+ if (dx !== 0 || dy !== 0) {
679
+ this.#undo.push({ type: 'multi-transform', transforms });
680
+ this.#redo = [];
681
+ }
682
+ this.#updateScrollExtent();
683
+ this.dispatchEvent(new CustomEvent('change', { detail: { ...e.detail, id: nodeId } }));
684
+ return;
685
+ }
686
+
687
+ const node = this.getNodeById(nodeId) as any;
688
+ // Clamp to the canvas. A clamped resize (no `final`) keeps the far edge
689
+ // in place by giving back the overshoot; a clamped move keeps its size.
690
+ let cx = x;
691
+ let cy = y;
692
+ let cw = width;
693
+ let ch = height;
694
+ if (cx < 0) {
695
+ if (!final && node) cw = Math.max(cw + cx, node.getMinWidth());
696
+ cx = 0;
697
+ }
698
+ if (cy < 0) {
699
+ if (!final && node) ch = Math.max(ch + cy, node.getMinHeight());
700
+ cy = 0;
701
+ }
702
+ if (node && (cx !== x || cy !== y || cw !== width || ch !== height)) {
703
+ node.x = cx;
704
+ node.y = cy;
705
+ node.width = cw;
706
+ node.height = ch;
707
+ }
708
+
709
+ this.#pushTransform(nodeId, before, { x: cx, y: cy, width: cw, height: ch }, !final);
710
+ this.#updatePins(nodeId);
711
+ this.#updateScrollExtent();
712
+ const item = this.items.find((i: any) => i.id === nodeId);
713
+ if (item) {
714
+ item.x = cx;
715
+ item.y = cy;
716
+ // Sizes matching the type default (minWidth) / content height stay
717
+ // implicit so nodes follow the registry on reload.
718
+ if (node) {
719
+ if (cw === node.getMinWidth()) { delete item.width; } else { item.width = cw; }
720
+ if (ch === node.getMinHeight()) { delete item.height; } else { item.height = ch; }
721
+ } else {
722
+ item.width = cw;
723
+ item.height = ch;
724
+ }
725
+ }
726
+ this.dispatchEvent(new CustomEvent('change', {
727
+ detail: { ...e.detail, id: nodeId, x: cx, y: cy, width: cw, height: ch },
728
+ }));
729
+ } else if (type === 'arg') {
730
+ const { id, key, value } = e.detail;
731
+ const item = this.items.find((x: any) => x.id === id);
732
+ if (!item) return;
733
+ const args = item.args ?? (item.args = {});
734
+ args[key] = value;
735
+ // Editors can change the node's height on value changes (TextArray
736
+ // rows); pg-node reflows before dispatching, so sync the cached state
737
+ // and the connector's box to the element here.
738
+ const node = this.getNodeById(id) as any;
739
+ if (node) {
740
+ const state = this.#nodeStates.get(id);
741
+ if (state && (state.width !== node.width || state.height !== node.height)) {
742
+ this.#nodeStates.set(id, { ...state, width: node.width, height: node.height });
743
+ this.#updatePins(id);
744
+ this.#updateScrollExtent();
745
+ }
746
+ }
747
+ this.dispatchEvent(new CustomEvent('change', { detail: e.detail }));
748
+ }
749
+ }
750
+
751
+ // Drag previews: while a node header is dragged the nodes themselves stay
752
+ // put (no pin/path redraws) and light blue rectangles track the snapped
753
+ // drop position. The rectangles live in one container so every move is a
754
+ // single transform update.
755
+ #handleNodeDragStart(e: any) {
756
+ const nodeId = e.detail.nodeId as number;
757
+ this.#nodeDragging = true;
758
+ // Dragging an unselected node makes it the selection, like clicking it.
759
+ if (!this.#selected.has(nodeId)) {
760
+ this.clearSelection();
761
+ this.#selected.add(nodeId);
762
+ this.getNodeById(nodeId)?.select();
763
+ }
764
+ this.$dragPreview.replaceChildren();
765
+ Array.from(this.$items.children).forEach((child: any) => {
766
+ if (!this.#selected.has(child.itemId)) return;
767
+ const rect = document.createElement('div');
768
+ rect.style.left = `${child.x}rem`;
769
+ rect.style.top = `${child.y}rem`;
770
+ rect.style.width = `${child.width ?? 12}rem`;
771
+ rect.style.height = `${child.height ?? 3}rem`;
772
+ this.$dragPreview.appendChild(rect);
773
+ });
774
+ this.$dragPreview.style.transform = 'translate(0, 0)';
775
+ this.$dragPreview.classList.add('active');
776
+ }
777
+
778
+ #handleNodeDragMove(e: any) {
779
+ const { dx, dy } = e.detail;
780
+ this.$dragPreview.style.transform = `translate(${dx}rem, ${dy}rem)`;
781
+ }
782
+
783
+ #handleNodeDragEnd() {
784
+ this.#nodeDragging = false;
785
+ this.$dragPreview.classList.remove('active');
786
+ this.$dragPreview.replaceChildren();
787
+ }
788
+
789
+ // Moves all selected nodes by a grid delta in one pass, recording a single
790
+ // undo entry and batching the connector updates.
791
+ #moveSelectedBy(dx: number, dy: number) {
792
+ const byId = new Map<number, any>();
793
+ Array.from(this.$items.children).forEach((child: any) => byId.set(child.itemId, child));
794
+
795
+ // Prune ids whose nodes no longer exist before touching anything.
796
+ Array.from(this.#selected).forEach((id) => {
797
+ if (!byId.has(id)) this.#selected.delete(id);
798
+ });
799
+ const ids = Array.from(this.#selected);
800
+ if (ids.length === 0) return;
801
+
802
+ const befores = new Map<number, NodeState>(ids.map((id) => {
803
+ const node = byId.get(id);
804
+ return [id, this.#nodeStates.get(id) ?? { x: node.x, y: node.y, width: node.width ?? 12, height: node.height ?? 4 }];
805
+ }));
806
+ // Clamp the group delta so no node ends up off-canvas (unreachable).
807
+ befores.forEach((before) => {
808
+ dx = Math.max(dx, -before.x);
809
+ dy = Math.max(dy, -before.y);
810
+ });
811
+ if (dx === 0 && dy === 0) return;
812
+
813
+ const transforms = ids.map((id) => {
814
+ const node = byId.get(id);
815
+ const before = befores.get(id)!;
816
+ const after: NodeState = { x: before.x + dx, y: before.y + dy, width: before.width, height: before.height };
817
+ node.x = after.x;
818
+ node.y = after.y;
819
+ this.#nodeStates.set(id, after);
820
+ this.#schedulePinUpdate(id);
821
+ const item = this.items.find((i: any) => i.id === id);
822
+ if (item) { item.x = after.x; item.y = after.y; }
823
+ return { nodeId: id, before, after };
824
+ });
825
+
826
+ if (transforms.length === 1) {
827
+ const { nodeId, before, after } = transforms[0];
828
+ this.#pushTransform(nodeId, before, after);
829
+ } else {
830
+ // Merge held arrow keys on the same selection into one undo entry.
831
+ const last = this.#undo.at(-1);
832
+ const afterById = new Map(transforms.map((t) => [t.nodeId, t.after]));
833
+ if (last?.type === 'multi-transform'
834
+ && last.transforms.length === transforms.length
835
+ && last.transforms.every((t) => afterById.has(t.nodeId))) {
836
+ last.transforms.forEach((t) => { t.after = afterById.get(t.nodeId)!; });
837
+ } else {
838
+ this.#undo.push({ type: 'multi-transform', transforms });
839
+ }
840
+ this.#redo = [];
841
+ }
842
+ this.#updateScrollExtent();
843
+ }
844
+
845
+ // Every connector.setNode triggers a full path redraw, so coalesce updates
846
+ // from key repeats into at most one batch per animation frame.
847
+ #pinUpdateIds = new Set<number>();
848
+ #pinUpdateScheduled = false;
849
+ #schedulePinUpdate(nodeId: number) {
850
+ this.#pinUpdateIds.add(nodeId);
851
+ if (this.#pinUpdateScheduled) return;
852
+ this.#pinUpdateScheduled = true;
853
+ requestAnimationFrame(() => {
854
+ this.#pinUpdateScheduled = false;
855
+ const ids = Array.from(this.#pinUpdateIds);
856
+ this.#pinUpdateIds.clear();
857
+ ids.forEach((id) => this.#updatePins(id));
858
+ });
859
+ }
860
+
861
+ #copySelected() {
862
+ // The entry node is excluded: a script has exactly one entry point.
863
+ const entries = Array.from(this.#selected)
864
+ .map((id) => this.items.find((x: any) => x.id === id))
865
+ .filter((item: any) => item && item.node)
866
+ .map((item: any) => {
867
+ const state = this.#nodeStates.get(item.id)
868
+ ?? { x: item.x, y: item.y, width: item.width ?? 12, height: item.height ?? 4 };
869
+ const { editors, ...rest } = item;
870
+ return { item: JSON.parse(JSON.stringify(rest)), state: { ...state } };
871
+ });
872
+ if (entries.length === 0) return;
873
+ this.#clipboard = entries;
874
+ this.#pasteCount = 0;
875
+ }
876
+
877
+ #pasteClipboard() {
878
+ if (!this.#clipboard || this.#clipboard.length === 0) return;
879
+
880
+ const copiedIds = new Set(this.#clipboard.map(({ item }) => item.id));
881
+ const idMap = new Map<number, number>();
882
+ this.#clipboard.forEach(({ item }) => {
883
+ idMap.set(item.id, this.#nextNodeId++);
884
+ });
885
+
886
+ this.clearSelection();
887
+
888
+ // Cascade repeated pastes of the same clipboard instead of stacking them.
889
+ this.#pasteCount += 1;
890
+ const offset = 2 * this.#pasteCount;
891
+
892
+ const newItems = this.#clipboard.map(({ item, state }) => {
893
+ const newId = idMap.get(item.id)!;
894
+ const newNodes: Record<string, number[]> = {};
895
+ if (item.nodes && typeof item.nodes === 'object') {
896
+ Object.entries(item.nodes as Record<string, number[]>).forEach(([key, targets]) => {
897
+ if (!Array.isArray(targets)) return;
898
+ const remapped = (targets as number[])
899
+ .filter((t) => copiedIds.has(t))
900
+ .map((t) => idMap.get(t)!);
901
+ if (remapped.length > 0) newNodes[key] = remapped;
902
+ });
903
+ }
904
+ return { ...JSON.parse(JSON.stringify(item)), id: newId, x: state.x + offset, y: state.y + offset, nodes: newNodes };
905
+ });
906
+
907
+ newItems.forEach((item) => this.items.push(item));
908
+
909
+ newItems.forEach((item) => {
910
+ this.#selected.add(item.id);
911
+ const node = this.getNodeById(item.id);
912
+ if (node) (node as any).select();
913
+ });
914
+ }
915
+
916
+ // Merges consecutive transforms on the same node (resize snaps, held arrow
917
+ // keys); pass merge = false to keep a completed drag as its own undo entry.
918
+ #pushTransform(nodeId: number, before: NodeState, after: NodeState, merge: boolean = true) {
919
+ const last = this.#undo.at(-1);
920
+ if (merge && last?.type === 'transform' && last.nodeId === nodeId) {
921
+ last.after = after;
922
+ } else {
923
+ this.#undo.push({ type: 'transform', nodeId, before, after });
924
+ }
925
+ this.#redo = [];
926
+ this.#nodeStates.set(nodeId, after);
927
+ }
928
+
929
+ #applyUndo() {
930
+ const item = this.#undo.pop();
931
+ if (!item) return;
932
+ this.#redo.push(item);
933
+ this.#applyItem(item, 'undo');
934
+ }
935
+
936
+ #applyRedo() {
937
+ const item = this.#redo.pop();
938
+ if (!item) return;
939
+ this.#undo.push(item);
940
+ this.#applyItem(item, 'redo');
941
+ }
942
+
943
+ #applyItem(item: UndoItem, direction: 'undo' | 'redo') {
944
+ switch (item.type) {
945
+ case 'transform': {
946
+ this.#applyNodeState(item.nodeId, direction === 'undo' ? item.before : item.after);
947
+ break;
948
+ }
949
+ case 'multi-transform': {
950
+ for (const t of item.transforms) {
951
+ this.#applyNodeState(t.nodeId, direction === 'undo' ? t.before : t.after);
952
+ }
953
+ break;
954
+ }
955
+ case 'delete': {
956
+ if (direction === 'undo') {
957
+ // Restore in reverse deletion order so the recorded indexes hold.
958
+ for (let i = item.entries.length - 1; i >= 0; i--) {
959
+ const entry = item.entries[i];
960
+ this.items.splice(entry.index, 0, entry.item);
961
+ this.#nodeStates.set(entry.item.id, {
962
+ x: entry.item.x,
963
+ y: entry.item.y,
964
+ width: entry.item.width ?? 12,
965
+ height: entry.item.height ?? 4,
966
+ });
967
+ }
968
+ // With every node back, restore the connections that pointed at them.
969
+ item.entries.forEach((entry) => {
970
+ entry.incoming.forEach(({ id, key, index }) => {
971
+ const source = this.items.find((x: any) => x.id === id);
972
+ if (!source) return;
973
+ const nodes = source.nodes ?? (source.nodes = {});
974
+ const targets = Array.isArray(nodes[key]) ? nodes[key] : (nodes[key] = []);
975
+ if (!targets.includes(entry.item.id)) {
976
+ targets.splice(Math.min(index, targets.length), 0, entry.item.id);
977
+ }
978
+ });
979
+ });
980
+ } else {
981
+ item.entries.forEach((entry) => this.#deleteNode(entry.item.id));
982
+ }
983
+ break;
984
+ }
985
+ }
986
+ this.#updateScrollExtent();
987
+ }
988
+
989
+ // Applies a recorded state to the element, the connector, and the data
990
+ // model; the items array must stay in sync or saving after undo would
991
+ // persist stale geometry.
992
+ #applyNodeState(nodeId: number, state: NodeState) {
993
+ const node = this.getNodeById(nodeId) as any;
994
+ const item = this.items.find((i: any) => i.id === nodeId);
995
+ if (!node && !item) return;
996
+ if (node) {
997
+ node.x = state.x;
998
+ node.y = state.y;
999
+ node.width = state.width;
1000
+ node.height = state.height;
1001
+ }
1002
+ if (item) {
1003
+ item.x = state.x;
1004
+ item.y = state.y;
1005
+ item.width = state.width;
1006
+ item.height = state.height;
1007
+ }
1008
+ this.#nodeStates.set(nodeId, state);
1009
+ this.#updatePins(nodeId);
1010
+ }
1011
+
1012
+ #deleteNode(nodeId: number) {
1013
+ const index = this.items.findIndex((x: any) => x.id === nodeId);
1014
+ // Index 0 is the entry node, which cannot be deleted.
1015
+ if (index > 0) {
1016
+ this.items.splice(index, 1);
1017
+ this.#nodeStates.delete(nodeId);
1018
+ this.#updateScrollExtent();
1019
+ }
1020
+ }
1021
+
1022
+ // Connections from surviving nodes into nodeId; recorded before deletion so
1023
+ // undo can put them back after the connector strips them from the model.
1024
+ #collectIncoming(nodeId: number): Array<{ id: number; key: string; index: number }> {
1025
+ const incoming: Array<{ id: number; key: string; index: number }> = [];
1026
+ this.items.forEach((item: any) => {
1027
+ if (item.id === nodeId || !item.nodes || typeof item.nodes !== 'object') return;
1028
+ Object.entries(item.nodes as Record<string, number[]>).forEach(([key, targets]) => {
1029
+ if (!Array.isArray(targets)) return;
1030
+ const index = targets.indexOf(nodeId);
1031
+ if (index > -1) incoming.push({ id: item.id, key, index });
1032
+ });
1033
+ });
1034
+ return incoming;
1035
+ }
1036
+
1037
+ // Deletes the given nodes as one undoable operation.
1038
+ #deleteNodesWithUndo(nodeIds: number[]) {
1039
+ const entries: DeleteEntry[] = [];
1040
+ nodeIds.forEach((nodeId) => {
1041
+ const index = this.items.findIndex((x: any) => x.id === nodeId);
1042
+ if (index <= 0) return;
1043
+ const node = this.getNodeById(nodeId) as any;
1044
+ const item = {
1045
+ ...this.items[index],
1046
+ ...(node ? { x: node.x, y: node.y, width: node.width, height: node.height } : {}),
1047
+ };
1048
+ const incoming = this.#collectIncoming(nodeId);
1049
+ this.#deleteNode(nodeId);
1050
+ entries.push({ item, index, incoming });
1051
+ });
1052
+ if (entries.length === 0) return;
1053
+ this.#undo.push({ type: 'delete', entries });
1054
+ this.#redo = [];
1055
+ }
1056
+
1057
+ #updatePins(nodeId: number) {
1058
+ const node = this.getNodeById(nodeId);
1059
+ if (!node) return;
1060
+ const { x, y, width, height } = node;
1061
+ this.#connector?.setNode(
1062
+ String(nodeId),
1063
+ x * this.gridSize,
1064
+ y * this.gridSize,
1065
+ (width ?? 12) * this.gridSize,
1066
+ (height ?? 3) * this.gridSize
1067
+ );
1068
+ }
1069
+
1070
+ #updateScrollExtent() {
1071
+ let maxX = 0;
1072
+ let maxY = 0;
1073
+ Array.from(this.$items.children).forEach((child: any) => {
1074
+ maxX = Math.max(maxX, child.x + (child.width ?? 12));
1075
+ maxY = Math.max(maxY, child.y + (child.height ?? 3));
1076
+ });
1077
+ this.$grid.style.setProperty('--pg-nodes-max-x', `${maxX + 40}rem`);
1078
+ this.$grid.style.setProperty('--pg-nodes-max-y', `${maxY + 40}rem`);
1079
+ }
1080
+
1081
+ debug(nodeId: number = 0) {
1082
+ this.$grid.classList.toggle('debug', true);
1083
+ this.#debug.forEach(id => {
1084
+ const n = this.getNodeById(id);
1085
+ if (n) n.debug = false;
1086
+ });
1087
+ this.#debug = [nodeId];
1088
+ this.#debugPrevious = nodeId;
1089
+ const n = this.getNodeById(nodeId);
1090
+ if (n) n.debug = true;
1091
+ }
1092
+
1093
+ async debugNext() {
1094
+ if (this.#debug.length === 0) return;
1095
+ const currentId = this.#debug.shift()!;
1096
+ const n = this.getNodeById(currentId);
1097
+ if (n) n.debug = false;
1098
+
1099
+ const item = this.items.find((x: any) => x.id === currentId);
1100
+ if (!item) return;
1101
+
1102
+ let nextIds: number[] = [];
1103
+ if (!item.node) {
1104
+ const thenTargets = item.nodes?.then;
1105
+ nextIds = Array.isArray(thenTargets)
1106
+ ? thenTargets.filter((id: any) => typeof id === 'number')
1107
+ : [];
1108
+ } else {
1109
+ const nodeType = this.nodes.find((x: any) => x.name === item.node);
1110
+ if (nodeType?.handler) {
1111
+ const params: any = { state: this.#state };
1112
+ // Globals ([key, value] pairs) are exposed to handlers; the node's
1113
+ // own args and slots take precedence over a colliding name.
1114
+ this.globals.forEach((entry: any) => {
1115
+ if (Array.isArray(entry)) {
1116
+ params[entry[0]] = entry[1];
1117
+ }
1118
+ });
1119
+ if (item.args) {
1120
+ Object.keys(item.args).forEach((key) => {
1121
+ if (typeof item.args[key] === 'string') {
1122
+ // String args are template literals; a malformed one (stray
1123
+ // backtick / ${) must not abort the run.
1124
+ try {
1125
+ let fn = new Function('state', `return \`${item.args[key]}\`;`);
1126
+ params[key] = fn(this.#state);
1127
+ } catch (error) {
1128
+ params[key] = item.args[key];
1129
+ }
1130
+ } else {
1131
+ params[key] = item.args[key];
1132
+ }
1133
+ });
1134
+ }
1135
+ if (item.nodes) {
1136
+ // get the type to know known keys
1137
+ (nodeType.nodes ?? []).forEach(({ key }: any) => {
1138
+ params[key] = item.nodes[key] ?? [];
1139
+ });
1140
+ }
1141
+ params.nodeId = item.id;
1142
+ params.node = this.#debugPrevious;
1143
+ let result;
1144
+ try {
1145
+ result = await nodeType.handler(params);
1146
+ } catch (error) {
1147
+ // Handlers throw to signal invalid scripts; surface it and stop.
1148
+ this.#debug = [];
1149
+ this.dispatchEvent(new CustomEvent('error', {
1150
+ detail: { nodeId: currentId, error },
1151
+ }));
1152
+ return;
1153
+ }
1154
+ nextIds = (Array.isArray(result) ? result : [result])
1155
+ .filter((id: any) => typeof id === 'number');
1156
+ this.#debugPrevious = currentId;
1157
+ this.dispatchEvent(new CustomEvent('debug', {
1158
+ detail: {
1159
+ state: this.#state,
1160
+ },
1161
+ }));
1162
+ }
1163
+ }
1164
+ this.#debug.unshift(...nextIds);
1165
+ const nextId = this.#debug.length ? this.#debug[0] : null;
1166
+ if (nextId) {
1167
+ const next = this.getNodeById(nextId);
1168
+ if (next) next.debug = true;
1169
+ }
1170
+ }
1171
+
1172
+ #playing = false;
1173
+ async play() {
1174
+ if (this.#playing) return;
1175
+ this.#playing = true;
1176
+ let steps = 0;
1177
+ try {
1178
+ while (this.#debug.length > 0) {
1179
+ if (++steps > 10000) {
1180
+ this.dispatchEvent(new CustomEvent('error', {
1181
+ detail: { error: new Error('play() stopped after 10000 steps (cyclic script?)') },
1182
+ }));
1183
+ break;
1184
+ }
1185
+ await this.debugNext();
1186
+ }
1187
+ } finally {
1188
+ this.#playing = false;
1189
+ }
1190
+ }
1191
+
1192
+ restart() {
1193
+ this.#state.clear();
1194
+ this.debug();
1195
+ }
1196
+
1197
+ #state = new Map<string, string>();
1198
+ get state() {
1199
+ return this.#state;
1200
+ }
1201
+ set state(value: any) {
1202
+ this.#state = value;
1203
+ }
1204
+
1205
+ get json() {
1206
+ const normalize = JSON.parse(JSON.stringify(this.items));
1207
+ return JSON.stringify(
1208
+ normalize.map(x => { delete x.editors; return x; })
1209
+ );
1210
+ }
1211
+ set json(value: string) {
1212
+ const parsed = JSON.parse(value);
1213
+ if (!Array.isArray(parsed)) {
1214
+ throw new TypeError('<pg-nodes> json must be a JSON array of node items.');
1215
+ }
1216
+ // Undo history, selection, and cached geometry all reference the previous
1217
+ // items; carrying them across a load corrupts the new script. The
1218
+ // clipboard survives intentionally (ids are remapped on paste).
1219
+ this.#exitSelectionMode();
1220
+ this.#selected.clear();
1221
+ this.#undo = [];
1222
+ this.#redo = [];
1223
+ this.#nodeStates.clear();
1224
+ this.#pinUpdateIds.clear();
1225
+ this.#debug = [];
1226
+ this.#debugPrevious = 0;
1227
+ this.items = parsed;
1228
+ let nextNodeId = 0;
1229
+ this.items.forEach((item: any) => {
1230
+ if (typeof item.id === 'number' && Number.isFinite(item.id)) {
1231
+ nextNodeId = Math.max(nextNodeId, item.id);
1232
+ }
1233
+ });
1234
+ this.#nextNodeId = nextNodeId + 1;
1235
+ }
1236
+
1237
+ reset() {
1238
+ this.#connector?.reset();
1239
+ }
1240
+ }