virtual-tree-canvas 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -131,7 +131,8 @@ tree.setModel(model, meta, {
131
131
  presentation: 'pane',
132
132
  flatRoot: true, // render root properties directly
133
133
  enforceMeta: true, // fields without metadata are readonly/disabled
134
- filter: true // use the header as a filter input
134
+ filter: true, // use the header as a filter input
135
+ markUpdated: false // do not show update dots for local user edits
135
136
  });
136
137
  ```
137
138
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virtual-tree-canvas",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "scripts": {
31
31
  "demo": "python3 -m http.server 4173 --bind localhost",
32
- "test": "node --test test/tree-view-controller.test.js test/theme-icons.test.js test/tree-columns.test.js test/benchmark.test.js test/tree-worker-operations.test.js test/model-inspector.test.js"
32
+ "test": "node --test test/tree-view-controller.test.js test/theme-icons.test.js test/tree-columns.test.js test/benchmark.test.js test/tree-worker-operations.test.js test/model-inspector.test.js test/dynamic-state.test.js"
33
33
  },
34
34
  "keywords": [
35
35
  "tree",
@@ -0,0 +1,47 @@
1
+ const hasOwn = Object.prototype.hasOwnProperty;
2
+
3
+ const knownDynamicStateKeys = new Set([
4
+ 'value',
5
+ 'status',
6
+ 'progress',
7
+ 'pulse',
8
+ 'color',
9
+ 'selected',
10
+ 'highlighted',
11
+ 'visible',
12
+ 'updated',
13
+ 'updatedAt',
14
+ 'icon',
15
+ ]);
16
+
17
+ /**
18
+ * @param {import('./types.js').NodeDynamicState} target
19
+ * @param {import('./types.js').NodeDynamicState} state
20
+ */
21
+ export function mergeDynamicState(target, state) {
22
+ if (!state) return target;
23
+
24
+ if (hasOwn.call(state, 'value') && target.value !== state.value) target.value = state.value;
25
+ if (hasOwn.call(state, 'status') && target.status !== state.status) target.status = state.status;
26
+ if (hasOwn.call(state, 'progress') && target.progress !== state.progress) target.progress = state.progress;
27
+ if (hasOwn.call(state, 'pulse') && target.pulse !== state.pulse) target.pulse = state.pulse;
28
+ if (hasOwn.call(state, 'color') && target.color !== state.color) target.color = state.color;
29
+ if (hasOwn.call(state, 'selected') && target.selected !== state.selected) target.selected = state.selected;
30
+ if (hasOwn.call(state, 'highlighted') && target.highlighted !== state.highlighted) target.highlighted = state.highlighted;
31
+ if (hasOwn.call(state, 'visible') && target.visible !== state.visible) target.visible = state.visible;
32
+ if (hasOwn.call(state, 'updated') && target.updated !== state.updated) target.updated = state.updated;
33
+ if (hasOwn.call(state, 'updatedAt') && target.updatedAt !== state.updatedAt) target.updatedAt = state.updatedAt;
34
+ if (hasOwn.call(state, 'icon') && target.icon !== state.icon) target.icon = state.icon;
35
+
36
+ for (const key in state) {
37
+ if (!hasOwn.call(state, key) || knownDynamicStateKeys.has(key)) continue;
38
+ if (target[key] !== state[key]) target[key] = state[key];
39
+ }
40
+
41
+ return target;
42
+ }
43
+
44
+ /** @param {import('./types.js').NodeDynamicState} state */
45
+ export function cloneDynamicState(state) {
46
+ return mergeDynamicState({}, state);
47
+ }
@@ -84,6 +84,9 @@ export class IconRegistry {
84
84
  this.register('control', drawControl);
85
85
  this.register('situation', drawSituation);
86
86
  this.register('damage', drawDamage);
87
+ this.register('inspector-object', drawInspectorObject);
88
+ this.register('inspector-array', drawInspectorArray);
89
+ this.register('inspector-value', drawInspectorValue);
87
90
  }
88
91
  }
89
92
 
@@ -420,3 +423,44 @@ function drawDamage(ctx, x, y, size, color) {
420
423
  ctx.lineTo(x + size * 0.82, y + size * 0.18);
421
424
  ctx.stroke();
422
425
  }
426
+
427
+ function drawInspectorObject(ctx, x, y, size, color) {
428
+ ctx.strokeStyle = color;
429
+ ctx.lineWidth = 1.2;
430
+ const left = x + size * 0.24;
431
+ const top = y + size * 0.22;
432
+ const width = size * 0.52;
433
+ const height = size * 0.56;
434
+ ctx.strokeRect(left, top, width, height);
435
+ ctx.beginPath();
436
+ ctx.moveTo(left + width * 0.28, top);
437
+ ctx.lineTo(left + width * 0.28, top + height);
438
+ ctx.moveTo(left + width * 0.72, top);
439
+ ctx.lineTo(left + width * 0.72, top + height);
440
+ ctx.stroke();
441
+ }
442
+
443
+ function drawInspectorArray(ctx, x, y, size, color) {
444
+ ctx.strokeStyle = color;
445
+ ctx.lineWidth = 1.2;
446
+ const cx = x + size * 0.5;
447
+ const cy = y + size * 0.5;
448
+ ctx.beginPath();
449
+ ctx.arc(cx - size * 0.18, cy, size * 0.18, 0, Math.PI * 2);
450
+ ctx.arc(cx + size * 0.18, cy, size * 0.18, 0, Math.PI * 2);
451
+ ctx.stroke();
452
+ }
453
+
454
+ function drawInspectorValue(ctx, x, y, size, color) {
455
+ const cx = x + size * 0.5;
456
+ const cy = y + size * 0.5;
457
+ ctx.strokeStyle = color;
458
+ ctx.fillStyle = color;
459
+ ctx.lineWidth = 1.25;
460
+ ctx.beginPath();
461
+ ctx.arc(cx, cy, size * 0.3, 0, Math.PI * 2);
462
+ ctx.stroke();
463
+ ctx.beginPath();
464
+ ctx.arc(cx, cy, size * 0.11, 0, Math.PI * 2);
465
+ ctx.fill();
466
+ }
@@ -1,3 +1,5 @@
1
+ import { cloneDynamicState, mergeDynamicState } from './dynamic-state.js';
2
+
1
3
  export class PatchBatcher {
2
4
  constructor() {
3
5
  /** @type {Map<string, import('./types.js').NodeDynamicState>} */
@@ -7,8 +9,8 @@ export class PatchBatcher {
7
9
  /** @param {string} id @param {import('./types.js').NodeDynamicState} state */
8
10
  set(id, state) {
9
11
  const current = this.pending.get(id);
10
- if (current) Object.assign(current, state);
11
- else this.pending.set(id, { ...state });
12
+ if (current) mergeDynamicState(current, state);
13
+ else this.pending.set(id, cloneDynamicState(state));
12
14
  }
13
15
 
14
16
  /** @param {Array<import('./types.js').DynamicPatch>} patches */
@@ -26,4 +28,3 @@ export class PatchBatcher {
26
28
  return this.pending.size;
27
29
  }
28
30
  }
29
-
@@ -1,7 +1,8 @@
1
1
  export const darkTheme = {
2
2
  rowHeight: 28,
3
3
  indentWidth: 18,
4
- font: '12px system-ui, sans-serif',
4
+ font: '12px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
5
+ monoFont: '12px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace',
5
6
  colors: {
6
7
  background: '#0b1020',
7
8
  row: '#0b1020',
@@ -37,6 +38,12 @@ export const darkTheme = {
37
38
  warning: { icon: 'warning', color: '#facc15' },
38
39
  error: { icon: 'error', color: '#ef4444' },
39
40
  task: { icon: 'task', color: '#f97316' },
41
+ object: { icon: 'inspector-object', color: '#7dd3fc' },
42
+ array: { icon: 'inspector-array', color: '#a78bfa' },
43
+ string: { icon: 'inspector-value', color: '#94a3b8' },
44
+ number: { icon: 'inspector-value', color: '#34d399' },
45
+ boolean: { icon: 'inspector-value', color: '#22c55e' },
46
+ null: { icon: 'inspector-value', color: '#64748b' },
40
47
  },
41
48
  statuses: {
42
49
  0: { label: 'OK', color: '#22c55e' },
@@ -1,4 +1,5 @@
1
1
  import { TreeIndex } from './tree-index.js';
2
+ import { mergeDynamicState } from './dynamic-state.js';
2
3
 
3
4
  /**
4
5
  * Owns structural nodes, expanded state, and per-node dynamic state.
@@ -68,7 +69,7 @@ export class TreeModel extends EventTarget {
68
69
  for (const patch of patches) {
69
70
  const current = this.dynamicState.get(patch.id);
70
71
  if (!current) continue;
71
- Object.assign(current, patch.state);
72
+ mergeDynamicState(current, patch.state);
72
73
  }
73
74
  }
74
75
 
package/src/core/types.js CHANGED
@@ -20,6 +20,9 @@
20
20
  * @property {boolean} [selected]
21
21
  * @property {boolean} [highlighted]
22
22
  * @property {boolean} [visible]
23
+ * @property {boolean} [updated]
24
+ * @property {number} [updatedAt]
25
+ * @property {string} [icon]
23
26
  */
24
27
 
25
28
  /**
@@ -41,4 +44,3 @@
41
44
  * @property {string} id
42
45
  * @property {NodeDynamicState} state
43
46
  */
44
-
@@ -61,18 +61,24 @@ export class TreeViewInputController {
61
61
  return;
62
62
  }
63
63
  const hit = this.#hitTest(event);
64
- this.canvas.style.cursor = hit?.area === 'header' && hit.part === 'resize' ? 'col-resize' : '';
64
+ this.canvas.style.cursor = cursorForHit(hit);
65
65
  const id = hit?.row?.nodeId ?? null;
66
- if (id === this.hoveredId) return;
66
+ const key = hitKey(hit);
67
+ if (id === this.hoveredId && key === this.hoveredHitKey) return;
67
68
  this.hoveredId = id;
68
- this.controller?.setHover(id);
69
+ this.hoveredHitKey = key;
70
+ if (this.controller?.setHoverHit) this.controller.setHoverHit(hit);
71
+ else this.controller?.setHover(id);
69
72
  this.onHoverChanged?.(id);
70
73
  };
71
74
 
72
75
  #onMouseLeave = () => {
73
76
  if (!this.resizeDrag) this.canvas.style.cursor = '';
74
77
  this.hoveredId = null;
75
- this.controller?.setHover(null);
78
+ this.hoveredHitKey = null;
79
+ this.controller?.setActiveHit?.(null);
80
+ if (this.controller?.setHoverHit) this.controller.setHoverHit(null);
81
+ else this.controller?.setHover(null);
76
82
  this.onHoverChanged?.(null);
77
83
  };
78
84
 
@@ -118,6 +124,7 @@ export class TreeViewInputController {
118
124
 
119
125
  #onMouseDown = (event) => {
120
126
  const hit = this.#hitTest(event);
127
+ this.controller?.setActiveHit?.(hit);
121
128
  if (this.cellEditor?.handlePointerDown(event, hit)) {
122
129
  event.preventDefault();
123
130
  return;
@@ -136,6 +143,7 @@ export class TreeViewInputController {
136
143
  #onMouseUp = () => {
137
144
  this.resizeDrag = null;
138
145
  this.canvas.style.cursor = '';
146
+ this.controller?.setActiveHit?.(null);
139
147
  };
140
148
 
141
149
  #onKeyDown = (event) => {
@@ -234,3 +242,18 @@ export class TreeViewInputController {
234
242
  return { area: 'row', row, x, y, part };
235
243
  }
236
244
  }
245
+
246
+ function cursorForHit(hit) {
247
+ if (hit?.area === 'header' && hit.part === 'resize') return 'col-resize';
248
+ if (hit?.area === 'header' && hit.part === 'filter') return 'text';
249
+ if (hit?.area !== 'row') return '';
250
+ if (hit.part === 'button' || hit.part === 'checkbox' || hit.part === 'arrayAdd' || hit.part === 'arrayRemove' || hit.part === 'chevron') return 'pointer';
251
+ if (hit.part === 'editor' || hit.part === 'number') return 'text';
252
+ if (hit.part === 'range') return 'ew-resize';
253
+ return '';
254
+ }
255
+
256
+ function hitKey(hit) {
257
+ if (!hit?.row || !hit.column) return null;
258
+ return `${hit.row.nodeId}:${hit.column.id}:${hit.part}`;
259
+ }
@@ -78,6 +78,7 @@ export class CellEditorManager {
78
78
  const rect = this.#clampRectToHost(this.#overlayRect(hit), 4);
79
79
  const hostRect = this.host.getBoundingClientRect();
80
80
  const element = createEditorElement(data);
81
+ element.className = `vtc-editor vtc-editor-${data.editorType}`;
81
82
  Object.assign(element.style, {
82
83
  position: 'absolute',
83
84
  left: `${rect.x - hostRect.left}px`,
@@ -90,12 +91,14 @@ export class CellEditorManager {
90
91
  boxSizing: 'border-box',
91
92
  margin: '0',
92
93
  outline: 'none',
93
- border: '1px solid #38bdf8',
94
- borderRadius: '3px',
95
- background: '#0b1020',
94
+ appearance: 'none',
95
+ border: '1px solid rgba(56, 189, 248, 0.65)',
96
+ borderRadius: '6px',
97
+ background: '#0f172a',
96
98
  color: '#e5e7eb',
97
- font: '12px system-ui, sans-serif',
98
- padding: data.editorType === 'color' ? '0 2px' : '0 6px',
99
+ boxShadow: '0 0 0 1px rgba(15, 23, 42, 0.8), 0 8px 20px rgba(0, 0, 0, 0.28)',
100
+ font: editorFont(data),
101
+ padding: data.editorType === 'color' ? '0 2px' : data.editorType === 'select' ? '0 22px 0 8px' : '0 8px',
99
102
  textAlign: data.editorType === 'number' || data.editorType === 'range' ? 'right' : 'left',
100
103
  });
101
104
  const commit = () => {
@@ -154,7 +157,7 @@ export class CellEditorManager {
154
157
  borderRadius: '4px',
155
158
  background: '#0b1020',
156
159
  color: '#e5e7eb',
157
- font: '12px system-ui, sans-serif',
160
+ font: SANS_FONT,
158
161
  padding: '0 8px',
159
162
  });
160
163
  element.addEventListener('input', () => this.controller.setFilter(element.value));
@@ -267,7 +270,10 @@ function createEditorElement(data) {
267
270
  return select;
268
271
  }
269
272
  const input = document.createElement('input');
270
- input.type = data.editorType === 'color' ? 'color' : data.editorType === 'number' || data.editorType === 'range' ? 'number' : 'text';
273
+ input.type = data.editorType === 'color' ? 'color' : 'text';
274
+ if (data.editorType === 'number' || data.editorType === 'range') {
275
+ input.inputMode = data.meta.integer ? 'numeric' : 'decimal';
276
+ }
271
277
  input.value = data.value ?? '';
272
278
  if (data.meta.min !== undefined) input.min = data.meta.min;
273
279
  if (data.meta.max !== undefined) input.max = data.meta.max;
@@ -288,7 +294,13 @@ function ensureOverlayHost(host) {
288
294
  }
289
295
 
290
296
  function parseEditorValue(element, data) {
291
- if (data.editorType === 'number' || data.editorType === 'range') return data.meta.integer ? Number.parseInt(element.value, 10) : Number(element.value);
297
+ if (data.editorType === 'number' || data.editorType === 'range') {
298
+ const value = data.meta.integer ? Number.parseInt(element.value, 10) : Number(element.value);
299
+ if (!Number.isFinite(value)) return data.value;
300
+ const min = Number.isFinite(data.meta.min) ? data.meta.min : -Infinity;
301
+ const max = Number.isFinite(data.meta.max) ? data.meta.max : Infinity;
302
+ return Math.max(min, Math.min(max, value));
303
+ }
292
304
  if (data.editorType === 'select') {
293
305
  const values = Object.values(data.meta.options ?? {});
294
306
  const match = values.find((value) => String(value) === element.value);
@@ -296,3 +308,10 @@ function parseEditorValue(element, data) {
296
308
  }
297
309
  return element.value;
298
310
  }
311
+
312
+ const SANS_FONT = '12px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
313
+ const MONO_FONT = '12px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace';
314
+
315
+ function editorFont(data) {
316
+ return data.editorType === 'number' || data.editorType === 'range' ? MONO_FONT : SANS_FONT;
317
+ }
@@ -28,19 +28,21 @@ export class Canvas2DRenderer {
28
28
  if (!this.canvas || !this.ctx || !this.scene) return;
29
29
  const { viewport } = frameState;
30
30
  const dpr = Math.max(1, window.devicePixelRatio || 1);
31
- const width = Math.floor(this.canvas.clientWidth * dpr);
32
- const height = Math.floor(this.canvas.clientHeight * dpr);
31
+ const viewportWidth = Math.max(0, viewport.viewportWidth);
32
+ const viewportHeight = Math.max(0, viewport.viewportHeight);
33
+ if (viewportWidth <= 0 || viewportHeight <= 0) return;
34
+ const width = Math.floor(viewportWidth * dpr);
35
+ const height = Math.floor(viewportHeight * dpr);
33
36
  if (this.canvas.width !== width || this.canvas.height !== height) {
34
37
  this.canvas.width = width;
35
38
  this.canvas.height = height;
36
- viewport.resize(this.canvas.clientWidth, this.canvas.clientHeight);
37
39
  }
38
40
 
39
41
  const ctx = this.ctx;
40
42
  const theme = this.themeManager?.get() ?? {};
41
43
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
42
44
  ctx.fillStyle = theme.background ?? '#101419';
43
- ctx.fillRect(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
45
+ ctx.fillRect(0, 0, viewportWidth, viewportHeight);
44
46
 
45
47
  this.visibleNodeIndices = cullLayoutNodes(this.scene.layout.nodes, viewport.getWorldBounds());
46
48
  const visibleSet = new Set(this.visibleNodeIndices);
@@ -35,19 +35,21 @@ export class TreeRowRenderer {
35
35
  if (!this.canvas || !this.ctx || !this.scene) return;
36
36
  const { viewport, theme } = this.scene;
37
37
  const dpr = Math.max(1, window.devicePixelRatio || 1);
38
- const width = Math.floor(this.canvas.clientWidth * dpr);
39
- const height = Math.floor(this.canvas.clientHeight * dpr);
38
+ const viewportWidth = Math.max(0, viewport.viewportWidth);
39
+ const viewportHeight = Math.max(0, viewport.viewportHeight);
40
+ if (viewportWidth <= 0 || viewportHeight <= 0) return;
41
+ const width = Math.floor(viewportWidth * dpr);
42
+ const height = Math.floor(viewportHeight * dpr);
40
43
  if (this.canvas.width !== width || this.canvas.height !== height) {
41
44
  this.canvas.width = width;
42
45
  this.canvas.height = height;
43
- viewport.resize(this.canvas.clientWidth, this.canvas.clientHeight);
44
46
  }
45
47
 
46
48
  const ctx = this.ctx;
47
49
  const colors = theme.colors;
48
50
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
49
51
  ctx.fillStyle = colors.background;
50
- ctx.fillRect(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
52
+ ctx.fillRect(0, 0, viewportWidth, viewportHeight);
51
53
 
52
54
  ctx.save();
53
55
  ctx.translate(viewport.renderInsetX ?? 0, viewport.renderInsetY ?? 0);
@@ -76,7 +78,7 @@ export class TreeRowRenderer {
76
78
  drawTruncatedText(ctx, column.label, column.x + 10, viewport.headerHeight / 2, column.width - 20);
77
79
  }
78
80
  if (sort?.columnId === column.id && sort.direction) {
79
- drawTruncatedText(ctx, sort.direction === 'asc' ? '^' : 'v', column.x + column.width - 16, viewport.headerHeight / 2, 12);
81
+ this.#drawSortIndicator(ctx, column.x + column.width - 16, viewport.headerHeight / 2, sort.direction, theme);
80
82
  }
81
83
  ctx.strokeStyle = colors.border;
82
84
  ctx.beginPath();
@@ -110,6 +112,22 @@ export class TreeRowRenderer {
110
112
  drawTruncatedText(ctx, filterQuery || 'Filter inspector', x + 8, viewport.headerHeight / 2, width - 16);
111
113
  }
112
114
 
115
+ #drawSortIndicator(ctx, x, y, direction, theme) {
116
+ ctx.fillStyle = theme.colors.focus;
117
+ ctx.beginPath();
118
+ if (direction === 'asc') {
119
+ ctx.moveTo(x, y + 3);
120
+ ctx.lineTo(x + 5, y - 3);
121
+ ctx.lineTo(x + 10, y + 3);
122
+ } else {
123
+ ctx.moveTo(x, y - 3);
124
+ ctx.lineTo(x + 5, y + 3);
125
+ ctx.lineTo(x + 10, y - 3);
126
+ }
127
+ ctx.closePath();
128
+ ctx.fill();
129
+ }
130
+
113
131
  #drawRows(ctx) {
114
132
  const { rows, visibleRange, viewport } = this.scene;
115
133
  this.renderedRows = visibleRange.count;
@@ -176,7 +194,7 @@ export class TreeRowRenderer {
176
194
  }
177
195
 
178
196
  #drawRow(ctx, row) {
179
- const { columns, nodes, dynamicState, selection, hoverNodeId, focusNodeId, searchMatches, theme, viewport } = this.scene;
197
+ const { columns, nodes, dynamicState, selection, hoverNodeId, hoverPart, activeNodeId, activePart, focusNodeId, searchMatches, theme, viewport } = this.scene;
180
198
  const node = nodes[row.nodeIndex];
181
199
  const state = dynamicState.get(row.nodeId) ?? {};
182
200
  const style = resolveNodeStyle(theme, node, state);
@@ -196,7 +214,19 @@ export class TreeRowRenderer {
196
214
 
197
215
  for (const column of columns) {
198
216
  const rect = { x: column.x, y, width: column.width, height: row.height };
199
- this.#drawCell(ctx, { node, state, row, column, rect, theme, style });
217
+ this.#drawCell(ctx, {
218
+ node,
219
+ state,
220
+ row,
221
+ column,
222
+ rect,
223
+ theme,
224
+ style,
225
+ selected,
226
+ hovered,
227
+ hoverPart: hoverNodeId === row.nodeId ? hoverPart : null,
228
+ activePart: activeNodeId === row.nodeId ? activePart : null,
229
+ });
200
230
  ctx.strokeStyle = colors.border;
201
231
  ctx.beginPath();
202
232
  ctx.moveTo(column.x + column.width + 0.5, y);
@@ -233,7 +263,7 @@ export class TreeRowRenderer {
233
263
  else this.#drawTextCell(ctx, cell);
234
264
  }
235
265
 
236
- #drawInspectorPaneCell(ctx, { node, row, rect, theme, style }) {
266
+ #drawInspectorPaneCell(ctx, { node, row, rect, theme, style, hovered, hoverPart, activePart }) {
237
267
  const visibleRight = this.scene.viewport.scrollX + this.scene.viewport.viewportWidth;
238
268
  rect = { ...rect, width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x) };
239
269
  const data = node.data ?? {};
@@ -270,13 +300,22 @@ export class TreeRowRenderer {
270
300
  if (data.valueType === 'array') {
271
301
  ctx.fillStyle = colors.textMuted;
272
302
  drawTruncatedText(ctx, data.valueText, editorX, cy, Math.max(20, editorWidth - 58));
273
- this.#drawSmallButton(ctx, rect.x + rect.width - 54, rect.y + 5, 22, rect.height - 10, '+', theme);
274
- this.#drawSmallButton(ctx, rect.x + rect.width - 28, rect.y + 5, 22, rect.height - 10, '-', theme);
303
+ this.#drawSmallButton(ctx, rect.x + rect.width - 54, rect.y + 5, 22, rect.height - 10, '+', theme, {
304
+ hovered: hovered && hoverPart === 'arrayAdd',
305
+ active: activePart === 'arrayAdd',
306
+ });
307
+ this.#drawSmallButton(ctx, rect.x + rect.width - 28, rect.y + 5, 22, rect.height - 10, '-', theme, {
308
+ hovered: hovered && hoverPart === 'arrayRemove',
309
+ active: activePart === 'arrayRemove',
310
+ });
275
311
  } else {
276
312
  this.#drawInspectorValueCell(ctx, {
277
313
  node,
278
314
  rect: { x: editorX, y: rect.y, width: editorWidth, height: rect.height },
279
315
  theme,
316
+ hovered,
317
+ hoverPart,
318
+ activePart,
280
319
  suppressUpdatedMarker: true,
281
320
  });
282
321
  }
@@ -284,7 +323,7 @@ export class TreeRowRenderer {
284
323
  ctx.globalAlpha = 1;
285
324
  }
286
325
 
287
- #drawInspectorValueCell(ctx, { node, rect, theme, suppressUpdatedMarker = false }) {
326
+ #drawInspectorValueCell(ctx, { node, rect, theme, hovered = false, hoverPart = null, activePart = null, suppressUpdatedMarker = false }) {
288
327
  const data = node.data ?? {};
289
328
  const meta = data.meta ?? {};
290
329
  const disabled = data.disabled;
@@ -299,9 +338,12 @@ export class TreeRowRenderer {
299
338
  ctx.globalAlpha = disabled ? DISABLED_ALPHA : 1;
300
339
 
301
340
  if (data.editorType === 'checkbox') {
302
- this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 7, Boolean(data.value), theme);
341
+ this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
303
342
  } else if (data.editorType === 'range') {
304
- this.#drawInspectorRange(ctx, x, rect.y + rect.height / 2 - 4, width, data, theme);
343
+ this.#drawInspectorRange(ctx, x, rect.y + rect.height / 2 - 4, width, data, theme, {
344
+ hoveredNumber: hovered && hoverPart === 'number',
345
+ activeNumber: activePart === 'number',
346
+ });
305
347
  } else if (data.editorType === 'color') {
306
348
  ctx.fillStyle = String(data.value || '#000000');
307
349
  ctx.fillRect(x, y + 2, 28, height - 4);
@@ -309,20 +351,25 @@ export class TreeRowRenderer {
309
351
  ctx.strokeRect(x + 0.5, y + 2.5, 28, height - 4);
310
352
  this.#drawMutedText(ctx, String(data.value ?? ''), x + 38, rect.y + rect.height / 2, width - 38, theme);
311
353
  } else if (data.editorType === 'button') {
312
- ctx.fillStyle = readonly || disabled ? theme.colors.progressTrack : theme.colors.rowHover;
313
- roundRect(ctx, x, y, meta.fullWidthButton ? width : Math.min(width, 140), height, 4);
314
- ctx.fill();
354
+ const buttonWidth = meta.fullWidthButton ? width : Math.min(width, 140);
355
+ this.#drawControlSurface(ctx, x, y, buttonWidth, height, theme, {
356
+ hovered: hovered && hoverPart === 'button',
357
+ active: activePart === 'button',
358
+ disabled: readonly || disabled,
359
+ });
315
360
  ctx.fillStyle = theme.colors.text;
316
361
  ctx.textAlign = 'center';
317
- drawTruncatedText(ctx, meta.button ?? node.label, x + (meta.fullWidthButton ? width : Math.min(width, 140)) / 2, rect.y + rect.height / 2, Math.max(10, (meta.fullWidthButton ? width : Math.min(width, 140)) - 12));
362
+ drawTruncatedText(ctx, meta.button ?? node.label, x + buttonWidth / 2, rect.y + rect.height / 2, Math.max(10, buttonWidth - 12));
318
363
  ctx.textAlign = 'left';
319
364
  } else if (data.editorType === 'select') {
320
- ctx.fillStyle = theme.colors.rowHover;
321
- roundRect(ctx, x, y, Math.min(width, 180), height, 4);
322
- ctx.fill();
323
- this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, Math.min(width, 180) - 22, theme);
324
- ctx.fillStyle = theme.colors.chevron;
325
- drawTruncatedText(ctx, 'v', x + Math.min(width, 180) - 14, rect.y + rect.height / 2, 10);
365
+ const selectWidth = Math.min(width, 180);
366
+ this.#drawControlSurface(ctx, x, y, selectWidth, height, theme, {
367
+ hovered: hovered && hoverPart === 'editor',
368
+ active: activePart === 'editor',
369
+ disabled: readonly || disabled,
370
+ });
371
+ this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme);
372
+ this.#drawSelectChevron(ctx, x + selectWidth - 18, rect.y + rect.height / 2, theme, disabled);
326
373
  } else {
327
374
  this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
328
375
  }
@@ -346,7 +393,7 @@ export class TreeRowRenderer {
346
393
  }
347
394
 
348
395
  #drawInspectorTypeCell(ctx, { node, rect, theme }) {
349
- this.#drawMutedText(ctx, node.data?.valueType ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme);
396
+ this.#drawMutedText(ctx, node.data?.valueType ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme, false, theme.monoFont);
350
397
  }
351
398
 
352
399
  #drawInspectorDescriptionCell(ctx, { node, rect, theme }) {
@@ -354,20 +401,18 @@ export class TreeRowRenderer {
354
401
  }
355
402
 
356
403
  #drawCheckbox(ctx, x, y, checked, theme) {
357
- ctx.strokeStyle = theme.colors.textMuted;
358
- ctx.strokeRect(x + 0.5, y + 0.5, 14, 14);
359
- if (!checked) return;
360
- ctx.strokeStyle = theme.colors.progressFill;
361
- ctx.lineWidth = 2;
362
- ctx.beginPath();
363
- ctx.moveTo(x + 3, y + 7);
364
- ctx.lineTo(x + 6, y + 11);
365
- ctx.lineTo(x + 12, y + 3);
404
+ roundRect(ctx, x, y, 16, 16, 3);
405
+ ctx.fillStyle = theme.colors.progressTrack;
406
+ ctx.fill();
407
+ ctx.strokeStyle = checked ? theme.colors.progressFill : theme.colors.textMuted;
366
408
  ctx.stroke();
367
- ctx.lineWidth = 1;
409
+ if (!checked) return;
410
+ ctx.fillStyle = theme.colors.progressFill;
411
+ roundRect(ctx, x + 4, y + 4, 8, 8, 1.5);
412
+ ctx.fill();
368
413
  }
369
414
 
370
- #drawInspectorRange(ctx, x, y, width, data, theme) {
415
+ #drawInspectorRange(ctx, x, y, width, data, theme, state = {}) {
371
416
  const meta = data.meta ?? {};
372
417
  const min = meta.min ?? 0;
373
418
  const max = meta.max ?? 100;
@@ -380,19 +425,20 @@ export class TreeRowRenderer {
380
425
  ctx.fillRect(x, y, barWidth, 8);
381
426
  ctx.fillStyle = theme.colors.progressFill;
382
427
  ctx.fillRect(x, y, barWidth * ratio, 8);
383
- ctx.fillStyle = theme.colors.rowHover;
384
- roundRect(ctx, x + barWidth + gap, y - 6, valueWidth, 20, 3);
385
- ctx.fill();
428
+ this.#drawControlSurface(ctx, x + barWidth + gap, y - 6, valueWidth, 20, theme, {
429
+ hovered: Boolean(state.hoveredNumber),
430
+ active: Boolean(state.activeNumber),
431
+ });
386
432
  ctx.fillStyle = theme.colors.text;
433
+ ctx.font = theme.monoFont ?? theme.font;
387
434
  ctx.textAlign = 'right';
388
435
  drawTruncatedText(ctx, String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
389
436
  ctx.textAlign = 'left';
437
+ ctx.font = theme.font;
390
438
  }
391
439
 
392
- #drawSmallButton(ctx, x, y, width, height, label, theme) {
393
- ctx.fillStyle = theme.colors.rowHover;
394
- roundRect(ctx, x, y, width, height, 3);
395
- ctx.fill();
440
+ #drawSmallButton(ctx, x, y, width, height, label, theme, state = {}) {
441
+ this.#drawControlSurface(ctx, x, y, width, height, theme, state);
396
442
  ctx.fillStyle = theme.colors.text;
397
443
  ctx.textAlign = 'center';
398
444
  ctx.textBaseline = 'middle';
@@ -400,9 +446,34 @@ export class TreeRowRenderer {
400
446
  ctx.textAlign = 'left';
401
447
  }
402
448
 
403
- #drawMutedText(ctx, text, x, y, width, theme, readonly = false) {
449
+ #drawControlSurface(ctx, x, y, width, height, theme, { hovered = false, active = false, disabled = false } = {}) {
450
+ const colors = theme.colors;
451
+ ctx.fillStyle = disabled
452
+ ? colors.progressTrack
453
+ : active
454
+ ? mixColor(colors.rowHover, colors.focus, 0.36)
455
+ : hovered
456
+ ? mixColor(colors.rowHover, colors.focus, 0.18)
457
+ : colors.progressTrack;
458
+ roundRect(ctx, x, y, width, height, 5);
459
+ ctx.fill();
460
+ ctx.strokeStyle = active ? colors.focus : hovered ? mixColor(colors.border, colors.focus, 0.5) : colors.border;
461
+ ctx.stroke();
462
+ }
463
+
464
+ #drawSelectChevron(ctx, x, y, theme, disabled = false) {
465
+ ctx.fillStyle = disabled ? theme.colors.textMuted : theme.colors.chevron;
466
+ ctx.beginPath();
467
+ ctx.moveTo(x - 4, y - 2);
468
+ ctx.lineTo(x + 4, y - 2);
469
+ ctx.lineTo(x, y + 3);
470
+ ctx.closePath();
471
+ ctx.fill();
472
+ }
473
+
474
+ #drawMutedText(ctx, text, x, y, width, theme, readonly = false, font = null) {
404
475
  ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
405
- ctx.font = theme.font;
476
+ ctx.font = font ?? theme.font;
406
477
  ctx.textBaseline = 'middle';
407
478
  ctx.textAlign = 'left';
408
479
  drawTruncatedText(ctx, String(text ?? ''), x, y, Math.max(10, width));
@@ -429,7 +500,7 @@ export class TreeRowRenderer {
429
500
  roundRect(ctx, x, y, badgeWidth, 16, 8);
430
501
  ctx.fill();
431
502
  ctx.fillStyle = theme.colors.badgeText;
432
- ctx.font = '10px system-ui, sans-serif';
503
+ ctx.font = '10px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace';
433
504
  ctx.textAlign = 'center';
434
505
  ctx.textBaseline = 'middle';
435
506
  drawTruncatedText(ctx, style.status.label, x + badgeWidth / 2, y + 8, badgeWidth - 8);
@@ -452,7 +523,7 @@ export class TreeRowRenderer {
452
523
  if (column.kind === 'updated' && typeof value === 'number') value = formatTime(value);
453
524
  if (typeof value === 'number') value = Math.round(value).toString();
454
525
  ctx.fillStyle = column.kind === 'type' ? resolveNodeStyle(theme, node, state).color : theme.colors.textMuted;
455
- ctx.font = theme.font;
526
+ ctx.font = column.kind === 'type' || column.kind === 'updated' || typeof value === 'number' ? theme.monoFont ?? theme.font : theme.font;
456
527
  ctx.textBaseline = 'middle';
457
528
  ctx.textAlign = column.align;
458
529
  const x = column.align === 'right' ? rect.x + rect.width - 10 : column.align === 'center' ? rect.x + rect.width / 2 : rect.x + 10;
@@ -538,6 +609,10 @@ function clamp01(value) {
538
609
 
539
610
  function roundRect(ctx, x, y, width, height, radius) {
540
611
  ctx.beginPath();
612
+ if (typeof ctx.roundRect === 'function') {
613
+ ctx.roundRect(x, y, width, height, radius);
614
+ return;
615
+ }
541
616
  ctx.moveTo(x + radius, y);
542
617
  ctx.lineTo(x + width - radius, y);
543
618
  ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
@@ -549,6 +624,23 @@ function roundRect(ctx, x, y, width, height, radius) {
549
624
  ctx.quadraticCurveTo(x, y, x + radius, y);
550
625
  }
551
626
 
627
+ function mixColor(a, b, amount) {
628
+ const from = parseHexColor(a);
629
+ const to = parseHexColor(b);
630
+ if (!from || !to) return amount >= 0.5 ? b : a;
631
+ const t = clamp01(amount);
632
+ const value = from.map((channel, index) => Math.round(channel + (to[index] - channel) * t));
633
+ return `rgb(${value[0]}, ${value[1]}, ${value[2]})`;
634
+ }
635
+
636
+ function parseHexColor(value) {
637
+ const text = String(value ?? '').trim();
638
+ const match = /^#([0-9a-f]{6})$/i.exec(text);
639
+ if (!match) return null;
640
+ const number = Number.parseInt(match[1], 16);
641
+ return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
642
+ }
643
+
552
644
  function drawTruncatedText(ctx, text, x, y, maxWidth) {
553
645
  const value = String(text ?? '');
554
646
  const width = Math.max(0, maxWidth);
@@ -556,22 +648,54 @@ function drawTruncatedText(ctx, text, x, y, maxWidth) {
556
648
  ctx.fillText(fitText(ctx, value, width), x, y);
557
649
  }
558
650
 
651
+ const TEXT_FIT_CACHE_LIMIT = 6000;
652
+ const textFitCache = new Map();
653
+
559
654
  function fitText(ctx, text, maxWidth) {
560
- if (ctx.measureText(text).width <= maxWidth) return text;
655
+ const safeWidth = Math.max(0, Math.floor(maxWidth));
656
+ const cacheKey = `${ctx.font}\u0000${safeWidth}\u0000${text}`;
657
+ const cached = textFitCache.get(cacheKey);
658
+ if (cached !== undefined) return cached;
659
+
660
+ let result = text;
661
+ if (ctx.measureText(text).width <= safeWidth) {
662
+ setTextFitCache(cacheKey, result);
663
+ return result;
664
+ }
561
665
  const ellipsis = '...';
562
666
  const ellipsisWidth = ctx.measureText(ellipsis).width;
563
- if (ellipsisWidth > maxWidth) return '';
667
+ if (ellipsisWidth > safeWidth) {
668
+ setTextFitCache(cacheKey, '');
669
+ return '';
670
+ }
564
671
  let low = 0;
565
672
  let high = text.length;
566
673
  while (low < high) {
567
674
  const mid = Math.ceil((low + high) / 2);
568
675
  const candidate = text.slice(0, mid);
569
- if (ctx.measureText(candidate).width + ellipsisWidth <= maxWidth) low = mid;
676
+ if (ctx.measureText(candidate).width + ellipsisWidth <= safeWidth) low = mid;
570
677
  else high = mid - 1;
571
678
  }
572
- return `${text.slice(0, low)}${ellipsis}`;
679
+ result = `${text.slice(0, low)}${ellipsis}`;
680
+ setTextFitCache(cacheKey, result);
681
+ return result;
573
682
  }
574
683
 
684
+ function setTextFitCache(key, value) {
685
+ textFitCache.set(key, value);
686
+ if (textFitCache.size <= TEXT_FIT_CACHE_LIMIT) return;
687
+ const firstKey = textFitCache.keys().next().value;
688
+ if (firstKey !== undefined) textFitCache.delete(firstKey);
689
+ }
690
+
691
+ const timeFormatter = new Intl.DateTimeFormat([], {
692
+ hour: '2-digit',
693
+ minute: '2-digit',
694
+ second: '2-digit',
695
+ });
696
+
575
697
  function formatTime(value) {
576
- return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
698
+ const time = typeof value === 'number' ? value : Number(value);
699
+ if (!Number.isFinite(time)) return '';
700
+ return timeFormatter.format(time);
577
701
  }
@@ -44,6 +44,9 @@ export class TreeViewController {
44
44
  this.searchHighlights = new Set();
45
45
  this.filterQuery = '';
46
46
  this.hoverId = null;
47
+ this.hoverPart = null;
48
+ this.activeId = null;
49
+ this.activePart = null;
47
50
  this.focusedId = null;
48
51
  this.anchorRowIndex = null;
49
52
  this.lastPatchCount = 0;
@@ -63,6 +66,8 @@ export class TreeViewController {
63
66
  this.canvas = canvas;
64
67
  this.renderer.initialize(canvas);
65
68
  this.renderer.setScene(this.scene);
69
+ this.#resizeToCanvasClientSize();
70
+ this.#observeCanvasSize();
66
71
  return this;
67
72
  }
68
73
 
@@ -468,9 +473,27 @@ export class TreeViewController {
468
473
  setHover(nodeId) {
469
474
  if (this.hoverId === nodeId) return;
470
475
  this.hoverId = nodeId;
476
+ this.hoverPart = null;
471
477
  this.events.emit('nodehover', { nodeId });
472
478
  }
473
479
 
480
+ setHoverHit(hit) {
481
+ const nodeId = hit?.row?.nodeId ?? null;
482
+ const part = hit?.part ?? null;
483
+ if (this.hoverId === nodeId && this.hoverPart === part) return;
484
+ this.hoverId = nodeId;
485
+ this.hoverPart = part;
486
+ this.events.emit('nodehover', { nodeId, part });
487
+ }
488
+
489
+ setActiveHit(hit) {
490
+ const nodeId = hit?.row?.nodeId ?? null;
491
+ const part = hit?.part ?? null;
492
+ if (this.activeId === nodeId && this.activePart === part) return;
493
+ this.activeId = nodeId;
494
+ this.activePart = part;
495
+ }
496
+
474
497
  clickNode(nodeId, event = {}) {
475
498
  const row = this.rowModel.getRowById(nodeId);
476
499
  if (!row) return;
@@ -564,7 +587,6 @@ export class TreeViewController {
564
587
  }
565
588
 
566
589
  renderMeasured(time = performance.now()) {
567
- this.#syncViewportFromCanvas();
568
590
  const sceneStart = performance.now();
569
591
  this.scene = this.createRenderScene();
570
592
  const sceneMs = performance.now() - sceneStart;
@@ -588,6 +610,9 @@ export class TreeViewController {
588
610
  dynamicState: this.model.dynamicState,
589
611
  selection: this.selection.selected,
590
612
  hoverNodeId: this.hoverId,
613
+ hoverPart: this.hoverPart,
614
+ activeNodeId: this.activeId,
615
+ activePart: this.activePart,
591
616
  focusNodeId: this.focusedId,
592
617
  searchMatches: this.searchHighlights,
593
618
  sort: this.columnModel.sort,
@@ -661,6 +686,8 @@ export class TreeViewController {
661
686
  else if (localX >= treeX + 26 && localX <= treeX + 44) part = 'icon';
662
687
  else if (localX >= treeX + 48) part = 'label';
663
688
  else part = 'cell';
689
+ } else if (column.kind === 'inspectorValue') {
690
+ part = this.#inspectorEditorPart(row, x - column.x, column.width);
664
691
  }
665
692
  return { area: 'row', part, row, column, x, y: rowY };
666
693
  }
@@ -830,15 +857,23 @@ export class TreeViewController {
830
857
  return Math.max(0, Math.floor(this.viewport.scrollY / this.rowModel.rowHeight));
831
858
  }
832
859
 
833
- #syncViewportFromCanvas() {
860
+ #observeCanvasSize() {
861
+ if (!this.canvas || typeof ResizeObserver === 'undefined') return;
862
+ this.#resizeObserver?.disconnect();
863
+ this.#resizeObserver = new ResizeObserver((entries) => {
864
+ const entry = entries[0];
865
+ const width = Math.floor(entry?.contentRect?.width ?? 0);
866
+ const height = Math.floor(entry?.contentRect?.height ?? 0);
867
+ if (width > 0 && height > 0) this.resize(width, height);
868
+ });
869
+ this.#resizeObserver.observe(this.canvas);
870
+ }
871
+
872
+ #resizeToCanvasClientSize() {
834
873
  if (!this.canvas) return;
835
- const width = this.canvas.clientWidth;
836
- const height = this.canvas.clientHeight;
837
- if (width > 0 && height > 0 && (this.viewport.viewportWidth !== width || this.viewport.viewportHeight !== height)) {
838
- this.viewport.resize(width, height);
839
- this.#fitInspectorPaneColumn(width);
840
- this.#syncContentSize();
841
- }
874
+ const width = Math.floor(this.canvas.clientWidth || this.canvas.getBoundingClientRect().width || 0);
875
+ const height = Math.floor(this.canvas.clientHeight || this.canvas.getBoundingClientRect().height || 0);
876
+ if (width > 0 && height > 0) this.resize(width, height);
842
877
  }
843
878
 
844
879
  #computeInspectorPaneLabelEnd(visibleRange) {
@@ -901,6 +936,8 @@ export class TreeViewController {
901
936
  this.#rebuildRows();
902
937
  if (focusId) this.focusedId = focusId;
903
938
  }
939
+
940
+ #resizeObserver = null;
904
941
  }
905
942
 
906
943
  function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {