html-overlay-node 0.1.9 → 0.1.10

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.
@@ -1,268 +1,441 @@
1
- /**
2
- * PropertyPanel - Node property editor panel
3
- */
4
- export class PropertyPanel {
5
- constructor(container, { graph, hooks, registry, render }) {
6
- this.container = container;
7
- this.graph = graph;
8
- this.hooks = hooks;
9
- this.registry = registry;
10
- this.render = render; // Store render callback
11
-
12
- this.panel = null;
13
- this.currentNode = null;
14
- this.isVisible = false;
15
-
16
- this._createPanel();
17
- }
18
-
19
- _createPanel() {
20
- // Create panel element
21
- this.panel = document.createElement('div');
22
- this.panel.className = 'property-panel';
23
- this.panel.style.display = 'none';
24
-
25
- // Panel HTML structure
26
- this.panel.innerHTML = `
27
- <div class="panel-inner">
28
- <div class="panel-header">
29
- <div class="panel-title">
30
- <span class="title-text">Node Properties</span>
31
- </div>
32
- <button class="panel-close" type="button">×</button>
33
- </div>
34
- <div class="panel-content">
35
- <!-- Content will be dynamically generated -->
36
- </div>
37
- </div>
38
- `;
39
-
40
- this.container.appendChild(this.panel);
41
-
42
- // Event listeners
43
- this.panel.querySelector('.panel-close').addEventListener('click', () => {
44
- this.close();
45
- });
46
-
47
- // Close on ESC key
48
- document.addEventListener('keydown', (e) => {
49
- if (e.key === 'Escape' && this.isVisible) {
50
- this.close();
51
- }
52
- });
53
- }
54
-
55
- open(node) {
56
- if (!node) return;
57
-
58
- this.currentNode = node;
59
- this.isVisible = true;
60
-
61
- // Update content
62
- this._renderContent();
63
-
64
- // Show panel
65
- this.panel.style.display = 'block';
66
- this.panel.classList.add('panel-visible');
67
- }
68
-
69
- close() {
70
- this.isVisible = false;
71
- this.panel.classList.remove('panel-visible');
72
-
73
- setTimeout(() => {
74
- this.panel.style.display = 'none';
75
- this.currentNode = null;
76
- }, 200);
77
- }
78
-
79
- _renderContent() {
80
- const node = this.currentNode;
81
- if (!node) return;
82
-
83
- const content = this.panel.querySelector('.panel-content');
84
-
85
- content.innerHTML = `
86
- <div class="section">
87
- <div class="section-title">Basic Info</div>
88
- <div class="section-body">
89
- <div class="field">
90
- <label>Type</label>
91
- <input type="text" value="${node.type}" readonly />
92
- </div>
93
- <div class="field">
94
- <label>Title</label>
95
- <input type="text" data-field="title" value="${node.title || ''}" />
96
- </div>
97
- <div class="field">
98
- <label>ID</label>
99
- <input type="text" value="${node.id}" readonly />
100
- </div>
101
- </div>
102
- </div>
103
-
104
- <div class="section">
105
- <div class="section-title">Position & Size</div>
106
- <div class="section-body">
107
- <div class="field-row">
108
- <div class="field">
109
- <label>X</label>
110
- <input type="number" data-field="x" value="${Math.round(node.computed.x)}" />
111
- </div>
112
- <div class="field">
113
- <label>Y</label>
114
- <input type="number" data-field="y" value="${Math.round(node.computed.y)}" />
115
- </div>
116
- </div>
117
- <div class="field-row">
118
- <div class="field">
119
- <label>Width</label>
120
- <input type="number" data-field="width" value="${node.computed.w}" />
121
- </div>
122
- <div class="field">
123
- <label>Height</label>
124
- <input type="number" data-field="height" value="${node.computed.h}" />
125
- </div>
126
- </div>
127
- </div>
128
- </div>
129
-
130
- ${this._renderPorts(node)}
131
- ${this._renderState(node)}
132
-
133
- <div class="panel-actions">
134
- <button class="btn-secondary panel-close-btn">Close</button>
135
- </div>
136
- `;
137
-
138
- // Add event listeners for inputs
139
- this._attachInputListeners();
140
- }
141
-
142
- _renderPorts(node) {
143
- if (!node.inputs.length && !node.outputs.length) return '';
144
-
145
- return `
146
- <div class="section">
147
- <div class="section-title">Ports</div>
148
- <div class="section-body">
149
- ${node.inputs.length ? `
150
- <div class="port-group">
151
- <div class="port-group-title">Inputs (${node.inputs.length})</div>
152
- ${node.inputs.map(p => `
153
- <div class="port-item">
154
- <span class="port-icon ${p.portType || 'data'}"></span>
155
- <span class="port-name">${p.name}</span>
156
- ${p.datatype ? `<span class="port-type">${p.datatype}</span>` : ''}
157
- </div>
158
- `).join('')}
159
- </div>
160
- ` : ''}
161
-
162
- ${node.outputs.length ? `
163
- <div class="port-group">
164
- <div class="port-group-title">Outputs (${node.outputs.length})</div>
165
- ${node.outputs.map(p => `
166
- <div class="port-item">
167
- <span class="port-icon ${p.portType || 'data'}"></span>
168
- <span class="port-name">${p.name}</span>
169
- ${p.datatype ? `<span class="port-type">${p.datatype}</span>` : ''}
170
- </div>
171
- `).join('')}
172
- </div>
173
- ` : ''}
174
- </div>
175
- </div>
176
- `;
177
- }
178
-
179
- _renderState(node) {
180
- if (!node.state || Object.keys(node.state).length === 0) return '';
181
-
182
- return `
183
- <div class="section">
184
- <div class="section-title">State</div>
185
- <div class="section-body">
186
- ${Object.entries(node.state).map(([key, value]) => `
187
- <div class="field">
188
- <label>${key}</label>
189
- <input
190
- type="${typeof value === 'number' ? 'number' : 'text'}"
191
- data-field="state.${key}"
192
- value="${value}"
193
- />
194
- </div>
195
- `).join('')}
196
- </div>
197
- </div>
198
- `;
199
- }
200
-
201
- _attachInputListeners() {
202
- const inputs = this.panel.querySelectorAll('[data-field]');
203
-
204
- inputs.forEach(input => {
205
- input.addEventListener('change', () => {
206
- this._handleFieldChange(input.dataset.field, input.value);
207
- });
208
- });
209
-
210
- // Close button
211
- this.panel.querySelector('.panel-close-btn').addEventListener('click', () => {
212
- this.close();
213
- });
214
- }
215
-
216
- _handleFieldChange(field, value) {
217
- const node = this.currentNode;
218
- if (!node) return;
219
-
220
- switch (field) {
221
- case 'title':
222
- node.title = value;
223
- break;
224
-
225
- case 'x':
226
- node.pos.x = parseFloat(value);
227
- this.graph.updateWorldTransforms();
228
- break;
229
-
230
- case 'y':
231
- node.pos.y = parseFloat(value);
232
- this.graph.updateWorldTransforms();
233
- break;
234
-
235
- case 'width':
236
- node.size.width = parseFloat(value);
237
- break;
238
-
239
- case 'height':
240
- node.size.height = parseFloat(value);
241
- break;
242
-
243
- default:
244
- // Handle state fields
245
- if (field.startsWith('state.')) {
246
- const key = field.substring(6);
247
- if (node.state) {
248
- const originalValue = node.state[key];
249
- node.state[key] = typeof originalValue === 'number' ? parseFloat(value) : value;
250
- }
251
- }
252
- }
253
-
254
- // Emit update event
255
- this.hooks?.emit('node:updated', node);
256
-
257
- // Trigger render to update canvas immediately
258
- if (this.render) {
259
- this.render();
260
- }
261
- }
262
-
263
- destroy() {
264
- if (this.panel) {
265
- this.panel.remove();
266
- }
267
- }
268
- }
1
+ /**
2
+ * PropertyPanel - Node property editor panel
3
+ */
4
+ export class PropertyPanel {
5
+ constructor(container, { graph, hooks, registry, render }) {
6
+ this.container = container;
7
+ this.graph = graph;
8
+ this.hooks = hooks;
9
+ this.registry = registry;
10
+ this.render = render;
11
+ this._def = null; // current node type definition
12
+
13
+ this.panel = null;
14
+ this.currentNode = null;
15
+ this.isVisible = false;
16
+ this._selfUpdating = false; // prevent re-render loop while user is editing
17
+
18
+ this._createPanel();
19
+ this._bindHooks();
20
+ }
21
+
22
+ _bindHooks() {
23
+ // Refresh when edges change
24
+ this.hooks?.on('edge:create', () => {
25
+ if (this._canRefresh()) this._renderContent();
26
+ });
27
+ this.hooks?.on('edge:delete', () => {
28
+ if (this._canRefresh()) this._renderContent();
29
+ });
30
+ // Refresh when node state changes externally
31
+ this.hooks?.on('node:updated', (node) => {
32
+ if (this._canRefresh() && this.currentNode?.id === node?.id && !this._selfUpdating) {
33
+ this._renderContent();
34
+ }
35
+ });
36
+ // Refresh position fields when node moves
37
+ this.hooks?.on('node:move', (node) => {
38
+ if (this._canRefresh() && this.currentNode?.id === node?.id) {
39
+ this._updatePositionFields();
40
+ }
41
+ });
42
+ // Refresh live values on every runner tick (lightweight DOM update)
43
+ this.hooks?.on('runner:tick', () => {
44
+ if (this._canRefresh()) this._updateLiveValues();
45
+ });
46
+ this.hooks?.on('runner:stop', () => {
47
+ if (this._canRefresh()) this._updateLiveValues();
48
+ });
49
+ }
50
+
51
+ _canRefresh() {
52
+ if (!this.isVisible || !this.currentNode) return false;
53
+ // Don't clobber in-progress edits
54
+ return !this.panel.querySelector('[data-field]:focus');
55
+ }
56
+
57
+ _createPanel() {
58
+ this.panel = document.createElement('div');
59
+ this.panel.className = 'property-panel';
60
+ this.panel.style.display = 'none';
61
+
62
+ this.panel.innerHTML = `
63
+ <div class="panel-inner">
64
+ <div class="panel-header">
65
+ <div class="panel-title">
66
+ <span class="title-text">Node Properties</span>
67
+ </div>
68
+ <button class="panel-close" type="button">×</button>
69
+ </div>
70
+ <div class="panel-content">
71
+ <!-- Content will be dynamically generated -->
72
+ </div>
73
+ </div>
74
+ `;
75
+
76
+ this.container.appendChild(this.panel);
77
+
78
+ this.panel.querySelector('.panel-close').addEventListener('click', () => {
79
+ this.close();
80
+ });
81
+
82
+ document.addEventListener('keydown', (e) => {
83
+ if (e.key === 'Escape' && this.isVisible) {
84
+ this.close();
85
+ }
86
+ });
87
+ }
88
+
89
+ open(node) {
90
+ if (!node) return;
91
+ this.currentNode = node;
92
+ this._def = this.registry?.types?.get(node.type) || null;
93
+ this.isVisible = true;
94
+ this._renderContent();
95
+ this.panel.style.display = 'block';
96
+ this.panel.classList.add('panel-visible');
97
+ }
98
+
99
+ close() {
100
+ this.isVisible = false;
101
+ this.panel.classList.remove('panel-visible');
102
+ setTimeout(() => {
103
+ this.panel.style.display = 'none';
104
+ this.currentNode = null;
105
+ }, 200);
106
+ }
107
+
108
+ _renderContent() {
109
+ const node = this.currentNode;
110
+ if (!node) return;
111
+
112
+ const content = this.panel.querySelector('.panel-content');
113
+ content.innerHTML = `
114
+ <div class="section">
115
+ <div class="section-title">Basic Info</div>
116
+ <div class="section-body">
117
+ <div class="field">
118
+ <label>Type</label>
119
+ <input type="text" value="${node.type}" readonly />
120
+ </div>
121
+ <div class="field">
122
+ <label>Title</label>
123
+ <input type="text" data-field="title" value="${node.title || ''}" />
124
+ </div>
125
+ <div class="field">
126
+ <label>ID</label>
127
+ <input type="text" value="${node.id}" readonly />
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <div class="section">
133
+ <div class="section-title">Position &amp; Size</div>
134
+ <div class="section-body">
135
+ <div class="field-row">
136
+ <div class="field">
137
+ <label>X</label>
138
+ <input type="number" data-field="x" value="${Math.round(node.computed.x)}" />
139
+ </div>
140
+ <div class="field">
141
+ <label>Y</label>
142
+ <input type="number" data-field="y" value="${Math.round(node.computed.y)}" />
143
+ </div>
144
+ </div>
145
+ <div class="field-row">
146
+ <div class="field">
147
+ <label>Width</label>
148
+ <input type="number" data-field="width" value="${node.computed.w}" />
149
+ </div>
150
+ <div class="field">
151
+ <label>Height</label>
152
+ <input type="number" data-field="height" value="${node.computed.h}" />
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+
158
+ ${this._renderConnections(node)}
159
+ ${this._renderPorts(node)}
160
+ ${this._renderLiveValues(node)}
161
+ ${this._renderState(node)}
162
+
163
+ <div class="panel-actions">
164
+ <button class="btn-secondary panel-close-btn">Close</button>
165
+ </div>
166
+ `;
167
+
168
+ this._attachInputListeners();
169
+ }
170
+
171
+ _renderConnections(node) {
172
+ const edges = [...this.graph.edges.values()];
173
+ const incoming = edges.filter(e => e.toNode === node.id);
174
+ const outgoing = edges.filter(e => e.fromNode === node.id);
175
+
176
+ if (!incoming.length && !outgoing.length) return '';
177
+
178
+ const edgeLabel = (e, dir) => {
179
+ const otherId = dir === 'in' ? e.fromNode : e.toNode;
180
+ const other = this.graph.nodes.get(otherId);
181
+ return `<div class="port-item">
182
+ <span class="port-icon data"></span>
183
+ <span class="port-name" style="font-size:10px;color:#5a5a78;">${other?.title ?? otherId}</span>
184
+ </div>`;
185
+ };
186
+
187
+ return `
188
+ <div class="section">
189
+ <div class="section-title">Connections</div>
190
+ <div class="section-body">
191
+ ${incoming.length ? `
192
+ <div class="port-group">
193
+ <div class="port-group-title">Incoming (${incoming.length})</div>
194
+ ${incoming.map(e => edgeLabel(e, 'in')).join('')}
195
+ </div>` : ''}
196
+ ${outgoing.length ? `
197
+ <div class="port-group">
198
+ <div class="port-group-title">Outgoing (${outgoing.length})</div>
199
+ ${outgoing.map(e => edgeLabel(e, 'out')).join('')}
200
+ </div>` : ''}
201
+ </div>
202
+ </div>
203
+ `;
204
+ }
205
+
206
+ _renderLiveValues(node) {
207
+ // Show live runtime values from the graph buffer (inputs & outputs)
208
+ const cur = this.graph?._curBuf?.();
209
+ if (!cur) return '';
210
+
211
+ const lines = [];
212
+
213
+ for (const input of node.inputs) {
214
+ const key = `${node.id}:${input.id}`;
215
+ // For inputs: look at the connected upstream node's output
216
+ for (const edge of this.graph.edges.values()) {
217
+ if (edge.toNode === node.id && edge.toPort === input.id) {
218
+ const upKey = `${edge.fromNode}:${edge.fromPort}`;
219
+ const val = cur.get(upKey);
220
+ if (val !== undefined) {
221
+ lines.push(`<div class="port-item">
222
+ <span class="port-icon data"></span>
223
+ <span class="port-name">↳ ${input.name}</span>
224
+ <span class="port-type" style="color:var(--color-primary);background:rgba(99,102,241,0.1);">${JSON.stringify(val)}</span>
225
+ </div>`);
226
+ }
227
+ break;
228
+ }
229
+ }
230
+ }
231
+
232
+ for (const output of node.outputs) {
233
+ const key = `${node.id}:${output.id}`;
234
+ const val = cur.get(key);
235
+ if (val !== undefined) {
236
+ lines.push(`<div class="port-item">
237
+ <span class="port-icon exec" style="background:#10b981;"></span>
238
+ <span class="port-name">↳ ${output.name}</span>
239
+ <span class="port-type" style="color:#10b981;background:rgba(16,185,129,0.1);">${JSON.stringify(val)}</span>
240
+ </div>`);
241
+ }
242
+ }
243
+
244
+ if (!lines.length) return '';
245
+
246
+ return `
247
+ <div class="section">
248
+ <div class="section-title">Live Values</div>
249
+ <div class="section-body">
250
+ ${lines.join('')}
251
+ </div>
252
+ </div>
253
+ `;
254
+ }
255
+
256
+ _renderPorts(node) {
257
+ if (!node.inputs.length && !node.outputs.length) return '';
258
+
259
+ return `
260
+ <div class="section">
261
+ <div class="section-title">Ports</div>
262
+ <div class="section-body">
263
+ ${node.inputs.length ? `
264
+ <div class="port-group">
265
+ <div class="port-group-title">Inputs (${node.inputs.length})</div>
266
+ ${node.inputs.map(p => `
267
+ <div class="port-item">
268
+ <span class="port-icon ${p.portType || 'data'}"></span>
269
+ <span class="port-name">${p.name}</span>
270
+ ${p.datatype ? `<span class="port-type">${p.datatype}</span>` : ''}
271
+ </div>
272
+ `).join('')}
273
+ </div>
274
+ ` : ''}
275
+ ${node.outputs.length ? `
276
+ <div class="port-group">
277
+ <div class="port-group-title">Outputs (${node.outputs.length})</div>
278
+ ${node.outputs.map(p => `
279
+ <div class="port-item">
280
+ <span class="port-icon ${p.portType || 'data'}"></span>
281
+ <span class="port-name">${p.name}</span>
282
+ ${p.datatype ? `<span class="port-type">${p.datatype}</span>` : ''}
283
+ </div>
284
+ `).join('')}
285
+ </div>
286
+ ` : ''}
287
+ </div>
288
+ </div>
289
+ `;
290
+ }
291
+
292
+ _renderState(node) {
293
+ if (!node.state) return '';
294
+
295
+ // Only show primitive, non-private keys
296
+ const entries = Object.entries(node.state).filter(([key, value]) => {
297
+ if (key.startsWith('_')) return false;
298
+ const t = typeof value;
299
+ return t === 'string' || t === 'number' || t === 'boolean';
300
+ });
301
+
302
+ if (!entries.length) return '';
303
+
304
+ const fieldHtml = ([key, value]) => {
305
+ if (typeof value === 'boolean') {
306
+ return `
307
+ <div class="field">
308
+ <label>${key}</label>
309
+ <select data-field="state.${key}">
310
+ <option value="true"${value ? ' selected' : ''}>true</option>
311
+ <option value="false"${!value ? ' selected' : ''}>false</option>
312
+ </select>
313
+ </div>`;
314
+ }
315
+ return `
316
+ <div class="field">
317
+ <label>${key}</label>
318
+ <input type="${typeof value === 'number' ? 'number' : 'text'}"
319
+ data-field="state.${key}"
320
+ value="${value}" />
321
+ </div>`;
322
+ };
323
+
324
+ return `
325
+ <div class="section">
326
+ <div class="section-title">State</div>
327
+ <div class="section-body">
328
+ ${entries.map(fieldHtml).join('')}
329
+ </div>
330
+ </div>
331
+ `;
332
+ }
333
+
334
+ _attachInputListeners() {
335
+ this.panel.querySelectorAll('[data-field]').forEach(input => {
336
+ input.addEventListener('change', () => {
337
+ this._selfUpdating = true;
338
+ this._handleFieldChange(input.dataset.field, input.value);
339
+ this._selfUpdating = false;
340
+ });
341
+ });
342
+
343
+ this.panel.querySelector('.panel-close-btn')?.addEventListener('click', () => {
344
+ this.close();
345
+ });
346
+ }
347
+
348
+ _handleFieldChange(field, value) {
349
+ const node = this.currentNode;
350
+ if (!node) return;
351
+
352
+ switch (field) {
353
+ case 'title':
354
+ node.title = value;
355
+ break;
356
+ case 'x':
357
+ node.pos.x = parseFloat(value);
358
+ this.graph.updateWorldTransforms();
359
+ break;
360
+ case 'y':
361
+ node.pos.y = parseFloat(value);
362
+ this.graph.updateWorldTransforms();
363
+ break;
364
+ case 'width':
365
+ node.size.width = parseFloat(value);
366
+ break;
367
+ case 'height':
368
+ node.size.height = parseFloat(value);
369
+ break;
370
+ default:
371
+ if (field.startsWith('state.')) {
372
+ const key = field.substring(6);
373
+ if (node.state && key in node.state) {
374
+ const orig = node.state[key];
375
+ if (typeof orig === 'boolean') {
376
+ node.state[key] = value === 'true';
377
+ } else if (typeof orig === 'number') {
378
+ node.state[key] = parseFloat(value);
379
+ } else {
380
+ node.state[key] = value;
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ this.hooks?.emit('node:updated', node);
387
+ this.render?.();
388
+ }
389
+
390
+ /** Lightweight update of position fields only (no full re-render) */
391
+ _updatePositionFields() {
392
+ const node = this.currentNode;
393
+ if (!node) return;
394
+ const xEl = this.panel.querySelector('[data-field="x"]');
395
+ const yEl = this.panel.querySelector('[data-field="y"]');
396
+ if (xEl) xEl.value = Math.round(node.computed.x);
397
+ if (yEl) yEl.value = Math.round(node.computed.y);
398
+ }
399
+
400
+ /** Lightweight in-place update of the Live Values section */
401
+ _updateLiveValues() {
402
+ const node = this.currentNode;
403
+ if (!node) return;
404
+
405
+ const cur = this.graph?._curBuf?.();
406
+ if (!cur) return;
407
+
408
+ // Find or create the live values section container
409
+ let section = this.panel.querySelector('.live-values-section');
410
+ const newHtml = this._renderLiveValues(node);
411
+
412
+ if (!newHtml) {
413
+ // No live values — remove section if present
414
+ if (section) section.remove();
415
+ return;
416
+ }
417
+
418
+ const wrapper = document.createElement('div');
419
+ wrapper.innerHTML = newHtml;
420
+ const newSection = wrapper.firstElementChild;
421
+ newSection.classList.add('live-values-section');
422
+
423
+ if (section) {
424
+ section.replaceWith(newSection);
425
+ } else {
426
+ // Insert before the State section, or before panel-actions
427
+ const stateSection = this.panel.querySelectorAll('.section');
428
+ const actions = this.panel.querySelector('.panel-actions');
429
+ // insert as second-to-last section (before actions)
430
+ if (actions) {
431
+ actions.before(newSection);
432
+ } else {
433
+ this.panel.querySelector('.panel-content').appendChild(newSection);
434
+ }
435
+ }
436
+ }
437
+
438
+ destroy() {
439
+ this.panel?.remove();
440
+ }
441
+ }