virtual-tree-canvas 0.3.5 → 0.3.7

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.7",
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",
@@ -8,13 +8,21 @@ export class TreeSearchIndex {
8
8
 
9
9
  /** @param {import('./tree-model.js').TreeModel} model */
10
10
  rebuild(model) {
11
- this.records = model.nodes.map((node) => ({
12
- id: node.id,
13
- label: (node.label ?? '').toLowerCase(),
14
- path: (model.index.pathById.get(node.id) ?? '').toLowerCase(),
15
- tags: (node.tags ?? []).join(' ').toLowerCase(),
16
- type: (node.type ?? '').toLowerCase(),
17
- }));
11
+ this.records = model.nodes.map((node) => {
12
+ const path = model.index.pathById.get(node.id) ?? '';
13
+ const searchId = searchableNodeId(node);
14
+ const record = {
15
+ id: node.id,
16
+ searchId: normalize(searchId),
17
+ label: normalize(node.label ?? ''),
18
+ path: normalize(path),
19
+ tags: normalize((node.tags ?? []).join(' ')),
20
+ type: normalize(node.type ?? ''),
21
+ value: normalize(searchableNodeValue(node)),
22
+ };
23
+ record.searchText = defaultSearchText(node, record);
24
+ return record;
25
+ });
18
26
  }
19
27
 
20
28
  /**
@@ -32,11 +40,16 @@ export class TreeSearchIndex {
32
40
  const fields = options.fields ?? ['label', 'id', 'path', 'tags', 'type'];
33
41
  const limit = options.limit ?? 100;
34
42
  const results = [];
43
+ const defaultFields = isDefaultSearchFields(fields);
35
44
  for (const record of this.records) {
36
- for (const field of fields) {
37
- if (String(record[field] ?? '').includes(q)) {
38
- results.push(record.id);
39
- break;
45
+ if (defaultFields) {
46
+ if (record.searchText.includes(q)) results.push(record.id);
47
+ } else {
48
+ for (const field of fields) {
49
+ if (searchFieldValue(record, field).includes(q)) {
50
+ results.push(record.id);
51
+ break;
52
+ }
40
53
  }
41
54
  }
42
55
  if (results.length >= limit) break;
@@ -68,3 +81,42 @@ export class TreeSearchIndex {
68
81
  this.lastQuery = '';
69
82
  }
70
83
  }
84
+
85
+ function isDefaultSearchFields(fields) {
86
+ return fields.length === 5 && fields.includes('label') && fields.includes('id') && fields.includes('path') && fields.includes('tags') && fields.includes('type');
87
+ }
88
+
89
+ function searchFieldValue(record, field) {
90
+ if (field === 'id') return record.searchId || normalize(record.id);
91
+ return normalize(record[field] ?? '');
92
+ }
93
+
94
+ function searchableNodeId(node) {
95
+ if (node?.data?.inspector) return node.data.key ?? node.label ?? node.id;
96
+ return node?.id ?? '';
97
+ }
98
+
99
+ function searchableNodeValue(node) {
100
+ if (!node?.data?.inspector) return '';
101
+ return node.data.valueText ?? node.data.value ?? '';
102
+ }
103
+
104
+ function defaultSearchText(node, record) {
105
+ if (node?.data?.inspector) {
106
+ const data = node.data;
107
+ return normalize([
108
+ record.searchId,
109
+ node.label ?? '',
110
+ data.key ?? '',
111
+ data.valueText ?? '',
112
+ data.valueType ?? '',
113
+ record.tags,
114
+ record.type,
115
+ ].join(' '));
116
+ }
117
+ return normalize(`${record.searchId} ${record.label} ${record.path} ${record.tags} ${record.type}`);
118
+ }
119
+
120
+ function normalize(value) {
121
+ return String(value ?? '').toLowerCase();
122
+ }
@@ -13,10 +13,17 @@ export class TreeViewViewport extends EventTarget {
13
13
  this.contentWidth = 1;
14
14
  this.contentHeight = 1;
15
15
  this.zoom = 1;
16
+ this.scrollbarSize = 0;
17
+ this.verticalScrollbarVisible = false;
18
+ this.horizontalScrollbarVisible = false;
19
+ }
20
+
21
+ get contentViewportWidth() {
22
+ return Math.max(1, this.viewportWidth - (this.verticalScrollbarVisible ? this.scrollbarSize : 0));
16
23
  }
17
24
 
18
25
  get rowViewportHeight() {
19
- return Math.max(1, this.viewportHeight - this.headerHeight);
26
+ return Math.max(1, this.viewportHeight - this.headerHeight - (this.horizontalScrollbarVisible ? this.scrollbarSize : 0));
20
27
  }
21
28
 
22
29
  resize(width, height) {
@@ -33,6 +40,21 @@ export class TreeViewViewport extends EventTarget {
33
40
  this.dispatchEvent(new Event('change'));
34
41
  }
35
42
 
43
+ setScrollbarState({ size = this.scrollbarSize, vertical = this.verticalScrollbarVisible, horizontal = this.horizontalScrollbarVisible } = {}) {
44
+ const nextSize = Math.max(0, size);
45
+ const nextVertical = Boolean(vertical && nextSize > 0);
46
+ const nextHorizontal = Boolean(horizontal && nextSize > 0);
47
+ const changed =
48
+ this.scrollbarSize !== nextSize ||
49
+ this.verticalScrollbarVisible !== nextVertical ||
50
+ this.horizontalScrollbarVisible !== nextHorizontal;
51
+ this.scrollbarSize = nextSize;
52
+ this.verticalScrollbarVisible = nextVertical;
53
+ this.horizontalScrollbarVisible = nextHorizontal;
54
+ this.clamp();
55
+ return changed;
56
+ }
57
+
36
58
  scrollBy(dx, dy) {
37
59
  this.scrollX += dx;
38
60
  this.scrollY += dy;
@@ -61,7 +83,7 @@ export class TreeViewViewport extends EventTarget {
61
83
  }
62
84
 
63
85
  clamp() {
64
- const maxX = Math.max(0, this.contentWidth - this.viewportWidth);
86
+ const maxX = Math.max(0, this.contentWidth - this.contentViewportWidth);
65
87
  const maxY = Math.max(0, this.contentHeight - this.rowViewportHeight);
66
88
  this.scrollX = Math.max(0, Math.min(maxX, this.scrollX));
67
89
  this.scrollY = Math.max(0, Math.min(maxY, this.scrollY));
@@ -27,14 +27,18 @@ export function createWorkerTreeState(nodes) {
27
27
  const records = nodes.map((node) => {
28
28
  const path = pathById.get(node.id) ?? '';
29
29
  const tags = (node.tags ?? []).join(' ');
30
- return {
30
+ const record = {
31
31
  id: node.id,
32
+ searchId: searchableNodeId(node),
32
33
  label: node.label ?? '',
33
34
  path,
34
35
  tags,
35
36
  type: node.type ?? '',
36
- searchText: normalize(`${node.id} ${node.label ?? ''} ${path} ${tags} ${node.type ?? ''}`),
37
+ value: searchableNodeValue(node),
37
38
  };
39
+ record.searchText = defaultSearchText(node, record);
40
+ record.filterText = defaultFilterText(node, record);
41
+ return record;
38
42
  });
39
43
 
40
44
  return { nodes, idToIndex, childrenByParent, parentById, roots, pathById, records };
@@ -47,7 +51,7 @@ export function searchWorkerTree(state, query, { fields = ['label', 'id', 'path'
47
51
  const defaultFields = fields.length === 5 && fields.includes('label') && fields.includes('id') && fields.includes('path') && fields.includes('tags') && fields.includes('type');
48
52
 
49
53
  for (const record of state.records) {
50
- const matches = defaultFields ? record.searchText.includes(q) : fields.some((field) => normalize(record[field]).includes(q));
54
+ const matches = defaultFields ? record.searchText.includes(q) : fields.some((field) => searchFieldValue(record, field).includes(q));
51
55
  if (!matches) continue;
52
56
  results.push(record.id);
53
57
  if (results.length >= limit) break;
@@ -115,7 +119,7 @@ export function getIncludedIdsForQuery(state, query, candidateIds = null) {
115
119
  const records = candidateIds ? idsToRecords(state, candidateIds) : state.records;
116
120
 
117
121
  for (const record of records) {
118
- if (!record.searchText.includes(q)) continue;
122
+ if (!(record.filterText ?? record.searchText).includes(q)) continue;
119
123
  matchingIds.push(record.id);
120
124
  let id = record.id;
121
125
  while (id !== null && id !== undefined && !includedIds.has(id)) {
@@ -152,3 +156,50 @@ function columnValue(node, columnId) {
152
156
  function normalize(value) {
153
157
  return String(value ?? '').toLowerCase();
154
158
  }
159
+
160
+ function searchFieldValue(record, field) {
161
+ if (field === 'id') return normalize(record.searchId || record.id);
162
+ return normalize(record[field]);
163
+ }
164
+
165
+ function searchableNodeId(node) {
166
+ if (node?.data?.inspector) return node.data.key ?? node.label ?? node.id;
167
+ return node?.id ?? '';
168
+ }
169
+
170
+ function searchableNodeValue(node) {
171
+ if (!node?.data?.inspector) return '';
172
+ return node.data.valueText ?? node.data.value ?? '';
173
+ }
174
+
175
+ function defaultSearchText(node, record) {
176
+ if (node?.data?.inspector) {
177
+ const data = node.data;
178
+ return normalize([
179
+ record.searchId,
180
+ node.label ?? '',
181
+ data.key ?? '',
182
+ data.valueText ?? '',
183
+ data.valueType ?? '',
184
+ record.tags,
185
+ record.type,
186
+ ].join(' '));
187
+ }
188
+ return normalize(`${record.searchId} ${record.label} ${record.path} ${record.tags} ${record.type}`);
189
+ }
190
+
191
+ function defaultFilterText(node, record) {
192
+ if (node?.data?.inspector) {
193
+ const data = node.data;
194
+ return normalize([
195
+ node.label ?? '',
196
+ node.type ?? '',
197
+ data.path ?? '',
198
+ data.key ?? '',
199
+ data.valueText ?? '',
200
+ data.meta?.description ?? '',
201
+ ...Object.keys(data.meta?.options ?? {}),
202
+ ].join(' '));
203
+ }
204
+ return record.searchText;
205
+ }
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);
@@ -139,7 +141,7 @@ export class CellEditorManager {
139
141
  const rect = this.#clampRectToHost({
140
142
  x: headerRect.x + 8,
141
143
  y: headerRect.y + 5,
142
- width: Math.max(24, Math.min(headerRect.width, this.controller.viewport.viewportWidth) - 16),
144
+ width: Math.max(24, Math.min(headerRect.width, this.controller.viewport.contentViewportWidth) - 16),
143
145
  height: Math.max(20, headerRect.height - 10),
144
146
  }, 0);
145
147
  const hostRect = this.host.getBoundingClientRect();
@@ -212,7 +214,7 @@ export class CellEditorManager {
212
214
  #overlayRect(hit) {
213
215
  if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
214
216
  const rect = this.controller.getCellClientRect(hit);
215
- const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.viewportWidth));
217
+ const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.contentViewportWidth));
216
218
  const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
217
219
  const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
218
220
  if (hit.part === 'number') {
@@ -225,7 +227,7 @@ export class CellEditorManager {
225
227
  #rangeBarRect(hit) {
226
228
  const rect = this.controller.getCellClientRect(hit);
227
229
  if (hit.column?.kind === 'inspectorPane') {
228
- const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.viewportWidth));
230
+ const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.contentViewportWidth));
229
231
  const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
230
232
  const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
231
233
  const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
@@ -58,9 +58,13 @@ export class TreeRowRenderer {
58
58
  const { viewport, columns, theme, sort, headerFilter, filterQuery } = this.scene;
59
59
  if (viewport.headerHeight <= 0) return;
60
60
  const colors = theme.colors;
61
+ const visibleWidth = viewport.contentViewportWidth ?? viewport.viewportWidth;
61
62
  ctx.save();
62
63
  ctx.fillStyle = colors.row;
63
64
  ctx.fillRect(0, 0, viewport.viewportWidth, viewport.headerHeight);
65
+ ctx.beginPath();
66
+ ctx.rect(0, 0, visibleWidth, viewport.headerHeight);
67
+ ctx.clip();
64
68
  ctx.translate(-viewport.scrollX, 0);
65
69
  ctx.font = theme.font;
66
70
  ctx.textBaseline = 'middle';
@@ -86,7 +90,7 @@ export class TreeRowRenderer {
86
90
  ctx.strokeStyle = colors.border;
87
91
  ctx.beginPath();
88
92
  ctx.moveTo(0, viewport.headerHeight + 0.5);
89
- ctx.lineTo(viewport.viewportWidth, viewport.headerHeight + 0.5);
93
+ ctx.lineTo(visibleWidth, viewport.headerHeight + 0.5);
90
94
  ctx.stroke();
91
95
  }
92
96
 
@@ -94,7 +98,7 @@ export class TreeRowRenderer {
94
98
  const colors = theme.colors;
95
99
  const x = column.x + 8;
96
100
  const y = 5;
97
- const visibleRight = viewport.scrollX + viewport.viewportWidth;
101
+ const visibleRight = viewport.scrollX + (viewport.contentViewportWidth ?? viewport.viewportWidth);
98
102
  const visibleWidth = Math.max(1, Math.min(column.x + column.width, visibleRight) - column.x);
99
103
  const width = Math.max(40, visibleWidth - 16);
100
104
  const height = Math.max(18, viewport.headerHeight - 10);
@@ -126,9 +130,10 @@ export class TreeRowRenderer {
126
130
  #drawRows(ctx) {
127
131
  const { rows, visibleRange, viewport } = this.scene;
128
132
  this.renderedRows = visibleRange.count;
133
+ const visibleWidth = viewport.contentViewportWidth ?? viewport.viewportWidth;
129
134
  ctx.save();
130
135
  ctx.beginPath();
131
- ctx.rect(0, viewport.headerHeight, viewport.viewportWidth, viewport.rowViewportHeight);
136
+ ctx.rect(0, viewport.headerHeight, visibleWidth, viewport.rowViewportHeight);
132
137
  ctx.clip();
133
138
  ctx.translate(-viewport.scrollX, viewport.headerHeight - viewport.scrollY);
134
139
  for (let i = visibleRange.first; i <= visibleRange.last; i++) {
@@ -150,7 +155,7 @@ export class TreeRowRenderer {
150
155
  const focused = focusNodeId === row.nodeId;
151
156
  const y = row.y;
152
157
  const visibleX = viewport.scrollX;
153
- const visibleWidth = viewport.viewportWidth;
158
+ const visibleWidth = viewport.contentViewportWidth ?? viewport.viewportWidth;
154
159
 
155
160
  ctx.fillStyle = selected ? colors.rowSelected : highlighted ? colors.rowHighlighted : hovered ? colors.rowHover : colors.row;
156
161
  ctx.fillRect(visibleX, y, visibleWidth, row.height);
@@ -209,7 +214,7 @@ export class TreeRowRenderer {
209
214
  }
210
215
 
211
216
  #drawInspectorPaneCell(ctx, { node, row, rect, theme, style, hovered, hoverPart, activePart }) {
212
- const visibleRight = this.scene.viewport.scrollX + this.scene.viewport.viewportWidth;
217
+ const visibleRight = this.scene.viewport.scrollX + (this.scene.viewport.contentViewportWidth ?? this.scene.viewport.viewportWidth);
213
218
  rect = { ...rect, width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x) };
214
219
  const data = node.data ?? {};
215
220
  const colors = theme.colors;
@@ -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
  }
@@ -640,6 +700,7 @@ export class TreeViewController {
640
700
  const localX = clientX - (this.viewport.renderInsetX ?? 0);
641
701
  const localY = clientY - (this.viewport.renderInsetY ?? 0);
642
702
  if (localX < 0 || localY < 0) return null;
703
+ if (localX >= this.viewport.contentViewportWidth) return null;
643
704
  const x = localX + this.viewport.scrollX;
644
705
  if (localY < this.viewport.headerHeight) {
645
706
  const resizeColumn = this.columnModel.getResizeHandleAt(x);
@@ -651,6 +712,7 @@ export class TreeViewController {
651
712
  return column ? { area: 'header', part: 'label', column, x, y: localY } : { area: 'header', part: 'header', column: null, x, y: localY };
652
713
  }
653
714
 
715
+ if (localY >= this.viewport.headerHeight + this.viewport.rowViewportHeight) return null;
654
716
  const rowY = localY - this.viewport.headerHeight + this.viewport.scrollY;
655
717
  const rowIndex = Math.floor(rowY / this.rowModel.rowHeight);
656
718
  const row = this.rowModel.getRow(rowIndex);
@@ -716,7 +778,7 @@ export class TreeViewController {
716
778
  }
717
779
 
718
780
  #visibleInspectorPaneWidth(column) {
719
- return Math.max(1, Math.min(column.width, this.viewport.scrollX + this.viewport.viewportWidth - column.x));
781
+ return Math.max(1, Math.min(column.width, this.viewport.scrollX + this.viewport.contentViewportWidth - column.x));
720
782
  }
721
783
 
722
784
  getInspectorPaneLayout(width, row = null, editorType = '') {
@@ -737,10 +799,11 @@ export class TreeViewController {
737
799
  getHeaderClientRect(hit) {
738
800
  if (!hit?.column || !this.canvas) return { x: 0, y: 0, width: 0, height: 0 };
739
801
  const rect = this.canvas.getBoundingClientRect();
802
+ const visibleWidth = Math.max(0, this.viewport.contentViewportWidth - Math.max(0, hit.column.x - this.viewport.scrollX));
740
803
  return {
741
804
  x: rect.left + (this.viewport.renderInsetX ?? 0) + hit.column.x - this.viewport.scrollX,
742
805
  y: rect.top + (this.viewport.renderInsetY ?? 0),
743
- width: hit.column.width,
806
+ width: Math.min(hit.column.width, visibleWidth || hit.column.width),
744
807
  height: this.viewport.headerHeight,
745
808
  };
746
809
  }
@@ -838,7 +901,9 @@ export class TreeViewController {
838
901
  }
839
902
 
840
903
  #syncContentSize() {
841
- if (this.viewport.viewportWidth > 1) this.#fitInspectorPaneColumn(this.viewport.viewportWidth);
904
+ this.#syncScrollbarState(this.columnModel.contentWidth, this.rowModel.contentHeight);
905
+ if (this.viewport.viewportWidth > 1) this.#fitInspectorPaneColumn(this.viewport.contentViewportWidth);
906
+ this.#syncScrollbarState(this.columnModel.contentWidth, this.rowModel.contentHeight);
842
907
  this.viewport.setContentSize(this.columnModel.contentWidth, this.rowModel.contentHeight);
843
908
  }
844
909
 
@@ -913,12 +978,13 @@ export class TreeViewController {
913
978
  applyNativeScrollbarTheme([vertical, horizontal, corner], this.themeManager.get());
914
979
 
915
980
  const size = nativeScrollbarSize();
981
+ this.#nativeScrollbarSize = size;
916
982
  Object.assign(vertical.style, {
917
983
  position: 'absolute',
918
- top: `${this.viewport.headerHeight}px`,
919
- right: '0',
920
- bottom: '0',
984
+ top: '0',
985
+ left: '0',
921
986
  width: `${size}px`,
987
+ height: '1px',
922
988
  overflowX: 'hidden',
923
989
  overflowY: 'auto',
924
990
  zIndex: '4',
@@ -926,8 +992,8 @@ export class TreeViewController {
926
992
  Object.assign(horizontal.style, {
927
993
  position: 'absolute',
928
994
  left: '0',
929
- right: '0',
930
- bottom: '0',
995
+ top: '0',
996
+ width: '1px',
931
997
  height: `${size}px`,
932
998
  overflowX: 'auto',
933
999
  overflowY: 'hidden',
@@ -935,8 +1001,8 @@ export class TreeViewController {
935
1001
  });
936
1002
  Object.assign(corner.style, {
937
1003
  position: 'absolute',
938
- right: '0',
939
- bottom: '0',
1004
+ left: '0',
1005
+ top: '0',
940
1006
  width: `${size}px`,
941
1007
  height: `${size}px`,
942
1008
  zIndex: '4',
@@ -958,17 +1024,24 @@ export class TreeViewController {
958
1024
 
959
1025
  let syncing = false;
960
1026
  const sync = () => {
961
- const maxX = Math.max(0, this.viewport.contentWidth - this.viewport.viewportWidth);
1027
+ this.#syncScrollbarState(this.viewport.contentWidth, this.viewport.contentHeight);
1028
+ const maxX = Math.max(0, this.viewport.contentWidth - this.viewport.contentViewportWidth);
962
1029
  const maxY = Math.max(0, this.viewport.contentHeight - this.viewport.rowViewportHeight);
963
1030
  const showX = maxX > 0;
964
1031
  const showY = maxY > 0;
1032
+ const canvasMetrics = this.#canvasHostMetrics();
965
1033
 
966
1034
  vertical.style.display = showY ? 'block' : 'none';
967
1035
  horizontal.style.display = showX ? 'block' : 'none';
968
1036
  corner.style.display = showX && showY ? 'block' : 'none';
969
- vertical.style.bottom = showX ? `${size}px` : '0';
970
- horizontal.style.right = showY ? `${size}px` : '0';
971
- vertical.style.top = `${this.viewport.headerHeight}px`;
1037
+ vertical.style.left = `${canvasMetrics.left + canvasMetrics.width - size}px`;
1038
+ vertical.style.top = `${canvasMetrics.top + this.viewport.headerHeight}px`;
1039
+ vertical.style.height = `${Math.max(1, canvasMetrics.height - this.viewport.headerHeight - (showX ? size : 0))}px`;
1040
+ horizontal.style.left = `${canvasMetrics.left}px`;
1041
+ horizontal.style.top = `${canvasMetrics.top + canvasMetrics.height - size}px`;
1042
+ horizontal.style.width = `${Math.max(1, canvasMetrics.width - (showY ? size : 0))}px`;
1043
+ corner.style.left = `${canvasMetrics.left + canvasMetrics.width - size}px`;
1044
+ corner.style.top = `${canvasMetrics.top + canvasMetrics.height - size}px`;
972
1045
 
973
1046
  verticalSpacer.style.height = `${Math.ceil(maxY + vertical.clientHeight)}px`;
974
1047
  horizontalSpacer.style.width = `${Math.ceil(maxX + horizontal.clientWidth)}px`;
@@ -1005,11 +1078,37 @@ export class TreeViewController {
1005
1078
  vertical.remove();
1006
1079
  horizontal.remove();
1007
1080
  corner.remove();
1081
+ this.#nativeScrollbarSize = 0;
1082
+ this.viewport.setScrollbarState({ size: 0, vertical: false, horizontal: false });
1008
1083
  if (hostPositionChanged) host.style.position = previousHostPosition;
1009
1084
  },
1010
1085
  };
1011
1086
  }
1012
1087
 
1088
+ #syncScrollbarState(contentWidth, contentHeight) {
1089
+ const size = this.nativeScrollbars ? this.#nativeScrollbarSize : 0;
1090
+ if (!size) {
1091
+ this.viewport.setScrollbarState({ size: 0, vertical: false, horizontal: false });
1092
+ return;
1093
+ }
1094
+ let availableWidth = this.viewport.viewportWidth;
1095
+ let availableRowHeight = Math.max(1, this.viewport.viewportHeight - this.viewport.headerHeight);
1096
+ let showY = contentHeight > availableRowHeight;
1097
+ let showX = contentWidth > availableWidth;
1098
+ if (showY && contentWidth > Math.max(1, availableWidth - size)) showX = true;
1099
+ if (showX && contentHeight > Math.max(1, availableRowHeight - size)) showY = true;
1100
+ this.viewport.setScrollbarState({ size, vertical: showY, horizontal: showX });
1101
+ }
1102
+
1103
+ #canvasHostMetrics() {
1104
+ if (!this.canvas) return { left: 0, top: 0, width: this.viewport.viewportWidth, height: this.viewport.viewportHeight };
1105
+ const left = Number.isFinite(this.canvas.offsetLeft) ? this.canvas.offsetLeft : 0;
1106
+ const top = Number.isFinite(this.canvas.offsetTop) ? this.canvas.offsetTop : 0;
1107
+ const width = Math.max(1, Math.floor(this.canvas.clientWidth || this.canvas.getBoundingClientRect?.().width || this.viewport.viewportWidth));
1108
+ const height = Math.max(1, Math.floor(this.canvas.clientHeight || this.canvas.getBoundingClientRect?.().height || this.viewport.viewportHeight));
1109
+ return { left, top, width, height };
1110
+ }
1111
+
1013
1112
  #computeInspectorPaneLabelEnd(visibleRange) {
1014
1113
  const column = this.columnModel.columns.find((item) => item.kind === 'inspectorPane');
1015
1114
  if (!column) return 0;
@@ -1073,6 +1172,7 @@ export class TreeViewController {
1073
1172
 
1074
1173
  #resizeObserver = null;
1075
1174
  #nativeScroll = null;
1175
+ #nativeScrollbarSize = 0;
1076
1176
  }
1077
1177
 
1078
1178
  const NATIVE_SCROLLBAR_STYLE_ID = 'virtual-tree-canvas-native-scrollbar-style';
@@ -1201,6 +1301,18 @@ function nativeScrollbarSize() {
1201
1301
  return size;
1202
1302
  }
1203
1303
 
1304
+ function assertPositiveNumber(value, name) {
1305
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
1306
+ throw new TypeError(`${name} must be a positive number`);
1307
+ }
1308
+ }
1309
+
1310
+ function assertNonNegativeNumber(value, name) {
1311
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
1312
+ throw new TypeError(`${name} must be a non-negative number`);
1313
+ }
1314
+ }
1315
+
1204
1316
  function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
1205
1317
  const safeWidth = Math.max(1, width);
1206
1318
  const rightPadding = 14;