virtual-tree-canvas 0.1.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +335 -0
  3. package/package.json +40 -0
  4. package/src/assets/.gitkeep +1 -0
  5. package/src/benchmark/benchmark-stats.js +99 -0
  6. package/src/benchmark/index.js +2 -0
  7. package/src/benchmark/renderer-state.js +62 -0
  8. package/src/core/asset-manager.js +25 -0
  9. package/src/core/culling.js +12 -0
  10. package/src/core/event-emitter.js +21 -0
  11. package/src/core/icon-registry.js +173 -0
  12. package/src/core/index.js +19 -0
  13. package/src/core/layout-engine.js +64 -0
  14. package/src/core/patch-batcher.js +29 -0
  15. package/src/core/scene.js +13 -0
  16. package/src/core/search-index.js +70 -0
  17. package/src/core/selection-manager.js +38 -0
  18. package/src/core/theme-manager.js +162 -0
  19. package/src/core/tree-column-model.js +151 -0
  20. package/src/core/tree-expansion-manager.js +67 -0
  21. package/src/core/tree-index.js +79 -0
  22. package/src/core/tree-model.js +80 -0
  23. package/src/core/tree-view-viewport.js +69 -0
  24. package/src/core/tree-worker-client.js +50 -0
  25. package/src/core/tree-worker-operations.js +152 -0
  26. package/src/core/types.js +44 -0
  27. package/src/core/viewport.js +66 -0
  28. package/src/core/visible-row-model.js +137 -0
  29. package/src/index.js +6 -0
  30. package/src/input/index.js +2 -0
  31. package/src/input/pointer-controller.js +66 -0
  32. package/src/input/tree-view-input-controller.js +235 -0
  33. package/src/inspector/cell-editor-manager.js +256 -0
  34. package/src/inspector/editor-resolver.js +33 -0
  35. package/src/inspector/index.js +4 -0
  36. package/src/inspector/model-inspector-builder.js +139 -0
  37. package/src/inspector/model-path.js +48 -0
  38. package/src/renderers/canvas2d-renderer.js +120 -0
  39. package/src/renderers/index.js +3 -0
  40. package/src/renderers/renderer.js +10 -0
  41. package/src/renderers/tree-row-renderer.js +443 -0
  42. package/src/tree-view-controller.js +825 -0
  43. package/src/workers/tree-worker.js +59 -0
