virtual-tree-canvas 0.5.0 → 0.6.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 (92) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +105 -27
  3. package/docs/icon-catalog.html +67 -0
  4. package/docs/icon-catalog.json +380 -0
  5. package/docs/icon-catalog.md +167 -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.svg +1 -0
  48. package/resources/icons/storm.svg +1 -0
  49. package/resources/icons/sun.svg +1 -0
  50. package/resources/icons/thermometer.svg +1 -0
  51. package/resources/icons/tree.svg +1 -0
  52. package/resources/icons/wind.svg +1 -0
  53. package/src/assets/icons.js +73 -25
  54. package/src/core/icon-registry.js +6 -8
  55. package/src/core/theme-manager.js +14 -4
  56. package/src/core/tree-column-model.js +16 -3
  57. package/src/core/tree-model.js +20 -0
  58. package/src/core/view-options.js +30 -0
  59. package/src/index.d.ts +162 -5
  60. package/src/index.js +3 -0
  61. package/src/input/index.js +1 -0
  62. package/src/input/row-reorder-input.js +143 -0
  63. package/src/input/tree-filter-bar.js +55 -0
  64. package/src/input/tree-tooltip.js +55 -0
  65. package/src/input/tree-view-input-controller.js +5 -0
  66. package/src/inspector/cell-editor-manager.js +19 -4
  67. package/src/renderers/tree-row-renderer.js +58 -9
  68. package/src/tree-view-controller.js +257 -31
  69. package/src/tree-view.js +181 -0
  70. package/src/view/styles.js +14 -0
  71. package/src/assets/icons/air.svg +0 -1
  72. package/src/assets/icons/radar.svg +0 -1
  73. /package/{src/assets → resources}/icons/aircraft.svg +0 -0
  74. /package/{src/assets/icons/bus.svg → resources/icons/bus-vehicle.svg} +0 -0
  75. /package/{src/assets → resources}/icons/control.svg +0 -0
  76. /package/{src/assets → resources}/icons/damage.svg +0 -0
  77. /package/{src/assets → resources}/icons/error.svg +0 -0
  78. /package/{src/assets → resources}/icons/folder.svg +0 -0
  79. /package/{src/assets → resources}/icons/ground.svg +0 -0
  80. /package/{src/assets → resources}/icons/inspector-array.svg +0 -0
  81. /package/{src/assets → resources}/icons/inspector-object.svg +0 -0
  82. /package/{src/assets → resources}/icons/inspector-value.svg +0 -0
  83. /package/{src/assets → resources}/icons/munition.svg +0 -0
  84. /package/{src/assets → resources}/icons/placeholder.svg +0 -0
  85. /package/{src/assets → resources}/icons/point.svg +0 -0
  86. /package/{src/assets → resources}/icons/situation.svg +0 -0
  87. /package/{src/assets → resources}/icons/space.svg +0 -0
  88. /package/{src/assets → resources}/icons/subsurface.svg +0 -0
  89. /package/{src/assets → resources}/icons/surface.svg +0 -0
  90. /package/{src/assets → resources}/icons/task.svg +0 -0
  91. /package/{src/assets → resources}/icons/track.svg +0 -0
  92. /package/{src/assets → resources}/icons/warning.svg +0 -0
