@pictogrammers/components 0.5.15 → 0.5.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/pg/autocompleteItem/__examples__/basic/basic.html +3 -0
- package/pg/autocompleteItem/autocompleteItem.css +55 -0
- package/pg/autocompleteItem/autocompleteItem.html +1 -0
- package/pg/autocompleteItem/autocompleteItem.ts +65 -0
- package/pg/button/button.css +5 -0
- package/pg/buttonMenu/buttonMenu.ts +1 -1
- package/pg/inputCombobox/__examples__/basic/basic.ts +33 -0
- package/pg/inputCombobox/inputCombobox.ts +158 -0
- package/pg/inputCombobox/inputCombobox.ts.tmp.44252.751480cb81cc +158 -0
- package/pg/inputCombobox/inputCombobox.ts.tmp.44252.dcea847c40a8 +156 -0
- package/pg/inputPill/inputPill.html +6 -0
- package/pg/inputPill/inputPill.html.tmp.26852.84ac63f4c46f +6 -0
- package/pg/inputPill/inputPill.ts +41 -0
- package/pg/inputPills/__examples__/basic/basic.html +9 -0
- package/pg/inputPills/__examples__/basic/basic.html.tmp.26852.4771e4cad5b7 +9 -0
- package/pg/inputPills/__examples__/basic/basic.ts.tmp.26852.d8fe41df18bd +0 -0
- package/pg/inputPixelEditor/README.md +104 -69
- package/pg/inputPixelEditor/README.md.tmp.42596.658de5f0a6f5 +0 -0
- package/pg/inputPixelEditor/__examples__/basic/basic.ts +6 -5
- package/pg/inputPixelEditor/inputPixelEditor.ts +96 -24
- package/pg/inputPixelEditor/inputPixelEditor.ts.tmp.38516.96529dd82670 +1703 -0
- package/pg/inputPixelEditor/inputPixelEditor.ts.tmp.42596.48ecc3ad9c72 +1724 -0
- package/pg/inputPixelEditor/utils/bitmapToMask.ts +113 -79
- package/pg/inputPixelEditor/utils/getLassoPixels.ts +51 -0
- package/pg/inputSelect/inputSelect.ts +5 -1
- package/pg/inputText/README.md +6 -6
- package/pg/inputText/__examples__/basic/basic.ts +1 -1
- package/pg/inputText/inputText.css +2 -2
- package/pg/inputText copy/README.md +38 -0
- package/pg/inputText copy/inputText.css +41 -0
- package/pg/inputText copy/inputText.html +1 -0
- package/pg/inputText copy/inputText.spec.ts +59 -0
- package/pg/inputText copy/inputText.ts +67 -0
- package/pg/inputTextarea/README.md +39 -0
- package/pg/inputTextarea/__examples__/basic/basic.html +9 -0
- package/pg/inputTextarea/__examples__/basic/basic.ts +30 -0
- package/pg/inputTextarea/inputTextarea.ts +121 -0
- package/pg/inputTextarea copy/README.md +39 -0
- package/pg/inputTextarea copy/inputTextarea.css +41 -0
- package/pg/inputTextarea copy/inputTextarea.html +1 -0
- package/pg/inputTextarea copy/inputTextarea.spec.ts +59 -0
- package/pg/inputTextarea copy/inputTextarea.ts +79 -0
- package/pg/inputTextareaAutocomplete/README.md +70 -0
- package/pg/inputTextareaAutocomplete/__examples__/basic/basic.ts +67 -0
- package/pg/inputTextareaAutocomplete/inputTextareaAutocomplete.ts +267 -0
- package/pg/node/README.md +35 -0
- package/pg/node/__examples__/basic/basic.ts +34 -0
- package/pg/node/index.ts +3 -0
- package/pg/node/node.css +53 -0
- package/pg/node/node.html +5 -0
- package/pg/node/node.ts +270 -0
- package/pg/nodeEditorLink/nodeEditorLink.css +69 -0
- package/pg/nodeEditorLink/nodeEditorLink.css.tmp.1408.09e961fd0dcc +69 -0
- package/pg/nodeEditorLink/nodeEditorLink.html +9 -0
- package/pg/nodeEditorNumber/nodeEditorNumber.css +59 -0
- package/pg/nodeEditorNumber/nodeEditorNumber.ts +57 -0
- package/pg/nodeEditorRange/nodeEditorRange.css +23 -0
- package/pg/nodeEditorRange/nodeEditorRange.ts +69 -0
- package/pg/nodeEditorText/__examples__/basic/basic.html +9 -0
- package/pg/nodeEditorText/__examples__/basic/basic.ts +30 -0
- package/pg/nodeEditorText/nodeEditorText.css +17 -0
- package/pg/nodeEditorText/nodeEditorText.html +4 -0
- package/pg/nodeEditorText/nodeEditorText.ts +57 -0
- package/pg/nodeEditorText copy/README.md +35 -0
- package/pg/nodeEditorText copy/nodeEditorText.css +49 -0
- package/pg/nodeEditorText copy/nodeEditorText.html +4 -0
- package/pg/nodeEditorText copy/nodeEditorText.ts +50 -0
- package/pg/nodeEditorTextArray/nodeEditorTextArray.css +68 -0
- package/pg/nodeEditorTextArray/nodeEditorTextArray.html +9 -0
- package/pg/nodeEditorTextArray/nodeEditorTextArray.ts +162 -0
- package/pg/nodeEntry/nodeEntry.css +61 -0
- package/pg/nodeEntry/nodeEntry.ts +164 -0
- package/pg/nodeEntry/nodeEntry.ts.tmp.28724.dda67762bca5 +164 -0
- package/pg/nodeInputText/nodeInputText.css +75 -0
- package/pg/nodeInputText/nodeInputText.html +4 -0
- package/pg/nodeInputText/nodeInputText.ts +105 -0
- package/pg/nodeOutput/nodeOutput.css +32 -0
- package/pg/nodeResize/README.md +3 -0
- package/pg/nodeResize/THINKING.md +81 -0
- package/pg/nodeResize/THINKING.md.tmp.24812.99a70ba81062 +81 -0
- package/pg/nodeResize/basic/basic.ts +17 -0
- package/pg/nodeResize/dragUtil.ts +105 -0
- package/pg/nodeResize/dragUtil.ts.tmp.34372.787464c73309 +75 -0
- package/pg/nodeResize/nodeResize.css +187 -0
- package/pg/nodeResize/nodeResize.html +9 -0
- package/pg/nodeResize/nodeResize.ts +156 -0
- package/pg/nodes/README.md +177 -0
- package/pg/nodes/__examples__/basic/basic.html +33 -0
- package/pg/nodes/__examples__/basic/basic.ts +716 -0
- package/pg/nodes/__examples__/basic/exampleNodes.ts +129 -0
- package/pg/nodes/__examples__/basic/exampleScripts.ts +129 -0
- package/pg/nodes/__examples__/basic/keystore.ts +38 -0
- package/pg/nodes/executeInSandbox.ts +32 -0
- package/pg/nodes/index.ts +3 -0
- package/pg/nodes/nodes.css +69 -0
- package/pg/nodes/nodes.html +6 -0
- package/pg/nodes/nodes.ts +1240 -0
- package/pg/nodes copy/nodes.css +41 -0
- package/pg/nodes copy/nodes.html +1 -0
- package/pg/overlayContextMenu/__examples__/filter/filter.ts +87 -0
- package/pg/overlayContextMenu/overlayContextMenu.css +14 -0
- package/pg/overlayContextMenu/overlayContextMenu.html +4 -3
- package/pg/overlayContextMenu/overlayContextMenu.ts +6 -2
- package/pg/overlayContextMenu/overlayContextMenu.ts.tmp.37088.530ff1c0a321 +0 -0
- package/pg/overlayMenu/overlayMenu.css +3 -1
- package/pg/overlayMenu/overlayMenu.ts +11 -2
- package/pg/overlayMenu/overlayMenu.ts.tmp.44252.9bb22fd59ede +80 -0
- package/pg/overlaySelectMenu/overlaySelectMenu.css +2 -0
- package/pg/overlaySubMenu/overlaySubMenu.css +8 -6
- package/pg/poco/README.md +22 -0
- package/pg/poco/__examples__/basic/basic.html +11 -0
- package/pg/poco/__examples__/basic/basic.ts +95 -0
- package/pg/poco/__examples__/basic/examples.ts +258 -0
- package/pg/poco/__examples__/basic/myFont.ts +1300 -0
- package/pg/poco/index.ts +3 -0
- package/pg/poco/mockPoco.ts +640 -0
- package/pg/poco/poco.css +7 -0
- package/pg/poco/poco.html +1 -0
- package/pg/poco/poco.ts +75 -0
- package/pg/popover/README.md +47 -0
- package/pg/popover/__examples__/basic/basic.html +3 -0
- package/pg/popover/popover.html +3 -0
- package/pg/popover/popover.ts +44 -0
- package/pg/popoverAutocomplete/__examples__/basic/basic.html +4 -0
- package/pg/popoverAutocomplete/__examples__/basic/basic.ts +46 -0
- package/pg/popoverAutocomplete/popoverAutocomplete.css +25 -0
- package/pg/popoverAutocomplete/popoverAutocomplete.html +1 -0
- package/pg/popoverAutocomplete/popoverAutocomplete.ts +134 -0
- package/pg/popoverAutocompleteItem/popoverAutocompleteItem.css +69 -0
- package/pg/popoverAutocompleteItem/popoverAutocompleteItem.ts +90 -0
- package/pg/table/__examples__/basic/basic.ts +1 -0
- package/pg/tableCellFileLocal/tableCellFileLocal.html +3 -0
- package/pg/tableCellFileLocal/tableCellFileLocal.html.tmp.20484.68ee2e30710e +3 -0
- package/pg/tableCellFileLocal/tableCellFileLocal.ts +44 -0
- package/pg/tableCellSelect/tableCellSelect.html +3 -0
- package/pg/tableCellSelect/tableCellSelect.html.tmp.41808.99c1a6f14340 +3 -0
- package/pg/tableCellSelect/tableCellSelect.ts +51 -0
- package/pg/tree/__examples__/basic/basic.ts +12 -5
- package/pg/tree/tree.html +1 -1
- package/pg/tree/tree.ts +385 -227
- package/pg/tree/tree.ts.tmp.38376.07d376a04f41 +343 -0
- package/pg/tree/tree.ts.tmp.38376.2a04599ade9f +361 -0
- package/pg/tree/tree.ts.tmp.38376.6cb2c663d476 +0 -0
- package/pg/tree/tree.ts.tmp.38376.ac5af1aff2b0 +393 -0
- package/pg/tree/tree.ts.tmp.38376.ac9acfc4f95f +324 -0
- package/pg/treeItem/treeItem.ts +536 -558
- package/pg/treeItem/treeItem.ts.tmp.38376.145d3121d648 +530 -0
- 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
|
+
}
|