@@ -0,0 +1,825 @@
1
+ import {
2
+ EventEmitter,
3
+ IconRegistry,
4
+ PatchBatcher,
5
+ ThemeManager,
6
+ TreeColumnModel,
7
+ TreeExpansionManager,
8
+ TreeModel,
9
+ TreeSearchIndex,
10
+ TreeSelectionManager,
11
+ TreeWorkerClient,
12
+ TreeViewViewport,
13
+ VisibleRowModel,
14
+ } from './core/index.js';
15
+ import { formatInspectorValue, getAtPath, inspectorColumns, inspectorPaneColumns, ModelInspectorBuilder, setAtPath } from './inspector/index.js';
16
+ import { TreeRowRenderer } from './renderers/index.js';
17
+
18
+ export class TreeViewController {
19
+ constructor(options = {}) {
20
+ this.events = new EventEmitter();
21
+ this.model = new TreeModel();
22
+ this.expansion = new TreeExpansionManager(this.model);
23
+ this.rowModel = new VisibleRowModel({
24
+ model: this.model,
25
+ expansion: this.expansion,
26
+ rowHeight: options.rowHeight ?? 28,
27
+ indentWidth: options.indentWidth ?? 18,
28
+ });
29
+ this.viewport = new TreeViewViewport({
30
+ rowHeight: this.rowModel.rowHeight,
31
+ indentWidth: this.rowModel.indentWidth,
32
+ headerHeight: options.headerHeight ?? 28,
33
+ });
34
+ this.viewport.renderInsetX = options.renderInsetX ?? 0;
35
+ this.viewport.renderInsetY = options.renderInsetY ?? 0;
36
+ this.columnModel = new TreeColumnModel(options.columns);
37
+ this.searchIndex = new TreeSearchIndex();
38
+ this.selection = new TreeSelectionManager();
39
+ this.patchBatcher = new PatchBatcher();
40
+ this.themeManager = options.themeManager ?? new ThemeManager();
41
+ this.iconRegistry = options.iconRegistry ?? new IconRegistry();
42
+ this.renderer = options.renderer ?? new TreeRowRenderer({ themeManager: this.themeManager, iconRegistry: this.iconRegistry });
43
+ this.initialExpandDepth = options.initialExpandDepth ?? 1;
44
+ this.searchHighlights = new Set();
45
+ this.filterQuery = '';
46
+ this.hoverId = null;
47
+ this.focusedId = null;
48
+ this.anchorRowIndex = null;
49
+ this.lastPatchCount = 0;
50
+ this.lastDirtyNodeCount = 0;
51
+ this.lastRenderedRows = 0;
52
+ this.rebuildCount = 0;
53
+ this.workerClient = null;
54
+ this.workerRevision = 0;
55
+ this.sortValueSnapshot = null;
56
+ this.inspector = null;
57
+ this.scene = this.createRenderScene();
58
+
59
+ if (options.canvas) this.initialize(options.canvas);
60
+ }
61
+
62
+ initialize(canvas) {
63
+ this.canvas = canvas;
64
+ this.renderer.initialize(canvas);
65
+ this.renderer.setScene(this.scene);
66
+ return this;
67
+ }
68
+
69
+ on(type, listener) {
70
+ return this.events.on(type, listener);
71
+ }
72
+
73
+ off(type, listener) {
74
+ this.events.off(type, listener);
75
+ }
76
+
77
+ setData(nodes) {
78
+ this.inspector = null;
79
+ this.model.setTree(nodes);
80
+ this.expansion.expandToDepth(this.initialExpandDepth);
81
+ this.searchIndex.rebuild(this.model);
82
+ this.workerClient?.setData(this.model.nodes);
83
+ this.#rebuildRows();
84
+ }
85
+
86
+ setModel(model, meta = {}, options = {}) {
87
+ const builder = new ModelInspectorBuilder();
88
+ const presentation = options.presentation ?? options.mode ?? 'table';
89
+ const inspectorOptions = {
90
+ presentation,
91
+ flatRoot: Boolean(options.flatRoot),
92
+ enforceMeta: Boolean(options.enforceMeta),
93
+ filter: Boolean(options.filter),
94
+ };
95
+ const nodes = builder.build(model, meta, inspectorOptions);
96
+ this.inspector = { model, meta, builder, presentation, options: inspectorOptions };
97
+ this.setColumns(presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns());
98
+ this.model.setTree(nodes);
99
+ this.expansion.expandToDepth(this.initialExpandDepth);
100
+ this.searchIndex.rebuild(this.model);
101
+ this.#rebuildRows();
102
+ this.events.emit('modelchange', { model, meta, structural: true });
103
+ }
104
+
105
+ updateInspectorValue(nodeId, newValue, editorType = 'unknown') {
106
+ const node = this.model.index.getNode(nodeId);
107
+ if (!node?.data?.inspector || !this.inspector) return false;
108
+ const data = node.data;
109
+ if (data.readonly || data.disabled) return false;
110
+ const oldValue = data.value;
111
+ if (Object.is(oldValue, newValue)) return true;
112
+ setAtPath(this.inspector.model, data.path, newValue);
113
+ data.value = newValue;
114
+ data.valueType = Array.isArray(newValue) ? 'array' : newValue === null ? 'null' : typeof newValue === 'object' ? 'object' : typeof newValue;
115
+ data.valueText = formatInspectorValue(newValue, data.meta);
116
+ this.setDynamicState([{ id: nodeId, state: { updated: true } }]);
117
+ const detail = { path: data.path, oldValue, newValue, nodeId, editorType };
118
+ this.events.emit('valuechange', detail);
119
+ this.events.emit('modelchange', { model: this.inspector.model, path: data.path, oldValue, newValue, nodeId });
120
+ return true;
121
+ }
122
+
123
+ triggerInspectorAction(nodeId) {
124
+ const node = this.model.index.getNode(nodeId);
125
+ if (!node?.data?.inspector) return false;
126
+ const label = node.data.meta?.button ?? node.label;
127
+ this.events.emit('action', { path: node.data.path, label, nodeId });
128
+ return true;
129
+ }
130
+
131
+ addInspectorArrayItem(nodeId) {
132
+ const node = this.model.index.getNode(nodeId);
133
+ if (!node?.data?.inspector || node.data.valueType !== 'array' || !this.inspector) return false;
134
+ const array = getAtPath(this.inspector.model, node.data.path);
135
+ if (!Array.isArray(array)) return false;
136
+ const item = createDefaultArrayItem(node.data.meta);
137
+ array.push(item);
138
+ this.#rebuildInspectorModel(node.data.path);
139
+ this.events.emit('modelchange', { model: this.inspector.model, path: node.data.path, structural: true, action: 'array-add', value: item });
140
+ return true;
141
+ }
142
+
143
+ removeInspectorArrayItem(nodeId) {
144
+ const node = this.model.index.getNode(nodeId);
145
+ if (!node?.data?.inspector || node.data.valueType !== 'array' || !this.inspector) return false;
146
+ const array = getAtPath(this.inspector.model, node.data.path);
147
+ if (!Array.isArray(array) || !array.length) return false;
148
+ const value = array.pop();
149
+ this.#rebuildInspectorModel(node.data.path);
150
+ this.events.emit('modelchange', { model: this.inspector.model, path: node.data.path, structural: true, action: 'array-remove', value });
151
+ return true;
152
+ }
153
+
154
+ enableWorkers(workerUrl) {
155
+ if (this.workerClient) return Promise.resolve(this);
156
+ this.workerClient = new TreeWorkerClient(workerUrl);
157
+ return this.workerClient.setData(this.model.nodes).then(() => this);
158
+ }
159
+
160
+ disableWorkers() {
161
+ this.workerClient?.destroy();
162
+ this.workerClient = null;
163
+ }
164
+
165
+ setColumns(columns) {
166
+ this.columnModel.setColumns(columns);
167
+ this.#syncContentSize();
168
+ this.events.emit('columnschange', { columns: this.columnModel.columns });
169
+ }
170
+
171
+ resizeColumn(columnId, width) {
172
+ if (!this.columnModel.resizeColumn(columnId, width)) return false;
173
+ this.#syncContentSize();
174
+ this.events.emit('columnschange', { columns: this.columnModel.columns });
175
+ return true;
176
+ }
177
+
178
+ moveColumn(columnId, targetIndex) {
179
+ if (!this.columnModel.moveColumn(columnId, targetIndex)) return false;
180
+ this.#syncContentSize();
181
+ this.events.emit('columnschange', { columns: this.columnModel.columns });
182
+ return true;
183
+ }
184
+
185
+ sortBy(columnId, direction = 'toggle') {
186
+ const column = this.columnModel.getColumn(columnId);
187
+ if (!column || column.sortable === false) return false;
188
+ const current = this.columnModel.sort;
189
+ let nextDirection = direction;
190
+ if (direction === 'toggle') {
191
+ if (current.columnId !== columnId) nextDirection = 'asc';
192
+ else if (current.direction === 'asc') nextDirection = 'desc';
193
+ else if (current.direction === 'desc') nextDirection = null;
194
+ else nextDirection = 'asc';
195
+ }
196
+ if (nextDirection !== 'asc' && nextDirection !== 'desc') {
197
+ this.clearSort();
198
+ return true;
199
+ }
200
+ this.columnModel.setSort(columnId, nextDirection);
201
+ this.sortValueSnapshot = createSortValueSnapshot(column, this.model.nodes, this.model.dynamicState);
202
+ this.rowModel.setSortComparator((a, b) => compareColumnValues(column, a, b, this.model.dynamicState, this.sortValueSnapshot) * (nextDirection === 'desc' ? -1 : 1));
203
+ this.#rebuildRows();
204
+ this.events.emit('sortchange', { columnId, direction: nextDirection });
205
+ return true;
206
+ }
207
+
208
+ clearSort() {
209
+ this.columnModel.setSort(null, null);
210
+ this.sortValueSnapshot = null;
211
+ this.rowModel.setSortComparator(null);
212
+ this.#rebuildRows();
213
+ this.events.emit('sortchange', { columnId: null, direction: null });
214
+ }
215
+
216
+ setFilter(queryOrPredicate = '') {
217
+ this.filterQuery = typeof queryOrPredicate === 'string' ? queryOrPredicate : '';
218
+ if (typeof queryOrPredicate === 'function') {
219
+ this.rowModel.setFilterPredicate(queryOrPredicate);
220
+ } else {
221
+ const query = queryOrPredicate.trim().toLowerCase();
222
+ this.rowModel.setFilterPredicate(query ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', query) : null);
223
+ }
224
+ this.#rebuildRows();
225
+ this.events.emit('filterchange', { query: this.filterQuery, visibleRows: this.rowModel.rows.length });
226
+ }
227
+
228
+ clearFilter() {
229
+ this.setFilter('');
230
+ }
231
+
232
+ async setFilterAsync(query = '') {
233
+ if (!this.workerClient || typeof query !== 'string') {
234
+ this.setFilter(query);
235
+ return this.rowModel.rows;
236
+ }
237
+ const totalStart = now();
238
+ const revision = ++this.workerRevision;
239
+ this.filterQuery = query;
240
+ const normalized = query.trim().toLowerCase();
241
+ this.rowModel.setFilterPredicate(normalized ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', normalized) : null);
242
+ const workerStart = now();
243
+ const result = await this.workerClient.rebuildRows(this.#workerRowOptions({ filterQuery: query }));
244
+ const workerMs = now() - workerStart;
245
+ if (revision !== this.workerRevision) return this.rowModel.rows;
246
+ this.#applyWorkerRows(result);
247
+ this.events.emit('filterchange', { query: this.filterQuery, visibleRows: this.rowModel.rows.length, worker: true, workerMs, totalMs: now() - totalStart });
248
+ return this.rowModel.rows;
249
+ }
250
+
251
+ setDynamicState(patches) {
252
+ this.lastPatchCount = patches.length;
253
+ this.lastDirtyNodeCount = new Set(patches.map((patch) => patch.id)).size;
254
+ this.model.applyDynamicPatches(patches);
255
+ this.renderer.updateDynamicState(patches);
256
+ }
257
+
258
+ setTheme(theme) {
259
+ const beforeRowHeight = this.rowModel.rowHeight;
260
+ const beforeIndentWidth = this.rowModel.indentWidth;
261
+ this.themeManager.setTheme(theme);
262
+ const nextTheme = this.themeManager.get();
263
+ this.rowModel.rowHeight = nextTheme.rowHeight;
264
+ this.rowModel.indentWidth = nextTheme.indentWidth;
265
+ this.viewport.rowHeight = nextTheme.rowHeight;
266
+ this.viewport.indentWidth = nextTheme.indentWidth;
267
+ if (beforeRowHeight !== nextTheme.rowHeight || beforeIndentWidth !== nextTheme.indentWidth) {
268
+ this.#rebuildRows();
269
+ }
270
+ this.events.emit('themechange', { theme: nextTheme });
271
+ }
272
+
273
+ registerIcon(name, imageOrUrl) {
274
+ return this.iconRegistry.register(name, imageOrUrl);
275
+ }
276
+
277
+ expand(nodeId) {
278
+ if (!this.expansion.expand(nodeId)) return false;
279
+ this.#rebuildRows();
280
+ this.events.emit('expand', { nodeId });
281
+ return true;
282
+ }
283
+
284
+ collapse(nodeId) {
285
+ if (!this.expansion.collapse(nodeId)) return false;
286
+ this.#rebuildRows();
287
+ this.events.emit('collapse', { nodeId });
288
+ return true;
289
+ }
290
+
291
+ toggle(nodeId) {
292
+ return this.expansion.isExpanded(nodeId) ? this.collapse(nodeId) : this.expand(nodeId);
293
+ }
294
+
295
+ expandAll() {
296
+ this.expansion.expandAll();
297
+ this.#rebuildRows();
298
+ this.events.emit('expand', { nodeId: null, all: true });
299
+ }
300
+
301
+ collapseAll() {
302
+ this.expansion.collapseAll();
303
+ this.#rebuildRows();
304
+ this.events.emit('collapse', { nodeId: null, all: true });
305
+ }
306
+
307
+ search(query, options = {}) {
308
+ for (const id of this.searchHighlights) this.patchBatcher.set(id, { highlighted: false });
309
+ const results = this.searchIndex.search(query, { limit: options.limit ?? 500, fields: options.fields });
310
+ this.searchHighlights = new Set(results);
311
+ const expandedSizeBefore = this.expansion.model.expanded.size;
312
+ for (const id of results) {
313
+ if (options.expand !== false) this.expansion.expandAncestors(id);
314
+ this.patchBatcher.set(id, { highlighted: true });
315
+ }
316
+ if (options.expand !== false && this.expansion.model.expanded.size !== expandedSizeBefore) this.#rebuildRows();
317
+ this.setDynamicState(this.patchBatcher.flush());
318
+ if (results[0] && options.focus !== false) {
319
+ this.scrollToNode(results[0], options.align ?? 'nearest');
320
+ this.focusedId = results[0];
321
+ this.selection.focused = results[0];
322
+ if (options.select) this.setSelection([results[0]]);
323
+ this.events.emit('focuschange', { nodeId: results[0] });
324
+ }
325
+ this.events.emit('searchchange', { query, results, cursor: this.searchIndex.cursor, current: this.searchIndex.currentSearchResult() });
326
+ return results;
327
+ }
328
+
329
+ async searchAsync(query, options = {}) {
330
+ if (!this.workerClient) return this.search(query, options);
331
+ const totalStart = now();
332
+ const revision = ++this.workerRevision;
333
+ for (const id of this.searchHighlights) this.patchBatcher.set(id, { highlighted: false });
334
+ const searchStart = now();
335
+ const results = await this.workerClient.search(query, { limit: options.limit ?? 500, fields: options.fields });
336
+ const searchMs = now() - searchStart;
337
+ if (revision !== this.workerRevision) return this.searchIndex.results;
338
+ this.searchIndex.lastQuery = query;
339
+ this.searchIndex.results = results;
340
+ this.searchIndex.cursor = results.length ? 0 : -1;
341
+ this.searchHighlights = new Set(results);
342
+ const expandedSizeBefore = this.expansion.model.expanded.size;
343
+ for (const id of results) {
344
+ if (options.expand !== false) this.expansion.expandAncestors(id);
345
+ this.patchBatcher.set(id, { highlighted: true });
346
+ }
347
+ let rowMs = 0;
348
+ const expansionChanged = options.expand !== false && this.expansion.model.expanded.size !== expandedSizeBefore;
349
+ if (expansionChanged || this.filterQuery) {
350
+ const rowStart = now();
351
+ const rowResult = await this.workerClient.rebuildRows(this.#workerRowOptions({ filterQuery: this.filterQuery }));
352
+ rowMs = now() - rowStart;
353
+ if (revision !== this.workerRevision) return this.searchIndex.results;
354
+ this.#applyWorkerRows(rowResult);
355
+ }
356
+ this.setDynamicState(this.patchBatcher.flush());
357
+ if (results[0] && options.focus !== false) {
358
+ this.scrollToNode(results[0], options.align ?? 'nearest');
359
+ this.focusedId = results[0];
360
+ this.selection.focused = results[0];
361
+ if (options.select) this.setSelection([results[0]]);
362
+ this.events.emit('focuschange', { nodeId: results[0] });
363
+ }
364
+ this.events.emit('searchchange', {
365
+ query,
366
+ results,
367
+ cursor: this.searchIndex.cursor,
368
+ current: this.searchIndex.currentSearchResult(),
369
+ worker: true,
370
+ searchMs,
371
+ rowMs,
372
+ workerMs: searchMs + rowMs,
373
+ totalMs: now() - totalStart,
374
+ });
375
+ return results;
376
+ }
377
+
378
+ clearSearch() {
379
+ this.workerRevision++;
380
+ for (const id of this.searchHighlights) this.patchBatcher.set(id, { highlighted: false });
381
+ this.searchHighlights.clear();
382
+ this.searchIndex.clear();
383
+ this.setDynamicState(this.patchBatcher.flush());
384
+ this.events.emit('searchchange', { query: '', results: [], cursor: -1, current: null });
385
+ }
386
+
387
+ getSearchState() {
388
+ return {
389
+ query: this.searchIndex.lastQuery,
390
+ results: this.searchIndex.results.slice(),
391
+ cursor: this.searchIndex.cursor,
392
+ current: this.searchIndex.currentSearchResult(),
393
+ count: this.searchIndex.results.length,
394
+ };
395
+ }
396
+
397
+ nextSearchResult() {
398
+ const id = this.searchIndex.nextSearchResult();
399
+ if (id) this.focusNode(id, { select: false });
400
+ this.events.emit('searchchange', { query: this.searchIndex.lastQuery, results: this.searchIndex.results.slice(), cursor: this.searchIndex.cursor, current: id });
401
+ return id;
402
+ }
403
+
404
+ previousSearchResult() {
405
+ const id = this.searchIndex.previousSearchResult();
406
+ if (id) this.focusNode(id, { select: false });
407
+ this.events.emit('searchchange', { query: this.searchIndex.lastQuery, results: this.searchIndex.results.slice(), cursor: this.searchIndex.cursor, current: id });
408
+ return id;
409
+ }
410
+
411
+ focusNode(nodeId, options = {}) {
412
+ if (!nodeId) return false;
413
+ this.expansion.expandAncestors(nodeId);
414
+ this.#rebuildRows();
415
+ if (!this.scrollToNode(nodeId, options.align ?? 'nearest')) return false;
416
+ this.focusedId = nodeId;
417
+ this.selection.focused = nodeId;
418
+ if (options.select) this.setSelection([nodeId]);
419
+ this.events.emit('focuschange', { nodeId });
420
+ return true;
421
+ }
422
+
423
+ scrollToNode(nodeId, align = 'nearest') {
424
+ const row = this.rowModel.getRowById(nodeId);
425
+ if (!row) return false;
426
+ this.viewport.scrollRowIntoView(row.rowIndex, align);
427
+ this.events.emit('viewportchange', this.getViewportState());
428
+ return true;
429
+ }
430
+
431
+ getSelection() {
432
+ return Array.from(this.selection.selected);
433
+ }
434
+
435
+ setSelection(ids) {
436
+ this.selection.selected.clear();
437
+ for (const id of ids) this.selection.selected.add(id);
438
+ this.selection.focused = ids[ids.length - 1] ?? null;
439
+ this.focusedId = this.selection.focused;
440
+ this.anchorRowIndex = this.focusedId ? this.rowModel.getRowById(this.focusedId)?.rowIndex ?? null : null;
441
+ this.#syncSelectionState();
442
+ this.events.emit('selectionchange', { selection: this.getSelection(), focusedId: this.focusedId });
443
+ }
444
+
445
+ clearSelection() {
446
+ this.setSelection([]);
447
+ }
448
+
449
+ setHover(nodeId) {
450
+ if (this.hoverId === nodeId) return;
451
+ this.hoverId = nodeId;
452
+ this.events.emit('nodehover', { nodeId });
453
+ }
454
+
455
+ clickNode(nodeId, event = {}) {
456
+ const row = this.rowModel.getRowById(nodeId);
457
+ if (!row) return;
458
+ this.selectRow(row.rowIndex, event);
459
+ this.events.emit('nodeclick', { nodeId, row, originalEvent: event });
460
+ }
461
+
462
+ doubleClickNode(nodeId, event = {}) {
463
+ const row = this.rowModel.getRowById(nodeId);
464
+ if (!row) return;
465
+ if (row.hasChildren) this.toggle(nodeId);
466
+ this.events.emit('nodedblclick', { nodeId, row, originalEvent: event });
467
+ }
468
+
469
+ selectRow(rowIndex, event = {}) {
470
+ const row = this.rowModel.getRow(rowIndex);
471
+ if (!row) return false;
472
+ if (event.shiftKey && this.anchorRowIndex !== null) {
473
+ this.selection.selected.clear();
474
+ const start = Math.min(this.anchorRowIndex, rowIndex);
475
+ const end = Math.max(this.anchorRowIndex, rowIndex);
476
+ for (let i = start; i <= end; i++) this.selection.selected.add(this.rowModel.rows[i].nodeId);
477
+ } else if (event.ctrlKey || event.metaKey || event.multi) {
478
+ if (this.selection.selected.has(row.nodeId)) this.selection.selected.delete(row.nodeId);
479
+ else this.selection.selected.add(row.nodeId);
480
+ this.anchorRowIndex = rowIndex;
481
+ } else {
482
+ this.selection.selected.clear();
483
+ this.selection.selected.add(row.nodeId);
484
+ this.anchorRowIndex = rowIndex;
485
+ }
486
+ this.focusedId = row.nodeId;
487
+ this.selection.focused = row.nodeId;
488
+ this.#syncSelectionState();
489
+ this.events.emit('selectionchange', { selection: this.getSelection(), focusedId: this.focusedId });
490
+ this.events.emit('focuschange', { nodeId: this.focusedId });
491
+ return true;
492
+ }
493
+
494
+ handleKey(event) {
495
+ if (!this.rowModel.rows.length) return false;
496
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
497
+ this.selection.selected.clear();
498
+ for (const row of this.rowModel.rows) this.selection.selected.add(row.nodeId);
499
+ const focusedRow = this.rowModel.getRow(this.#focusedRowIndex());
500
+ this.focusedId = focusedRow?.nodeId ?? this.focusedId;
501
+ this.selection.focused = this.focusedId;
502
+ this.#syncSelectionState();
503
+ this.events.emit('selectionchange', { selection: this.getSelection(), focusedId: this.focusedId });
504
+ return true;
505
+ }
506
+ const currentRow = this.#focusedRowIndex();
507
+ let target = currentRow;
508
+ if (event.key === 'ArrowDown') target = Math.min(this.rowModel.rows.length - 1, currentRow + 1);
509
+ else if (event.key === 'ArrowUp') target = Math.max(0, currentRow - 1);
510
+ else if (event.key === 'PageDown') target = Math.min(this.rowModel.rows.length - 1, currentRow + Math.max(1, Math.floor(this.viewport.rowViewportHeight / this.rowModel.rowHeight) - 1));
511
+ else if (event.key === 'PageUp') target = Math.max(0, currentRow - Math.max(1, Math.floor(this.viewport.rowViewportHeight / this.rowModel.rowHeight) - 1));
512
+ else if (event.key === 'Home') target = 0;
513
+ else if (event.key === 'End') target = this.rowModel.rows.length - 1;
514
+ else if (event.key === 'ArrowRight') {
515
+ const row = this.rowModel.getRow(currentRow);
516
+ if (row?.hasChildren && !row.expanded) return this.expand(row.nodeId);
517
+ if (row?.expanded) target = Math.min(this.rowModel.rows.length - 1, currentRow + 1);
518
+ } else if (event.key === 'ArrowLeft') {
519
+ const row = this.rowModel.getRow(currentRow);
520
+ if (row?.expanded) return this.collapse(row.nodeId);
521
+ const node = row ? this.model.index.getNode(row.nodeId) : null;
522
+ const parentRow = node?.parentId ? this.rowModel.getRowById(node.parentId) : null;
523
+ if (parentRow) target = parentRow.rowIndex;
524
+ } else if (event.key === 'Enter' || event.key === ' ') {
525
+ const row = this.rowModel.getRow(currentRow);
526
+ if (row) this.selectRow(row.rowIndex, { ctrlKey: true });
527
+ return true;
528
+ } else return false;
529
+
530
+ const row = this.rowModel.getRow(target);
531
+ if (!row) return false;
532
+ this.selectRow(target, { shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey });
533
+ this.viewport.scrollRowIntoView(target, 'nearest');
534
+ this.events.emit('viewportchange', this.getViewportState());
535
+ return true;
536
+ }
537
+
538
+ scrollBy(dx, dy) {
539
+ this.viewport.scrollBy(dx, dy);
540
+ this.events.emit('viewportchange', this.getViewportState());
541
+ }
542
+
543
+ render(time = performance.now()) {
544
+ this.renderMeasured(time);
545
+ }
546
+
547
+ renderMeasured(time = performance.now()) {
548
+ this.#syncViewportFromCanvas();
549
+ const sceneStart = performance.now();
550
+ this.scene = this.createRenderScene();
551
+ const sceneMs = performance.now() - sceneStart;
552
+ this.renderer.setScene(this.scene);
553
+ const renderStart = performance.now();
554
+ this.renderer.render(this.scene, time);
555
+ const renderMs = performance.now() - renderStart;
556
+ this.lastRenderedRows = this.renderer.renderedRows ?? 0;
557
+ return { scene: this.scene, sceneMs, renderMs, renderedRows: this.lastRenderedRows };
558
+ }
559
+
560
+ createRenderScene() {
561
+ const visibleRange = this.rowModel.getVisibleRange(this.viewport, 4);
562
+ return {
563
+ rows: this.rowModel.rows,
564
+ visibleRange,
565
+ viewport: this.viewport,
566
+ columns: this.columnModel.columns,
567
+ theme: this.themeManager.get(),
568
+ nodes: this.model.nodes,
569
+ dynamicState: this.model.dynamicState,
570
+ selection: this.selection.selected,
571
+ hoverNodeId: this.hoverId,
572
+ focusNodeId: this.focusedId,
573
+ searchMatches: this.searchHighlights,
574
+ sort: this.columnModel.sort,
575
+ sortValues: this.sortValueSnapshot ? Array.from(this.sortValueSnapshot) : null,
576
+ filterQuery: this.filterQuery,
577
+ headerFilter: Boolean(this.inspector?.options?.filter),
578
+ stats: this.getStats(),
579
+ };
580
+ }
581
+
582
+ hitTest(clientX, clientY) {
583
+ const localX = clientX - (this.viewport.renderInsetX ?? 0);
584
+ const localY = clientY - (this.viewport.renderInsetY ?? 0);
585
+ if (localX < 0 || localY < 0) return null;
586
+ const x = localX + this.viewport.scrollX;
587
+ if (localY < this.viewport.headerHeight) {
588
+ const resizeColumn = this.columnModel.getResizeHandleAt(x);
589
+ if (resizeColumn) return { area: 'header', part: 'resize', column: resizeColumn, x, y: localY };
590
+ const column = this.columnModel.getColumnAt(x);
591
+ if (column?.kind === 'inspectorPane' && this.inspector?.options?.filter) {
592
+ return { area: 'header', part: 'filter', column, x, y: localY };
593
+ }
594
+ return column ? { area: 'header', part: 'label', column, x, y: localY } : { area: 'header', part: 'header', column: null, x, y: localY };
595
+ }
596
+
597
+ const rowY = localY - this.viewport.headerHeight + this.viewport.scrollY;
598
+ const rowIndex = Math.floor(rowY / this.rowModel.rowHeight);
599
+ const row = this.rowModel.getRow(rowIndex);
600
+ if (!row) return null;
601
+ const column = this.columnModel.getColumnAt(x);
602
+ if (!column) return { area: 'row', part: 'row', row, column: null, x, y: rowY };
603
+
604
+ let part = 'cell';
605
+ if (column.kind === 'inspectorPane') {
606
+ const localX = x - column.x;
607
+ const node = this.model.nodes[row.nodeIndex];
608
+ if (node?.data?.valueType === 'array') {
609
+ if (localX >= column.width - 54 && localX <= column.width - 32) part = 'arrayAdd';
610
+ else if (localX >= column.width - 28 && localX <= column.width - 6) part = 'arrayRemove';
611
+ else {
612
+ const editorLeft = Math.min(Math.max(210, column.width * 0.42), column.width - 180);
613
+ if (localX >= editorLeft) part = 'editor';
614
+ else {
615
+ const treeX = row.depth * this.rowModel.indentWidth;
616
+ part = localX >= treeX + 4 && localX <= treeX + 22 ? 'chevron' : 'label';
617
+ }
618
+ }
619
+ return { area: 'row', part, row, column, x, y: rowY };
620
+ }
621
+ const editorLeft = Math.min(Math.max(210, column.width * 0.42), column.width - 180);
622
+ if (localX >= editorLeft) part = this.#inspectorEditorPart(row, localX - editorLeft, column.width - editorLeft);
623
+ else {
624
+ const treeX = row.depth * this.rowModel.indentWidth;
625
+ if (localX >= treeX + 4 && localX <= treeX + 22) part = 'chevron';
626
+ else part = 'label';
627
+ }
628
+ } else if (column.kind === 'tree') {
629
+ const localX = x - column.x;
630
+ const treeX = row.depth * this.rowModel.indentWidth;
631
+ if (localX >= treeX + 4 && localX <= treeX + 22) part = 'chevron';
632
+ else if (localX >= treeX + 26 && localX <= treeX + 44) part = 'icon';
633
+ else if (localX >= treeX + 48) part = 'label';
634
+ else part = 'cell';
635
+ }
636
+ return { area: 'row', part, row, column, x, y: rowY };
637
+ }
638
+
639
+ #inspectorEditorPart(row, localEditorX, editorWidth) {
640
+ const node = this.model.nodes[row.nodeIndex];
641
+ const data = node?.data;
642
+ if (!data?.inspector) return 'cell';
643
+ if (data.editorType === 'checkbox') return 'checkbox';
644
+ if (data.editorType === 'range') {
645
+ const numberLeft = Math.max(50, editorWidth - 64);
646
+ return localEditorX >= numberLeft ? 'number' : 'range';
647
+ }
648
+ if (data.editorType === 'button') return 'button';
649
+ return 'editor';
650
+ }
651
+
652
+ getCellClientRect(hit) {
653
+ if (!hit?.row || !hit.column || !this.canvas) return { x: 0, y: 0, width: 0, height: 0 };
654
+ const rect = this.canvas.getBoundingClientRect();
655
+ return {
656
+ x: rect.left + (this.viewport.renderInsetX ?? 0) + hit.column.x - this.viewport.scrollX,
657
+ y: rect.top + (this.viewport.renderInsetY ?? 0) + this.viewport.headerHeight + hit.row.y - this.viewport.scrollY,
658
+ width: hit.column.width,
659
+ height: hit.row.height,
660
+ };
661
+ }
662
+
663
+ getHeaderClientRect(hit) {
664
+ if (!hit?.column || !this.canvas) return { x: 0, y: 0, width: 0, height: 0 };
665
+ const rect = this.canvas.getBoundingClientRect();
666
+ return {
667
+ x: rect.left + (this.viewport.renderInsetX ?? 0) + hit.column.x - this.viewport.scrollX,
668
+ y: rect.top + (this.viewport.renderInsetY ?? 0),
669
+ width: hit.column.width,
670
+ height: this.viewport.headerHeight,
671
+ };
672
+ }
673
+
674
+ resize(width, height) {
675
+ this.viewport.resize(width, height);
676
+ this.events.emit('viewportchange', this.getViewportState());
677
+ }
678
+
679
+ getStats() {
680
+ return {
681
+ totalNodes: this.model.nodes.length,
682
+ visibleRows: this.rowModel.rows.length,
683
+ renderedRows: this.lastRenderedRows,
684
+ patchesFrame: this.lastPatchCount,
685
+ dirtyNodes: this.lastDirtyNodeCount,
686
+ selectedCount: this.selection.selected.size,
687
+ rebuildCount: this.rebuildCount,
688
+ };
689
+ }
690
+
691
+ getViewportState() {
692
+ return {
693
+ rowHeight: this.viewport.rowHeight,
694
+ indentWidth: this.viewport.indentWidth,
695
+ headerHeight: this.viewport.headerHeight,
696
+ renderInsetX: this.viewport.renderInsetX,
697
+ renderInsetY: this.viewport.renderInsetY,
698
+ scrollX: this.viewport.scrollX,
699
+ scrollY: this.viewport.scrollY,
700
+ viewportWidth: this.viewport.viewportWidth,
701
+ viewportHeight: this.viewport.viewportHeight,
702
+ contentWidth: this.viewport.contentWidth,
703
+ contentHeight: this.viewport.contentHeight,
704
+ zoom: this.viewport.zoom,
705
+ };
706
+ }
707
+
708
+ #rebuildRows() {
709
+ const scrollX = this.viewport.scrollX;
710
+ const scrollY = this.viewport.scrollY;
711
+ this.rowModel.rebuild();
712
+ this.#syncContentSize();
713
+ this.viewport.scrollTo(scrollX, scrollY);
714
+ this.rebuildCount++;
715
+ }
716
+
717
+ #syncContentSize() {
718
+ this.viewport.setContentSize(this.columnModel.contentWidth, this.rowModel.contentHeight);
719
+ }
720
+
721
+ #syncSelectionState() {
722
+ for (const [id, state] of this.model.dynamicState) {
723
+ if (state.selected) this.patchBatcher.set(id, { selected: false });
724
+ }
725
+ for (const id of this.selection.selected) this.patchBatcher.set(id, { selected: true });
726
+ this.setDynamicState(this.patchBatcher.flush());
727
+ }
728
+
729
+ #focusedRowIndex() {
730
+ if (this.focusedId) {
731
+ const row = this.rowModel.getRowById(this.focusedId);
732
+ if (row) return row.rowIndex;
733
+ }
734
+ return Math.max(0, Math.floor(this.viewport.scrollY / this.rowModel.rowHeight));
735
+ }
736
+
737
+ #syncViewportFromCanvas() {
738
+ if (!this.canvas) return;
739
+ const width = this.canvas.clientWidth;
740
+ const height = this.canvas.clientHeight;
741
+ if (width > 0 && height > 0 && (this.viewport.viewportWidth !== width || this.viewport.viewportHeight !== height)) {
742
+ this.viewport.resize(width, height);
743
+ }
744
+ }
745
+
746
+ #workerRowOptions(overrides = {}) {
747
+ return {
748
+ expandedIds: Array.from(this.expansion.model.expanded),
749
+ rowHeight: this.rowModel.rowHeight,
750
+ indentWidth: this.rowModel.indentWidth,
751
+ sort: this.columnModel.sort,
752
+ filterQuery: this.filterQuery,
753
+ ...overrides,
754
+ };
755
+ }
756
+
757
+ #applyWorkerRows(result) {
758
+ const scrollX = this.viewport.scrollX;
759
+ const scrollY = this.viewport.scrollY;
760
+ this.rowModel.applyRows(result);
761
+ this.#syncContentSize();
762
+ this.viewport.scrollTo(scrollX, scrollY);
763
+ this.rebuildCount++;
764
+ }
765
+
766
+ #rebuildInspectorModel(focusPath = '') {
767
+ if (!this.inspector) return;
768
+ const expanded = new Set(this.expansion.model.expanded);
769
+ const focusId = focusPath ? `model:${focusPath}` : this.focusedId;
770
+ const nodes = this.inspector.builder.build(this.inspector.model, this.inspector.meta, this.inspector.options);
771
+ this.model.setTree(nodes);
772
+ this.expansion.model.expanded = expanded;
773
+ if (focusId) this.expansion.expandAncestors(focusId);
774
+ this.searchIndex.rebuild(this.model);
775
+ this.#rebuildRows();
776
+ if (focusId) this.focusedId = focusId;
777
+ }
778
+ }
779
+
780
+ function createDefaultArrayItem(meta = {}) {
781
+ if (meta.itemFactory) return meta.itemFactory();
782
+ if (meta.itemType === 'number') return 0;
783
+ if (meta.itemType === 'boolean') return false;
784
+ if (meta.itemType === 'object') return {};
785
+ return '';
786
+ }
787
+
788
+ function createSortValueSnapshot(column, nodes, dynamicState) {
789
+ return new Map(nodes.map((node) => [node.id, column.value(node, dynamicState.get(node.id) ?? {})]));
790
+ }
791
+
792
+ function compareColumnValues(column, a, b, dynamicState, snapshot = null) {
793
+ const aValue = snapshot ? snapshot.get(a.id) : column.value(a, dynamicState.get(a.id) ?? {});
794
+ const bValue = snapshot ? snapshot.get(b.id) : column.value(b, dynamicState.get(b.id) ?? {});
795
+ if (typeof aValue === 'number' && typeof bValue === 'number') return aValue - bValue;
796
+ return String(aValue ?? '').localeCompare(String(bValue ?? ''), undefined, { numeric: true, sensitivity: 'base' });
797
+ }
798
+
799
+ function matchesFilter(node, state, path, query) {
800
+ const inspector = node.data?.inspector;
801
+ const values = inspector
802
+ ? [
803
+ node.label,
804
+ node.type,
805
+ node.data?.path,
806
+ node.data?.key,
807
+ node.data?.valueText,
808
+ node.data?.meta?.description,
809
+ ...Object.keys(node.data?.meta?.options ?? {}),
810
+ ]
811
+ : [
812
+ node.id,
813
+ node.label,
814
+ node.type,
815
+ path,
816
+ ...(node.tags ?? []),
817
+ state.status,
818
+ state.value,
819
+ ];
820
+ return values.some((value) => String(value ?? '').toLowerCase().includes(query));
821
+ }
822
+
823
+ function now() {
824
+ return globalThis.performance?.now?.() ?? Date.now();
825
+ }