virtual-tree-canvas 0.3.5 → 0.3.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virtual-tree-canvas",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "High-performance Canvas2D virtual tree/table widget for very large hierarchical datasets.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.d.ts CHANGED
@@ -62,6 +62,8 @@ export class TreeRowRenderer {
62
62
 
63
63
  export class TreeViewController {
64
64
  canvas?: HTMLCanvasElement;
65
+ inputController?: TreeViewInputController | null;
66
+ cellEditor?: CellEditorManager | null;
65
67
  viewport: {
66
68
  viewportWidth: number;
67
69
  viewportHeight: number;
@@ -72,6 +74,10 @@ export class TreeViewController {
72
74
  expansion: any;
73
75
  selection: any;
74
76
  constructor(options?: Record<string, any> & { nativeScrollbars?: boolean });
77
+ initialize(canvas: HTMLCanvasElement): this;
78
+ attachCellEditor(options?: { host?: HTMLElement | null }): CellEditorManager;
79
+ attachInput(options?: { cellEditor?: CellEditorManager | null }): TreeViewInputController;
80
+ destroy(): void;
75
81
  on(type: string, listener: (event: any) => void): any;
76
82
  off(type: string, listener: (event: any) => void): void;
77
83
  setData(nodes: TreeNode[]): void;
@@ -79,6 +85,7 @@ export class TreeViewController {
79
85
  setColumns(columns: Column[]): void;
80
86
  setDynamicState(patches: DynamicPatch[]): void;
81
87
  setTheme(theme: any): void;
88
+ setLayoutMetrics(options?: { rowHeight?: number; indentWidth?: number; headerHeight?: number }): void;
82
89
  resize(width: number, height: number): void;
83
90
  render(time?: number): void;
84
91
  renderMeasured(time?: number): any;
@@ -98,12 +105,12 @@ export class TreeViewController {
98
105
  }
99
106
 
100
107
  export class TreeViewInputController {
101
- constructor(options?: Record<string, any>);
108
+ constructor(options: { controller: TreeViewController; cellEditor?: CellEditorManager | null });
102
109
  destroy(): void;
103
110
  }
104
111
 
105
112
  export class CellEditorManager {
106
- constructor(options?: Record<string, any>);
113
+ constructor(options: { controller: TreeViewController; host?: HTMLElement | null });
107
114
  destroy(): void;
108
115
  close(): void;
109
116
  }
@@ -1,20 +1,15 @@
1
1
  export class TreeViewInputController {
2
2
  /**
3
3
  * @param {{
4
- * canvas: HTMLCanvasElement,
5
- * viewport: import('../core/tree-view-viewport.js').TreeViewViewport,
6
- * rowModel: import('../core/visible-row-model.js').VisibleRowModel,
7
- * expansion: import('../core/tree-expansion-manager.js').TreeExpansionManager,
8
- * selection: import('../core/selection-manager.js').TreeSelectionManager,
9
- * controller?: import('../tree-view-controller.js').TreeViewController,
10
- * onRowsChanged?: () => void,
11
- * onSelectionChanged?: () => void,
12
- * onHoverChanged?: (id: string | null) => void
4
+ * controller: import('../tree-view-controller.js').TreeViewController,
5
+ * cellEditor?: import('../inspector/cell-editor-manager.js').CellEditorManager | null
13
6
  * }} options
14
7
  */
15
8
  constructor(options) {
16
- Object.assign(this, options);
17
- this.anchorRowIndex = null;
9
+ if (!options?.controller) throw new TypeError('TreeViewInputController requires a TreeViewController');
10
+ if (!options.controller.canvas) throw new Error('TreeViewInputController requires an initialized TreeViewController canvas');
11
+ this.controller = options.controller;
12
+ this.canvas = options.controller.canvas;
18
13
  this.hoveredId = null;
19
14
  this.resizeDrag = null;
20
15
  this.cellEditor = options.cellEditor ?? null;
@@ -44,11 +39,7 @@ export class TreeViewInputController {
44
39
  #onWheel = (event) => {
45
40
  event.preventDefault();
46
41
  this.cellEditor?.close?.();
47
- if (this.controller) {
48
- this.controller.scrollBy(event.shiftKey ? event.deltaY : event.deltaX, event.deltaY);
49
- return;
50
- }
51
- this.viewport.scrollBy(event.shiftKey ? event.deltaY : event.deltaX, event.deltaY);
42
+ this.controller.scrollBy(event.shiftKey ? event.deltaY : event.deltaX, event.deltaY);
52
43
  };
53
44
 
54
45
  #onMouseMove = (event) => {
@@ -56,7 +47,7 @@ export class TreeViewInputController {
56
47
  const rect = this.canvas.getBoundingClientRect();
57
48
  const clientX = event.clientX - rect.left;
58
49
  const nextWidth = this.resizeDrag.startWidth + (clientX - this.resizeDrag.startX);
59
- this.controller?.resizeColumn(this.resizeDrag.columnId, nextWidth);
50
+ this.controller.resizeColumn(this.resizeDrag.columnId, nextWidth);
60
51
  event.preventDefault();
61
52
  return;
62
53
  }
@@ -67,19 +58,15 @@ export class TreeViewInputController {
67
58
  if (id === this.hoveredId && key === this.hoveredHitKey) return;
68
59
  this.hoveredId = id;
69
60
  this.hoveredHitKey = key;
70
- if (this.controller?.setHoverHit) this.controller.setHoverHit(hit);
71
- else this.controller?.setHover(id);
72
- this.onHoverChanged?.(id);
61
+ this.controller.setHoverHit(hit);
73
62
  };
74
63
 
75
64
  #onMouseLeave = () => {
76
65
  if (!this.resizeDrag) this.canvas.style.cursor = '';
77
66
  this.hoveredId = null;
78
67
  this.hoveredHitKey = null;
79
- this.controller?.setActiveHit?.(null);
80
- if (this.controller?.setHoverHit) this.controller.setHoverHit(null);
81
- else this.controller?.setHover(null);
82
- this.onHoverChanged?.(null);
68
+ this.controller.setActiveHit(null);
69
+ this.controller.setHoverHit(null);
83
70
  };
84
71
 
85
72
  #onClick = (event) => {
@@ -88,43 +75,30 @@ export class TreeViewInputController {
88
75
  if (!hit) return;
89
76
  if (hit.area === 'header') {
90
77
  if (hit.part === 'filter' && this.cellEditor?.handleHeaderClick(event, hit)) return;
91
- if (!this.resizeDrag && hit.part === 'label' && hit.column) this.controller?.sortBy(hit.column.id);
78
+ if (!this.resizeDrag && hit.part === 'label' && hit.column) this.controller.sortBy(hit.column.id);
92
79
  return;
93
80
  }
94
81
  if (hit.part === 'chevron' && hit.row.hasChildren) {
95
- if (this.controller) {
96
- this.controller.toggle(hit.row.nodeId);
97
- this.onRowsChanged?.();
98
- return;
99
- }
100
- this.expansion.toggle(hit.row.nodeId);
101
- this.rowModel.rebuild();
102
- this.viewport.setContentSize(this.rowModel.contentWidth, this.rowModel.contentHeight);
103
- this.onRowsChanged?.();
82
+ this.controller.toggle(hit.row.nodeId);
104
83
  return;
105
84
  }
106
85
  if (this.cellEditor?.handleClick(event, hit)) return;
107
- if (this.controller) {
108
- this.controller.clickNode(hit.row.nodeId, {
109
- shiftKey: event.shiftKey,
110
- ctrlKey: event.ctrlKey,
111
- metaKey: event.metaKey,
112
- multi: this.canvas.dataset.multi === 'true',
113
- });
114
- this.onSelectionChanged?.();
115
- return;
116
- }
117
- this.#selectRow(hit.row.rowIndex, event);
86
+ this.controller.clickNode(hit.row.nodeId, {
87
+ shiftKey: event.shiftKey,
88
+ ctrlKey: event.ctrlKey,
89
+ metaKey: event.metaKey,
90
+ multi: this.canvas.dataset.multi === 'true',
91
+ });
118
92
  };
119
93
 
120
94
  #onDoubleClick = (event) => {
121
95
  const hit = this.#hitTest(event);
122
- if (hit?.area === 'row') this.controller?.doubleClickNode(hit.row.nodeId, event);
96
+ if (hit?.area === 'row') this.controller.doubleClickNode(hit.row.nodeId, event);
123
97
  };
124
98
 
125
99
  #onMouseDown = (event) => {
126
100
  const hit = this.#hitTest(event);
127
- this.controller?.setActiveHit?.(hit);
101
+ this.controller.setActiveHit(hit);
128
102
  if (this.cellEditor?.handlePointerDown(event, hit)) {
129
103
  event.preventDefault();
130
104
  return;
@@ -143,103 +117,20 @@ export class TreeViewInputController {
143
117
  #onMouseUp = () => {
144
118
  this.resizeDrag = null;
145
119
  this.canvas.style.cursor = '';
146
- this.controller?.setActiveHit?.(null);
120
+ this.controller.setActiveHit(null);
147
121
  };
148
122
 
149
123
  #onKeyDown = (event) => {
150
- if (this.controller?.handleKey(event)) {
124
+ if (this.controller.handleKey(event)) {
151
125
  event.preventDefault();
152
- this.onSelectionChanged?.();
153
- return;
154
- }
155
- if (!this.rowModel.rows.length) return;
156
- const currentRow = this.#focusedRowIndex();
157
- let target = currentRow;
158
- if (event.key === 'ArrowDown') target = Math.min(this.rowModel.rows.length - 1, currentRow + 1);
159
- else if (event.key === 'ArrowUp') target = Math.max(0, currentRow - 1);
160
- else if (event.key === 'Home') target = 0;
161
- else if (event.key === 'End') target = this.rowModel.rows.length - 1;
162
- else if (event.key === 'ArrowRight') {
163
- const row = this.rowModel.getRow(currentRow);
164
- if (row?.hasChildren && !row.expanded) this.#toggleAndRebuild(row.nodeId);
165
- else if (row?.expanded) target = Math.min(this.rowModel.rows.length - 1, currentRow + 1);
166
- } else if (event.key === 'ArrowLeft') {
167
- const row = this.rowModel.getRow(currentRow);
168
- if (row?.expanded) this.#toggleAndRebuild(row.nodeId);
169
- else {
170
- const node = row ? this.rowModel.model.index.getNode(row.nodeId) : null;
171
- const parentRow = node?.parentId ? this.rowModel.getRowById(node.parentId) : null;
172
- if (parentRow) target = parentRow.rowIndex;
173
- }
174
- } else if (event.key === 'Enter' || event.key === ' ') {
175
- const row = this.rowModel.getRow(currentRow);
176
- if (row) this.selection.toggle(row.nodeId);
177
- this.onSelectionChanged?.();
178
- event.preventDefault();
179
- return;
180
- } else return;
181
-
182
- const row = this.rowModel.getRow(target);
183
- if (row) {
184
- this.selection.select(row.nodeId);
185
- this.anchorRowIndex = target;
186
- this.viewport.scrollRowIntoView(target);
187
- this.onSelectionChanged?.();
188
126
  }
189
- event.preventDefault();
190
127
  };
191
128
 
192
- #selectRow(rowIndex, event) {
193
- const row = this.rowModel.getRow(rowIndex);
194
- if (!row) return;
195
- if (event.shiftKey && this.anchorRowIndex !== null) {
196
- this.selection.selected.clear();
197
- const start = Math.min(this.anchorRowIndex, rowIndex);
198
- const end = Math.max(this.anchorRowIndex, rowIndex);
199
- for (let i = start; i <= end; i++) this.selection.selected.add(this.rowModel.rows[i].nodeId);
200
- this.selection.focused = row.nodeId;
201
- this.selection.dispatchEvent(new Event('change'));
202
- } else if (event.ctrlKey || event.metaKey || this.canvas.dataset.multi === 'true') {
203
- this.selection.toggle(row.nodeId);
204
- this.anchorRowIndex = rowIndex;
205
- } else {
206
- this.selection.select(row.nodeId);
207
- this.anchorRowIndex = rowIndex;
208
- }
209
- this.onSelectionChanged?.();
210
- }
211
-
212
- #focusedRowIndex() {
213
- if (this.selection.focused) {
214
- const row = this.rowModel.getRowById(this.selection.focused);
215
- if (row) return row.rowIndex;
216
- }
217
- return Math.max(0, Math.floor(this.viewport.scrollY / this.rowModel.rowHeight));
218
- }
219
-
220
- #toggleAndRebuild(id) {
221
- this.expansion.toggle(id);
222
- this.rowModel.rebuild();
223
- this.viewport.setContentSize(this.rowModel.contentWidth, this.rowModel.contentHeight);
224
- this.onRowsChanged?.();
225
- }
226
-
227
129
  #hitTest(event) {
228
130
  const rect = this.canvas.getBoundingClientRect();
229
131
  const clientX = event.clientX - rect.left;
230
132
  const clientY = event.clientY - rect.top;
231
- if (this.controller) return this.controller.hitTest(clientX, clientY);
232
- const x = clientX + this.viewport.scrollX;
233
- const y = clientY - (this.viewport.headerHeight ?? 0) + this.viewport.scrollY;
234
- if (clientY < (this.viewport.headerHeight ?? 0)) return { area: 'header', part: 'header', x, y: clientY };
235
- const rowIndex = Math.floor(y / this.rowModel.rowHeight);
236
- const row = this.rowModel.getRow(rowIndex);
237
- if (!row) return null;
238
- const rowX = row.depth * this.rowModel.indentWidth;
239
- const chevronLeft = rowX + 4;
240
- const chevronRight = chevronLeft + 18;
241
- const part = x >= chevronLeft && x <= chevronRight ? 'chevron' : x <= rowX + 42 ? 'icon' : 'body';
242
- return { area: 'row', row, x, y, part };
133
+ return this.controller.hitTest(clientX, clientY);
243
134
  }
244
135
  }
245
136
 
@@ -1,8 +1,10 @@
1
1
  export class CellEditorManager {
2
- constructor({ controller, canvas, host = null }) {
2
+ constructor({ controller, host = null }) {
3
+ if (!controller) throw new TypeError('CellEditorManager requires a TreeViewController');
4
+ if (!controller.canvas) throw new Error('CellEditorManager requires an initialized TreeViewController canvas');
3
5
  this.controller = controller;
4
- this.canvas = canvas;
5
- this.host = host ?? canvas.parentElement ?? document.body;
6
+ this.canvas = controller.canvas;
7
+ this.host = host ?? controller.canvas.parentElement ?? document.body;
6
8
  this.overlay = null;
7
9
  this.rangeDrag = null;
8
10
  this.onMouseMove = this.#onMouseMove.bind(this);
@@ -12,7 +12,9 @@ import {
12
12
  TreeViewViewport,
13
13
  VisibleRowModel,
14
14
  } from './core/index.js';
15
+ import { CellEditorManager } from './inspector/cell-editor-manager.js';
15
16
  import { formatInspectorValue, getAtPath, inspectorColumns, inspectorPaneColumns, ModelInspectorBuilder, setAtPath } from './inspector/index.js';
17
+ import { TreeViewInputController } from './input/tree-view-input-controller.js';
16
18
  import { TreeRowRenderer } from './renderers/index.js';
17
19
 
18
20
  export class TreeViewController {
@@ -58,12 +60,19 @@ export class TreeViewController {
58
60
  this.workerRevision = 0;
59
61
  this.sortValueSnapshot = null;
60
62
  this.inspector = null;
63
+ this.inputController = null;
64
+ this.cellEditor = null;
61
65
  this.scene = this.createRenderScene();
62
66
 
63
67
  if (options.canvas) this.initialize(options.canvas);
68
+ if (options.editable !== false && (options.host || options.editable)) this.attachCellEditor({ host: options.host });
69
+ if (options.canvas && options.input !== false) this.attachInput();
64
70
  }
65
71
 
66
72
  initialize(canvas) {
73
+ if (!canvas || typeof canvas.getContext !== 'function') {
74
+ throw new TypeError('TreeViewController.initialize requires an HTMLCanvasElement');
75
+ }
67
76
  this.canvas = canvas;
68
77
  this.renderer.initialize(canvas);
69
78
  this.renderer.setScene(this.scene);
@@ -73,6 +82,39 @@ export class TreeViewController {
73
82
  return this;
74
83
  }
75
84
 
85
+ attachCellEditor(options = {}) {
86
+ if (!this.canvas) throw new Error('TreeViewController.attachCellEditor requires an initialized canvas');
87
+ this.cellEditor?.destroy?.();
88
+ this.cellEditor = new CellEditorManager({
89
+ controller: this,
90
+ host: options.host,
91
+ });
92
+ return this.cellEditor;
93
+ }
94
+
95
+ attachInput(options = {}) {
96
+ if (!this.canvas) throw new Error('TreeViewController.attachInput requires an initialized canvas');
97
+ this.inputController?.destroy?.();
98
+ this.inputController = new TreeViewInputController({
99
+ controller: this,
100
+ cellEditor: options.cellEditor ?? this.cellEditor,
101
+ });
102
+ return this.inputController;
103
+ }
104
+
105
+ destroy() {
106
+ this.inputController?.destroy?.();
107
+ this.cellEditor?.destroy?.();
108
+ this.disableWorkers();
109
+ this.#resizeObserver?.disconnect();
110
+ this.#resizeObserver = null;
111
+ this.#nativeScroll?.destroy?.();
112
+ this.#nativeScroll = null;
113
+ this.inputController = null;
114
+ this.cellEditor = null;
115
+ this.canvas = null;
116
+ }
117
+
76
118
  on(type, listener) {
77
119
  return this.events.on(type, listener);
78
120
  }
@@ -287,6 +329,24 @@ export class TreeViewController {
287
329
  this.events.emit('themechange', { theme: nextTheme });
288
330
  }
289
331
 
332
+ setLayoutMetrics(options = {}) {
333
+ const rowHeight = options.rowHeight ?? this.rowModel.rowHeight;
334
+ const indentWidth = options.indentWidth ?? this.rowModel.indentWidth;
335
+ const headerHeight = options.headerHeight ?? this.viewport.headerHeight;
336
+ assertPositiveNumber(rowHeight, 'rowHeight');
337
+ assertPositiveNumber(indentWidth, 'indentWidth');
338
+ assertNonNegativeNumber(headerHeight, 'headerHeight');
339
+ const rowsChanged = this.rowModel.rowHeight !== rowHeight || this.rowModel.indentWidth !== indentWidth;
340
+ this.rowModel.rowHeight = rowHeight;
341
+ this.rowModel.indentWidth = indentWidth;
342
+ this.viewport.rowHeight = rowHeight;
343
+ this.viewport.indentWidth = indentWidth;
344
+ this.viewport.headerHeight = headerHeight;
345
+ if (rowsChanged) this.#rebuildRows();
346
+ else this.#syncContentSize();
347
+ this.events.emit('layoutchange', this.getViewportState());
348
+ }
349
+
290
350
  registerIcon(name, imageOrUrl) {
291
351
  return this.iconRegistry.register(name, imageOrUrl);
292
352
  }
@@ -1201,6 +1261,18 @@ function nativeScrollbarSize() {
1201
1261
  return size;
1202
1262
  }
1203
1263
 
1264
+ function assertPositiveNumber(value, name) {
1265
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
1266
+ throw new TypeError(`${name} must be a positive number`);
1267
+ }
1268
+ }
1269
+
1270
+ function assertNonNegativeNumber(value, name) {
1271
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
1272
+ throw new TypeError(`${name} must be a non-negative number`);
1273
+ }
1274
+ }
1275
+
1204
1276
  function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
1205
1277
  const safeWidth = Math.max(1, width);
1206
1278
  const rightPadding = 14;