virtual-tree-canvas 0.3.3 → 0.3.5

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.3",
3
+ "version": "0.3.5",
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
@@ -71,7 +71,7 @@ export class TreeViewController {
71
71
  rowModel: any;
72
72
  expansion: any;
73
73
  selection: any;
74
- constructor(options?: Record<string, any>);
74
+ constructor(options?: Record<string, any> & { nativeScrollbars?: boolean });
75
75
  on(type: string, listener: (event: any) => void): any;
76
76
  off(type: string, listener: (event: any) => void): void;
77
77
  setData(nodes: TreeNode[]): void;
@@ -89,6 +89,7 @@ export class TreeViewController {
89
89
  clearFilter(): void;
90
90
  focusNode(nodeId: string, options?: Record<string, any>): boolean;
91
91
  scrollToNode(nodeId: string, align?: TreeViewAlign): boolean;
92
+ scrollTo(x: number, y: number): void;
92
93
  getSelection(): string[];
93
94
  setSelection(ids: string[]): void;
94
95
  clearSelection(): void;
@@ -101,7 +101,10 @@ export class CellEditorManager {
101
101
  padding: data.editorType === 'color' ? '0 2px' : data.editorType === 'select' ? '0 22px 0 8px' : '0 8px',
102
102
  textAlign: data.editorType === 'number' || data.editorType === 'range' ? 'right' : 'left',
103
103
  });
104
+ let committed = false;
104
105
  const commit = () => {
106
+ if (committed) return;
107
+ committed = true;
105
108
  const nextValue = parseEditorValue(element, data);
106
109
  this.controller.updateInspectorValue(node.id, nextValue, data.editorType);
107
110
  this.#removeOverlay();
@@ -110,6 +113,9 @@ export class CellEditorManager {
110
113
  if (event.key === 'Enter') commit();
111
114
  else if (event.key === 'Escape') this.#removeOverlay();
112
115
  });
116
+ if (data.editorType === 'select') {
117
+ element.addEventListener('change', commit);
118
+ }
113
119
  element.addEventListener('blur', commit);
114
120
  ensureOverlayHost(this.host);
115
121
  this.host.append(element);
@@ -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;
@@ -40,6 +40,7 @@ export class TreeViewController {
40
40
  this.themeManager = options.themeManager ?? new ThemeManager();
41
41
  this.iconRegistry = options.iconRegistry ?? new IconRegistry();
42
42
  this.renderer = options.renderer ?? new TreeRowRenderer({ themeManager: this.themeManager, iconRegistry: this.iconRegistry });
43
+ this.nativeScrollbars = options.nativeScrollbars !== false;
43
44
  this.initialExpandDepth = options.initialExpandDepth ?? 1;
44
45
  this.searchHighlights = new Set();
45
46
  this.filterQuery = '';
@@ -68,6 +69,7 @@ export class TreeViewController {
68
69
  this.renderer.setScene(this.scene);
69
70
  this.#resizeToCanvasClientSize();
70
71
  this.#observeCanvasSize();
72
+ this.#setupNativeScrollbars();
71
73
  return this;
72
74
  }
73
75
 
@@ -281,6 +283,7 @@ export class TreeViewController {
281
283
  if (beforeRowHeight !== nextTheme.rowHeight || beforeIndentWidth !== nextTheme.indentWidth) {
282
284
  this.#rebuildRows();
283
285
  }
286
+ this.#nativeScroll?.applyTheme?.(nextTheme);
284
287
  this.events.emit('themechange', { theme: nextTheme });
285
288
  }
286
289
 
@@ -582,6 +585,11 @@ export class TreeViewController {
582
585
  this.events.emit('viewportchange', this.getViewportState());
583
586
  }
584
587
 
588
+ scrollTo(x, y) {
589
+ this.viewport.scrollTo(x, y);
590
+ this.events.emit('viewportchange', this.getViewportState());
591
+ }
592
+
585
593
  render(time = performance.now()) {
586
594
  this.renderMeasured(time);
587
595
  }
@@ -876,6 +884,132 @@ export class TreeViewController {
876
884
  if (width > 0 && height > 0) this.resize(width, height);
877
885
  }
878
886
 
887
+ #setupNativeScrollbars() {
888
+ if (!this.nativeScrollbars || !this.canvas || typeof document === 'undefined') return;
889
+ const host = this.canvas.parentElement;
890
+ if (!host) return;
891
+
892
+ this.#nativeScroll?.destroy?.();
893
+ ensureNativeScrollbarStyles(document);
894
+
895
+ const previousHostPosition = host.style.position;
896
+ let hostPositionChanged = false;
897
+ if (typeof getComputedStyle === 'function' && getComputedStyle(host).position === 'static') {
898
+ host.style.position = 'relative';
899
+ hostPositionChanged = true;
900
+ }
901
+
902
+ const vertical = document.createElement('div');
903
+ const verticalSpacer = document.createElement('div');
904
+ const horizontal = document.createElement('div');
905
+ const horizontalSpacer = document.createElement('div');
906
+ const corner = document.createElement('div');
907
+
908
+ vertical.className = 'virtual-tree-canvas-scrollbar virtual-tree-canvas-scrollbar-y';
909
+ horizontal.className = 'virtual-tree-canvas-scrollbar virtual-tree-canvas-scrollbar-x';
910
+ corner.className = 'virtual-tree-canvas-scrollbar-corner';
911
+ vertical.appendChild(verticalSpacer);
912
+ horizontal.appendChild(horizontalSpacer);
913
+ applyNativeScrollbarTheme([vertical, horizontal, corner], this.themeManager.get());
914
+
915
+ const size = nativeScrollbarSize();
916
+ Object.assign(vertical.style, {
917
+ position: 'absolute',
918
+ top: `${this.viewport.headerHeight}px`,
919
+ right: '0',
920
+ bottom: '0',
921
+ width: `${size}px`,
922
+ overflowX: 'hidden',
923
+ overflowY: 'auto',
924
+ zIndex: '4',
925
+ });
926
+ Object.assign(horizontal.style, {
927
+ position: 'absolute',
928
+ left: '0',
929
+ right: '0',
930
+ bottom: '0',
931
+ height: `${size}px`,
932
+ overflowX: 'auto',
933
+ overflowY: 'hidden',
934
+ zIndex: '4',
935
+ });
936
+ Object.assign(corner.style, {
937
+ position: 'absolute',
938
+ right: '0',
939
+ bottom: '0',
940
+ width: `${size}px`,
941
+ height: `${size}px`,
942
+ zIndex: '4',
943
+ background: 'transparent',
944
+ pointerEvents: 'none',
945
+ });
946
+ Object.assign(verticalSpacer.style, {
947
+ width: '1px',
948
+ minHeight: '1px',
949
+ });
950
+ Object.assign(horizontalSpacer.style, {
951
+ height: '1px',
952
+ minWidth: '1px',
953
+ });
954
+
955
+ host.appendChild(vertical);
956
+ host.appendChild(horizontal);
957
+ host.appendChild(corner);
958
+
959
+ let syncing = false;
960
+ const sync = () => {
961
+ const maxX = Math.max(0, this.viewport.contentWidth - this.viewport.viewportWidth);
962
+ const maxY = Math.max(0, this.viewport.contentHeight - this.viewport.rowViewportHeight);
963
+ const showX = maxX > 0;
964
+ const showY = maxY > 0;
965
+
966
+ vertical.style.display = showY ? 'block' : 'none';
967
+ horizontal.style.display = showX ? 'block' : 'none';
968
+ 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`;
972
+
973
+ verticalSpacer.style.height = `${Math.ceil(maxY + vertical.clientHeight)}px`;
974
+ horizontalSpacer.style.width = `${Math.ceil(maxX + horizontal.clientWidth)}px`;
975
+
976
+ syncing = true;
977
+ vertical.scrollTop = this.viewport.scrollY;
978
+ horizontal.scrollLeft = this.viewport.scrollX;
979
+ syncing = false;
980
+ };
981
+ const onVerticalScroll = () => {
982
+ if (syncing) return;
983
+ this.viewport.scrollTo(this.viewport.scrollX, vertical.scrollTop);
984
+ this.events.emit('viewportchange', this.getViewportState());
985
+ };
986
+ const onHorizontalScroll = () => {
987
+ if (syncing) return;
988
+ this.viewport.scrollTo(horizontal.scrollLeft, this.viewport.scrollY);
989
+ this.events.emit('viewportchange', this.getViewportState());
990
+ };
991
+ const onViewportChange = () => sync();
992
+
993
+ vertical.addEventListener('scroll', onVerticalScroll, { passive: true });
994
+ horizontal.addEventListener('scroll', onHorizontalScroll, { passive: true });
995
+ this.viewport.addEventListener('change', onViewportChange);
996
+ sync();
997
+
998
+ this.#nativeScroll = {
999
+ sync,
1000
+ applyTheme: (theme) => applyNativeScrollbarTheme([vertical, horizontal, corner], theme),
1001
+ destroy: () => {
1002
+ vertical.removeEventListener('scroll', onVerticalScroll);
1003
+ horizontal.removeEventListener('scroll', onHorizontalScroll);
1004
+ this.viewport.removeEventListener('change', onViewportChange);
1005
+ vertical.remove();
1006
+ horizontal.remove();
1007
+ corner.remove();
1008
+ if (hostPositionChanged) host.style.position = previousHostPosition;
1009
+ },
1010
+ };
1011
+ }
1012
+
879
1013
  #computeInspectorPaneLabelEnd(visibleRange) {
880
1014
  const column = this.columnModel.columns.find((item) => item.kind === 'inspectorPane');
881
1015
  if (!column) return 0;
@@ -938,6 +1072,133 @@ export class TreeViewController {
938
1072
  }
939
1073
 
940
1074
  #resizeObserver = null;
1075
+ #nativeScroll = null;
1076
+ }
1077
+
1078
+ const NATIVE_SCROLLBAR_STYLE_ID = 'virtual-tree-canvas-native-scrollbar-style';
1079
+
1080
+ function ensureNativeScrollbarStyles(doc) {
1081
+ if (!doc || typeof doc.createElement !== 'function') return;
1082
+ if (typeof doc.getElementById === 'function' && doc.getElementById(NATIVE_SCROLLBAR_STYLE_ID)) return;
1083
+ const style = doc.createElement('style');
1084
+ style.id = NATIVE_SCROLLBAR_STYLE_ID;
1085
+ style.textContent = `
1086
+ .virtual-tree-canvas-scrollbar {
1087
+ --vtc-scrollbar-size: 12px;
1088
+ --vtc-scrollbar-track: rgba(15, 23, 42, 0.32);
1089
+ --vtc-scrollbar-thumb: rgba(148, 163, 184, 0.58);
1090
+ --vtc-scrollbar-thumb-hover: rgba(148, 163, 184, 0.78);
1091
+ --vtc-scrollbar-thumb-active: rgba(56, 189, 248, 0.76);
1092
+ --vtc-scrollbar-thumb-border: rgba(255, 255, 255, 0.08);
1093
+ background: var(--vtc-scrollbar-track);
1094
+ scrollbar-color: var(--vtc-scrollbar-thumb) var(--vtc-scrollbar-track);
1095
+ scrollbar-width: thin;
1096
+ }
1097
+
1098
+ .virtual-tree-canvas-scrollbar:hover {
1099
+ scrollbar-color: var(--vtc-scrollbar-thumb-hover) var(--vtc-scrollbar-track);
1100
+ }
1101
+
1102
+ .virtual-tree-canvas-scrollbar::-webkit-scrollbar {
1103
+ width: var(--vtc-scrollbar-size);
1104
+ height: var(--vtc-scrollbar-size);
1105
+ }
1106
+
1107
+ .virtual-tree-canvas-scrollbar::-webkit-scrollbar-track {
1108
+ background: var(--vtc-scrollbar-track);
1109
+ }
1110
+
1111
+ .virtual-tree-canvas-scrollbar::-webkit-scrollbar-thumb {
1112
+ min-width: 28px;
1113
+ min-height: 28px;
1114
+ border: 3px solid transparent;
1115
+ border-radius: 999px;
1116
+ background:
1117
+ linear-gradient(180deg, var(--vtc-scrollbar-thumb-highlight), transparent 52%),
1118
+ var(--vtc-scrollbar-thumb);
1119
+ background-clip: content-box;
1120
+ box-shadow: inset 0 0 0 1px var(--vtc-scrollbar-thumb-border);
1121
+ }
1122
+
1123
+ .virtual-tree-canvas-scrollbar:hover::-webkit-scrollbar-thumb {
1124
+ background:
1125
+ linear-gradient(180deg, var(--vtc-scrollbar-thumb-highlight), transparent 52%),
1126
+ var(--vtc-scrollbar-thumb-hover);
1127
+ background-clip: content-box;
1128
+ }
1129
+
1130
+ .virtual-tree-canvas-scrollbar::-webkit-scrollbar-thumb:active {
1131
+ background:
1132
+ linear-gradient(180deg, var(--vtc-scrollbar-thumb-highlight), transparent 52%),
1133
+ var(--vtc-scrollbar-thumb-active);
1134
+ background-clip: content-box;
1135
+ }
1136
+
1137
+ .virtual-tree-canvas-scrollbar::-webkit-scrollbar-corner,
1138
+ .virtual-tree-canvas-scrollbar-corner {
1139
+ background: var(--vtc-scrollbar-track);
1140
+ }
1141
+ `;
1142
+ const target = doc.head ?? doc.body ?? doc.documentElement;
1143
+ target?.appendChild?.(style);
1144
+ }
1145
+
1146
+ function applyNativeScrollbarTheme(elements, theme) {
1147
+ const colors = theme?.colors ?? {};
1148
+ const track = alphaHex(colors.row ?? colors.background ?? '#0b1020', 0.74);
1149
+ const thumb = mixHex(colors.borderStrong ?? colors.border ?? '#334155', colors.textMuted ?? colors.focus ?? '#94a3b8', 0.55);
1150
+ const hover = mixHex(thumb, colors.focus ?? '#38bdf8', 0.32);
1151
+ const active = mixHex(thumb, colors.focus ?? '#38bdf8', 0.52);
1152
+ const highlight = alphaHex('#ffffff', 0.2);
1153
+ const border = alphaHex('#ffffff', 0.09);
1154
+ for (const element of elements) {
1155
+ if (!element?.style?.setProperty) continue;
1156
+ element.style.setProperty('--vtc-scrollbar-track', track);
1157
+ element.style.setProperty('--vtc-scrollbar-thumb', thumb);
1158
+ element.style.setProperty('--vtc-scrollbar-thumb-hover', hover);
1159
+ element.style.setProperty('--vtc-scrollbar-thumb-active', active);
1160
+ element.style.setProperty('--vtc-scrollbar-thumb-highlight', highlight);
1161
+ element.style.setProperty('--vtc-scrollbar-thumb-border', border);
1162
+ }
1163
+ }
1164
+
1165
+ function mixHex(a, b, amount) {
1166
+ const from = parseHexColor(a);
1167
+ const to = parseHexColor(b);
1168
+ if (!from || !to) return amount >= 0.5 ? b : a;
1169
+ const t = Math.max(0, Math.min(1, amount));
1170
+ const value = from.map((channel, index) => Math.round(channel + (to[index] - channel) * t));
1171
+ return `rgb(${value[0]}, ${value[1]}, ${value[2]})`;
1172
+ }
1173
+
1174
+ function alphaHex(value, alpha) {
1175
+ const color = parseHexColor(value);
1176
+ if (!color) return value;
1177
+ return `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${Math.max(0, Math.min(1, alpha))})`;
1178
+ }
1179
+
1180
+ function parseHexColor(value) {
1181
+ const text = String(value ?? '').trim();
1182
+ const match = /^#([0-9a-f]{6})$/i.exec(text);
1183
+ if (!match) return null;
1184
+ const number = Number.parseInt(match[1], 16);
1185
+ return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
1186
+ }
1187
+
1188
+ function nativeScrollbarSize() {
1189
+ if (typeof document === 'undefined') return 14;
1190
+ const probe = document.createElement('div');
1191
+ Object.assign(probe.style, {
1192
+ position: 'absolute',
1193
+ top: '-9999px',
1194
+ width: '100px',
1195
+ height: '100px',
1196
+ overflow: 'scroll',
1197
+ });
1198
+ document.body?.appendChild(probe);
1199
+ const size = Math.max(10, probe.offsetWidth - probe.clientWidth || 14);
1200
+ probe.remove();
1201
+ return size;
941
1202
  }
942
1203
 
943
1204
  function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {