virtual-tree-canvas 0.3.4 → 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.4",
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",
@@ -110,19 +110,86 @@ function drawFolder(ctx, x, y, size, color) {
110
110
  }
111
111
 
112
112
  function drawAircraft(ctx, x, y, size, color) {
113
+ const cx = x + size * 0.5;
114
+ ctx.save();
115
+ ctx.strokeStyle = color;
113
116
  ctx.fillStyle = color;
117
+ ctx.lineWidth = 1.35;
118
+ ctx.lineCap = 'round';
119
+ ctx.lineJoin = 'round';
120
+
114
121
  ctx.beginPath();
115
- ctx.moveTo(x + size * 0.5, y + 1);
116
- ctx.lineTo(x + size * 0.62, y + size * 0.58);
117
- ctx.lineTo(x + size - 1, y + size * 0.72);
118
- ctx.lineTo(x + size * 0.58, y + size * 0.78);
119
- ctx.lineTo(x + size * 0.54, y + size - 1);
120
- ctx.lineTo(x + size * 0.46, y + size - 1);
121
- ctx.lineTo(x + size * 0.42, y + size * 0.78);
122
- ctx.lineTo(x + 1, y + size * 0.72);
123
- ctx.lineTo(x + size * 0.38, y + size * 0.58);
122
+ ctx.moveTo(cx, y + 1.5);
123
+ ctx.lineTo(cx + size * 0.08, y + size * 0.56);
124
+ ctx.lineTo(cx + size * 0.05, y + size - 2);
125
+ ctx.lineTo(cx - size * 0.05, y + size - 2);
126
+ ctx.lineTo(cx - size * 0.08, y + size * 0.56);
124
127
  ctx.closePath();
125
128
  ctx.fill();
129
+
130
+ ctx.beginPath();
131
+ ctx.moveTo(cx - size * 0.08, y + size * 0.54);
132
+ ctx.lineTo(x + 1.5, y + size * 0.76);
133
+ ctx.lineTo(cx - size * 0.03, y + size * 0.68);
134
+ ctx.lineTo(cx + size * 0.03, y + size * 0.68);
135
+ ctx.lineTo(x + size - 1.5, y + size * 0.76);
136
+ ctx.lineTo(cx + size * 0.08, y + size * 0.54);
137
+ ctx.stroke();
138
+
139
+ ctx.beginPath();
140
+ ctx.moveTo(cx - size * 0.05, y + size * 0.82);
141
+ ctx.lineTo(x + size * 0.25, y + size - 1.5);
142
+ ctx.moveTo(cx + size * 0.05, y + size * 0.82);
143
+ ctx.lineTo(x + size * 0.75, y + size - 1.5);
144
+ ctx.stroke();
145
+ ctx.restore();
146
+ }
147
+
148
+ function drawTask(ctx, x, y, size, color) {
149
+ ctx.save();
150
+ ctx.strokeStyle = color;
151
+ ctx.fillStyle = color;
152
+ ctx.lineWidth = 1.35;
153
+ ctx.lineCap = 'round';
154
+ ctx.lineJoin = 'round';
155
+
156
+ roundIconRect(ctx, x + 3.5, y + 2.5, size - 7, size - 5, 2);
157
+ ctx.stroke();
158
+ ctx.beginPath();
159
+ ctx.moveTo(x + size * 0.4, y + 2.5);
160
+ ctx.lineTo(x + size * 0.6, y + 2.5);
161
+ ctx.stroke();
162
+
163
+ ctx.beginPath();
164
+ ctx.moveTo(x + 5.2, y + size * 0.48);
165
+ ctx.lineTo(x + size * 0.38, y + size * 0.63);
166
+ ctx.lineTo(x + size * 0.6, y + size * 0.38);
167
+ ctx.stroke();
168
+
169
+ ctx.beginPath();
170
+ ctx.moveTo(x + size * 0.62, y + size * 0.6);
171
+ ctx.lineTo(x + size - 4.5, y + size * 0.6);
172
+ ctx.moveTo(x + 5, y + size * 0.78);
173
+ ctx.lineTo(x + size - 4.5, y + size * 0.78);
174
+ ctx.stroke();
175
+ ctx.restore();
176
+ }
177
+
178
+ function roundIconRect(ctx, x, y, width, height, radius) {
179
+ ctx.beginPath();
180
+ if (typeof ctx.roundRect === 'function') {
181
+ ctx.roundRect(x, y, width, height, radius);
182
+ return;
183
+ }
184
+ ctx.moveTo(x + radius, y);
185
+ ctx.lineTo(x + width - radius, y);
186
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
187
+ ctx.lineTo(x + width, y + height - radius);
188
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
189
+ ctx.lineTo(x + radius, y + height);
190
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
191
+ ctx.lineTo(x, y + radius);
192
+ ctx.quadraticCurveTo(x, y, x + radius, y);
126
193
  }
