virtual-tree-canvas 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +181 -27
  3. package/docs/icon-catalog.html +67 -0
  4. package/docs/icon-catalog.json +392 -0
  5. package/docs/icon-catalog.md +171 -0
  6. package/examples/generate-tree.js +86 -0
  7. package/examples/reorder-table.html +33 -0
  8. package/examples/units.html +43 -0
  9. package/package.json +7 -3
  10. package/resources/icons/bicycle.svg +1 -0
  11. package/resources/icons/bird.svg +1 -0
  12. package/resources/icons/box.svg +1 -0
  13. package/resources/icons/building.svg +1 -0
  14. package/resources/icons/calendar.svg +1 -0
  15. package/resources/icons/car.svg +1 -0
  16. package/resources/icons/cat.svg +1 -0
  17. package/resources/icons/child.svg +1 -0
  18. package/resources/icons/clock.svg +1 -0
  19. package/resources/icons/cloud.svg +1 -0
  20. package/resources/icons/data-bus.svg +1 -0
  21. package/resources/icons/database.svg +1 -0
  22. package/resources/icons/dog.svg +1 -0
  23. package/resources/icons/drone.svg +1 -0
  24. package/resources/icons/fish.svg +1 -0
  25. package/resources/icons/flower.svg +1 -0
  26. package/resources/icons/fog.svg +1 -0
  27. package/resources/icons/globe.svg +1 -0
  28. package/resources/icons/grip.svg +1 -0
  29. package/resources/icons/heart.svg +1 -0
  30. package/resources/icons/helicopter.svg +1 -0
  31. package/resources/icons/insect.svg +1 -0
  32. package/resources/icons/leaf.svg +1 -0
  33. package/resources/icons/link.svg +1 -0
  34. package/resources/icons/lock.svg +1 -0
  35. package/resources/icons/moon.svg +1 -0
  36. package/resources/icons/network.svg +1 -0
  37. package/resources/icons/people.svg +1 -0
  38. package/resources/icons/person.svg +1 -0
  39. package/resources/icons/pin.svg +1 -0
  40. package/resources/icons/radar.svg +1 -0
  41. package/resources/icons/rain.svg +1 -0
  42. package/resources/icons/seedling.svg +1 -0
  43. package/resources/icons/sensor.svg +1 -0
  44. package/resources/icons/server.svg +1 -0
  45. package/resources/icons/ship.svg +1 -0
  46. package/resources/icons/snow.svg +1 -0
  47. package/resources/icons/star-filled.svg +1 -0
  48. package/resources/icons/star.svg +1 -0
  49. package/resources/icons/storm.svg +1 -0
  50. package/resources/icons/sun.svg +1 -0
  51. package/resources/icons/thermometer.svg +1 -0
  52. package/resources/icons/trash.svg +1 -0
  53. package/resources/icons/tree.svg +1 -0
  54. package/resources/icons/wind.svg +1 -0
  55. package/src/assets/icons.js +75 -25
  56. package/src/core/icon-registry.js +6 -8
  57. package/src/core/theme-manager.js +17 -4
  58. package/src/core/tree-cell-layout.js +9 -0
  59. package/src/core/tree-column-model.js +27 -3
  60. package/src/core/tree-model.js +20 -0
  61. package/src/core/types.js +1 -0
  62. package/src/core/view-options.js +37 -0
  63. package/src/core/visible-row-model.js +1 -1
  64. package/src/index.d.ts +209 -5
  65. package/src/index.js +3 -0
  66. package/src/input/index.js +1 -0
  67. package/src/input/row-actions.js +115 -0
  68. package/src/input/row-reorder-input.js +168 -0
  69. package/src/input/tree-filter-bar.js +55 -0
  70. package/src/input/tree-tooltip.js +55 -0
  71. package/src/input/tree-view-input-controller.js +10 -3
  72. package/src/inspector/cell-editor-manager.js +42 -13
  73. package/src/inspector/pane-layout.js +12 -0
  74. package/src/renderers/checkbox.js +14 -0
  75. package/src/renderers/tree-row-renderer.js +103 -62
  76. package/src/tree-view-controller.js +309 -57
  77. package/src/tree-view.js +183 -0
  78. package/src/view/styles.js +14 -0
  79. package/src/assets/icons/air.svg +0 -1
  80. package/src/assets/icons/radar.svg +0 -1
  81. /package/{src/assets → resources}/icons/aircraft.svg +0 -0
  82. /package/{src/assets/icons/bus.svg → resources/icons/bus-vehicle.svg} +0 -0
  83. /package/{src/assets → resources}/icons/control.svg +0 -0
  84. /package/{src/assets → resources}/icons/damage.svg +0 -0
  85. /package/{src/assets → resources}/icons/error.svg +0 -0
  86. /package/{src/assets → resources}/icons/folder.svg +0 -0
  87. /package/{src/assets → resources}/icons/ground.svg +0 -0
  88. /package/{src/assets → resources}/icons/inspector-array.svg +0 -0
  89. /package/{src/assets → resources}/icons/inspector-object.svg +0 -0
  90. /package/{src/assets → resources}/icons/inspector-value.svg +0 -0
  91. /package/{src/assets → resources}/icons/munition.svg +0 -0
  92. /package/{src/assets → resources}/icons/placeholder.svg +0 -0
  93. /package/{src/assets → resources}/icons/point.svg +0 -0
  94. /package/{src/assets → resources}/icons/situation.svg +0 -0
  95. /package/{src/assets → resources}/icons/space.svg +0 -0
  96. /package/{src/assets → resources}/icons/subsurface.svg +0 -0
  97. /package/{src/assets → resources}/icons/surface.svg +0 -0
  98. /package/{src/assets → resources}/icons/task.svg +0 -0
  99. /package/{src/assets → resources}/icons/track.svg +0 -0
  100. /package/{src/assets → resources}/icons/warning.svg +0 -0
