virtual-tree-canvas 0.6.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.
@@ -1,3 +1,6 @@
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';
1
4
  import { validateTreeViewOptions } from './core/view-options.js';
2
5
  import { RowReorderInput } from './input/row-reorder-input.js';
3
6
  import { TreeTooltip } from './input/tree-tooltip.js';
@@ -45,6 +48,9 @@ export class TreeViewController {
45
48
  this.viewport.renderInsetX = options.renderInsetX ?? 0;
46
49
  this.viewport.renderInsetY = options.renderInsetY ?? 0;
47
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);
48
54
  this.rowReorder = Boolean(options.rowReorder);
49
55
  this.columnModel.setRowReorder(this.rowReorder);
50
56
  this.rowDrop = null;
@@ -100,6 +106,8 @@ export class TreeViewController {
100
106
  this.canvas = canvas;
101
107
  this.renderer.initialize(canvas);
102
108
  this.renderer.setScene(this.scene);
109
+ this.actionOverlay?.destroy();
110
+ this.actionOverlay = canvas.ownerDocument?.createElement && canvas.parentElement ? new RowActions(this) : null;
103
111
  this.#resizeToCanvasClientSize();
104
112
  this.#observeCanvasSize();
105
113
  this.#setupNativeScrollbars();
@@ -120,6 +128,22 @@ export class TreeViewController {
120
128
  return this.cellEditor;
121
129
  }
122
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
+
123
147
  setEditable(enabled) {
124
148
  validateTreeViewOptions({ editable: enabled });
125
149
  if (this.editable === enabled) return;
@@ -150,6 +174,7 @@ export class TreeViewController {
150
174
 
151
175
  destroy() {
152
176
  this.destroyed = true;
177
+ this.actionOverlay?.destroy();
153
178
  this.rowReorderInput?.destroy();
154
179
  this.rowReorderInput = null;
155
180
  this.tooltip?.destroy();
@@ -184,19 +209,31 @@ export class TreeViewController {
184
209
  this.closeEditor();
185
210
  this.rawNodes = new Map(nodes.map(node => [node.id, node]));
186
211
  nodes = resolved;
212
+ const preserveExpansion = !this.inspector;
187
213
  this.inspector = null;
188
214
  this.columnModel.setRowReorder(this.rowReorder);
189
215
  if (this.columnModel.columns.some(column => column.kind?.startsWith('inspector'))) {
190
216
  this.columnModel.setColumns([]);
191
217
  }
192
- this.model.setTree(nodes);
193
- this.expansion.expandToDepth(this.initialExpandDepth);
218
+ this.#replaceTree(nodes, preserveExpansion);
194
219
  this.searchIndex.rebuild(this.model);
195
220
  this.workerClient?.setData(this.model.nodes);
196
221
  this.#rebuildRows();
197
222
  this.events.emit('datachange', {});
198
223
  }
199
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);
228
+ this.model.setTree(nodes);
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
+
200
237
  setIconResolver(resolver) {
201
238
  validateTreeViewOptions({ iconResolver: resolver });
202
239
  if (this.inspector) return;
@@ -233,8 +270,9 @@ export class TreeViewController {
233
270
  this.events.emit('rowreorderchange', { enabled: this.rowReorder });
234
271
  }
235
272
 
236
- canReorderRows() {
237
- return this.rowReorder && !this.inspector && !this.columnModel.sort.direction && !this.rowModel.filterPredicate;
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);
238
276
  }
239
277
 
240
278
  getRowOrder(parentId = null) {
@@ -242,7 +280,7 @@ export class TreeViewController {
242
280
  }
243
281
 
244
282
  moveRow(nodeId, targetIndex, options = {}) {
245
- if (!this.canReorderRows()) return false;
283
+ if (!this.canReorderRows(nodeId)) return false;
246
284
  const anchorId = this.rowModel.getRow(this.anchorRowIndex)?.nodeId;
247
285
  const detail = this.model.moveNode(nodeId, targetIndex);
248
286
  if (!detail) return false;
@@ -268,7 +306,7 @@ export class TreeViewController {
268
306
  }
269
307
 
270
308
  getRowDropTarget(nodeId, clientX, clientY) {
271
- if (!this.canReorderRows()) return null;
309
+ if (!this.canReorderRows(nodeId)) return null;
272
310
  const x = clientX - this.viewport.renderInsetX;
273
311
  const y = clientY - this.viewport.renderInsetY;
274
312
  if (x < 0 || x >= this.viewport.contentViewportWidth || y < this.viewport.headerHeight
@@ -316,12 +354,10 @@ export class TreeViewController {
316
354
 
317
355
  setModel(model, meta = {}, options = {}) {
318
356
  validateTreeViewOptions({ ...options, mode: 'inspector', model, meta, presentation: options.presentation ?? options.mode ?? 'table' });
319
- this.closeEditor();
320
357
  this.columnModel.setRowReorder(false);
321
358
  const builder = new ModelInspectorBuilder();
322
359
  const presentation = options.presentation ?? options.mode ?? 'table';
323
- const previousExpanded = new Set(this.model.expanded);
324
- const previousBranches = new Set(this.inspector ? this.model.nodes.filter(node => this.expansion.hasChildren(node.id)).map(node => node.id) : []);
360
+ const preserveExpansion = Boolean(this.inspector);
325
361
  const inspectorOptions = {
326
362
  presentation,
327
363
  flatRoot: Boolean(options.flatRoot),
@@ -330,16 +366,14 @@ export class TreeViewController {
330
366
  markUpdated: options.markUpdated !== false,
331
367
  };
332
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);
333
373
  this.rawNodes.clear();
334
374
  this.inspector = { model, meta, builder, presentation, options: inspectorOptions };
335
- this.setColumns(presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns());
336
- this.model.setTree(nodes);
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
- }
375
+ if (!sameLayout) this.setColumns(presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns());
376
+ this.#replaceTree(nodes, preserveExpansion);
343
377
  this.searchIndex.rebuild(this.model);
344
378
  this.#rebuildRows();
345
379
  this.events.emit('datachange', {});
@@ -895,6 +929,7 @@ export class TreeViewController {
895
929
  this.renderer.setScene(this.scene);
896
930
  const renderStart = performance.now();
897
931
  this.renderer.render(this.scene, time);
932
+ this.actionOverlay?.render(this.scene);
898
933
  const renderMs = performance.now() - renderStart;
899
934
  this.lastRenderedRows = this.renderer.renderedRows ?? 0;
900
935
  return { scene: this.scene, sceneMs, renderMs, renderedRows: this.lastRenderedRows };
@@ -903,6 +938,7 @@ export class TreeViewController {
903
938
  createRenderScene() {
904
939
  const visibleRange = this.rowModel.getVisibleRange(this.viewport, 4);
905
940
  return {
941
+ rowDrag: this.rowDrag,
906
942
  rowReorder: this.rowReorder,
907
943
  canReorderRows: this.canReorderRows(),
908
944
  rowDrop: this.rowDrop,
@@ -946,7 +982,7 @@ export class TreeViewController {
946
982
  const resizeColumn = this.columnModel.getResizeHandleAt(x);
947
983
  if (resizeColumn) return { area: 'header', part: 'resize', column: resizeColumn, x, y: localY };
948
984
  const column = this.columnModel.getColumnAt(x);
949
- if (column && (this.headerFilter ?? Boolean(this.inspector?.options?.filter)) && column === this.columnModel.columns.find(item => item.kind !== 'rowOrder')) {
985
+ if (column && (this.headerFilter ?? Boolean(this.inspector?.options?.filter)) && column === this.columnModel.columns.find(item => item.kind !== 'rowOrder' && item.id !== '__vtc_actions')) {
950
986
  return { area: 'header', part: 'filter', column, x, y: localY };
951
987
  }
952
988
  return column ? { area: 'header', part: 'label', column, x, y: localY } : { area: 'header', part: 'header', column: null, x, y: localY };
@@ -995,10 +1031,10 @@ export class TreeViewController {
995
1031
  }
996
1032
  } else if (column.kind === 'tree') {
997
1033
  const localX = x - column.x;
998
- const treeX = row.depth * this.rowModel.indentWidth;
999
- if (localX >= treeX + 4 && localX <= treeX + 22) part = 'chevron';
1000
- else if (localX >= treeX + 26 && localX <= treeX + 44) part = 'icon';
1001
- 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';
1002
1038
  else part = 'cell';
1003
1039
  } else if (column.kind === 'inspectorValue') {
1004
1040
  part = this.#inspectorEditorPart(row, x - column.x, column.width);
@@ -1076,7 +1112,7 @@ export class TreeViewController {
1076
1112
  width = Math.max(0, layout.editorWidth - 20);
1077
1113
  }
1078
1114
  } else if (hit.column.kind === 'tree') {
1079
- 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);
1080
1116
  text = node.label ?? node.id;
1081
1117
  width = Math.max(0, hit.column.width - labelX - 6);
1082
1118
  } else if (hit.column.kind === 'inspectorValue') {
@@ -1090,7 +1126,7 @@ export class TreeViewController {
1090
1126
  width = Math.max(0, hit.column.width - 20);
1091
1127
  } else if (typeof hit.column.value === 'function') {
1092
1128
  const value = hit.column.value(node, state);
1093
- text = value == null ? '' : String(value);
1129
+ text = hit.column.format ? hit.column.format(value, node, state) : value == null ? '' : String(value);
1094
1130
  width = Math.max(0, hit.column.width - 20);
1095
1131
  }
1096
1132
 
@@ -1175,7 +1211,10 @@ export class TreeViewController {
1175
1211
  const entry = entries[0];
1176
1212
  const width = Math.floor(entry?.contentRect?.width ?? 0);
1177
1213
  const height = Math.floor(entry?.contentRect?.height ?? 0);
1178
- 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
+ }
1179
1218
  });
1180
1219
  this.#resizeObserver.observe(this.canvas);
1181
1220
  }
@@ -1366,7 +1405,9 @@ export class TreeViewController {
1366
1405
  }
1367
1406
 
1368
1407
  #fitInspectorPaneColumn(width) {
1369
- 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);
1370
1411
  if (column?.kind !== 'inspectorPane' && column?.kind !== 'tree') return;
1371
1412
  if (column.kind === 'inspectorPane' && this.inspector?.options?.presentation !== 'pane') return;
1372
1413
  const nextWidth = Math.max(column.minWidth, Math.floor(width));
@@ -1571,21 +1612,6 @@ function assertNonNegativeNumber(value, name) {
1571
1612
  }
1572
1613
  }
1573
1614
 
1574
- function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
1575
- const safeWidth = Math.max(1, width);
1576
- const rightPadding = 14;
1577
- if (editorType === 'checkbox') {
1578
- const editorWidth = 34;
1579
- return { editorLeft: Math.max(64, safeWidth - rightPadding - editorWidth), editorWidth };
1580
- }
1581
- const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
1582
- const minLabelEnd = Math.max(88, depth * indentWidth + 104);
1583
- const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
1584
- const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
1585
- const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
1586
- const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
1587
- return { editorLeft, editorWidth };
1588
- }
1589
1615
 
1590
1616
  function createDefaultArrayItem(meta = {}) {
1591
1617
  if (meta.itemFactory) return meta.itemFactory();
package/src/tree-view.js CHANGED
@@ -79,6 +79,8 @@ export class TreeView {
79
79
  if (changed('rowHeight') || changed('indentWidth')) controller.setLayoutMetrics({ rowHeight: next.rowHeight, indentWidth: next.indentWidth });
80
80
  if (changed('theme') || changed('fontFamily')) this.applyTheme();
81
81
  if (changed('editable')) controller.setEditable(next.editable);
82
+ if (changed('rowActions')) controller.setRowActions(next.rowActions);
83
+ if (changed('rowDrag')) controller.setRowDrag(next.rowDrag);
82
84
  if (changed('rowReorder')) controller.setRowReorder(next.rowReorder);
83
85
  if (replaceData) {
84
86
  if (next.mode === 'tree') controller.setData(next.nodes ?? [], { iconResolver: next.iconResolver });
@@ -88,18 +90,18 @@ export class TreeView {
88
90
  if (changed('markUpdated')) controller.setInspectorOptions({ markUpdated: next.markUpdated });
89
91
  }
90
92
  if (changed('columns') || (replaceData && next.columns !== null)) controller.setColumns(next.columns);
91
- if (replaceData || ['filter', 'filterPlacement', 'headerHeight'].some(changed)) this.syncLayout();
93
+ if (replaceData || ['filter', 'filterPlacement', 'headerHeight', 'showHeader'].some(changed)) this.syncLayout();
92
94
  } finally {
93
95
  this.flushing = false;
94
96
  }
95
97
  }
96
98
 
97
99
  syncLayout() {
98
- const { filter, filterPlacement, headerHeight, mode, presentation } = this.options;
99
- const bar = filter && (filterPlacement === 'bar' || (filterPlacement === 'auto' && (mode === 'tree' || presentation === 'pane')));
100
+ const { filter, filterPlacement, headerHeight, showHeader, mode, presentation } = this.options;
101
+ const bar = filter && (!showHeader || filterPlacement === 'bar' || (filterPlacement === 'auto' && (mode === 'tree' || presentation === 'pane')));
100
102
  this.filterBar.setVisible(bar);
101
103
  this._controller.setHeaderFilter(filter && !bar);
102
- this._controller.setLayoutMetrics({ headerHeight: bar && mode === 'inspector' && presentation === 'pane' ? 0 : headerHeight });
104
+ this._controller.setLayoutMetrics({ headerHeight: !showHeader || (bar && mode === 'inspector' && presentation === 'pane') ? 0 : headerHeight });
103
105
  }
104
106
 
105
107
  applyTheme() {
@@ -1,8 +1,8 @@
1
1
  export const treeViewStyles = `
2
2
  .vtc-view { position:relative; display:flex; flex-direction:column; width:100%; height:100%; min-width:0; min-height:0; overflow:hidden; box-sizing:border-box; background:var(--vtc-background); color:var(--vtc-text); }
3
3
  .vtc-view .vtc-canvas-host { position:relative; flex:1 1 0; min-width:0; min-height:0; overflow:hidden; }
4
- .vtc-view canvas { display:block; width:100%; height:100%; box-sizing:border-box; }
5
- .vtc-view canvas:focus-visible { outline:1px solid var(--vtc-focus); outline-offset:-1px; }
4
+ .vtc-view .vtc-canvas-host > canvas { display:block; width:100%; height:100%; box-sizing:border-box; }
5
+ .vtc-view .vtc-canvas-host > canvas:focus-visible { outline:1px solid var(--vtc-border); outline-offset:-1px; }
6
6
  .vtc-view .vtc-filter { display:flex; flex:0 0 30px; box-sizing:border-box; gap:4px; padding:4px 8px; min-width:0; }
7
7
  .vtc-view .vtc-filter[hidden] { display:none; }
8
8
  .vtc-view .vtc-filter input, .vtc-view .vtc-filter button { height:22px; box-sizing:border-box; border:1px solid var(--vtc-border); border-radius:4px; background:var(--vtc-background); color:var(--vtc-text); font:12px var(--vtc-font-family, ui-monospace, monospace); }