127
194
 
128
195
  function drawRadar(ctx, x, y, size, color) {
@@ -163,17 +230,6 @@ function drawError(ctx, x, y, size, color) {
163
230
  ctx.stroke();
164
231
  }
165
232
 
166
- function drawTask(ctx, x, y, size, color) {
167
- ctx.strokeStyle = color;
168
- ctx.lineWidth = 1.5;
169
- ctx.strokeRect(x + 3, y + 2, size - 6, size - 4);
170
- ctx.beginPath();
171
- ctx.moveTo(x + 5, y + size * 0.52);
172
- ctx.lineTo(x + size * 0.42, y + size - 5);
173
- ctx.lineTo(x + size - 5, y + 5);
174
- ctx.stroke();
175
- }
176
-
177
233
  function drawBus(ctx, x, y, size, color) {
178
234
  const left = x + size * 0.22;
179
235
  const right = x + size * 0.82;
@@ -4,20 +4,32 @@ export const darkTheme = {
4
4
  font: '12px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
5
5
  monoFont: '12px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace',
6
6
  colors: {
7
- background: '#0b1020',
8
- row: '#0b1020',
9
- rowHover: '#111827',
10
- rowSelected: '#1e3a8a',
11
- rowHighlighted: '#3b0764',
12
- text: '#e5e7eb',
13
- textMuted: '#94a3b8',
14
- guide: '#1f2937',
15
- chevron: '#94a3b8',
7
+ background: '#0a0f1c',
8
+ row: '#0a0f1c',
9
+ rowAlt: '#0c1322',
10
+ rowGroup: '#101827',
11
+ rowHover: '#111b2d',
12
+ rowActive: '#15243a',
13
+ rowSelected: '#16345f',
14
+ rowHighlighted: '#27144a',
15
+ rowFocus: '#38bdf8',
16
+ text: '#e8edf4',
17
+ textStrong: '#f8fafc',
18
+ textMuted: '#8ea0b5',
19
+ textSubtle: '#617086',
20
+ guide: '#1b2637',
21
+ chevron: '#8ea0b5',
16
22
  focus: '#38bdf8',
17
- progressTrack: '#1f2937',
18
- progressFill: '#22c55e',
23
+ progressTrack: '#182235',
24
+ progressFill: '#3dd889',
25
+ control: '#121b2b',
26
+ controlHover: '#17263a',
27
+ controlActive: '#1c3350',
28
+ badgeFill: '#132132',
19
29
  badgeText: '#ffffff',
20
- border: '#1f2937',
30
+ border: '#1b2637',
31
+ borderStrong: '#2b3b52',
32
+ shadow: 'rgba(0,0,0,.24)',
21
33
  },
22
34
  types: {
23
35
  root: { icon: 'folder', color: '#38bdf8' },
@@ -58,18 +70,30 @@ export const lightTheme = {
58
70
  colors: {
59
71
  background: '#f8fafc',
60
72
  row: '#ffffff',
61
- rowHover: '#e2e8f0',
62
- rowSelected: '#bfdbfe',
73
+ rowAlt: '#f8fafc',
74
+ rowGroup: '#f1f5f9',
75
+ rowHover: '#edf2f7',
76
+ rowActive: '#e0f2fe',
77
+ rowSelected: '#dbeafe',
63
78
  rowHighlighted: '#f3e8ff',
79
+ rowFocus: '#0284c7',
64
80
  text: '#0f172a',
81
+ textStrong: '#020617',
65
82
  textMuted: '#64748b',
83
+ textSubtle: '#94a3b8',
66
84
  guide: '#cbd5e1',
67
85
  chevron: '#64748b',
68
86
  focus: '#0284c7',
69
87
  progressTrack: '#e2e8f0',
70
88
  progressFill: '#16a34a',
89
+ control: '#ffffff',
90
+ controlHover: '#f1f5f9',
91
+ controlActive: '#e0f2fe',
71
92
  badgeText: '#ffffff',
93
+ badgeFill: '#e2e8f0',
72
94
  border: '#e2e8f0',
95
+ borderStrong: '#cbd5e1',
96
+ shadow: 'rgba(15,23,42,.1)',
73
97
  },
74
98
  };
75
99
 
@@ -78,18 +102,27 @@ export const tacticalTheme = {
78
102
  colors: {
79
103
  background: '#050806',
80
104
  row: '#050806',
105
+ rowAlt: '#07100a',
106
+ rowGroup: '#0a1710',
81
107
  rowHover: '#0d1f13',
108
+ rowActive: '#102c1a',
82
109
  rowSelected: '#12351f',
83
110
  rowHighlighted: '#372b0a',
111
+ rowFocus: '#7ddc92',
84
112
  text: '#d7ffe3',
113
+ textStrong: '#edfff2',
85
114
  textMuted: '#7fa88c',
115
+ textSubtle: '#5f806a',
86
116
  guide: '#183321',
87
117
  chevron: '#7fa88c',
88
118
  focus: '#7ddc92',
89
119
  progressTrack: '#102217',
90
120
  progressFill: '#45d483',
91
121
  badgeText: '#031006',
122
+ badgeFill: '#102217',
92
123
  border: '#183321',
124
+ borderStrong: '#295337',
125
+ shadow: 'rgba(0,0,0,.3)',
93
126
  },
94
127
  types: {
95
128
  ...darkTheme.types,
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;
@@ -71,7 +73,11 @@ export class TreeViewController {
71
73
  rowModel: any;
72
74
  expansion: any;
73
75
  selection: any;
74
- constructor(options?: Record<string, any>);
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;
@@ -89,6 +96,7 @@ export class TreeViewController {
89
96
  clearFilter(): void;
90
97
  focusNode(nodeId: string, options?: Record<string, any>): boolean;
91
98
  scrollToNode(nodeId: string, align?: TreeViewAlign): boolean;
99
+ scrollTo(x: number, y: number): void;
92
100
  getSelection(): string[];
93
101
  setSelection(ids: string[]): void;
94
102
  clearSelection(): void;
@@ -97,12 +105,12 @@ export class TreeViewController {
97
105
  }
98
106
 
99
107
  export class TreeViewInputController {
100
- constructor(options?: Record<string, any>);
108
+ constructor(options: { controller: TreeViewController; cellEditor?: CellEditorManager | null });
101
109
  destroy(): void;
102
110
  }
103
111
 
104
112
  export class CellEditorManager {
105
- constructor(options?: Record<string, any>);
113
+ constructor(options: { controller: TreeViewController; host?: HTMLElement | null });
106
114
  destroy(): void;
107
115
  close(): void;
108
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);
@@ -1,8 +1,6 @@
1
1
  import { IconRegistry } from '../core/icon-registry.js';
2
2
 
3
3
  const DISABLED_ALPHA = 0.72;
4
- const SCROLL_INDICATOR_HOLD_MS = 1600;
5
- const SCROLL_INDICATOR_FADE_MS = 700;
6
4
 
7
5
  export class TreeRowRenderer {
8
6
  constructor({ iconRegistry } = {}) {
@@ -11,8 +9,6 @@ export class TreeRowRenderer {
11
9
  this.scene = null;
12
10
  this.iconRegistry = iconRegistry ?? new IconRegistry();
13
11
  this.renderedRows = 0;
14
- this.scrollIndicatorState = null;
15
- this.scrollIndicatorVisibleUntil = 0;
16
12
  }
17
13
 
18
14
  /** @param {HTMLCanvasElement} canvas */
@@ -55,7 +51,6 @@ export class TreeRowRenderer {
55
51
  ctx.translate(viewport.renderInsetX ?? 0, viewport.renderInsetY ?? 0);
56
52
  this.#drawHeader(ctx);
57
53
  this.#drawRows(ctx);
58
- this.#drawScrollIndicator(ctx, time);
59
54
  ctx.restore();
60
55
  }
61
56
 
@@ -143,56 +138,6 @@ export class TreeRowRenderer {
143
138
  ctx.restore();
144
139
  }
145
140
 
146
- #drawScrollIndicator(ctx, time) {
147
- const { viewport, theme, rows } = this.scene;
148
- const maxY = Math.max(0, viewport.contentHeight - viewport.rowViewportHeight);
149
- if (maxY <= 0 || viewport.viewportHeight <= viewport.headerHeight + 12) return;
150
- const alpha = this.#scrollIndicatorAlpha(viewport, rows?.length ?? 0, time);
151
- if (alpha <= 0) return;
152
-
153
- const trackTop = viewport.headerHeight + 4;
154
- const trackHeight = Math.max(1, viewport.viewportHeight - viewport.headerHeight - 8);
155
- const thumbHeight = Math.max(18, Math.min(trackHeight, trackHeight * (viewport.rowViewportHeight / viewport.contentHeight)));
156
- const thumbY = trackTop + (trackHeight - thumbHeight) * (viewport.scrollY / maxY);
157
- const x = Math.max(2, viewport.viewportWidth - 4);
158
-
159
- ctx.save();
160
- ctx.lineCap = 'round';
161
- ctx.lineWidth = 2;
162
- ctx.globalAlpha = 0.28 * alpha;
163
- ctx.strokeStyle = theme.colors.guide;
164
- ctx.beginPath();
165
- ctx.moveTo(x, trackTop);
166
- ctx.lineTo(x, trackTop + trackHeight);
167
- ctx.stroke();
168
-
169
- ctx.globalAlpha = 0.9 * alpha;
170
- ctx.strokeStyle = theme.colors.focus;
171
- ctx.beginPath();
172
- ctx.moveTo(x, thumbY);
173
- ctx.lineTo(x, thumbY + thumbHeight);
174
- ctx.stroke();
175
- ctx.restore();
176
- }
177
-
178
- #scrollIndicatorAlpha(viewport, rowCount, time) {
179
- const nextState = {
180
- scrollY: viewport.scrollY,
181
- contentHeight: viewport.contentHeight,
182
- rowViewportHeight: viewport.rowViewportHeight,
183
- viewportHeight: viewport.viewportHeight,
184
- rowCount,
185
- };
186
- if (!sameScrollIndicatorState(this.scrollIndicatorState, nextState)) {
187
- this.scrollIndicatorState = nextState;
188
- this.scrollIndicatorVisibleUntil = time + SCROLL_INDICATOR_HOLD_MS;
189
- return 1;
190
- }
191
- if (time <= this.scrollIndicatorVisibleUntil) return 1;
192
- const fade = 1 - ((time - this.scrollIndicatorVisibleUntil) / SCROLL_INDICATOR_FADE_MS);
193
- return clamp01(fade);
194
- }
195
-
196
141
  #drawRow(ctx, row) {
197
142
  const { columns, nodes, dynamicState, selection, hoverNodeId, hoverPart, activeNodeId, activePart, focusNodeId, searchMatches, theme, viewport } = this.scene;
198
143
  const node = nodes[row.nodeIndex];
@@ -421,10 +366,7 @@ export class TreeRowRenderer {
421
366
  const valueWidth = Math.min(64, Math.max(42, width * 0.28));
422
367
  const gap = 8;
423
368
  const barWidth = Math.max(24, width - valueWidth - gap);
424
- ctx.fillStyle = theme.colors.progressTrack;
425
- ctx.fillRect(x, y, barWidth, 8);
426
- ctx.fillStyle = theme.colors.progressFill;
427
- ctx.fillRect(x, y, barWidth * ratio, 8);
369
+ this.#drawMeterBar(ctx, x, y + 0.5, barWidth, 7, ratio, theme.colors.progressFill, theme);
428
370
  this.#drawControlSurface(ctx, x + barWidth + gap, y - 6, valueWidth, 20, theme, {
429
371
  hovered: Boolean(state.hoveredNumber),
430
372
  active: Boolean(state.activeNumber),
@@ -495,27 +437,53 @@ export class TreeRowRenderer {
495
437
  #drawStatusCell(ctx, { rect, style, theme }) {
496
438
  const badgeWidth = Math.min(58, rect.width - 12);
497
439
  const x = rect.x + (rect.width - badgeWidth) / 2;
498
- const y = rect.y + (rect.height - 16) / 2;
499
- ctx.fillStyle = style.status.color;
500
- roundRect(ctx, x, y, badgeWidth, 16, 8);
440
+ const y = rect.y + (rect.height - 17) / 2;
441
+ const statusColor = style.status.color;
442
+ const baseFill = theme.colors.badgeFill ?? theme.colors.progressTrack;
443
+ ctx.fillStyle = mixColor(baseFill, statusColor, 0.18);
444
+ roundRect(ctx, x, y, badgeWidth, 17, 8);
501
445
  ctx.fill();
502
- ctx.fillStyle = theme.colors.badgeText;
446
+ ctx.strokeStyle = mixColor(theme.colors.border, statusColor, 0.42);
447
+ ctx.stroke();
448
+ ctx.fillStyle = mixColor(theme.colors.text, statusColor, 0.36);
503
449
  ctx.font = '10px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace';
504
450
  ctx.textAlign = 'center';
505
451
  ctx.textBaseline = 'middle';
506
- drawTruncatedText(ctx, style.status.label, x + badgeWidth / 2, y + 8, badgeWidth - 8);
452
+ drawTruncatedText(ctx, style.status.label, x + badgeWidth / 2, y + 8.5, badgeWidth - 8);
507
453
  ctx.textAlign = 'left';
508
454
  }
509
455
 
510
456
  #drawProgressCell(ctx, { state, rect, theme, style }) {
511
457
  if (state.progress === undefined) return this.#drawTextCell(ctx, { state, rect, theme, style, column: { align: 'right', value: () => '' }, node: {} });
458
+ const ratio = clamp01(state.progress);
512
459
  const barX = rect.x + 10;
513
- const barY = rect.y + (rect.height - 8) / 2;
460
+ const barHeight = 7;
461
+ const barY = rect.y + (rect.height - barHeight) / 2;
514
462
  const barWidth = Math.max(20, rect.width - 20);
463
+ const fillColor = state.color ?? theme.colors.progressFill;
464
+ this.#drawMeterBar(ctx, barX, barY, barWidth, barHeight, ratio, fillColor, theme);
465
+ }
466
+
467
+ #drawMeterBar(ctx, x, y, width, height, ratio, fillColor, theme) {
468
+ const radius = height / 2;
515
469
  ctx.fillStyle = theme.colors.progressTrack;
516
- ctx.fillRect(barX, barY, barWidth, 8);
517
- ctx.fillStyle = state.color ?? theme.colors.progressFill;
518
- ctx.fillRect(barX, barY, barWidth * clamp01(state.progress), 8);
470
+ roundRect(ctx, x, y, width, height, radius);
471
+ ctx.fill();
472
+ ctx.strokeStyle = mixColor(theme.colors.border, fillColor, 0.22);
473
+ ctx.stroke();
474
+
475
+ const fillWidth = width * clamp01(ratio);
476
+ if (fillWidth <= 0) return;
477
+ ctx.save();
478
+ roundRect(ctx, x, y, width, height, radius);
479
+ ctx.clip();
480
+ ctx.fillStyle = fillColor;
481
+ roundRect(ctx, x, y, Math.max(1, fillWidth), height, Math.min(radius, fillWidth / 2));
482
+ ctx.fill();
483
+ ctx.globalAlpha = 0.28;
484
+ ctx.fillStyle = mixColor(fillColor, '#ffffff', 0.38);
485
+ ctx.fillRect(x + 1, y + 1, Math.max(0, fillWidth - 2), 1);
486
+ ctx.restore();
519
487
  }
520
488
 
521
489
  #drawTextCell(ctx, { node, state, rect, column, theme }) {
@@ -578,15 +546,6 @@ function resolveNodeStyle(theme, node, state = {}) {
578
546
  };
579
547
  }
580
548
 
581
- function sameScrollIndicatorState(a, b) {
582
- return Boolean(a) &&
583
- a.scrollY === b.scrollY &&
584
- a.contentHeight === b.contentHeight &&
585
- a.rowViewportHeight === b.rowViewportHeight &&
586
- a.viewportHeight === b.viewportHeight &&
587
- a.rowCount === b.rowCount;
588
- }
589
-
590
549
  function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
591
550
  const safeWidth = Math.max(1, width);
592
551
  const rightPadding = 14;
@@ -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 {
@@ -40,6 +42,7 @@ export class TreeViewController {
40
42
  this.themeManager = options.themeManager ?? new ThemeManager();
41
43
  this.iconRegistry = options.iconRegistry ?? new IconRegistry();
42
44
  this.renderer = options.renderer ?? new TreeRowRenderer({ themeManager: this.themeManager, iconRegistry: this.iconRegistry });
45
+ this.nativeScrollbars = options.nativeScrollbars !== false;
43
46
  this.initialExpandDepth = options.initialExpandDepth ?? 1;
44
47
  this.searchHighlights = new Set();
45
48
  this.filterQuery = '';
@@ -57,20 +60,61 @@ export class TreeViewController {
57
60
  this.workerRevision = 0;
58
61
  this.sortValueSnapshot = null;
59
62
  this.inspector = null;
63
+ this.inputController = null;
64
+ this.cellEditor = null;
60
65
  this.scene = this.createRenderScene();
61
66
 
62
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();
63
70
  }
64
71
 
65
72
  initialize(canvas) {
73
+ if (!canvas || typeof canvas.getContext !== 'function') {
74
+ throw new TypeError('TreeViewController.initialize requires an HTMLCanvasElement');
75
+ }
66
76
  this.canvas = canvas;
67
77
  this.renderer.initialize(canvas);
68
78
  this.renderer.setScene(this.scene);
69
79
  this.#resizeToCanvasClientSize();
70
80
  this.#observeCanvasSize();
81
+ this.#setupNativeScrollbars();
71
82
  return this;
72
83
  }
73
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
+
74
118
  on(type, listener) {
75
119
  return this.events.on(type, listener);
76
120
  }
@@ -281,9 +325,28 @@ export class TreeViewController {
281
325
  if (beforeRowHeight !== nextTheme.rowHeight || beforeIndentWidth !== nextTheme.indentWidth) {
282
326
  this.#rebuildRows();
283
327
  }
328
+ this.#nativeScroll?.applyTheme?.(nextTheme);
284
329
  this.events.emit('themechange', { theme: nextTheme });
285
330
  }
286
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
+
287
350
  registerIcon(name, imageOrUrl) {
288
351
  return this.iconRegistry.register(name, imageOrUrl);
289
352
  }
@@ -582,6 +645,11 @@ export class TreeViewController {
582
645
  this.events.emit('viewportchange', this.getViewportState());
583
646
  }
584
647
 
648
+ scrollTo(x, y) {
649
+ this.viewport.scrollTo(x, y);
650
+ this.events.emit('viewportchange', this.getViewportState());
651
+ }
652
+
585
653
  render(time = performance.now()) {
586
654
  this.renderMeasured(time);
587
655
  }
@@ -876,6 +944,132 @@ export class TreeViewController {
876
944
  if (width > 0 && height > 0) this.resize(width, height);
877
945
  }
878
946
 
947
+ #setupNativeScrollbars() {
948
+ if (!this.nativeScrollbars || !this.canvas || typeof document === 'undefined') return;
949
+ const host = this.canvas.parentElement;
950
+ if (!host) return;
951
+
952
+ this.#nativeScroll?.destroy?.();
953
+ ensureNativeScrollbarStyles(document);
954
+
955
+ const previousHostPosition = host.style.position;
956
+ let hostPositionChanged = false;
957
+ if (typeof getComputedStyle === 'function' && getComputedStyle(host).position === 'static') {
958
+ host.style.position = 'relative';
959
+ hostPositionChanged = true;
960
+ }
961
+
962
+ const vertical = document.createElement('div');
963
+ const verticalSpacer = document.createElement('div');
964
+ const horizontal = document.createElement('div');
965
+ const horizontalSpacer = document.createElement('div');
966
+ const corner = document.createElement('div');
967
+
968
+ vertical.className = 'virtual-tree-canvas-scrollbar virtual-tree-canvas-scrollbar-y';
969
+ horizontal.className = 'virtual-tree-canvas-scrollbar virtual-tree-canvas-scrollbar-x';
970
+ corner.className = 'virtual-tree-canvas-scrollbar-corner';
971
+ vertical.appendChild(verticalSpacer);
972
+ horizontal.appendChild(horizontalSpacer);
973
+ applyNativeScrollbarTheme([vertical, horizontal, corner], this.themeManager.get());
974
+
975
+ const size = nativeScrollbarSize();
976
+ Object.assign(vertical.style, {
977
+ position: 'absolute',
978
+ top: `${this.viewport.headerHeight}px`,
979
+ right: '0',
980
+ bottom: '0',
981
+ width: `${size}px`,
982
+ overflowX: 'hidden',
983
+ overflowY: 'auto',
984
+ zIndex: '4',
985
+ });
986
+ Object.assign(horizontal.style, {
987
+ position: 'absolute',
988
+ left: '0',
989
+ right: '0',
990
+ bottom: '0',
991
+ height: `${size}px`,
992
+ overflowX: 'auto',
993
+ overflowY: 'hidden',
994
+ zIndex: '4',
995
+ });
996
+ Object.assign(corner.style, {
997
+ position: 'absolute',
998
+ right: '0',
999
+ bottom: '0',
1000
+ width: `${size}px`,
1001
+ height: `${size}px`,
1002
+ zIndex: '4',
1003
+ background: 'transparent',
1004
+ pointerEvents: 'none',
1005
+ });
1006
+ Object.assign(verticalSpacer.style, {
1007
+ width: '1px',
1008
+ minHeight: '1px',
1009
+ });
1010
+ Object.assign(horizontalSpacer.style, {
1011
+ height: '1px',
1012
+ minWidth: '1px',
1013
+ });
1014
+
1015
+ host.appendChild(vertical);
1016
+ host.appendChild(horizontal);
1017
+ host.appendChild(corner);
1018
+
1019
+ let syncing = false;
1020
+ const sync = () => {
1021
+ const maxX = Math.max(0, this.viewport.contentWidth - this.viewport.viewportWidth);
1022
+ const maxY = Math.max(0, this.viewport.contentHeight - this.viewport.rowViewportHeight);
1023
+ const showX = maxX > 0;
1024
+ const showY = maxY > 0;
1025
+
1026
+ vertical.style.display = showY ? 'block' : 'none';
1027
+ horizontal.style.display = showX ? 'block' : 'none';
1028
+ 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`;
1032
+
1033
+ verticalSpacer.style.height = `${Math.ceil(maxY + vertical.clientHeight)}px`;
1034
+ horizontalSpacer.style.width = `${Math.ceil(maxX + horizontal.clientWidth)}px`;
1035
+
1036
+ syncing = true;
1037
+ vertical.scrollTop = this.viewport.scrollY;
1038
+ horizontal.scrollLeft = this.viewport.scrollX;
1039
+ syncing = false;
1040
+ };
1041
+ const onVerticalScroll = () => {
1042
+ if (syncing) return;
1043
+ this.viewport.scrollTo(this.viewport.scrollX, vertical.scrollTop);
1044
+ this.events.emit('viewportchange', this.getViewportState());
1045
+ };
1046
+ const onHorizontalScroll = () => {
1047
+ if (syncing) return;
1048
+ this.viewport.scrollTo(horizontal.scrollLeft, this.viewport.scrollY);
1049
+ this.events.emit('viewportchange', this.getViewportState());
1050
+ };
1051
+ const onViewportChange = () => sync();
1052
+
1053
+ vertical.addEventListener('scroll', onVerticalScroll, { passive: true });
1054
+ horizontal.addEventListener('scroll', onHorizontalScroll, { passive: true });
1055
+ this.viewport.addEventListener('change', onViewportChange);
1056
+ sync();
1057
+
1058
+ this.#nativeScroll = {
1059
+ sync,
1060
+ applyTheme: (theme) => applyNativeScrollbarTheme([vertical, horizontal, corner], theme),
1061
+ destroy: () => {
1062
+ vertical.removeEventListener('scroll', onVerticalScroll);
1063
+ horizontal.removeEventListener('scroll', onHorizontalScroll);
1064
+ this.viewport.removeEventListener('change', onViewportChange);
1065
+ vertical.remove();
1066
+ horizontal.remove();
1067
+ corner.remove();
1068
+ if (hostPositionChanged) host.style.position = previousHostPosition;
1069
+ },
1070
+ };
1071
+ }
1072
+
879
1073
  #computeInspectorPaneLabelEnd(visibleRange) {
880
1074
  const column = this.columnModel.columns.find((item) => item.kind === 'inspectorPane');
881
1075
  if (!column) return 0;
@@ -938,6 +1132,145 @@ export class TreeViewController {
938
1132
  }
939
1133
 
940
1134
  #resizeObserver = null;
1135
+ #nativeScroll = null;
1136
+ }
1137
+
1138
+ const NATIVE_SCROLLBAR_STYLE_ID = 'virtual-tree-canvas-native-scrollbar-style';
1139
+
1140
+ function ensureNativeScrollbarStyles(doc) {
1141
+ if (!doc || typeof doc.createElement !== 'function') return;
1142
+ if (typeof doc.getElementById === 'function' && doc.getElementById(NATIVE_SCROLLBAR_STYLE_ID)) return;
1143
+ const style = doc.createElement('style');
1144
+ style.id = NATIVE_SCROLLBAR_STYLE_ID;
1145
+ style.textContent = `
1146
+ .virtual-tree-canvas-scrollbar {
1147
+ --vtc-scrollbar-size: 12px;
1148
+ --vtc-scrollbar-track: rgba(15, 23, 42, 0.32);
1149
+ --vtc-scrollbar-thumb: rgba(148, 163, 184, 0.58);
1150
+ --vtc-scrollbar-thumb-hover: rgba(148, 163, 184, 0.78);
1151
+ --vtc-scrollbar-thumb-active: rgba(56, 189, 248, 0.76);
1152
+ --vtc-scrollbar-thumb-border: rgba(255, 255, 255, 0.08);
1153
+ background: var(--vtc-scrollbar-track);
1154
+ scrollbar-color: var(--vtc-scrollbar-thumb) var(--vtc-scrollbar-track);
1155
+ scrollbar-width: thin;
1156
+ }
1157
+
1158
+ .virtual-tree-canvas-scrollbar:hover {
1159
+ scrollbar-color: var(--vtc-scrollbar-thumb-hover) var(--vtc-scrollbar-track);
1160
+ }
1161
+
1162
+ .virtual-tree-canvas-scrollbar::-webkit-scrollbar {
1163
+ width: var(--vtc-scrollbar-size);
1164
+ height: var(--vtc-scrollbar-size);
1165
+ }
1166
+
1167
+ .virtual-tree-canvas-scrollbar::-webkit-scrollbar-track {
1168
+ background: var(--vtc-scrollbar-track);
1169
+ }
1170
+
1171
+ .virtual-tree-canvas-scrollbar::-webkit-scrollbar-thumb {
1172
+ min-width: 28px;
1173
+ min-height: 28px;
1174
+ border: 3px solid transparent;
1175
+ border-radius: 999px;
1176
+ background:
1177
+ linear-gradient(180deg, var(--vtc-scrollbar-thumb-highlight), transparent 52%),
1178
+ var(--vtc-scrollbar-thumb);
1179
+ background-clip: content-box;
1180
+ box-shadow: inset 0 0 0 1px var(--vtc-scrollbar-thumb-border);
1181
+ }
1182
+
1183
+ .virtual-tree-canvas-scrollbar:hover::-webkit-scrollbar-thumb {
1184
+ background:
1185
+ linear-gradient(180deg, var(--vtc-scrollbar-thumb-highlight), transparent 52%),
1186
+ var(--vtc-scrollbar-thumb-hover);
1187
+ background-clip: content-box;
1188
+ }
1189
+
1190
+ .virtual-tree-canvas-scrollbar::-webkit-scrollbar-thumb:active {
1191
+ background:
1192
+ linear-gradient(180deg, var(--vtc-scrollbar-thumb-highlight), transparent 52%),
1193
+ var(--vtc-scrollbar-thumb-active);
1194
+ background-clip: content-box;
1195
+ }
1196
+
1197
+ .virtual-tree-canvas-scrollbar::-webkit-scrollbar-corner,
1198
+ .virtual-tree-canvas-scrollbar-corner {
1199
+ background: var(--vtc-scrollbar-track);
1200
+ }
1201
+ `;
1202
+ const target = doc.head ?? doc.body ?? doc.documentElement;
1203
+ target?.appendChild?.(style);
1204
+ }
1205
+
1206
+ function applyNativeScrollbarTheme(elements, theme) {
1207
+ const colors = theme?.colors ?? {};
1208
+ const track = alphaHex(colors.row ?? colors.background ?? '#0b1020', 0.74);
1209
+ const thumb = mixHex(colors.borderStrong ?? colors.border ?? '#334155', colors.textMuted ?? colors.focus ?? '#94a3b8', 0.55);
1210
+ const hover = mixHex(thumb, colors.focus ?? '#38bdf8', 0.32);
1211
+ const active = mixHex(thumb, colors.focus ?? '#38bdf8', 0.52);
1212
+ const highlight = alphaHex('#ffffff', 0.2);
1213
+ const border = alphaHex('#ffffff', 0.09);
1214
+ for (const element of elements) {
1215
+ if (!element?.style?.setProperty) continue;
1216
+ element.style.setProperty('--vtc-scrollbar-track', track);
1217
+ element.style.setProperty('--vtc-scrollbar-thumb', thumb);
1218
+ element.style.setProperty('--vtc-scrollbar-thumb-hover', hover);
1219
+ element.style.setProperty('--vtc-scrollbar-thumb-active', active);
1220
+ element.style.setProperty('--vtc-scrollbar-thumb-highlight', highlight);
1221
+ element.style.setProperty('--vtc-scrollbar-thumb-border', border);
1222
+ }
1223
+ }
1224
+
1225
+ function mixHex(a, b, amount) {
1226
+ const from = parseHexColor(a);
1227
+ const to = parseHexColor(b);
1228
+ if (!from || !to) return amount >= 0.5 ? b : a;
1229
+ const t = Math.max(0, Math.min(1, amount));
1230
+ const value = from.map((channel, index) => Math.round(channel + (to[index] - channel) * t));
1231
+ return `rgb(${value[0]}, ${value[1]}, ${value[2]})`;
1232
+ }
1233
+
1234
+ function alphaHex(value, alpha) {
1235
+ const color = parseHexColor(value);
1236
+ if (!color) return value;
1237
+ return `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${Math.max(0, Math.min(1, alpha))})`;
1238
+ }
1239
+
1240
+ function parseHexColor(value) {
1241
+ const text = String(value ?? '').trim();
1242
+ const match = /^#([0-9a-f]{6})$/i.exec(text);
1243
+ if (!match) return null;
1244
+ const number = Number.parseInt(match[1], 16);
1245
+ return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
1246
+ }
1247
+
1248
+ function nativeScrollbarSize() {
1249
+ if (typeof document === 'undefined') return 14;
1250
+ const probe = document.createElement('div');
1251
+ Object.assign(probe.style, {
1252
+ position: 'absolute',
1253
+ top: '-9999px',
1254
+ width: '100px',
1255
+ height: '100px',
1256
+ overflow: 'scroll',
1257
+ });
1258
+ document.body?.appendChild(probe);
1259
+ const size = Math.max(10, probe.offsetWidth - probe.clientWidth || 14);
1260
+ probe.remove();
1261
+ return size;
1262
+ }
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
+ }
941
1274
  }
942
1275
 
943
1276
  function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {