virtual-tree-canvas 0.6.0 → 0.7.1

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,30 +1,110 @@
1
1
  import { resolveTheme } from './theme-manager.js';
2
- /** Supported TreeView configuration options and defaults. */
2
+
3
+ /**
4
+ * Supported TreeView configuration options and defaults.
5
+ */
3
6
  export const treeViewDefaults = Object.freeze({
4
- mode: 'tree', presentation: 'pane', nodes: Object.freeze([]), model: null, meta: null,
5
- columns: null, theme: 'dark', flatRoot: true, enforceMeta: false,
6
- filter: true, filterPlacement: 'auto', markUpdated: true, editable: true,
7
- rowReorder: false, initialExpandDepth: 1, rowHeight: undefined,
8
- indentWidth: undefined, headerHeight: 28, iconResolver: null, fontFamily: null,
7
+ mode: 'tree',
8
+ presentation: 'pane',
9
+ nodes: Object.freeze([]),
10
+ model: null,
11
+ meta: null,
12
+ columns: null,
13
+ theme: 'dark',
14
+ flatRoot: true,
15
+ enforceMeta: false,
16
+ filter: true,
17
+ filterPlacement: 'auto',
18
+ markUpdated: true,
19
+ editable: true,
20
+ rowActions: Object.freeze([]),
21
+ rowDrag: null,
22
+ rowReorder: false,
23
+ initialExpandDepth: 1,
24
+ rowHeight: undefined,
25
+ indentWidth: undefined,
26
+ showHeader: true,
27
+ headerHeight: 28,
28
+ iconResolver: null,
29
+ fontFamily: null
9
30
  });
10
31
  export const treeViewOptionNames = Object.freeze(Object.keys(treeViewDefaults));
11
32
 