@@ -1,3 +1,9 @@
1
+ import { treeCellLayout } from './core/tree-cell-layout.js';
2
+ import { inspectorPaneLayout } from './inspector/pane-layout.js';
3
+ import { RowActions } from './input/row-actions.js';
4
+ import { validateTreeViewOptions } from './core/view-options.js';
5
+ import { RowReorderInput } from './input/row-reorder-input.js';
6
+ import { TreeTooltip } from './input/tree-tooltip.js';
1
7
  import {
2
8
  EventEmitter,
3
9
  IconRegistry,
@@ -14,12 +20,17 @@ import {
14
20
  } from './core/index.js';
15
21
  import { numericLayout } from './inspector/numeric-layout.js';
16
22
  import { CellEditorManager } from './inspector/cell-editor-manager.js';
17
- import { formatInspectorValue, getAtPath, inspectorColumns, inspectorPaneColumns, ModelInspectorBuilder, setAtPath } from './inspector/index.js';
23
+ import { formatInspectorValue, getAtPath, inspectorNodeId, inspectorColumns, inspectorPaneColumns, ModelInspectorBuilder, setAtPath } from './inspector/index.js';
18
24
  import { TreeViewInputController } from './input/tree-view-input-controller.js';
19
25
  import { TreeRowRenderer } from './renderers/index.js';
20
26
 
21
27
  export class TreeViewController {
22
28
  constructor(options = {}) {
29
+ validateTreeViewOptions(options);
30
+ this.layoutOverrides = { rowHeight: options.rowHeight, indentWidth: options.indentWidth };
31
+ this.rawNodes = new Map();
32
+ this.editable = options.editable !== false;
33
+ this.destroyed = false;
23
34
  this.events = new EventEmitter();
24
35
  this.model = new TreeModel();
25
36
  this.expansion = new TreeExpansionManager(this.model);
@@ -37,11 +48,21 @@ export class TreeViewController {
37
48
  this.viewport.renderInsetX = options.renderInsetX ?? 0;
38
49
  this.viewport.renderInsetY = options.renderInsetY ?? 0;
39
50
  this.columnModel = new TreeColumnModel(options.columns);
51
+ this.rowActions = options.rowActions ?? [];
52
+ this.rowDrag = options.rowDrag ?? null;
53
+ this.columnModel.setRowActions(this.rowActions.length);
54
+ this.rowReorder = Boolean(options.rowReorder);
55
+ this.columnModel.setRowReorder(this.rowReorder);
56
+ this.rowDrop = null;
57
+ this.autoRender = Boolean(options.autoRender);
58
+ this.renderFrame = null;
59
+ this.tooltipEnabled = Boolean(options.tooltip);
60
+ this.host = options.host ?? null;
40
61
  this.searchIndex = new TreeSearchIndex();
41
62
  this.selection = new TreeSelectionManager();
42
63
  this.patchBatcher = new PatchBatcher();
43
- this.themeManager = options.themeManager ?? new ThemeManager();
44
- this.iconRegistry = options.iconRegistry ?? new IconRegistry();
64
+ this.themeManager = options.themeManager ?? new ThemeManager(options.theme);
65
+ this.iconRegistry = options.iconRegistry ?? new IconRegistry({ iconsBaseUrl: options.iconsBaseUrl });
45
66
  this.renderer = options.renderer ?? new TreeRowRenderer({ themeManager: this.themeManager, iconRegistry: this.iconRegistry });
46
67
  this.nativeScrollbars = options.nativeScrollbars !== false;
47
68
  this.initialExpandDepth = options.initialExpandDepth ?? 1;
@@ -64,10 +85,17 @@ export class TreeViewController {
64
85
  this.inspector = null;
65
86
  this.inputController = null;
66
87
  this.cellEditor = null;
88
+ const initialTheme = this.themeManager.get();
89
+ this.rowModel.rowHeight = this.viewport.rowHeight = options.rowHeight ?? initialTheme.rowHeight;
90
+ this.rowModel.indentWidth = this.viewport.indentWidth = options.indentWidth ?? initialTheme.indentWidth;
67
91
  this.scene = this.createRenderScene();
92
+ this.renderer.setInvalidationHandler?.(() => this.requestRender(true));
68
93
 
94
+ for (const type of ['viewportchange', 'columnschange', 'sortchange', 'filterchange', 'themechange', 'layoutchange', 'expand', 'collapse', 'searchchange', 'nodehover', 'selectionchange', 'focuschange', 'rowreorder', 'rowdrag', 'rowreorderchange', 'modelchange']) {
95
+ this.events.on(type, () => this.requestRender());
96
+ }
69
97
  if (options.canvas) this.initialize(options.canvas);
70
- if (options.editable !== false && (options.host || options.editable)) this.attachCellEditor({ host: options.host });
98
+ if (options.canvas && (options.host || options.editable)) this.attachCellEditor({ host: options.host });
71
99
  if (options.canvas && options.input !== false) this.attachInput();
72
100
  }
73
101
 
@@ -78,9 +106,13 @@ export class TreeViewController {
78
106
  this.canvas = canvas;
79
107
  this.renderer.initialize(canvas);
80
108
  this.renderer.setScene(this.scene);
109
+ this.actionOverlay?.destroy();
110
+ this.actionOverlay = canvas.ownerDocument?.createElement && canvas.parentElement ? new RowActions(this) : null;
81
111
  this.#resizeToCanvasClientSize();
82
112
  this.#observeCanvasSize();
83
113
  this.#setupNativeScrollbars();
114
+ if (this.tooltipEnabled) this.attachTooltip({ host: this.host });
115
+ this.requestRender();
84
116
  return this;
85
117
  }
86
118
 
@@ -91,12 +123,48 @@ export class TreeViewController {
91
123
  controller: this,
92
124
  host: options.host,
93
125
  });
126
+ this.cellEditor.setEditable(this.editable);
127
+ if (this.inputController) this.inputController.cellEditor = this.cellEditor;
94
128
  return this.cellEditor;
95
129
  }
96
130
 
131
+ setRowActions(actions) {
132
+ validateTreeViewOptions({ rowActions: actions });
133
+ this.rowActions = actions;
134
+ this.columnModel.setRowActions(actions.length);
135
+ this.columnModel.setRowReorder(this.rowReorder && !this.inspector);
136
+ this.#syncContentSize();
137
+ this.requestRender();
138
+ }
139
+
140
+ setRowDrag(resolver) {
141
+ validateTreeViewOptions({ rowDrag: resolver });
142
+ this.rowReorderInput?.cancel();
143
+ this.rowDrag = resolver;
144
+ this.rowReorderInput?.syncMode();
145
+ }
146
+
147
+ setEditable(enabled) {
148
+ validateTreeViewOptions({ editable: enabled });
149
+ if (this.editable === enabled) return;
150
+ this.editable = enabled;
151
+ if (!this.cellEditor && this.canvas) this.attachCellEditor({ host: this.host });
152
+ this.cellEditor?.setEditable(enabled);
153
+ this.events.emit('editablechange', { editable: enabled });
154
+ this.requestRender();
155
+ }
156
+
157
+ setInitialExpandDepth(depth) {
158
+ validateTreeViewOptions({ initialExpandDepth: depth });
159
+ this.initialExpandDepth = depth;
160
+ }
161
+
162
+
97
163
  attachInput(options = {}) {
98
164
  if (!this.canvas) throw new Error('TreeViewController.attachInput requires an initialized canvas');
99
165
  this.inputController?.destroy?.();
166
+ this.rowReorderInput?.destroy();
167
+ this.rowReorderInput = new RowReorderInput(this);
100
168
  this.inputController = new TreeViewInputController({
101
169
  controller: this,
102
170
  cellEditor: options.cellEditor ?? this.cellEditor,
@@ -105,6 +173,13 @@ export class TreeViewController {
105
173
  }
106
174
 
107
175
  destroy() {
176
+ this.destroyed = true;
177
+ this.actionOverlay?.destroy();
178
+ this.rowReorderInput?.destroy();
179
+ this.rowReorderInput = null;
180
+ this.tooltip?.destroy();
181
+ this.tooltip = null;
182
+ this.cancelRender();
108
183
  this.inputController?.destroy?.();
109
184
  this.cellEditor?.destroy?.();
110
185
  this.disableWorkers();
@@ -116,6 +191,7 @@ export class TreeViewController {
116
191
  this.cellEditor = null;
117
192
  this.renderer?.destroy?.();
118
193
  this.canvas = null;
194
+ this.events.listeners.clear();
119
195
  }
120
196
 
121
197
  on(type, listener) {
@@ -126,23 +202,162 @@ export class TreeViewController {
126
202
  this.events.off(type, listener);
127
203
  }
128
204
 
129
- setData(nodes) {
205
+ setData(nodes, options = {}) {
206
+ if (!Array.isArray(nodes)) throw new TypeError('nodes must be an array');
207
+ validateTreeViewOptions({ iconResolver: options.iconResolver ?? null });
208
+ const resolved = resolveNodeIcons(nodes, options.iconResolver);
209
+ this.closeEditor();
210
+ this.rawNodes = new Map(nodes.map(node => [node.id, node]));
211
+ nodes = resolved;
212
+ const preserveExpansion = !this.inspector;
130
213
  this.inspector = null;
131
- if (this.columnModel.columns.length === 1 && this.columnModel.columns[0]?.kind === 'inspectorPane') {
214
+ this.columnModel.setRowReorder(this.rowReorder);
215
+ if (this.columnModel.columns.some(column => column.kind?.startsWith('inspector'))) {
132
216
  this.columnModel.setColumns([]);
133
217
  }
218
+ this.#replaceTree(nodes, preserveExpansion);
219
+ this.searchIndex.rebuild(this.model);
220
+ this.workerClient?.setData(this.model.nodes);
221
+ this.#rebuildRows();
222
+ this.events.emit('datachange', {});
223
+ }
224
+
225
+ #replaceTree(nodes, preserveExpansion) {
226
+ const branches = preserveExpansion ? this.model.nodes.filter(node => this.expansion.hasChildren(node.id)).map(node => node.id) : [];
227
+ const expanded = new Set(this.model.expanded);
134
228
  this.model.setTree(nodes);
135
229
  this.expansion.expandToDepth(this.initialExpandDepth);
230
+ for (const id of branches) {
231
+ if (!this.model.index.getNode(id)) continue;
232
+ if (expanded.has(id)) this.model.expanded.add(id);
233
+ else this.model.expanded.delete(id);
234
+ }
235
+ }
236
+
237
+ setIconResolver(resolver) {
238
+ validateTreeViewOptions({ iconResolver: resolver });
239
+ if (this.inspector) return;
240
+ const source = this.model.nodes.map(node => this.rawNodes.get(node.id) ?? node);
241
+ const nodes = resolveNodeIcons(source, resolver);
242
+ this.model.nodes = nodes;
243
+ this.model.index.rebuild(nodes);
136
244
  this.searchIndex.rebuild(this.model);
245
+ this.workerClient?.setData(nodes);
246
+ this.#rebuildRows();
247
+ }
248
+
249
+ setHeaderFilter(enabled) {
250
+ if (this.headerFilter !== Boolean(enabled)) this.closeEditor();
251
+ this.headerFilter = Boolean(enabled);
252
+ this.requestRender();
253
+ }
254
+
255
+ setInspectorOptions(options = {}) {
256
+ validateTreeViewOptions(options);
257
+ if (!this.inspector) return;
258
+ for (const key of ['filter', 'markUpdated']) {
259
+ if (options[key] !== undefined) this.inspector.options[key] = options[key];
260
+ }
261
+ this.requestRender();
262
+ }
263
+
264
+ setRowReorder(enabled) {
265
+ validateTreeViewOptions({ rowReorder: enabled });
266
+ this.rowReorder = Boolean(enabled);
267
+ this.rowDrop = null;
268
+ this.columnModel.setRowReorder(this.rowReorder && !this.inspector);
269
+ this.#syncContentSize();
270
+ this.events.emit('rowreorderchange', { enabled: this.rowReorder });
271
+ }
272
+
273
+ canReorderRows(nodeId) {
274
+ return this.rowReorder && !this.inspector && !this.columnModel.sort.direction && !this.rowModel.filterPredicate
275
+ && (nodeId == null || this.model.index.getNode(nodeId)?.reorderable !== false);
276
+ }
277
+
278
+ getRowOrder(parentId = null) {
279
+ return this.model.index.getChildren(parentId).slice();
280
+ }
281
+
282
+ moveRow(nodeId, targetIndex, options = {}) {
283
+ if (!this.canReorderRows(nodeId)) return false;
284
+ const anchorId = this.rowModel.getRow(this.anchorRowIndex)?.nodeId;
285
+ const detail = this.model.moveNode(nodeId, targetIndex);
286
+ if (!detail) return false;
287
+ this.workerRevision++;
137
288
  this.workerClient?.setData(this.model.nodes);
289
+ this.searchIndex.rebuild(this.model);
138
290
  this.#rebuildRows();
291
+ this.anchorRowIndex = this.rowModel.getRowById(anchorId)?.rowIndex ?? null;
292
+ this.focusedId = nodeId;
293
+ this.selection.focused = nodeId;
294
+ this.cellEditor?.close?.();
295
+ this.scrollToNode(nodeId);
296
+ this.events.emit('rowreorder', { ...detail, source: options.source ?? 'api' });
297
+ this.events.emit('focuschange', { nodeId });
298
+ return true;
299
+ }
300
+
301
+ moveRowBy(nodeId, offset, options = {}) {
302
+ const node = this.model.index.getNode(nodeId);
303
+ if (!node || !Number.isInteger(offset)) return false;
304
+ const siblings = this.model.index.getChildren(node.parentId ?? null);
305
+ return this.moveRow(nodeId, siblings.indexOf(nodeId) + offset, options);
306
+ }
307
+
308
+ getRowDropTarget(nodeId, clientX, clientY) {
309
+ if (!this.canReorderRows(nodeId)) return null;
310
+ const x = clientX - this.viewport.renderInsetX;
311
+ const y = clientY - this.viewport.renderInsetY;
312
+ if (x < 0 || x >= this.viewport.contentViewportWidth || y < this.viewport.headerHeight
313
+ || y >= this.viewport.headerHeight + this.viewport.rowViewportHeight) return null;
314
+ const contentY = y - this.viewport.headerHeight + this.viewport.scrollY;
315
+ const row = this.rowModel.getRow(Math.min(this.rowModel.rows.length - 1, Math.floor(contentY / this.rowModel.rowHeight)));
316
+ const source = this.model.index.getNode(nodeId);
317
+ const target = row && this.model.index.getNode(row.nodeId);
318
+ if (!source || !target || (source.parentId ?? null) !== (target.parentId ?? null)) return null;
319
+ const siblings = this.model.index.getChildren(source.parentId ?? null);
320
+ const after = contentY >= row.y + row.height / 2;
321
+ const from = siblings.indexOf(nodeId);
322
+ let index = siblings.indexOf(target.id) + Number(after);
323
+ if (index > from) index--;
324
+ return index === from ? null : { nodeId, targetId: target.id, index, y: row.y + (after ? row.height : 0) };
325
+ }
326
+
327
+ setRowDrop(target) {
328
+ this.rowDrop = target;
329
+ this.events.emit('rowdrag', { target });
330
+ }
331
+
332
+ requestRender(force = false) {
333
+ if (this.destroyed || (!this.autoRender && !force) || !this.canvas || this.renderFrame !== null) return;
334
+ const view = this.canvas.ownerDocument?.defaultView ?? globalThis;
335
+ const schedule = view.requestAnimationFrame?.bind(view) ?? (callback => setTimeout(callback, 0));
336
+ this.renderFrame = schedule(() => {
337
+ this.renderFrame = null;
338
+ if (this.canvas) this.render();
339
+ });
340
+ }
341
+
342
+ cancelRender() {
343
+ if (this.renderFrame === null) return;
344
+ const view = this.canvas?.ownerDocument?.defaultView ?? globalThis;
345
+ (view.cancelAnimationFrame?.bind(view) ?? clearTimeout)(this.renderFrame);
346
+ this.renderFrame = null;
347
+ }
348
+
349
+ attachTooltip(options = {}) {
350
+ this.tooltip?.destroy();
351
+ this.tooltip = new TreeTooltip({ controller: this, host: options.host ?? this.canvas?.parentElement });
352
+ return this.tooltip;
139
353
  }
140
354
 
141
355
  setModel(model, meta = {}, options = {}) {
356
+ validateTreeViewOptions({ ...options, mode: 'inspector', model, meta, presentation: options.presentation ?? options.mode ?? 'table' });
357
+ this.columnModel.setRowReorder(false);
142
358
  const builder = new ModelInspectorBuilder();
143
359
  const presentation = options.presentation ?? options.mode ?? 'table';
144
- const previousExpanded = new Set(this.model.expanded);
145
- const shouldPreserveExpansion = Boolean(this.inspector);
360
+ const preserveExpansion = Boolean(this.inspector);
146
361
  const inspectorOptions = {
147
362
  presentation,
148
363
  flatRoot: Boolean(options.flatRoot),
@@ -151,17 +366,21 @@ export class TreeViewController {
151
366
  markUpdated: options.markUpdated !== false,
152
367
  };
153
368
  const nodes = builder.build(model, meta, inspectorOptions);
369
+ const sameLayout = this.inspector?.presentation === presentation &&
370
+ nodes.length === this.model.nodes.length && nodes.every((node, index) => node.id === this.model.nodes[index].id);
371
+ if (!sameLayout) this.closeEditor();
372
+ else this.cellEditor?.reconcile(nodes);
373
+ this.rawNodes.clear();
154
374
  this.inspector = { model, meta, builder, presentation, options: inspectorOptions };
155
- this.setColumns(presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns());
156
- this.model.setTree(nodes);
157
- if (shouldPreserveExpansion) this.#restoreExpansion(previousExpanded);
158
- else this.expansion.expandToDepth(this.initialExpandDepth);
375
+ if (!sameLayout) this.setColumns(presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns());
376
+ this.#replaceTree(nodes, preserveExpansion);
159
377
  this.searchIndex.rebuild(this.model);
160
378
  this.#rebuildRows();
379
+ this.events.emit('datachange', {});
161
380
  this.events.emit('modelchange', { model, meta, structural: true });
162
381
  }
163
382
 
164
- updateInspectorValue(nodeId, newValue, editorType = 'unknown') {
383
+ updateInspectorValue(nodeId, newValue, editorType = 'unknown', options = {}) {
165
384
  const node = this.model.index.getNode(nodeId);
166
385
  if (!node?.data?.inspector || !this.inspector) return false;
167
386
  const data = node.data;
@@ -175,17 +394,25 @@ export class TreeViewController {
175
394
  if (this.inspector.options.markUpdated !== false) {
176
395
  this.setDynamicState([{ id: nodeId, state: { updated: true } }]);
177
396
  }
178
- const detail = { path: data.path, oldValue, newValue, nodeId, editorType };
179
- this.events.emit('valuechange', detail);
180
- this.events.emit('modelchange', { model: this.inspector.model, path: data.path, oldValue, newValue, nodeId });
397
+ const detail = { path: data.path, oldValue, newValue, nodeId, editorType, model: this.inspector.model, source: options.source ?? 'user' };
398
+ if (options.emit !== false) {
399
+ this.events.emit('valuechange', detail);
400
+ this.events.emit('modelchange', { ...detail });
401
+ }
402
+ this.requestRender();
181
403
  return true;
182
404
  }
183
405
 
406
+ setInspectorValue(path, value, options = {}) {
407
+ if (typeof path !== 'string') throw new TypeError('path must be a string');
408
+ return this.updateInspectorValue(inspectorNodeId(path === '$' ? '' : path), value, 'api', { ...options, source: 'api' });
409
+ }
410
+
184
411
  triggerInspectorAction(nodeId) {
185
412
  const node = this.model.index.getNode(nodeId);
186
413
  if (!node?.data?.inspector) return false;
187
414
  const label = node.data.meta?.button ?? node.label;
188
- this.events.emit('action', { path: node.data.path, label, nodeId });
415
+ this.events.emit('action', { path: node.data.path, label, nodeId, model: this.inspector?.model, source: 'user' });
189
416
  return true;
190
417
  }
191
418
 
@@ -230,7 +457,11 @@ export class TreeViewController {
230
457
  }
231
458
 
232
459
  setColumns(columns) {
460
+ if (columns == null) columns = this.inspector ? (this.inspector.presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns()) : [];
461
+ if (!Array.isArray(columns)) throw new TypeError('columns must be an array or null');
462
+ this.closeEditor();
233
463
  this.columnModel.setColumns(columns);
464
+ this.columnModel.setRowReorder(this.rowReorder && !this.inspector);
234
465
  this.#syncContentSize();
235
466
  this.events.emit('columnschange', { columns: this.columnModel.columns });
236
467
  }
@@ -281,6 +512,8 @@ export class TreeViewController {
281
512
  }
282
513
 
283
514
  setFilter(queryOrPredicate = '', options = {}) {
515
+ if (typeof queryOrPredicate !== 'string' && typeof queryOrPredicate !== 'function') throw new TypeError('filter must be a string or predicate');
516
+ if (this.cellEditor?.overlayKind !== 'filter') this.closeEditor();
284
517
  this.filterQuery = typeof queryOrPredicate === 'string' ? queryOrPredicate : '';
285
518
  this.filterOptions = typeof queryOrPredicate === 'string'
286
519
  ? { caseSensitive: Boolean(options.caseSensitive), wholeWord: Boolean(options.wholeWord) }
@@ -320,10 +553,12 @@ export class TreeViewController {
320
553
  }
321
554
 
322
555
  setDynamicState(patches) {
556
+ if (!Array.isArray(patches)) throw new TypeError('patches must be an array');
323
557
  this.lastPatchCount = patches.length;
324
558
  this.lastDirtyNodeCount = new Set(patches.map((patch) => patch.id)).size;
325
559
  this.model.applyDynamicPatches(patches);
326
560
  this.renderer.updateDynamicState(patches);
561
+ this.requestRender();
327
562
  }
328
563
 
329
564
  setTheme(theme) {
@@ -331,11 +566,10 @@ export class TreeViewController {
331
566
  const beforeIndentWidth = this.rowModel.indentWidth;
332
567
  this.themeManager.setTheme(theme);
333
568
  const nextTheme = this.themeManager.get();
334
- this.rowModel.rowHeight = nextTheme.rowHeight;
335
- this.rowModel.indentWidth = nextTheme.indentWidth;
336
- this.viewport.rowHeight = nextTheme.rowHeight;
337
- this.viewport.indentWidth = nextTheme.indentWidth;
338
- if (beforeRowHeight !== nextTheme.rowHeight || beforeIndentWidth !== nextTheme.indentWidth) {
569
+ this.rowModel.rowHeight = this.viewport.rowHeight = this.layoutOverrides.rowHeight ?? nextTheme.rowHeight;
570
+ this.rowModel.indentWidth = this.viewport.indentWidth = this.layoutOverrides.indentWidth ?? nextTheme.indentWidth;
571
+ if (beforeRowHeight !== this.rowModel.rowHeight || beforeIndentWidth !== this.rowModel.indentWidth) {
572
+ this.closeEditor();
339
573
  this.#rebuildRows();
340
574
  }
341
575
  this.#nativeScroll?.applyTheme?.(nextTheme);
@@ -343,13 +577,17 @@ export class TreeViewController {
343
577
  }
344
578
 
345
579
  setLayoutMetrics(options = {}) {
346
- const rowHeight = options.rowHeight ?? this.rowModel.rowHeight;
347
- const indentWidth = options.indentWidth ?? this.rowModel.indentWidth;
580
+ const rowHeight = Object.hasOwn(options, 'rowHeight') ? options.rowHeight ?? this.themeManager.get().rowHeight : this.rowModel.rowHeight;
581
+ const indentWidth = Object.hasOwn(options, 'indentWidth') ? options.indentWidth ?? this.themeManager.get().indentWidth : this.rowModel.indentWidth;
348
582
  const headerHeight = options.headerHeight ?? this.viewport.headerHeight;
349
583
  assertPositiveNumber(rowHeight, 'rowHeight');
350
584
  assertPositiveNumber(indentWidth, 'indentWidth');
351
585
  assertNonNegativeNumber(headerHeight, 'headerHeight');
586
+ for (const key of ['rowHeight', 'indentWidth']) {
587
+ if (Object.hasOwn(options, key)) this.layoutOverrides[key] = options[key];
588
+ }
352
589
  const rowsChanged = this.rowModel.rowHeight !== rowHeight || this.rowModel.indentWidth !== indentWidth;
590
+ if (rowsChanged || headerHeight !== this.viewport.headerHeight) this.closeEditor();
353
591
  this.rowModel.rowHeight = rowHeight;
354
592
  this.rowModel.indentWidth = indentWidth;
355
593
  this.viewport.rowHeight = rowHeight;
@@ -543,6 +781,7 @@ export class TreeViewController {
543
781
  }
544
782
 
545
783
  setSelection(ids) {
784
+ if (!Array.isArray(ids)) throw new TypeError('ids must be an array');
546
785
  this.selection.selected.clear();
547
786
  for (const id of ids) this.selection.selected.add(id);
548
787
  this.selection.focused = ids[ids.length - 1] ?? null;
@@ -578,6 +817,7 @@ export class TreeViewController {
578
817
  if (this.activeId === nodeId && this.activePart === part) return;
579
818
  this.activeId = nodeId;
580
819
  this.activePart = part;
820
+ this.requestRender();
581
821
  }
582
822
 
583
823
  clickNode(nodeId, event = {}) {
@@ -621,6 +861,11 @@ export class TreeViewController {
621
861
 
622
862
  handleKey(event) {
623
863
  if (!this.rowModel.rows.length) return false;
864
+ if (this.rowReorder && event.altKey && ['ArrowUp', 'ArrowDown'].includes(event.key)) {
865
+ const id = this.focusedId ?? this.rowModel.getRow(this.#focusedRowIndex())?.nodeId;
866
+ this.moveRowBy(id, event.key === 'ArrowUp' ? -1 : 1, { source: 'keyboard' });
867
+ return true;
868
+ }
624
869
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
625
870
  this.selection.selected.clear();
626
871
  for (const row of this.rowModel.rows) this.selection.selected.add(row.nodeId);
@@ -684,6 +929,7 @@ export class TreeViewController {
684
929
  this.renderer.setScene(this.scene);
685
930
  const renderStart = performance.now();
686
931
  this.renderer.render(this.scene, time);
932
+ this.actionOverlay?.render(this.scene);
687
933
  const renderMs = performance.now() - renderStart;
688
934
  this.lastRenderedRows = this.renderer.renderedRows ?? 0;
689
935
  return { scene: this.scene, sceneMs, renderMs, renderedRows: this.lastRenderedRows };
@@ -692,9 +938,14 @@ export class TreeViewController {
692
938
  createRenderScene() {
693
939
  const visibleRange = this.rowModel.getVisibleRange(this.viewport, 4);
694
940
  return {
941
+ rowDrag: this.rowDrag,
942
+ rowReorder: this.rowReorder,
943
+ canReorderRows: this.canReorderRows(),
944
+ rowDrop: this.rowDrop,
945
+ childrenByParent: this.model.index.childrenByParent,
695
946
  rows: this.rowModel.rows,
696
947
  visibleRange,
697
- stickyRows: this.rowModel.getStickyRows(this.viewport),
948
+ stickyRows: this.rowReorder ? [] : this.rowModel.getStickyRows(this.viewport),
698
949
  viewport: this.viewport,
699
950
  columns: this.columnModel.columns,
700
951
  theme: this.themeManager.get(),
@@ -711,12 +962,13 @@ export class TreeViewController {
711
962
  sortValues: this.sortValueSnapshot ? Array.from(this.sortValueSnapshot) : null,
712
963
  inspectorPaneLabelEnd: this.#computeInspectorPaneLabelEnd(visibleRange),
713
964
  filterQuery: this.filterQuery,
714
- headerFilter: Boolean(this.inspector?.options?.filter),
965
+ headerFilter: this.headerFilter ?? Boolean(this.inspector?.options?.filter),
715
966
  stats: this.getStats(),
716
967
  };
717
968
  }
718
969
 
719
970
  closeEditor() {
971
+ this.cellEditor?.close?.();
720
972
  this.events.emit('editorclose', {});
721
973
  }
722
974
 
@@ -730,7 +982,7 @@ export class TreeViewController {
730
982
  const resizeColumn = this.columnModel.getResizeHandleAt(x);
731
983
  if (resizeColumn) return { area: 'header', part: 'resize', column: resizeColumn, x, y: localY };
732
984
  const column = this.columnModel.getColumnAt(x);
733
- if (column?.kind === 'inspectorPane' && this.inspector?.options?.filter) {
985
+ if (column && (this.headerFilter ?? Boolean(this.inspector?.options?.filter)) && column === this.columnModel.columns.find(item => item.kind !== 'rowOrder' && item.id !== '__vtc_actions')) {
734
986
  return { area: 'header', part: 'filter', column, x, y: localY };
735
987
  }
736
988
  return column ? { area: 'header', part: 'label', column, x, y: localY } : { area: 'header', part: 'header', column: null, x, y: localY };
@@ -744,6 +996,10 @@ export class TreeViewController {
744
996
  const column = this.columnModel.getColumnAt(x);
745
997
  if (!column) return { area: 'row', part: 'row', row, column: null, x, y: rowY };
746
998
 
999
+ if (column.kind === 'rowOrder') {
1000
+ const offset = x - column.x;
1001
+ return { area: 'row', part: offset < 24 ? 'rowDrag' : offset < 48 ? 'rowUp' : 'rowDown', row, column, x, y: rowY };
1002
+ }
747
1003
  let part = 'cell';
748
1004
  if (column.kind === 'inspectorPane') {
749
1005
  const localX = x - column.x;
@@ -775,10 +1031,10 @@ export class TreeViewController {
775
1031
  }
776
1032
  } else if (column.kind === 'tree') {
777
1033
  const localX = x - column.x;
778
- const treeX = row.depth * this.rowModel.indentWidth;
779
- if (localX >= treeX + 4 && localX <= treeX + 22) part = 'chevron';
780
- else if (localX >= treeX + 26 && localX <= treeX + 44) part = 'icon';
781
- else if (localX >= treeX + 48) part = 'label';
1034
+ const layout = treeCellLayout(row.depth, this.rowModel.indentWidth, this.model.index.childrenByParent.size > 1);
1035
+ if (row.hasChildren && localX >= layout.chevronX - 6 && localX <= layout.chevronX + 12) part = 'chevron';
1036
+ else if (localX >= layout.iconX - 1 && localX <= layout.iconX + 17) part = 'icon';
1037
+ else if (localX >= layout.labelX - 2) part = 'label';
782
1038
  else part = 'cell';
783
1039
  } else if (column.kind === 'inspectorValue') {
784
1040
  part = this.#inspectorEditorPart(row, x - column.x, column.width);
@@ -856,7 +1112,7 @@ export class TreeViewController {
856
1112
  width = Math.max(0, layout.editorWidth - 20);
857
1113
  }
858
1114
  } else if (hit.column.kind === 'tree') {
859
- const labelX = hit.row.depth * this.rowModel.indentWidth + 50;
1115
+ const { labelX } = treeCellLayout(hit.row.depth, this.rowModel.indentWidth, this.model.index.childrenByParent.size > 1);
860
1116
  text = node.label ?? node.id;
861
1117
  width = Math.max(0, hit.column.width - labelX - 6);
862
1118
  } else if (hit.column.kind === 'inspectorValue') {
@@ -870,7 +1126,7 @@ export class TreeViewController {
870
1126
  width = Math.max(0, hit.column.width - 20);
871
1127
  } else if (typeof hit.column.value === 'function') {
872
1128
  const value = hit.column.value(node, state);
873
- text = value == null ? '' : String(value);
1129
+ text = hit.column.format ? hit.column.format(value, node, state) : value == null ? '' : String(value);
874
1130
  width = Math.max(0, hit.column.width - 20);
875
1131
  }
876
1132
 
@@ -880,6 +1136,7 @@ export class TreeViewController {
880
1136
  }
881
1137
 
882
1138
  resize(width, height) {
1139
+ if (width !== this.viewport.viewportWidth || height !== this.viewport.viewportHeight) this.closeEditor();
883
1140
  this.viewport.resize(width, height);
884
1141
  this.#fitInspectorPaneColumn(width);
885
1142
  this.#syncContentSize();
@@ -921,6 +1178,7 @@ export class TreeViewController {
921
1178
  this.#syncContentSize();
922
1179
  this.#restoreScrollAnchor(scrollAnchor);
923
1180
  this.rebuildCount++;
1181
+ this.requestRender();
924
1182
  }
925
1183
 
926
1184
  #syncContentSize() {
@@ -938,13 +1196,6 @@ export class TreeViewController {
938
1196
  this.setDynamicState(this.patchBatcher.flush());
939
1197
  }
940
1198
 
941
- #restoreExpansion(expandedIds) {
942
- this.model.expanded.clear();
943
- for (const id of expandedIds) {
944
- if (this.model.index.getNode(id) && this.expansion.hasChildren(id)) this.model.expanded.add(id);
945
- }
946
- }
947
-
948
1199
  #focusedRowIndex() {
949
1200
  if (this.focusedId) {
950
1201
  const row = this.rowModel.getRowById(this.focusedId);
@@ -960,7 +1211,10 @@ export class TreeViewController {
960
1211
  const entry = entries[0];
961
1212
  const width = Math.floor(entry?.contentRect?.width ?? 0);
962
1213
  const height = Math.floor(entry?.contentRect?.height ?? 0);
963
- if (width > 0 && height > 0) this.resize(width, height);
1214
+ if (width > 0 && height > 0) {
1215
+ this.resize(width, height);
1216
+ if (this.autoRender) { this.cancelRender(); this.render(); }
1217
+ }
964
1218
  });
965
1219
  this.#resizeObserver.observe(this.canvas);
966
1220
  }
@@ -1151,7 +1405,9 @@ export class TreeViewController {
1151
1405
  }
1152
1406
 
1153
1407
  #fitInspectorPaneColumn(width) {
1154
- const column = this.columnModel.columns.length === 1 ? this.columnModel.columns[0] : null;
1408
+ const columns = this.columnModel.columns.filter(c => !['__vtc_actions', '__vtc_row_order'].includes(c.id));
1409
+ const column = columns.length === 1 ? columns[0] : null;
1410
+ width -= this.columnModel.columns.filter(c => !columns.includes(c)).reduce((sum, c) => sum + c.width, 0);
1155
1411
  if (column?.kind !== 'inspectorPane' && column?.kind !== 'tree') return;
1156
1412
  if (column.kind === 'inspectorPane' && this.inspector?.options?.presentation !== 'pane') return;
1157
1413
  const nextWidth = Math.max(column.minWidth, Math.floor(width));
@@ -1356,21 +1612,6 @@ function assertNonNegativeNumber(value, name) {
1356
1612
  }
1357
1613
  }
1358
1614
 
1359
- function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
1360
- const safeWidth = Math.max(1, width);
1361
- const rightPadding = 14;
1362
- if (editorType === 'checkbox') {
1363
- const editorWidth = 34;
1364
- return { editorLeft: Math.max(64, safeWidth - rightPadding - editorWidth), editorWidth };
1365
- }
1366
- const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
1367
- const minLabelEnd = Math.max(88, depth * indentWidth + 104);
1368
- const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
1369
- const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
1370
- const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
1371
- const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
1372
- return { editorLeft, editorWidth };
1373
- }
1374
1615
 
1375
1616
  function createDefaultArrayItem(meta = {}) {
1376
1617
  if (meta.itemFactory) return meta.itemFactory();
@@ -1450,3 +1691,14 @@ function isProbablyTruncated(text, width) {
1450
1691
  function now() {
1451
1692
  return globalThis.performance?.now?.() ?? Date.now();
1452
1693
  }
1694
+
1695
+ function resolveNodeIcons(nodes, resolver) {
1696
+ if (!resolver) return nodes;
1697
+ return nodes.map(node => {
1698
+ const visual = resolver(node);
1699
+ if (typeof visual === 'string') return { ...node, icon: visual };
1700
+ if (!visual || typeof visual !== 'object') return node;
1701
+ if ((visual.id !== undefined && visual.id !== node.id) || (visual.parentId !== undefined && visual.parentId !== node.parentId)) throw new TypeError('iconResolver cannot change node identity or parent');
1702
+ return { ...node, ...visual };
1703
+ });
1704
+ }