virtual-tree-canvas 0.3.6 → 0.3.8

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.6",
3
+ "version": "0.3.8",
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
+ }
@@ -141,7 +141,7 @@ export class CellEditorManager {
141
141
  const rect = this.#clampRectToHost({
142
142
  x: headerRect.x + 8,
143
143
  y: headerRect.y + 5,
144
- 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),
145
145
  height: Math.max(20, headerRect.height - 10),
146
146
  }, 0);
147
147
  const hostRect = this.host.getBoundingClientRect();
@@ -214,7 +214,7 @@ export class CellEditorManager {
214
214
  #overlayRect(hit) {
215
215
  if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
216
216
  const rect = this.controller.getCellClientRect(hit);
217
- 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));
218
218
  const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
219
219
  const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
220
220
  if (hit.part === 'number') {
@@ -227,7 +227,7 @@ export class CellEditorManager {
227
227
  #rangeBarRect(hit) {
228
228
  const rect = this.controller.getCellClientRect(hit);
229
229
  if (hit.column?.kind === 'inspectorPane') {
230
- 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));
231
231
  const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
232
232
  const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
233
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;
@@ -700,6 +700,7 @@ export class TreeViewController {
700
700
  const localX = clientX - (this.viewport.renderInsetX ?? 0);
701
701
  const localY = clientY - (this.viewport.renderInsetY ?? 0);
702
702
  if (localX < 0 || localY < 0) return null;
703
+ if (localX >= this.viewport.contentViewportWidth) return null;
703
704
  const x = localX + this.viewport.scrollX;
704
705
  if (localY < this.viewport.headerHeight) {
705
706
  const resizeColumn = this.columnModel.getResizeHandleAt(x);
@@ -711,6 +712,7 @@ export class TreeViewController {
711
712
  return column ? { area: 'header', part: 'label', column, x, y: localY } : { area: 'header', part: 'header', column: null, x, y: localY };
712
713
  }
713
714
 
715
+ if (localY >= this.viewport.headerHeight + this.viewport.rowViewportHeight) return null;
714
716
  const rowY = localY - this.viewport.headerHeight + this.viewport.scrollY;
715
717
  const rowIndex = Math.floor(rowY / this.rowModel.rowHeight);
716
718
  const row = this.rowModel.getRow(rowIndex);
@@ -776,7 +778,7 @@ export class TreeViewController {
776
778
  }
777
779
 
778
780
  #visibleInspectorPaneWidth(column) {
779
- 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));
780
782
  }
781
783
 
782
784
  getInspectorPaneLayout(width, row = null, editorType = '') {
@@ -797,10 +799,11 @@ export class TreeViewController {
797
799
  getHeaderClientRect(hit) {
798
800
  if (!hit?.column || !this.canvas) return { x: 0, y: 0, width: 0, height: 0 };
799
801
  const rect = this.canvas.getBoundingClientRect();
802
+ const visibleWidth = Math.max(0, this.viewport.contentViewportWidth - Math.max(0, hit.column.x - this.viewport.scrollX));
800
803
  return {
801
804
  x: rect.left + (this.viewport.renderInsetX ?? 0) + hit.column.x - this.viewport.scrollX,
802
805
  y: rect.top + (this.viewport.renderInsetY ?? 0),
803
- width: hit.column.width,
806
+ width: Math.min(hit.column.width, visibleWidth || hit.column.width),
804
807
  height: this.viewport.headerHeight,
805
808
  };
806
809
  }
@@ -889,16 +892,17 @@ export class TreeViewController {
889
892
  }
890
893
 
891
894
  #rebuildRows() {
892
- const scrollX = this.viewport.scrollX;
893
- const scrollY = this.viewport.scrollY;
895
+ const scrollAnchor = this.#captureScrollAnchor();
894
896
  this.rowModel.rebuild();
895
897
  this.#syncContentSize();
896
- this.viewport.scrollTo(scrollX, scrollY);
898
+ this.#restoreScrollAnchor(scrollAnchor);
897
899
  this.rebuildCount++;
898
900
  }
899
901
 
900
902
  #syncContentSize() {
901
- if (this.viewport.viewportWidth > 1) this.#fitInspectorPaneColumn(this.viewport.viewportWidth);
903
+ this.#syncScrollbarState(this.columnModel.contentWidth, this.rowModel.contentHeight);
904
+ if (this.viewport.viewportWidth > 1) this.#fitInspectorPaneColumn(this.viewport.contentViewportWidth);
905
+ this.#syncScrollbarState(this.columnModel.contentWidth, this.rowModel.contentHeight);
902
906
  this.viewport.setContentSize(this.columnModel.contentWidth, this.rowModel.contentHeight);
903
907
  }
904
908
 
@@ -973,12 +977,13 @@ export class TreeViewController {
973
977
  applyNativeScrollbarTheme([vertical, horizontal, corner], this.themeManager.get());
974
978
 
975
979
  const size = nativeScrollbarSize();
980
+ this.#nativeScrollbarSize = size;
976
981
  Object.assign(vertical.style, {
977
982
  position: 'absolute',
978
- top: `${this.viewport.headerHeight}px`,
979
- right: '0',
980
- bottom: '0',
983
+ top: '0',
984
+ left: '0',
981
985
  width: `${size}px`,
986
+ height: '1px',
982
987
  overflowX: 'hidden',
983
988
  overflowY: 'auto',
984
989
  zIndex: '4',
@@ -986,8 +991,8 @@ export class TreeViewController {
986
991
  Object.assign(horizontal.style, {
987
992
  position: 'absolute',
988
993
  left: '0',
989
- right: '0',
990
- bottom: '0',
994
+ top: '0',
995
+ width: '1px',
991
996
  height: `${size}px`,
992
997
  overflowX: 'auto',
993
998
  overflowY: 'hidden',
@@ -995,8 +1000,8 @@ export class TreeViewController {
995
1000
  });
996
1001
  Object.assign(corner.style, {
997
1002
  position: 'absolute',
998
- right: '0',
999
- bottom: '0',
1003
+ left: '0',
1004
+ top: '0',
1000
1005
  width: `${size}px`,
1001
1006
  height: `${size}px`,
1002
1007
  zIndex: '4',
@@ -1018,17 +1023,24 @@ export class TreeViewController {
1018
1023
 
1019
1024
  let syncing = false;
1020
1025
  const sync = () => {
1021
- const maxX = Math.max(0, this.viewport.contentWidth - this.viewport.viewportWidth);
1026
+ this.#syncScrollbarState(this.viewport.contentWidth, this.viewport.contentHeight);
1027
+ const maxX = Math.max(0, this.viewport.contentWidth - this.viewport.contentViewportWidth);
1022
1028
  const maxY = Math.max(0, this.viewport.contentHeight - this.viewport.rowViewportHeight);
1023
1029
  const showX = maxX > 0;
1024
1030
  const showY = maxY > 0;
1031
+ const canvasMetrics = this.#canvasHostMetrics();
1025
1032
 
1026
1033
  vertical.style.display = showY ? 'block' : 'none';
1027
1034
  horizontal.style.display = showX ? 'block' : 'none';
1028
1035
  corner.style.display = showX && showY ? 'block' : 'none';
1029
- vertical.style.bottom = showX ? `${size}px` : '0';
1030
- horizontal.style.right = showY ? `${size}px` : '0';
1031
- vertical.style.top = `${this.viewport.headerHeight}px`;
1036
+ vertical.style.left = `${canvasMetrics.left + canvasMetrics.width - size}px`;
1037
+ vertical.style.top = `${canvasMetrics.top + this.viewport.headerHeight}px`;
1038
+ vertical.style.height = `${Math.max(1, canvasMetrics.height - this.viewport.headerHeight - (showX ? size : 0))}px`;
1039
+ horizontal.style.left = `${canvasMetrics.left}px`;
1040
+ horizontal.style.top = `${canvasMetrics.top + canvasMetrics.height - size}px`;
1041
+ horizontal.style.width = `${Math.max(1, canvasMetrics.width - (showY ? size : 0))}px`;
1042
+ corner.style.left = `${canvasMetrics.left + canvasMetrics.width - size}px`;
1043
+ corner.style.top = `${canvasMetrics.top + canvasMetrics.height - size}px`;
1032
1044
 
1033
1045
  verticalSpacer.style.height = `${Math.ceil(maxY + vertical.clientHeight)}px`;
1034
1046
  horizontalSpacer.style.width = `${Math.ceil(maxX + horizontal.clientWidth)}px`;
@@ -1065,11 +1077,37 @@ export class TreeViewController {
1065
1077
  vertical.remove();
1066
1078
  horizontal.remove();
1067
1079
  corner.remove();
1080
+ this.#nativeScrollbarSize = 0;
1081
+ this.viewport.setScrollbarState({ size: 0, vertical: false, horizontal: false });
1068
1082
  if (hostPositionChanged) host.style.position = previousHostPosition;
1069
1083
  },
1070
1084
  };
1071
1085
  }
1072
1086
 
1087
+ #syncScrollbarState(contentWidth, contentHeight) {
1088
+ const size = this.nativeScrollbars ? this.#nativeScrollbarSize : 0;
1089
+ if (!size) {
1090
+ this.viewport.setScrollbarState({ size: 0, vertical: false, horizontal: false });
1091
+ return;
1092
+ }
1093
+ let availableWidth = this.viewport.viewportWidth;
1094
+ let availableRowHeight = Math.max(1, this.viewport.viewportHeight - this.viewport.headerHeight);
1095
+ let showY = contentHeight > availableRowHeight;
1096
+ let showX = contentWidth > availableWidth;
1097
+ if (showY && contentWidth > Math.max(1, availableWidth - size)) showX = true;
1098
+ if (showX && contentHeight > Math.max(1, availableRowHeight - size)) showY = true;
1099
+ this.viewport.setScrollbarState({ size, vertical: showY, horizontal: showX });
1100
+ }
1101
+
1102
+ #canvasHostMetrics() {
1103
+ if (!this.canvas) return { left: 0, top: 0, width: this.viewport.viewportWidth, height: this.viewport.viewportHeight };
1104
+ const left = Number.isFinite(this.canvas.offsetLeft) ? this.canvas.offsetLeft : 0;
1105
+ const top = Number.isFinite(this.canvas.offsetTop) ? this.canvas.offsetTop : 0;
1106
+ const width = Math.max(1, Math.floor(this.canvas.clientWidth || this.canvas.getBoundingClientRect?.().width || this.viewport.viewportWidth));
1107
+ const height = Math.max(1, Math.floor(this.canvas.clientHeight || this.canvas.getBoundingClientRect?.().height || this.viewport.viewportHeight));
1108
+ return { left, top, width, height };
1109
+ }
1110
+
1073
1111
  #computeInspectorPaneLabelEnd(visibleRange) {
1074
1112
  const column = this.columnModel.columns.find((item) => item.kind === 'inspectorPane');
1075
1113
  if (!column) return 0;
@@ -1110,14 +1148,33 @@ export class TreeViewController {
1110
1148
  }
1111
1149
 
1112
1150
  #applyWorkerRows(result) {
1113
- const scrollX = this.viewport.scrollX;
1114
- const scrollY = this.viewport.scrollY;
1151
+ const scrollAnchor = this.#captureScrollAnchor();
1115
1152
  this.rowModel.applyRows(result);
1116
1153
  this.#syncContentSize();
1117
- this.viewport.scrollTo(scrollX, scrollY);
1154
+ this.#restoreScrollAnchor(scrollAnchor);
1118
1155
  this.rebuildCount++;
1119
1156
  }
1120
1157
 
1158
+ #captureScrollAnchor() {
1159
+ const scrollX = this.viewport.scrollX;
1160
+ const scrollY = this.viewport.scrollY;
1161
+ const rowIndex = Math.max(0, Math.floor(scrollY / this.rowModel.rowHeight));
1162
+ const row = this.rowModel.getRow(rowIndex);
1163
+ if (!row) return { scrollX, scrollY, nodeId: null, offsetY: 0 };
1164
+ return {
1165
+ scrollX,
1166
+ scrollY,
1167
+ nodeId: row.nodeId,
1168
+ offsetY: Math.max(0, scrollY - row.y),
1169
+ };
1170
+ }
1171
+
1172
+ #restoreScrollAnchor(anchor) {
1173
+ const row = anchor.nodeId ? this.rowModel.getRowById(anchor.nodeId) : null;
1174
+ const scrollY = row ? row.y + anchor.offsetY : anchor.scrollY;
1175
+ this.viewport.scrollTo(anchor.scrollX, scrollY);
1176
+ }
1177
+
1121
1178
  #rebuildInspectorModel(focusPath = '') {
1122
1179
  if (!this.inspector) return;
1123
1180
  const expanded = new Set(this.expansion.model.expanded);
@@ -1133,6 +1190,7 @@ export class TreeViewController {
1133
1190
 
1134
1191
  #resizeObserver = null;
1135
1192
  #nativeScroll = null;
1193
+ #nativeScrollbarSize = 0;
1136
1194
  }
1137
1195
 
1138
1196
  const NATIVE_SCROLLBAR_STYLE_ID = 'virtual-tree-canvas-native-scrollbar-style';