12
33
  export function validateTreeViewOptions(options) {
13
- if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Options must be an object');
34
+ if (!options || typeof options !== 'object' || Array.isArray(options))
35
+ throw new TypeError('Options must be an object');
14
36
  for (const [key, value] of Object.entries(options)) {
37
+ if (
38
+ key === 'rowActions' &&
39
+ (!Array.isArray(value) ||
40
+ value.some(
41
+ (action) =>
42
+ !action ||
43
+ typeof action.id !== 'string' ||
44
+ !action.id ||
45
+ typeof action.label !== 'string'
46
+ ) ||
47
+ new Set(value.map((action) => action.id)).size !== value.length)
48
+ )
49
+ throw new TypeError('rowActions requires unique ids and labels');
50
+ if (key === 'rowActions')
51
+ for (const action of value) {
52
+ if (action.kind !== undefined && !['button', 'checkbox'].includes(action.kind))
53
+ throw new TypeError('row action kind must be button or checkbox');
54
+ for (const property of ['visible', 'disabled', 'pressed', 'checked'])
55
+ if (
56
+ action[property] !== undefined &&
57
+ !['boolean', 'function'].includes(typeof action[property])
58
+ )
59
+ throw new TypeError(`${property} must be boolean or a function`);
60
+ if (action.icon !== undefined && !['string', 'function'].includes(typeof action.icon))
61
+ throw new TypeError('action icon must be a name or a function');
62
+ }
63
+ if (key === 'rowDrag' && value !== null && typeof value !== 'function')
64
+ throw new TypeError('rowDrag must be a function or null');
15
65
  if (key === 'theme') resolveTheme(value);
16
- if (key === 'mode' && !['tree', 'inspector'].includes(value)) throw new TypeError('mode must be tree or inspector');
17
- if (key === 'presentation' && !['pane', 'table'].includes(value)) throw new TypeError('presentation must be pane or table');
18
- if (key === 'filterPlacement' && !['auto', 'bar', 'header'].includes(value)) throw new TypeError('filterPlacement must be auto, bar or header');
66
+ if (key === 'mode' && !['tree', 'inspector'].includes(value))
67
+ throw new TypeError('mode must be tree or inspector');
68
+ if (key === 'presentation' && !['pane', 'table'].includes(value))
69
+ throw new TypeError('presentation must be pane or table');
70
+ if (key === 'filterPlacement' && !['auto', 'bar', 'header'].includes(value))
71
+ throw new TypeError('filterPlacement must be auto, bar or header');
19
72
  if (key === 'nodes' && !Array.isArray(value)) throw new TypeError('nodes must be an array');
20
- if (key === 'columns' && value !== null && !Array.isArray(value)) throw new TypeError('columns must be an array or null');
73
+ if (key === 'columns' && value !== null && !Array.isArray(value))
74
+ throw new TypeError('columns must be an array or null');
21
75
  if (key === 'model' && value === undefined) throw new TypeError('model must not be undefined');
22
- if (key === 'meta' && value !== null && (!value || typeof value !== 'object' || Array.isArray(value))) throw new TypeError('meta must be an object or null');
23
- if (['rowHeight', 'indentWidth'].includes(key) && value !== undefined && (!Number.isFinite(value) || value <= 0)) throw new TypeError(`${key} must be a positive number`);
24
- if (key === 'headerHeight' && (!Number.isFinite(value) || value < 0)) throw new TypeError('headerHeight must be non-negative');
25
- if (key === 'initialExpandDepth' && (!Number.isInteger(value) || value < 0)) throw new TypeError('initialExpandDepth must be a non-negative integer');
26
- if (key === 'iconResolver' && value !== null && typeof value !== 'function') throw new TypeError('iconResolver must be a function or null');
27
- if (key === 'fontFamily' && value !== null && typeof value !== 'string') throw new TypeError('fontFamily must be a string or null');
28
- if (['flatRoot', 'enforceMeta', 'filter', 'markUpdated', 'editable', 'rowReorder'].includes(key) && typeof value !== 'boolean') throw new TypeError(`${key} must be boolean`);
76
+ if (
77
+ key === 'meta' &&
78
+ value !== null &&
79
+ (!value || typeof value !== 'object' || Array.isArray(value))
80
+ )
81
+ throw new TypeError('meta must be an object or null');
82
+ if (
83
+ ['rowHeight', 'indentWidth'].includes(key) &&
84
+ value !== undefined &&
85
+ (!Number.isFinite(value) || value <= 0)
86
+ )
87
+ throw new TypeError(`${key} must be a positive number`);
88
+ if (key === 'headerHeight' && (!Number.isFinite(value) || value < 0))
89
+ throw new TypeError('headerHeight must be non-negative');
90
+ if (key === 'initialExpandDepth' && (!Number.isInteger(value) || value < 0))
91
+ throw new TypeError('initialExpandDepth must be a non-negative integer');
92
+ if (key === 'iconResolver' && value !== null && typeof value !== 'function')
93
+ throw new TypeError('iconResolver must be a function or null');
94
+ if (key === 'fontFamily' && value !== null && typeof value !== 'string')
95
+ throw new TypeError('fontFamily must be a string or null');
96
+ if (
97
+ [
98
+ 'flatRoot',
99
+ 'enforceMeta',
100
+ 'filter',
101
+ 'markUpdated',
102
+ 'editable',
103
+ 'rowReorder',
104
+ 'showHeader'
105
+ ].includes(key) &&
106
+ typeof value !== 'boolean'
107
+ )
108
+ throw new TypeError(`${key} must be boolean`);
29
109
  }
30
110
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  export class VisibleRowModel extends EventTarget {
15
+
15
16
  /**
16
17
  * @param {{
17
18
  * model: import('./tree-model.js').TreeModel,
@@ -26,9 +27,15 @@ export class VisibleRowModel extends EventTarget {
26
27
  this.expansion = expansion;
27
28
  this.rowHeight = rowHeight;
28
29
  this.indentWidth = indentWidth;
29
- /** @type {TreeRow[]} */
30
+
31
+ /**
32
+ * @type {TreeRow[]}
33
+ */
30
34
  this.rows = [];
31
- /** @type {Map<string, number>} */
35
+
36
+ /**
37
+ * @type {Map<string, number>}
38
+ */
32
39
  this.rowIndexById = new Map();
33
40
  this.contentWidth = 0;
34
41
  this.contentHeight = 0;
@@ -75,14 +82,18 @@ export class VisibleRowModel extends EventTarget {
75
82
  const node = this.model.index.getNode(id);
76
83
  const state = this.model.dynamicState.get(id) ?? {};
77
84
  const ownMatch = node ? this.filterPredicate(node, state) : false;
78
- const childMatch = this.model.index.getChildren(id).some((childId) => subtreeIncluded(childId));
85
+ const childMatch = this.model.index
86
+ .getChildren(id)
87
+ .some((childId) => subtreeIncluded(childId));
79
88
  const included = ownMatch || childMatch;
80
89
  includeCache.set(id, included);
81
90
  return included;
82
91
  };
83
92
 
84
93
  const sortedChildren = (id) => {
85
- const children = this.model.index.getChildren(id).filter((childId) => subtreeIncluded(childId));
94
+ const children = this.model.index
95
+ .getChildren(id)
96
+ .filter((childId) => subtreeIncluded(childId));
86
97
  if (!this.sortComparator) return children;
87
98
  return children.slice().sort((aId, bId) => {
88
99
  const a = this.model.index.getNode(aId);
@@ -98,7 +109,8 @@ export class VisibleRowModel extends EventTarget {
98
109
  const children = sortedChildren(id);
99
110
  const hasChildren = this.expansion.hasChildren(id);
100
111
  const autoExpanded = Boolean(this.filterPredicate && children.length > 0);
101
- const expanded = (this.expansion.isExpanded(id) || autoExpanded) && !this.filterCollapsed.has(id);
112
+ const expanded =
113
+ (this.expansion.isExpanded(id) || autoExpanded) && !this.filterCollapsed.has(id);
102
114
  const rowIndex = this.rows.length;
103
115
  this.rows.push({
104
116
  nodeId: id,
@@ -108,7 +120,7 @@ export class VisibleRowModel extends EventTarget {
108
120
  y: rowIndex * this.rowHeight,
109
121
  height: this.rowHeight,
110
122
  expanded,
111
- hasChildren,
123
+ hasChildren
112
124
  });
113
125
  this.rowIndexById.set(id, rowIndex);
114
126
  maxDepth = Math.max(maxDepth, depth);
@@ -118,7 +130,9 @@ export class VisibleRowModel extends EventTarget {
118
130
 
119
131
  const roots = this.model.index.roots.filter((rootId) => subtreeIncluded(rootId));
120
132
  if (this.sortComparator) {
121
- roots.sort((aId, bId) => this.sortComparator(this.model.index.getNode(aId), this.model.index.getNode(bId), aId, bId));
133
+ roots.sort((aId, bId) =>
134
+ this.sortComparator(this.model.index.getNode(aId), this.model.index.getNode(bId), aId, bId)
135
+ );
122
136
  }
123
137
  for (const rootId of roots) visit(rootId, 0);
124
138
  this.contentHeight = this.rows.length * this.rowHeight;
@@ -126,13 +140,17 @@ export class VisibleRowModel extends EventTarget {
126
140
  this.dispatchEvent(new Event('change'));
127
141
  }
128
142
 
129
- /** @param {string} id */
143
+ /**
144
+ * @param {string} id
145
+ */
130
146
  getRowById(id) {
131
147
  const rowIndex = this.rowIndexById.get(id);
132
148
  return rowIndex === undefined ? null : this.rows[rowIndex];
133
149
  }
134
150
 
135
- /** @param {number} rowIndex */
151
+ /**
152
+ * @param {number} rowIndex
153
+ */
136
154
  getRow(rowIndex) {
137
155
  return this.rows[rowIndex] ?? null;
138
156
  }
@@ -144,14 +162,23 @@ export class VisibleRowModel extends EventTarget {
144
162
  getVisibleRange(viewport, overscan = 6) {
145
163
  const rowViewportHeight = viewport.rowViewportHeight ?? viewport.viewportHeight;
146
164
  const first = Math.max(0, Math.floor(viewport.scrollY / this.rowHeight) - overscan);
147
- const last = Math.min(this.rows.length - 1, Math.ceil((viewport.scrollY + rowViewportHeight) / this.rowHeight) + overscan);
148
- return { first, last, count: last >= first ? last - first + 1 : 0 };
165
+ const last = Math.min(
166
+ this.rows.length - 1,
167
+ Math.ceil((viewport.scrollY + rowViewportHeight) / this.rowHeight) + overscan
168
+ );
169
+ return {
170
+ first,
171
+ last,
172
+ count: last >= first ? last - first + 1 : 0
173
+ };
149
174
  }
150
175
 
151
- /** @param {import('./tree-view-viewport.js').TreeViewViewport} viewport */
176
+ /**
177
+ * @param {import('./tree-view-viewport.js').TreeViewViewport} viewport
178
+ */
152
179
  getStickyRows(viewport) {
153
180
  const rowViewportHeight = viewport.rowViewportHeight ?? viewport.viewportHeight;
154
- if (rowViewportHeight < this.rowHeight * 2) return [];
181
+ if (viewport.scrollY <= 0 || rowViewportHeight < this.rowHeight * 2) return [];
155
182
  const max = Math.max(0, Math.min(6, Math.floor(rowViewportHeight / this.rowHeight) - 1));
156
183
  const first = Math.max(0, Math.floor(viewport.scrollY / this.rowHeight));
157
184
  let row = this.getRow(first);
@@ -169,20 +196,23 @@ export class VisibleRowModel extends EventTarget {
169
196
  sticky = [...sticky.slice(0, replaceAt), candidate];
170
197
  }
171
198
  }
172
- return sticky
173
- .slice(-max)
174
- .map((ancestor, index) => ({
175
- ...ancestor,
176
- stickyY: Math.max(ancestor.y - viewport.scrollY, index * this.rowHeight),
177
- }));
199
+ return sticky.slice(-max).map((ancestor, index) => ({
200
+ ...ancestor,
201
+ stickyY: Math.max(ancestor.y - viewport.scrollY, index * this.rowHeight)
202
+ }));
178
203
  }
179
204
 
180
- /** @param {TreeRow} row */
205
+ /**
206
+ * @param {TreeRow} row
207
+ */
181
208
  #getStickyPath(row) {
182
- const ancestors = this.model.index.getAncestors(row.nodeId).reverse()
209
+ const ancestors = this.model.index
210
+ .getAncestors(row.nodeId)
211
+ .reverse()
183
212
  .map((id) => this.getRowById(id))
184
213
  .filter((ancestor) => ancestor && ancestor.rowIndex < row.rowIndex);
185
214
  if (row.hasChildren) ancestors.push(row);
186
215
  return ancestors;
187
216
  }
217
+
188
218
  }
package/src/index.d.ts CHANGED
@@ -1,3 +1,16 @@
1
+ export interface RowAction {
2
+ kind?: 'button' | 'checkbox';
3
+ checked?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
4
+ id: string;
5
+ label: string;
6
+ icon?: string | ((node: TreeNode, state: Record<string, any>) => string);
7
+ /** Icon variants to rasterize before the first action. */
8
+ preloadIcons?: string[];
9
+ visible?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
10
+ disabled?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
11
+ pressed?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
12
+ }
13
+ export type RowDragResolver = ((node: TreeNode, state: Record<string, any>) => any | null) | null;
1
14
  export const builtinIconNames: readonly string[];
2
15
  export interface RowReorderDetail {
3
16
  nodeId: string;
@@ -14,6 +27,8 @@ export interface TreeViewOptions {
14
27
  iconsBaseUrl?: string | URL;
15
28
  iconRegistry?: IconRegistry;
16
29
  rowReorder?: boolean;
30
+ rowActions?: RowAction[];
31
+ rowDrag?: RowDragResolver;
17
32
  autoRender?: boolean;
18
33
  tooltip?: boolean;
19
34
  canvas?: HTMLCanvasElement;
@@ -49,6 +64,7 @@ export type TreeNode = {
49
64
  icon?: string;
50
65
  image?: string;
51
66
  tags?: string[];
67
+ reorderable?: boolean;
52
68
  data?: any;
53
69
  };
54
70
 
@@ -89,8 +105,12 @@ export type Column = {
89
105
  minWidth?: number;
90
106
  align?: 'left' | 'center' | 'right';
91
107
  kind?: string;
108
+ /** Optional semantic type for value-column coloring; defaults to the raw value type. */
109
+ valueType?: string | ((value: any, node: TreeNode, state: Record<string, any>) => string);
92
110
  sortable?: boolean;
93
111
  value?: (node: TreeNode, state: Record<string, any>) => string | number | boolean;
112
+ /** Formats displayed text and tooltips without changing sorting values. */
113
+ format?: (value: any, node: TreeNode, state: Record<string, any>) => string;
94
114
  render?: (ctx: CanvasRenderingContext2D, cell: any) => void;
95
115
  };
96
116
 
@@ -128,6 +148,10 @@ export class TreeViewController {
128
148
  model: any;
129
149
  inspector: any;
130
150
  columnModel: any;
151
+ setRowActions(actions: RowAction[]): void;
152
+ setRowDrag(resolver: RowDragResolver): void;
153
+ sortBy(id: string, direction?: string): boolean;
154
+ clearSort(): void;
131
155
  setEditable(enabled: boolean): void;
132
156
  setInitialExpandDepth(depth: number): void;
133
157
  setIconResolver(resolver: TreeViewConfiguration['iconResolver']): void;
@@ -141,7 +165,7 @@ export class TreeViewController {
141
165
  nextSearchResult(): string | null;
142
166
  previousSearchResult(): string | null;
143
167
  setRowReorder(enabled: boolean): void;
144
- canReorderRows(): boolean;
168
+ canReorderRows(nodeId?: string): boolean;
145
169
  getRowOrder(parentId?: string | null): string[];
146
170
  moveRow(nodeId: string, targetIndex: number, options?: {source?: RowReorderDetail["source"]}): boolean;
147
171
  moveRowBy(nodeId: string, offset: number, options?: {source?: RowReorderDetail["source"]}): boolean;
@@ -159,12 +183,14 @@ export class TreeViewController {
159
183
  setModel(model: any, meta?: Record<string, MetaRule>, options?: Record<string, any>): void;
160
184
  setColumns(columns: Column[] | null): void;
161
185
  setDynamicState(patches: DynamicPatch[]): void;
186
+ flushDynamicState(): Set<string>;
162
187
  setTheme(theme: any): void;
163
188
  setLayoutMetrics(options?: { rowHeight?: number; indentWidth?: number; headerHeight?: number }): void;
164
189
  registerIcon(name: string, icon: IconSource): any;
165
190
  resize(width: number, height: number): void;
166
191
  render(time?: number): void;
167
192
  renderMeasured(time?: number): any;
193
+ getStats(): TreeViewStats;
168
194
  hitTest(clientX: number, clientY: number): any;
169
195
  getTooltipForHit(hit: any): any;
170
196
  search(query: string, options?: Record<string, any> & { caseSensitive?: boolean; wholeWord?: boolean }): any;
@@ -176,10 +202,31 @@ export class TreeViewController {
176
202
  getSelection(): string[];
177
203
  setSelection(ids: string[]): void;
178
204
  clearSelection(): void;
205
+ toggle(nodeId: string): boolean;
179
206
  expandAll(): void;
180
207
  collapseAll(): void;
181
208
  }
182
209
 
210
+ export interface TreeViewStats {
211
+ totalNodes: number;
212
+ visibleRows: number;
213
+ renderedRows: number;
214
+ patchesFrame: number;
215
+ dirtyNodes: number;
216
+ selectedCount: number;
217
+ rebuildCount: number;
218
+ setDynamicStateCalls: number;
219
+ patchesReceived: number;
220
+ uniqueNodesReceived: number;
221
+ nodesChanged: number;
222
+ rendersRequested: number;
223
+ rendersExecuted: number;
224
+ rendersAvoidedNoChanges: number;
225
+ rendersAvoidedOffscreen: number;
226
+ rowActionsFullUpdates: number;
227
+ rowActionsIncrementalUpdates: number;
228
+ }
229
+
183
230
  export class TreeViewInputController {
184
231
  constructor(options: { controller: TreeViewController; cellEditor?: CellEditorManager | null });
185
232
  destroy(): void;
@@ -208,9 +255,13 @@ export interface TreeViewConfiguration {
208
255
  markUpdated?: boolean;
209
256
  editable?: boolean;
210
257
  rowReorder?: boolean;
258
+ rowActions?: RowAction[];
259
+ rowDrag?: RowDragResolver;
211
260
  initialExpandDepth?: number;
212
261
  rowHeight?: number;
213
262
  indentWidth?: number;
263
+ /** Hide column headings without hiding the filter bar. Defaults to true; inspector panes may hide headings automatically. */
264
+ showHeader?: boolean;
214
265
  headerHeight?: number;
215
266
  iconResolver?: ((node: TreeNode) => string | Partial<TreeNode> | null | undefined) | null;
216
267
  fontFamily?: string | null;
@@ -273,7 +324,18 @@ export interface TreeSearchState {
273
324
  current: string | null;
274
325
  count: number;
275
326
  }
327
+ export interface RowDragDetail {
328
+ nodeId: string;
329
+ payload: any;
330
+ label: string;
331
+ originalEvent: PointerEvent;
332
+ }
276
333
  export interface TreeViewEvents {
334
+ rowaction: {checked?: boolean; actionId: string; nodeId: string; node: TreeNode; originalEvent: MouseEvent};
335
+ rowdragstart: RowDragDetail;
336
+ rowdragmove: RowDragDetail;
337
+ rowdragend: RowDragDetail;
338
+ rowdragcancel: Omit<RowDragDetail, 'originalEvent'>;
277
339
  valuechange: InspectorValueChange;
278
340
  modelchange: {model: any; meta?: Record<string, MetaRule>; path?: string; structural?: boolean; action?: string; value?: any} & Partial<InspectorValueChange>;
279
341
  action: {path: string; label: string; nodeId: string; model: any; source: string};
@@ -288,3 +350,10 @@ export interface TreeViewEvents {
288
350
  rowreorderchange: {enabled: boolean};
289
351
  }
290
352
  export type TreeViewEvent<K extends keyof TreeViewEvents> = {type: K; detail: TreeViewEvents[K]};
353
+
354
+ /** Formats an inspector value using its options and precision. */
355
+ export function formatInspectorValue(value: any, meta?: MetaRule): string;
356
+
357
+ export class ModelInspectorBuilder {
358
+ build(model: any, meta?: Record<string, MetaRule>, options?: {flatRoot?: boolean; enforceMeta?: boolean}): TreeNode[];
359
+ }