@@ -10,7 +10,8 @@ export class TreeRowRenderer {
10
10
  this.scene = null;
11
11
  this.iconRegistry = iconRegistry ?? new IconRegistry();
12
12
  this.renderedRows = 0;
13
- this.iconRenderQueued = false;
13
+ this.iconRenderFrame = null;
14
+ this.invalidate = null;
14
15
  this.preparedIconThemeKey = null;
15
16
  this.stopIconListener = this.iconRegistry.onChange?.(() => this.#scheduleIconRender()) ?? null;
16
17
  }
@@ -26,7 +27,15 @@ export class TreeRowRenderer {
26
27
  this.scene = scene;
27
28
  }
28
29
 
30
+ setInvalidationHandler(invalidate) { this.invalidate = invalidate; }
31
+
29
32
  destroy() {
33
+ if (this.iconRenderFrame !== null) {
34
+ const view = this.canvas?.ownerDocument?.defaultView ?? globalThis;
35
+ (view.cancelAnimationFrame?.bind(view) ?? clearTimeout)(this.iconRenderFrame);
36
+ }
37
+ this.iconRenderFrame = null;
38
+ this.invalidate = null;
30
39
  this.stopIconListener?.();
31
40
  this.stopIconListener = null;
32
41
  this.canvas = null;
@@ -65,15 +74,18 @@ export class TreeRowRenderer {
65
74
  this.#drawHeader(ctx);
66
75
  this.#drawRows(ctx);
67
76
  this.#drawStickyRows(ctx);
77
+ this.#drawRowDrop(ctx);
68
78
  ctx.restore();
69
79
  }
70
80
 
71
81
  #scheduleIconRender() {
72
- if (this.iconRenderQueued) return;
73
- this.iconRenderQueued = true;
74
- const schedule = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0));
75
- schedule(() => {
76
- this.iconRenderQueued = false;
82
+ if (!this.canvas) return;
83
+ if (this.invalidate) return this.invalidate();
84
+ if (this.iconRenderFrame !== null) return;
85
+ const view = this.canvas.ownerDocument?.defaultView ?? globalThis;
86
+ const schedule = view.requestAnimationFrame?.bind(view) ?? (callback => setTimeout(callback, 0));
87
+ this.iconRenderFrame = schedule(() => {
88
+ this.iconRenderFrame = null;
77
89
  this.render();
78
90
  });
79
91
  }
@@ -107,7 +119,7 @@ export class TreeRowRenderer {
107
119
  ctx.textBaseline = 'middle';
108
120
 
109
121
  for (const column of columns) {
110
- if (headerFilter && column.kind === 'inspectorPane') {
122
+ if (headerFilter && column === columns.find(item => item.kind !== 'rowOrder')) {
111
123
  this.#drawHeaderFilter(ctx, column, viewport, theme, filterQuery);
112
124
  } else {
113
125
  ctx.fillStyle = colors.textMuted;
@@ -145,7 +157,7 @@ export class TreeRowRenderer {
145
157
  ctx.strokeStyle = colors.border;
146
158
  ctx.stroke();
147
159
  ctx.fillStyle = filterQuery ? colors.text : colors.textMuted;
148
- drawTruncatedText(ctx, filterQuery || 'Filter inspector', x + 8, viewport.headerHeight / 2, width - 16);
160
+ drawTruncatedText(ctx, filterQuery || 'Filter rows', x + 8, viewport.headerHeight / 2, width - 16);
149
161
  }
150
162
 
151
163
  #drawSortIndicator(ctx, x, y, direction, theme) {
@@ -268,7 +280,8 @@ export class TreeRowRenderer {
268
280
  cell.column.render(ctx, cell);
269
281
  return;
270
282
  }
271
- if (cell.column.kind === 'tree') this.#drawTreeCell(ctx, cell);
283
+ if (cell.column.kind === 'rowOrder') this.#drawRowOrder(ctx, cell);
284
+ else if (cell.column.kind === 'tree') this.#drawTreeCell(ctx, cell);
272
285
  else if (cell.column.kind === 'inspectorPane') this.#drawInspectorPaneCell(ctx, cell);
273
286
  else if (cell.column.kind === 'inspectorValue') this.#drawInspectorValueCell(ctx, cell);
274
287
  else if (cell.column.kind === 'inspectorType') this.#drawInspectorTypeCell(ctx, cell);
@@ -278,6 +291,42 @@ export class TreeRowRenderer {
278
291
  else this.#drawTextCell(ctx, cell);
279
292
  }
280
293
 
294
+ #drawRowOrder(ctx, { node, row, rect, theme }) {
295
+ const siblings = this.scene.childrenByParent?.get(node.parentId ?? null) ?? [];
296
+ const enabled = this.scene.canReorderRows && siblings.length > 1;
297
+ const cy = rect.y + row.height / 2;
298
+ ctx.save();
299
+ ctx.strokeStyle = theme.colors.textMuted;
300
+ ctx.fillStyle = theme.colors.textMuted;
301
+ ctx.lineWidth = 1.5;
302
+ ctx.globalAlpha = enabled ? 1 : 0.3;
303
+ for (const dx of [10, 14]) for (const dy of [-4, 0, 4]) ctx.fillRect(rect.x + dx, cy + dy, 1.5, 1.5);
304
+ for (const [center, direction, available] of [[36, -1, siblings[0] !== node.id], [60, 1, siblings.at(-1) !== node.id]]) {
305
+ ctx.globalAlpha = enabled && available ? 1 : 0.3;
306
+ ctx.beginPath();
307
+ ctx.moveTo(rect.x + center - 4, cy - direction * 2);
308
+ ctx.lineTo(rect.x + center, cy + direction * 2);
309
+ ctx.lineTo(rect.x + center + 4, cy - direction * 2);
310
+ ctx.stroke();
311
+ }
312
+ ctx.restore();
313
+ }
314
+
315
+ #drawRowDrop(ctx) {
316
+ const { rowDrop, viewport, theme } = this.scene;
317
+ if (!rowDrop) return;
318
+ const y = viewport.headerHeight + rowDrop.y - viewport.scrollY;
319
+ if (y < viewport.headerHeight || y > viewport.headerHeight + viewport.rowViewportHeight) return;
320
+ ctx.save();
321
+ ctx.strokeStyle = theme.colors.focus;
322
+ ctx.lineWidth = 2;
323
+ ctx.beginPath();
324
+ ctx.moveTo(3, y);
325
+ ctx.lineTo(viewport.contentViewportWidth - 3, y);
326
+ ctx.stroke();
327
+ ctx.restore();
328
+ }
329
+
281
330
  #drawInspectorPaneCell(ctx, { node, row, rect, theme, style, hovered, hoverPart, activePart }) {
282
331
  const visibleRight = this.scene.viewport.scrollX + (this.scene.viewport.contentViewportWidth ?? this.scene.viewport.viewportWidth);
283
332
  rect = { ...rect, width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x) };
@@ -1,3 +1,6 @@
1
+ import { validateTreeViewOptions } from './core/view-options.js';
2
+ import { RowReorderInput } from './input/row-reorder-input.js';
3
+ import { TreeTooltip } from './input/tree-tooltip.js';
1
4
  import {
2
5
  EventEmitter,
3
6
  IconRegistry,
@@ -14,12 +17,17 @@ import {
14
17
  } from './core/index.js';
15
18
  import { numericLayout } from './inspector/numeric-layout.js';
16
19
  import { CellEditorManager } from './inspector/cell-editor-manager.js';
17
- import { formatInspectorValue, getAtPath, inspectorColumns, inspectorPaneColumns, ModelInspectorBuilder, setAtPath } from './inspector/index.js';
20
+ import { formatInspectorValue, getAtPath, inspectorNodeId, inspectorColumns, inspectorPaneColumns, ModelInspectorBuilder, setAtPath } from './inspector/index.js';
18
21
  import { TreeViewInputController } from './input/tree-view-input-controller.js';
19
22
  import { TreeRowRenderer } from './renderers/index.js';
20
23
 
21
24
  export class TreeViewController {
22
25
  constructor(options = {}) {
26
+ validateTreeViewOptions(options);
27
+ this.layoutOverrides = { rowHeight: options.rowHeight, indentWidth: options.indentWidth };
28
+ this.rawNodes = new Map();
29
+ this.editable = options.editable !== false;
30
+ this.destroyed = false;
23
31
  this.events = new EventEmitter();
24
32
  this.model = new TreeModel();
25
33
  this.expansion = new TreeExpansionManager(this.model);
@@ -37,11 +45,18 @@ export class TreeViewController {
37
45
  this.viewport.renderInsetX = options.renderInsetX ?? 0;
38
46
  this.viewport.renderInsetY = options.renderInsetY ?? 0;
39
47
  this.columnModel = new TreeColumnModel(options.columns);
48
+ this.rowReorder = Boolean(options.rowReorder);
49
+ this.columnModel.setRowReorder(this.rowReorder);
50
+ this.rowDrop = null;
51
+ this.autoRender = Boolean(options.autoRender);
52
+ this.renderFrame = null;
53
+ this.tooltipEnabled = Boolean(options.tooltip);
54
+ this.host = options.host ?? null;
40
55
  this.searchIndex = new TreeSearchIndex();
41
56
  this.selection = new TreeSelectionManager();
42
57
  this.patchBatcher = new PatchBatcher();
43
- this.themeManager = options.themeManager ?? new ThemeManager();
44
- this.iconRegistry = options.iconRegistry ?? new IconRegistry();
58
+ this.themeManager = options.themeManager ?? new ThemeManager(options.theme);
59
+ this.iconRegistry = options.iconRegistry ?? new IconRegistry({ iconsBaseUrl: options.iconsBaseUrl });
45
60
  this.renderer = options.renderer ?? new TreeRowRenderer({ themeManager: this.themeManager, iconRegistry: this.iconRegistry });
46
61
  this.nativeScrollbars = options.nativeScrollbars !== false;
47
62
  this.initialExpandDepth = options.initialExpandDepth ?? 1;
@@ -64,10 +79,17 @@ export class TreeViewController {
64
79
  this.inspector = null;
65
80
  this.inputController = null;
66
81
  this.cellEditor = null;
82
+ const initialTheme = this.themeManager.get();
83
+ this.rowModel.rowHeight = this.viewport.rowHeight = options.rowHeight ?? initialTheme.rowHeight;
84
+ this.rowModel.indentWidth = this.viewport.indentWidth = options.indentWidth ?? initialTheme.indentWidth;
67
85
  this.scene = this.createRenderScene();
86
+ this.renderer.setInvalidationHandler?.(() => this.requestRender(true));
68
87
 
88
+ for (const type of ['viewportchange', 'columnschange', 'sortchange', 'filterchange', 'themechange', 'layoutchange', 'expand', 'collapse', 'searchchange', 'nodehover', 'selectionchange', 'focuschange', 'rowreorder', 'rowdrag', 'rowreorderchange', 'modelchange']) {
89
+ this.events.on(type, () => this.requestRender());
90
+ }
69
91
  if (options.canvas) this.initialize(options.canvas);
70
- if (options.editable !== false && (options.host || options.editable)) this.attachCellEditor({ host: options.host });
92
+ if (options.canvas && (options.host || options.editable)) this.attachCellEditor({ host: options.host });
71
93
  if (options.canvas && options.input !== false) this.attachInput();
72
94
  }
73
95
 
@@ -81,6 +103,8 @@ export class TreeViewController {
81
103
  this.#resizeToCanvasClientSize();
82
104
  this.#observeCanvasSize();
83
105
  this.#setupNativeScrollbars();
106
+ if (this.tooltipEnabled) this.attachTooltip({ host: this.host });
107
+ this.requestRender();
84
108
  return this;
85
109
  }
86
110
 
@@ -91,12 +115,32 @@ export class TreeViewController {
91
115
  controller: this,
92
116
  host: options.host,
93
117
  });
118
+ this.cellEditor.setEditable(this.editable);
119
+ if (this.inputController) this.inputController.cellEditor = this.cellEditor;
94
120
  return this.cellEditor;
95
121
  }
96
122
 
123
+ setEditable(enabled) {
124
+ validateTreeViewOptions({ editable: enabled });
125
+ if (this.editable === enabled) return;
126
+ this.editable = enabled;
127
+ if (!this.cellEditor && this.canvas) this.attachCellEditor({ host: this.host });
128
+ this.cellEditor?.setEditable(enabled);
129
+ this.events.emit('editablechange', { editable: enabled });
130
+ this.requestRender();
131
+ }
132
+
133
+ setInitialExpandDepth(depth) {
134
+ validateTreeViewOptions({ initialExpandDepth: depth });
135
+ this.initialExpandDepth = depth;
136
+ }
137
+
138
+
97
139
  attachInput(options = {}) {
98
140
  if (!this.canvas) throw new Error('TreeViewController.attachInput requires an initialized canvas');
99
141
  this.inputController?.destroy?.();
142
+ this.rowReorderInput?.destroy();
143
+ this.rowReorderInput = new RowReorderInput(this);
100
144
  this.inputController = new TreeViewInputController({
101
145
  controller: this,
102
146
  cellEditor: options.cellEditor ?? this.cellEditor,
@@ -105,6 +149,12 @@ export class TreeViewController {
105
149
  }
106
150
 
107
151
  destroy() {
152
+ this.destroyed = true;
153
+ this.rowReorderInput?.destroy();
154
+ this.rowReorderInput = null;
155
+ this.tooltip?.destroy();
156
+ this.tooltip = null;
157
+ this.cancelRender();
108
158
  this.inputController?.destroy?.();
109
159
  this.cellEditor?.destroy?.();
110
160
  this.disableWorkers();
@@ -116,6 +166,7 @@ export class TreeViewController {
116
166
  this.cellEditor = null;
117
167
  this.renderer?.destroy?.();
118
168
  this.canvas = null;
169
+ this.events.listeners.clear();
119
170
  }
120
171
 
121
172
  on(type, listener) {
@@ -126,9 +177,16 @@ export class TreeViewController {
126
177
  this.events.off(type, listener);
127
178
  }
128
179
 
129
- setData(nodes) {
180
+ setData(nodes, options = {}) {
181
+ if (!Array.isArray(nodes)) throw new TypeError('nodes must be an array');
182
+ validateTreeViewOptions({ iconResolver: options.iconResolver ?? null });
183
+ const resolved = resolveNodeIcons(nodes, options.iconResolver);
184
+ this.closeEditor();
185
+ this.rawNodes = new Map(nodes.map(node => [node.id, node]));
186
+ nodes = resolved;
130
187
  this.inspector = null;
131
- if (this.columnModel.columns.length === 1 && this.columnModel.columns[0]?.kind === 'inspectorPane') {
188
+ this.columnModel.setRowReorder(this.rowReorder);
189
+ if (this.columnModel.columns.some(column => column.kind?.startsWith('inspector'))) {
132
190
  this.columnModel.setColumns([]);
133
191
  }
134
192
  this.model.setTree(nodes);
@@ -136,13 +194,134 @@ export class TreeViewController {
136
194
  this.searchIndex.rebuild(this.model);
137
195
  this.workerClient?.setData(this.model.nodes);
138
196
  this.#rebuildRows();
197
+ this.events.emit('datachange', {});
198
+ }
199
+
200
+ setIconResolver(resolver) {
201
+ validateTreeViewOptions({ iconResolver: resolver });
202
+ if (this.inspector) return;
203
+ const source = this.model.nodes.map(node => this.rawNodes.get(node.id) ?? node);
204
+ const nodes = resolveNodeIcons(source, resolver);
205
+ this.model.nodes = nodes;
206
+ this.model.index.rebuild(nodes);
207
+ this.searchIndex.rebuild(this.model);
208
+ this.workerClient?.setData(nodes);
209
+ this.#rebuildRows();
210
+ }
211
+
212
+ setHeaderFilter(enabled) {
213
+ if (this.headerFilter !== Boolean(enabled)) this.closeEditor();
214
+ this.headerFilter = Boolean(enabled);
215
+ this.requestRender();
216
+ }
217
+
218
+ setInspectorOptions(options = {}) {
219
+ validateTreeViewOptions(options);
220
+ if (!this.inspector) return;
221
+ for (const key of ['filter', 'markUpdated']) {
222
+ if (options[key] !== undefined) this.inspector.options[key] = options[key];
223
+ }
224
+ this.requestRender();
225
+ }
226
+
227
+ setRowReorder(enabled) {
228
+ validateTreeViewOptions({ rowReorder: enabled });
229
+ this.rowReorder = Boolean(enabled);
230
+ this.rowDrop = null;
231
+ this.columnModel.setRowReorder(this.rowReorder && !this.inspector);
232
+ this.#syncContentSize();
233
+ this.events.emit('rowreorderchange', { enabled: this.rowReorder });
234
+ }
235
+
236
+ canReorderRows() {
237
+ return this.rowReorder && !this.inspector && !this.columnModel.sort.direction && !this.rowModel.filterPredicate;
238
+ }
239
+
240
+ getRowOrder(parentId = null) {
241
+ return this.model.index.getChildren(parentId).slice();
242
+ }
243
+
244
+ moveRow(nodeId, targetIndex, options = {}) {
245
+ if (!this.canReorderRows()) return false;
246
+ const anchorId = this.rowModel.getRow(this.anchorRowIndex)?.nodeId;
247
+ const detail = this.model.moveNode(nodeId, targetIndex);
248
+ if (!detail) return false;
249
+ this.workerRevision++;
250
+ this.workerClient?.setData(this.model.nodes);
251
+ this.searchIndex.rebuild(this.model);
252
+ this.#rebuildRows();
253
+ this.anchorRowIndex = this.rowModel.getRowById(anchorId)?.rowIndex ?? null;
254
+ this.focusedId = nodeId;
255
+ this.selection.focused = nodeId;
256
+ this.cellEditor?.close?.();
257
+ this.scrollToNode(nodeId);
258
+ this.events.emit('rowreorder', { ...detail, source: options.source ?? 'api' });
259
+ this.events.emit('focuschange', { nodeId });
260
+ return true;
261
+ }
262
+
263
+ moveRowBy(nodeId, offset, options = {}) {
264
+ const node = this.model.index.getNode(nodeId);
265
+ if (!node || !Number.isInteger(offset)) return false;
266
+ const siblings = this.model.index.getChildren(node.parentId ?? null);
267
+ return this.moveRow(nodeId, siblings.indexOf(nodeId) + offset, options);
268
+ }
269
+
270
+ getRowDropTarget(nodeId, clientX, clientY) {
271
+ if (!this.canReorderRows()) return null;
272
+ const x = clientX - this.viewport.renderInsetX;
273
+ const y = clientY - this.viewport.renderInsetY;
274
+ if (x < 0 || x >= this.viewport.contentViewportWidth || y < this.viewport.headerHeight
275
+ || y >= this.viewport.headerHeight + this.viewport.rowViewportHeight) return null;
276
+ const contentY = y - this.viewport.headerHeight + this.viewport.scrollY;
277
+ const row = this.rowModel.getRow(Math.min(this.rowModel.rows.length - 1, Math.floor(contentY / this.rowModel.rowHeight)));
278
+ const source = this.model.index.getNode(nodeId);
279
+ const target = row && this.model.index.getNode(row.nodeId);
280
+ if (!source || !target || (source.parentId ?? null) !== (target.parentId ?? null)) return null;
281
+ const siblings = this.model.index.getChildren(source.parentId ?? null);
282
+ const after = contentY >= row.y + row.height / 2;
283
+ const from = siblings.indexOf(nodeId);
284
+ let index = siblings.indexOf(target.id) + Number(after);
285
+ if (index > from) index--;
286
+ return index === from ? null : { nodeId, targetId: target.id, index, y: row.y + (after ? row.height : 0) };
287
+ }
288
+
289
+ setRowDrop(target) {
290
+ this.rowDrop = target;
291
+ this.events.emit('rowdrag', { target });
292
+ }
293
+
294
+ requestRender(force = false) {
295
+ if (this.destroyed || (!this.autoRender && !force) || !this.canvas || this.renderFrame !== null) return;
296
+ const view = this.canvas.ownerDocument?.defaultView ?? globalThis;
297
+ const schedule = view.requestAnimationFrame?.bind(view) ?? (callback => setTimeout(callback, 0));
298
+ this.renderFrame = schedule(() => {
299
+ this.renderFrame = null;
300
+ if (this.canvas) this.render();
301
+ });
302
+ }
303
+
304
+ cancelRender() {
305
+ if (this.renderFrame === null) return;
306
+ const view = this.canvas?.ownerDocument?.defaultView ?? globalThis;
307
+ (view.cancelAnimationFrame?.bind(view) ?? clearTimeout)(this.renderFrame);
308
+ this.renderFrame = null;
309
+ }
310
+
311
+ attachTooltip(options = {}) {
312
+ this.tooltip?.destroy();
313
+ this.tooltip = new TreeTooltip({ controller: this, host: options.host ?? this.canvas?.parentElement });
314
+ return this.tooltip;
139
315
  }
140
316
 
141
317
  setModel(model, meta = {}, options = {}) {
318
+ validateTreeViewOptions({ ...options, mode: 'inspector', model, meta, presentation: options.presentation ?? options.mode ?? 'table' });
319
+ this.closeEditor();
320
+ this.columnModel.setRowReorder(false);
142
321
  const builder = new ModelInspectorBuilder();
143
322
  const presentation = options.presentation ?? options.mode ?? 'table';
144
323
  const previousExpanded = new Set(this.model.expanded);
145
- const shouldPreserveExpansion = Boolean(this.inspector);
324
+ const previousBranches = new Set(this.inspector ? this.model.nodes.filter(node => this.expansion.hasChildren(node.id)).map(node => node.id) : []);
146
325
  const inspectorOptions = {
147
326
  presentation,
148
327
  flatRoot: Boolean(options.flatRoot),
@@ -151,17 +330,23 @@ export class TreeViewController {
151
330
  markUpdated: options.markUpdated !== false,
152
331
  };
153
332
  const nodes = builder.build(model, meta, inspectorOptions);
333
+ this.rawNodes.clear();
154
334
  this.inspector = { model, meta, builder, presentation, options: inspectorOptions };
155
335
  this.setColumns(presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns());
156
336
  this.model.setTree(nodes);
157
- if (shouldPreserveExpansion) this.#restoreExpansion(previousExpanded);
158
- else this.expansion.expandToDepth(this.initialExpandDepth);
337
+ this.expansion.expandToDepth(this.initialExpandDepth);
338
+ for (const id of previousBranches) {
339
+ if (!this.model.index.getNode(id)) continue;
340
+ if (previousExpanded.has(id)) this.model.expanded.add(id);
341
+ else this.model.expanded.delete(id);
342
+ }
159
343
  this.searchIndex.rebuild(this.model);
160
344
  this.#rebuildRows();
345
+ this.events.emit('datachange', {});
161
346
  this.events.emit('modelchange', { model, meta, structural: true });
162
347
  }
163
348
 
164
- updateInspectorValue(nodeId, newValue, editorType = 'unknown') {
349
+ updateInspectorValue(nodeId, newValue, editorType = 'unknown', options = {}) {
165
350
  const node = this.model.index.getNode(nodeId);
166
351
  if (!node?.data?.inspector || !this.inspector) return false;
167
352
  const data = node.data;
@@ -175,17 +360,25 @@ export class TreeViewController {
175
360
  if (this.inspector.options.markUpdated !== false) {
176
361
  this.setDynamicState([{ id: nodeId, state: { updated: true } }]);
177
362
  }
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 });
363
+ const detail = { path: data.path, oldValue, newValue, nodeId, editorType, model: this.inspector.model, source: options.source ?? 'user' };
364
+ if (options.emit !== false) {
365
+ this.events.emit('valuechange', detail);
366
+ this.events.emit('modelchange', { ...detail });
367
+ }
368
+ this.requestRender();
181
369
  return true;
182
370
  }
183
371
 
372
+ setInspectorValue(path, value, options = {}) {
373
+ if (typeof path !== 'string') throw new TypeError('path must be a string');
374
+ return this.updateInspectorValue(inspectorNodeId(path === '$' ? '' : path), value, 'api', { ...options, source: 'api' });
375
+ }
376
+
184
377
  triggerInspectorAction(nodeId) {
185
378
  const node = this.model.index.getNode(nodeId);
186
379
  if (!node?.data?.inspector) return false;
187
380
  const label = node.data.meta?.button ?? node.label;
188
- this.events.emit('action', { path: node.data.path, label, nodeId });
381
+ this.events.emit('action', { path: node.data.path, label, nodeId, model: this.inspector?.model, source: 'user' });
189
382
  return true;
190
383
  }
191
384
 
@@ -230,7 +423,11 @@ export class TreeViewController {
230
423
  }
231
424
 
232
425
  setColumns(columns) {
426
+ if (columns == null) columns = this.inspector ? (this.inspector.presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns()) : [];
427
+ if (!Array.isArray(columns)) throw new TypeError('columns must be an array or null');
428
+ this.closeEditor();
233
429
  this.columnModel.setColumns(columns);
430
+ this.columnModel.setRowReorder(this.rowReorder && !this.inspector);
234
431
  this.#syncContentSize();
235
432
  this.events.emit('columnschange', { columns: this.columnModel.columns });
236
433
  }
@@ -281,6 +478,8 @@ export class TreeViewController {
281
478
  }
282
479
 
283
480
  setFilter(queryOrPredicate = '', options = {}) {
481
+ if (typeof queryOrPredicate !== 'string' && typeof queryOrPredicate !== 'function') throw new TypeError('filter must be a string or predicate');
482
+ if (this.cellEditor?.overlayKind !== 'filter') this.closeEditor();
284
483
  this.filterQuery = typeof queryOrPredicate === 'string' ? queryOrPredicate : '';
285
484
  this.filterOptions = typeof queryOrPredicate === 'string'
286
485
  ? { caseSensitive: Boolean(options.caseSensitive), wholeWord: Boolean(options.wholeWord) }
@@ -320,10 +519,12 @@ export class TreeViewController {
320
519
  }
321
520
 
322
521
  setDynamicState(patches) {
522
+ if (!Array.isArray(patches)) throw new TypeError('patches must be an array');
323
523
  this.lastPatchCount = patches.length;
324
524
  this.lastDirtyNodeCount = new Set(patches.map((patch) => patch.id)).size;
325
525
  this.model.applyDynamicPatches(patches);
326
526
  this.renderer.updateDynamicState(patches);
527
+ this.requestRender();
327
528
  }
328
529
 
329
530
  setTheme(theme) {
@@ -331,11 +532,10 @@ export class TreeViewController {
331
532
  const beforeIndentWidth = this.rowModel.indentWidth;
332
533
  this.themeManager.setTheme(theme);
333
534
  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) {
535
+ this.rowModel.rowHeight = this.viewport.rowHeight = this.layoutOverrides.rowHeight ?? nextTheme.rowHeight;
536
+ this.rowModel.indentWidth = this.viewport.indentWidth = this.layoutOverrides.indentWidth ?? nextTheme.indentWidth;
537
+ if (beforeRowHeight !== this.rowModel.rowHeight || beforeIndentWidth !== this.rowModel.indentWidth) {
538
+ this.closeEditor();
339
539
  this.#rebuildRows();
340
540
  }
341
541
  this.#nativeScroll?.applyTheme?.(nextTheme);
@@ -343,13 +543,17 @@ export class TreeViewController {
343
543
  }
344
544
 
345
545
  setLayoutMetrics(options = {}) {
346
- const rowHeight = options.rowHeight ?? this.rowModel.rowHeight;
347
- const indentWidth = options.indentWidth ?? this.rowModel.indentWidth;
546
+ const rowHeight = Object.hasOwn(options, 'rowHeight') ? options.rowHeight ?? this.themeManager.get().rowHeight : this.rowModel.rowHeight;
547
+ const indentWidth = Object.hasOwn(options, 'indentWidth') ? options.indentWidth ?? this.themeManager.get().indentWidth : this.rowModel.indentWidth;
348
548
  const headerHeight = options.headerHeight ?? this.viewport.headerHeight;
349
549
  assertPositiveNumber(rowHeight, 'rowHeight');
350
550
  assertPositiveNumber(indentWidth, 'indentWidth');
351
551
  assertNonNegativeNumber(headerHeight, 'headerHeight');
552
+ for (const key of ['rowHeight', 'indentWidth']) {
553
+ if (Object.hasOwn(options, key)) this.layoutOverrides[key] = options[key];
554
+ }
352
555
  const rowsChanged = this.rowModel.rowHeight !== rowHeight || this.rowModel.indentWidth !== indentWidth;
556
+ if (rowsChanged || headerHeight !== this.viewport.headerHeight) this.closeEditor();
353
557
  this.rowModel.rowHeight = rowHeight;
354
558
  this.rowModel.indentWidth = indentWidth;
355
559
  this.viewport.rowHeight = rowHeight;
@@ -543,6 +747,7 @@ export class TreeViewController {
543
747
  }
544
748
 
545
749
  setSelection(ids) {
750
+ if (!Array.isArray(ids)) throw new TypeError('ids must be an array');
546
751
  this.selection.selected.clear();
547
752
  for (const id of ids) this.selection.selected.add(id);
548
753
  this.selection.focused = ids[ids.length - 1] ?? null;
@@ -578,6 +783,7 @@ export class TreeViewController {
578
783
  if (this.activeId === nodeId && this.activePart === part) return;
579
784
  this.activeId = nodeId;
580
785
  this.activePart = part;
786
+ this.requestRender();
581
787
  }
582
788
 
583
789
  clickNode(nodeId, event = {}) {
@@ -621,6 +827,11 @@ export class TreeViewController {
621
827
 
622
828
  handleKey(event) {
623
829
  if (!this.rowModel.rows.length) return false;
830
+ if (this.rowReorder && event.altKey && ['ArrowUp', 'ArrowDown'].includes(event.key)) {
831
+ const id = this.focusedId ?? this.rowModel.getRow(this.#focusedRowIndex())?.nodeId;
832
+ this.moveRowBy(id, event.key === 'ArrowUp' ? -1 : 1, { source: 'keyboard' });
833
+ return true;
834
+ }
624
835
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
625
836
  this.selection.selected.clear();
626
837
  for (const row of this.rowModel.rows) this.selection.selected.add(row.nodeId);
@@ -692,9 +903,13 @@ export class TreeViewController {
692
903
  createRenderScene() {
693
904
  const visibleRange = this.rowModel.getVisibleRange(this.viewport, 4);
694
905
  return {
906
+ rowReorder: this.rowReorder,
907
+ canReorderRows: this.canReorderRows(),
908
+ rowDrop: this.rowDrop,
909
+ childrenByParent: this.model.index.childrenByParent,
695
910
  rows: this.rowModel.rows,
696
911
  visibleRange,
697
- stickyRows: this.rowModel.getStickyRows(this.viewport),
912
+ stickyRows: this.rowReorder ? [] : this.rowModel.getStickyRows(this.viewport),
698
913
  viewport: this.viewport,
699
914
  columns: this.columnModel.columns,
700
915
  theme: this.themeManager.get(),
@@ -711,12 +926,13 @@ export class TreeViewController {
711
926
  sortValues: this.sortValueSnapshot ? Array.from(this.sortValueSnapshot) : null,
712
927
  inspectorPaneLabelEnd: this.#computeInspectorPaneLabelEnd(visibleRange),
713
928
  filterQuery: this.filterQuery,
714
- headerFilter: Boolean(this.inspector?.options?.filter),
929
+ headerFilter: this.headerFilter ?? Boolean(this.inspector?.options?.filter),
715
930
  stats: this.getStats(),
716
931
  };
717
932
  }
718
933
 
719
934
  closeEditor() {
935
+ this.cellEditor?.close?.();
720
936
  this.events.emit('editorclose', {});
721
937
  }
722
938
 
@@ -730,7 +946,7 @@ export class TreeViewController {
730
946
  const resizeColumn = this.columnModel.getResizeHandleAt(x);
731
947
  if (resizeColumn) return { area: 'header', part: 'resize', column: resizeColumn, x, y: localY };
732
948
  const column = this.columnModel.getColumnAt(x);
733
- if (column?.kind === 'inspectorPane' && this.inspector?.options?.filter) {
949
+ if (column && (this.headerFilter ?? Boolean(this.inspector?.options?.filter)) && column === this.columnModel.columns.find(item => item.kind !== 'rowOrder')) {
734
950
  return { area: 'header', part: 'filter', column, x, y: localY };
735
951
  }
736
952
  return column ? { area: 'header', part: 'label', column, x, y: localY } : { area: 'header', part: 'header', column: null, x, y: localY };
@@ -744,6 +960,10 @@ export class TreeViewController {
744
960
  const column = this.columnModel.getColumnAt(x);
745
961
  if (!column) return { area: 'row', part: 'row', row, column: null, x, y: rowY };
746
962
 
963
+ if (column.kind === 'rowOrder') {
964
+ const offset = x - column.x;
965
+ return { area: 'row', part: offset < 24 ? 'rowDrag' : offset < 48 ? 'rowUp' : 'rowDown', row, column, x, y: rowY };
966
+ }
747
967
  let part = 'cell';
748
968
  if (column.kind === 'inspectorPane') {
749
969
  const localX = x - column.x;
@@ -880,6 +1100,7 @@ export class TreeViewController {
880
1100
  }
881
1101
 
882
1102
  resize(width, height) {
1103
+ if (width !== this.viewport.viewportWidth || height !== this.viewport.viewportHeight) this.closeEditor();
883
1104
  this.viewport.resize(width, height);
884
1105
  this.#fitInspectorPaneColumn(width);
885
1106
  this.#syncContentSize();
@@ -921,6 +1142,7 @@ export class TreeViewController {
921
1142
  this.#syncContentSize();
922
1143
  this.#restoreScrollAnchor(scrollAnchor);
923
1144
  this.rebuildCount++;
1145
+ this.requestRender();
924
1146
  }
925
1147
 
926
1148
  #syncContentSize() {
@@ -938,13 +1160,6 @@ export class TreeViewController {
938
1160
  this.setDynamicState(this.patchBatcher.flush());
939
1161
  }
940
1162
 
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
1163
  #focusedRowIndex() {
949
1164
  if (this.focusedId) {
950
1165
  const row = this.rowModel.getRowById(this.focusedId);
@@ -1450,3 +1665,14 @@ function isProbablyTruncated(text, width) {
1450
1665
  function now() {
1451
1666
  return globalThis.performance?.now?.() ?? Date.now();
1452
1667
  }
1668
+
1669
+ function resolveNodeIcons(nodes, resolver) {
1670
+ if (!resolver) return nodes;
1671
+ return nodes.map(node => {
1672
+ const visual = resolver(node);
1673
+ if (typeof visual === 'string') return { ...node, icon: visual };
1674
+ if (!visual || typeof visual !== 'object') return node;
1675
+ 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');
1676
+ return { ...node, ...visual };
1677
+ });
1678
+ }