virtual-tree-canvas 0.1.0 → 0.2.0

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,12 +1,16 @@
1
1
  {
2
2
  "name": "virtual-tree-canvas",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
7
+ "types": "./src/index.d.ts",
7
8
  "sideEffects": false,
8
9
  "exports": {
9
- ".": "./src/index.js",
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
10
14
  "./core": "./src/core/index.js",
11
15
  "./inspector": "./src/inspector/index.js",
12
16
  "./renderers": "./src/renderers/index.js",
@@ -74,6 +74,16 @@ export class IconRegistry {
74
74
  this.register('error', drawError);
75
75
  this.register('task', drawTask);
76
76
  this.register('track', drawTrack);
77
+ this.register('point', drawPoint);
78
+ this.register('munition', drawMunition);
79
+ this.register('air', drawAircraft);
80
+ this.register('ground', drawGroundVehicle);
81
+ this.register('surface', drawSurfaceVehicle);
82
+ this.register('subsurface', drawSubsurfaceVehicle);
83
+ this.register('space', drawSpaceVehicle);
84
+ this.register('control', drawControl);
85
+ this.register('situation', drawSituation);
86
+ this.register('damage', drawDamage);
77
87
  }
78
88
  }
79
89
 
@@ -171,3 +181,231 @@ function drawTrack(ctx, x, y, size, color) {
171
181
  ctx.lineTo(x + size - 1, y + size / 2);
172
182
  ctx.stroke();
173
183
  }
184
+
185
+ function drawPoint(ctx, x, y, size, color) {
186
+ const cx = x + size / 2;
187
+ const cy = y + size / 2;
188
+ ctx.strokeStyle = color;
189
+ ctx.fillStyle = color;
190
+ ctx.lineWidth = 1.4;
191
+ ctx.beginPath();
192
+ ctx.arc(cx, cy, size * 0.22, 0, Math.PI * 2);
193
+ ctx.fill();
194
+ ctx.beginPath();
195
+ ctx.arc(cx, cy, size * 0.42, 0, Math.PI * 2);
196
+ ctx.stroke();
197
+ ctx.beginPath();
198
+ ctx.moveTo(cx, y + 1);
199
+ ctx.lineTo(cx, y + size * 0.22);
200
+ ctx.moveTo(cx, y + size * 0.78);
201
+ ctx.lineTo(cx, y + size - 1);
202
+ ctx.moveTo(x + 1, cy);
203
+ ctx.lineTo(x + size * 0.22, cy);
204
+ ctx.moveTo(x + size * 0.78, cy);
205
+ ctx.lineTo(x + size - 1, cy);
206
+ ctx.stroke();
207
+ }
208
+
209
+ function drawMunition(ctx, x, y, size, color) {
210
+ const cx = x + size * 0.5;
211
+ const bodyTop = y + size * 0.25;
212
+ const bodyBottom = y + size * 0.76;
213
+ ctx.fillStyle = color;
214
+
215
+ ctx.beginPath();
216
+ ctx.moveTo(cx, y + 1);
217
+ ctx.lineTo(cx + size * 0.17, bodyTop);
218
+ ctx.lineTo(cx - size * 0.17, bodyTop);
219
+ ctx.closePath();
220
+ ctx.fill();
221
+
222
+ ctx.fillRect(cx - size * 0.11, bodyTop, size * 0.22, bodyBottom - bodyTop);
223
+
224
+ ctx.beginPath();
225
+ ctx.moveTo(cx - size * 0.11, y + size * 0.63);
226
+ ctx.lineTo(x + 1, y + size - 2);
227
+ ctx.lineTo(cx - size * 0.11, y + size * 0.82);
228
+ ctx.closePath();
229
+ ctx.fill();
230
+
231
+ ctx.beginPath();
232
+ ctx.moveTo(cx + size * 0.11, y + size * 0.63);
233
+ ctx.lineTo(x + size - 1, y + size - 2);
234
+ ctx.lineTo(cx + size * 0.11, y + size * 0.82);
235
+ ctx.closePath();
236
+ ctx.fill();
237
+
238
+ ctx.strokeStyle = 'rgba(255,255,255,.35)';
239
+ ctx.lineWidth = 1;
240
+ ctx.beginPath();
241
+ ctx.moveTo(cx, bodyTop + 1);
242
+ ctx.lineTo(cx, bodyBottom - 1);
243
+ ctx.stroke();
244
+
245
+ ctx.strokeStyle = color;
246
+ ctx.beginPath();
247
+ ctx.moveTo(cx - size * 0.12, bodyBottom);
248
+ ctx.lineTo(cx, y + size - 1);
249
+ ctx.lineTo(cx + size * 0.12, bodyBottom);
250
+ ctx.stroke();
251
+ }
252
+
253
+ function drawGroundVehicle(ctx, x, y, size, color) {
254
+ ctx.fillStyle = color;
255
+ const bodyY = y + size * 0.42;
256
+ ctx.fillRect(x + size * 0.16, bodyY, size * 0.68, size * 0.26);
257
+ ctx.fillRect(x + size * 0.34, y + size * 0.26, size * 0.28, size * 0.2);
258
+ ctx.fillRect(x + size * 0.62, y + size * 0.34, size * 0.3, size * 0.06);
259
+ ctx.beginPath();
260
+ ctx.arc(x + size * 0.28, y + size * 0.76, size * 0.09, 0, Math.PI * 2);
261
+ ctx.arc(x + size * 0.5, y + size * 0.76, size * 0.09, 0, Math.PI * 2);
262
+ ctx.arc(x + size * 0.72, y + size * 0.76, size * 0.09, 0, Math.PI * 2);
263
+ ctx.fill();
264
+ }
265
+
266
+ function drawSurfaceVehicle(ctx, x, y, size, color) {
267
+ ctx.fillStyle = color;
268
+ ctx.beginPath();
269
+ ctx.moveTo(x + 1, y + size * 0.58);
270
+ ctx.lineTo(x + size * 0.82, y + size * 0.58);
271
+ ctx.lineTo(x + size - 1, y + size * 0.72);
272
+ ctx.lineTo(x + size * 0.18, y + size * 0.82);
273
+ ctx.closePath();
274
+ ctx.fill();
275
+
276
+ ctx.fillRect(x + size * 0.34, y + size * 0.34, size * 0.24, size * 0.2);
277
+ ctx.fillRect(x + size * 0.46, y + size * 0.18, size * 0.08, size * 0.18);
278
+ ctx.strokeStyle = color;
279
+ ctx.lineWidth = 1.2;
280
+ ctx.beginPath();
281
+ ctx.moveTo(x + size * 0.18, y + size * 0.88);
282
+ ctx.quadraticCurveTo(x + size * 0.34, y + size * 0.78, x + size * 0.5, y + size * 0.88);
283
+ ctx.quadraticCurveTo(x + size * 0.66, y + size * 0.98, x + size * 0.82, y + size * 0.88);
284
+ ctx.stroke();
285
+ }
286
+
287
+ function drawSubsurfaceVehicle(ctx, x, y, size, color) {
288
+ ctx.fillStyle = color;
289
+ ctx.beginPath();
290
+ ctx.ellipse(x + size * 0.5, y + size * 0.58, size * 0.38, size * 0.18, 0, 0, Math.PI * 2);
291
+ ctx.fill();
292
+ ctx.fillRect(x + size * 0.44, y + size * 0.28, size * 0.12, size * 0.18);
293
+ ctx.fillRect(x + size * 0.38, y + size * 0.26, size * 0.24, size * 0.06);
294
+ ctx.strokeStyle = color;
295
+ ctx.lineWidth = 1.2;
296
+ ctx.beginPath();
297
+ ctx.moveTo(x + size * 0.16, y + size * 0.86);
298
+ ctx.quadraticCurveTo(x + size * 0.32, y + size * 0.78, x + size * 0.5, y + size * 0.86);
299
+ ctx.quadraticCurveTo(x + size * 0.68, y + size * 0.94, x + size * 0.84, y + size * 0.86);
300
+ ctx.stroke();
301
+ }
302
+
303
+ function drawSpaceVehicle(ctx, x, y, size, color) {
304
+ const cx = x + size * 0.5;
305
+ const cy = y + size * 0.5;
306
+ ctx.strokeStyle = color;
307
+ ctx.fillStyle = color;
308
+ ctx.lineWidth = 1.2;
309
+ ctx.beginPath();
310
+ ctx.arc(cx, cy, size * 0.16, 0, Math.PI * 2);
311
+ ctx.fill();
312
+ ctx.strokeRect(x + size * 0.12, y + size * 0.32, size * 0.24, size * 0.36);
313
+ ctx.strokeRect(x + size * 0.64, y + size * 0.32, size * 0.24, size * 0.36);
314
+ ctx.beginPath();
315
+ ctx.moveTo(x + size * 0.36, cy);
316
+ ctx.lineTo(x + size * 0.64, cy);
317
+ ctx.stroke();
318
+ ctx.beginPath();
319
+ ctx.ellipse(cx, cy, size * 0.42, size * 0.18, -0.45, 0, Math.PI * 2);
320
+ ctx.stroke();
321
+ }
322
+
323
+ function drawControl(ctx, x, y, size, color) {
324
+ ctx.strokeStyle = color;
325
+ ctx.fillStyle = color;
326
+ ctx.lineWidth = 1.3;
327
+ const cx = x + size * 0.5;
328
+ const baseY = y + size * 0.68;
329
+
330
+ ctx.beginPath();
331
+ ctx.moveTo(x + size * 0.22, baseY);
332
+ ctx.lineTo(x + size * 0.78, baseY);
333
+ ctx.lineTo(x + size * 0.88, y + size * 0.88);
334
+ ctx.lineTo(x + size * 0.12, y + size * 0.88);
335
+ ctx.closePath();
336
+ ctx.stroke();
337
+
338
+ ctx.beginPath();
339
+ ctx.moveTo(cx, baseY);
340
+ ctx.lineTo(x + size * 0.42, y + size * 0.32);
341
+ ctx.stroke();
342
+
343
+ ctx.beginPath();
344
+ ctx.arc(x + size * 0.4, y + size * 0.28, size * 0.13, 0, Math.PI * 2);
345
+ ctx.fill();
346
+
347
+ ctx.beginPath();
348
+ ctx.arc(x + size * 0.66, y + size * 0.78, size * 0.055, 0, Math.PI * 2);
349
+ ctx.arc(x + size * 0.78, y + size * 0.78, size * 0.055, 0, Math.PI * 2);
350
+ ctx.fill();
351
+ }
352
+
353
+ function drawSituation(ctx, x, y, size, color) {
354
+ const cx = x + size * 0.5;
355
+ const cy = y + size * 0.5;
356
+ ctx.strokeStyle = color;
357
+ ctx.fillStyle = color;
358
+ ctx.lineWidth = 1.25;
359
+
360
+ ctx.beginPath();
361
+ ctx.arc(cx, cy, size * 0.42, 0, Math.PI * 2);
362
+ ctx.moveTo(cx - size * 0.42, cy);
363
+ ctx.lineTo(cx + size * 0.42, cy);
364
+ ctx.moveTo(cx, cy - size * 0.42);
365
+ ctx.lineTo(cx, cy + size * 0.42);
366
+ ctx.stroke();
367
+
368
+ ctx.globalAlpha = 0.55;
369
+ ctx.beginPath();
370
+ ctx.arc(cx, cy, size * 0.25, 0, Math.PI * 2);
371
+ ctx.stroke();
372
+ ctx.globalAlpha = 1;
373
+
374
+ ctx.beginPath();
375
+ ctx.moveTo(cx, cy);
376
+ ctx.lineTo(x + size * 0.82, y + size * 0.26);
377
+ ctx.stroke();
378
+
379
+ ctx.beginPath();
380
+ ctx.arc(x + size * 0.68, y + size * 0.38, size * 0.055, 0, Math.PI * 2);
381
+ ctx.arc(x + size * 0.36, y + size * 0.62, size * 0.045, 0, Math.PI * 2);
382
+ ctx.fill();
383
+ }
384
+
385
+ function drawDamage(ctx, x, y, size, color) {
386
+ const cx = x + size * 0.5;
387
+ const cy = y + size * 0.52;
388
+ ctx.strokeStyle = color;
389
+ ctx.fillStyle = color;
390
+ ctx.lineWidth = 1.4;
391
+
392
+ ctx.beginPath();
393
+ ctx.moveTo(cx, y + 1);
394
+ ctx.lineTo(x + size * 0.9, y + size * 0.34);
395
+ ctx.lineTo(x + size * 0.68, y + size * 0.42);
396
+ ctx.lineTo(x + size - 1, y + size * 0.64);
397
+ ctx.lineTo(x + size * 0.62, y + size * 0.6);
398
+ ctx.lineTo(x + size * 0.72, y + size - 1);
399
+ ctx.lineTo(cx, y + size * 0.72);
400
+ ctx.lineTo(x + size * 0.28, y + size - 1);
401
+ ctx.lineTo(x + size * 0.38, y + size * 0.6);
402
+ ctx.lineTo(x + 1, y + size * 0.64);
403
+ ctx.lineTo(x + size * 0.32, y + size * 0.42);
404
+ ctx.lineTo(x + size * 0.1, y + size * 0.34);
405
+ ctx.closePath();
406
+ ctx.stroke();
407
+
408
+ ctx.beginPath();
409
+ ctx.arc(cx, cy, size * 0.12, 0, Math.PI * 2);
410
+ ctx.fill();
411
+ }
@@ -22,8 +22,18 @@ export const darkTheme = {
22
22
  root: { icon: 'folder', color: '#38bdf8' },
23
23
  system: { icon: 'folder', color: '#818cf8' },
24
24
  platform: { icon: 'aircraft', color: '#60a5fa' },
25
+ air: { icon: 'air', color: '#60a5fa' },
26
+ ground: { icon: 'ground', color: '#a3e635' },
27
+ surface: { icon: 'surface', color: '#22d3ee' },
28
+ subsurface: { icon: 'subsurface', color: '#38bdf8' },
29
+ space: { icon: 'space', color: '#c084fc' },
30
+ munition: { icon: 'munition', color: '#fb7185' },
25
31
  sensor: { icon: 'radar', color: '#34d399' },
26
32
  track: { icon: 'track', color: '#a78bfa' },
33
+ geometry: { icon: 'point', color: '#38bdf8' },
34
+ control: { icon: 'control', color: '#f59e0b' },
35
+ situation: { icon: 'situation', color: '#2dd4bf' },
36
+ damage: { icon: 'damage', color: '#fb7185' },
27
37
  warning: { icon: 'warning', color: '#facc15' },
28
38
  error: { icon: 'error', color: '#ef4444' },
29
39
  task: { icon: 'task', color: '#f97316' },
@@ -77,8 +87,18 @@ export const tacticalTheme = {
77
87
  ...darkTheme.types,
78
88
  root: { icon: 'folder', color: '#7ddc92' },
79
89
  platform: { icon: 'aircraft', color: '#93c572' },
90
+ air: { icon: 'air', color: '#93c572' },
91
+ ground: { icon: 'ground', color: '#c7f36f' },
92
+ surface: { icon: 'surface', color: '#67e8f9' },
93
+ subsurface: { icon: 'subsurface', color: '#7dd3fc' },
94
+ space: { icon: 'space', color: '#d8b4fe' },
95
+ munition: { icon: 'munition', color: '#ff8fab' },
80
96
  sensor: { icon: 'radar', color: '#45d483' },
81
97
  track: { icon: 'track', color: '#d6f264' },
98
+ geometry: { icon: 'point', color: '#7ddc92' },
99
+ control: { icon: 'control', color: '#ffd166' },
100
+ situation: { icon: 'situation', color: '#8be9d9' },
101
+ damage: { icon: 'damage', color: '#ff8fab' },
82
102
  warning: { icon: 'warning', color: '#ffd166' },
83
103
  error: { icon: 'error', color: '#ff5c5c' },
84
104
  },
@@ -159,4 +179,3 @@ function mergeTheme(base, override) {
159
179
  statuses: { ...base.statuses, ...(override.statuses ?? {}) },
160
180
  };
161
181
  }
162
-
@@ -59,6 +59,7 @@ export function rebuildWorkerRows(state, options = {}) {
59
59
  const rowHeight = options.rowHeight ?? 28;
60
60
  const indentWidth = options.indentWidth ?? 18;
61
61
  const expanded = new Set(options.expandedIds ?? []);
62
+ const filterCollapsed = new Set(options.filterCollapsedIds ?? []);
62
63
  const query = normalize(options.filterQuery ?? '');
63
64
  const sort = options.sort ?? { columnId: null, direction: null };
64
65
  const sortValues = options.sortValues ? new Map(options.sortValues) : null;
@@ -81,7 +82,8 @@ export function rebuildWorkerRows(state, options = {}) {
81
82
  if (!isIncluded(id)) return;
82
83
  const nodeIndex = state.idToIndex.get(id);
83
84
  if (nodeIndex === undefined) return;
84
- const expandedRow = expanded.has(id);
85
+ const children = sortIds(state.childrenByParent.get(id) ?? []);
86
+ const expandedRow = (expanded.has(id) || Boolean(query && children.length > 0)) && !filterCollapsed.has(id);
85
87
  const rowIndex = rows.length;
86
88
  rows.push({
87
89
  nodeId: id,
@@ -94,8 +96,8 @@ export function rebuildWorkerRows(state, options = {}) {
94
96
  hasChildren: hasChildren(id),
95
97
  });
96
98
  maxDepth = Math.max(maxDepth, depth);
97
- if (!expandedRow && !query) return;
98
- for (const childId of sortIds(state.childrenByParent.get(id) ?? [])) visit(childId, depth + 1);
99
+ if (!expandedRow) return;
100
+ for (const childId of children) visit(childId, depth + 1);
99
101
  };
100
102
 
101
103
  for (const rootId of sortIds(state.roots)) visit(rootId, 0);
@@ -33,6 +33,7 @@ export class VisibleRowModel extends EventTarget {
33
33
  this.contentHeight = 0;
34
34
  this.sortComparator = null;
35
35
  this.filterPredicate = null;
36
+ this.filterCollapsed = new Set();
36
37
  }
37
38
 
38
39
  setSortComparator(comparator) {
@@ -41,6 +42,15 @@ export class VisibleRowModel extends EventTarget {
41
42
 
42
43
  setFilterPredicate(predicate) {
43
44
  this.filterPredicate = predicate;
45
+ this.filterCollapsed.clear();
46
+ }
47
+
48
+ collapseFilterBranch(id) {
49
+ this.filterCollapsed.add(id);
50
+ }
51
+
52
+ expandFilterBranch(id) {
53
+ return this.filterCollapsed.delete(id);
44
54
  }
45
55
 
46
56
  applyRows({ rows, contentHeight, contentWidth }) {
@@ -84,8 +94,10 @@ export class VisibleRowModel extends EventTarget {
84
94
  if (!subtreeIncluded(id)) return;
85
95
  const nodeIndex = this.model.index.idToIndex.get(id);
86
96
  if (nodeIndex === undefined) return;
97
+ const children = sortedChildren(id);
87
98
  const hasChildren = this.expansion.hasChildren(id);
88
- const expanded = this.expansion.isExpanded(id);
99
+ const autoExpanded = Boolean(this.filterPredicate && children.length > 0);
100
+ const expanded = (this.expansion.isExpanded(id) || autoExpanded) && !this.filterCollapsed.has(id);
89
101
  const rowIndex = this.rows.length;
90
102
  this.rows.push({
91
103
  nodeId: id,
@@ -99,8 +111,8 @@ export class VisibleRowModel extends EventTarget {
99
111
  });
100
112
  this.rowIndexById.set(id, rowIndex);
101
113
  maxDepth = Math.max(maxDepth, depth);
102
- if (!expanded && !this.filterPredicate) return;
103
- for (const childId of sortedChildren(id)) visit(childId, depth + 1);
114
+ if (!expanded) return;
115
+ for (const childId of children) visit(childId, depth + 1);
104
116
  };
105
117
 
106
118
  const roots = this.model.index.roots.filter((rootId) => subtreeIncluded(rootId));
package/src/index.d.ts ADDED
@@ -0,0 +1,108 @@
1
+ export type TreeViewAlign = 'start' | 'center' | 'end' | 'nearest';
2
+
3
+ export type TreeNode = {
4
+ id: string;
5
+ parentId?: string | null;
6
+ label?: string;
7
+ type?: string;
8
+ icon?: string;
9
+ image?: string;
10
+ tags?: string[];
11
+ data?: any;
12
+ };
13
+
14
+ export type DynamicPatch = {
15
+ id: string;
16
+ state?: Record<string, any>;
17
+ } & Record<string, any>;
18
+
19
+ export type MetaRule = {
20
+ min?: number;
21
+ max?: number;
22
+ step?: number;
23
+ integer?: boolean;
24
+ readonly?: boolean;
25
+ disabled?: boolean;
26
+ updated?: boolean;
27
+ description?: string;
28
+ color?: boolean;
29
+ options?: Record<string, string | number | boolean>;
30
+ button?: string;
31
+ label?: string;
32
+ fullWidthButton?: boolean;
33
+ itemType?: 'object' | 'number' | 'string' | 'boolean';
34
+ itemTitle?: (i: number, item: any) => string;
35
+ itemFactory?: () => any;
36
+ type?: string;
37
+ icon?: string;
38
+ };
39
+
40
+ export type Column = {
41
+ id: string;
42
+ label?: string;
43
+ width?: number;
44
+ minWidth?: number;
45
+ align?: 'left' | 'center' | 'right';
46
+ kind?: string;
47
+ sortable?: boolean;
48
+ value?: (node: TreeNode, state: Record<string, any>) => string | number | boolean;
49
+ render?: (ctx: CanvasRenderingContext2D, cell: any) => void;
50
+ };
51
+
52
+ export const themes: Record<string, any>;
53
+
54
+ export class TreeRowRenderer {
55
+ renderedRows: number;
56
+ constructor(options?: Record<string, any>);
57
+ initialize(canvas: HTMLCanvasElement): void;
58
+ setScene(scene: any): void;
59
+ updateDynamicState(patches: DynamicPatch[]): void;
60
+ render(scene?: any, time?: number): void;
61
+ }
62
+
63
+ export class TreeViewController {
64
+ canvas?: HTMLCanvasElement;
65
+ viewport: {
66
+ viewportWidth: number;
67
+ viewportHeight: number;
68
+ [key: string]: any;
69
+ };
70
+ filterQuery: string;
71
+ rowModel: any;
72
+ expansion: any;
73
+ selection: any;
74
+ constructor(options?: Record<string, any>);
75
+ on(type: string, listener: (event: any) => void): any;
76
+ off(type: string, listener: (event: any) => void): void;
77
+ setData(nodes: TreeNode[]): void;
78
+ setModel(model: any, meta?: Record<string, MetaRule>, options?: Record<string, any>): void;
79
+ setColumns(columns: Column[]): void;
80
+ setDynamicState(patches: DynamicPatch[]): void;
81
+ setTheme(theme: any): void;
82
+ resize(width: number, height: number): void;
83
+ render(time?: number): void;
84
+ renderMeasured(time?: number): any;
85
+ hitTest(clientX: number, clientY: number): any;
86
+ getTooltipForHit(hit: any): any;
87
+ search(query: string, options?: Record<string, any>): any;
88
+ setFilter(queryOrPredicate?: string | ((node: any, state: any) => boolean)): void;
89
+ clearFilter(): void;
90
+ focusNode(nodeId: string, options?: Record<string, any>): boolean;
91
+ scrollToNode(nodeId: string, align?: TreeViewAlign): boolean;
92
+ getSelection(): string[];
93
+ setSelection(ids: string[]): void;
94
+ clearSelection(): void;
95
+ expandAll(): void;
96
+ collapseAll(): void;
97
+ }
98
+
99
+ export class TreeViewInputController {
100
+ constructor(options?: Record<string, any>);
101
+ destroy(): void;
102
+ }
103
+
104
+ export class CellEditorManager {
105
+ constructor(options?: Record<string, any>);
106
+ destroy(): void;
107
+ close(): void;
108
+ }
@@ -43,6 +43,7 @@ export class TreeViewInputController {
43
43
 
44
44
  #onWheel = (event) => {
45
45
  event.preventDefault();
46
+ this.cellEditor?.close?.();
46
47
  if (this.controller) {
47
48
  this.controller.scrollBy(event.shiftKey ? event.deltaY : event.deltaX, event.deltaY);
48
49
  return;
@@ -15,11 +15,18 @@ export class CellEditorManager {
15
15
  window.removeEventListener('mouseup', this.onMouseUp);
16
16
  }
17
17
 
18
+ close() {
19
+ this.#removeOverlay();
20
+ this.rangeDrag = null;
21
+ window.removeEventListener('mousemove', this.onMouseMove);
22
+ window.removeEventListener('mouseup', this.onMouseUp);
23
+ }
24
+
18
25
  handlePointerDown(event, hit) {
19
26
  if (!this.#isEditableHit(hit)) return false;
20
27
  const data = hit.row ? this.controller.model.nodes[hit.row.nodeIndex]?.data : null;
21
28
  if (!data || data.readonly || data.disabled) return false;
22
- if (data.editorType === 'range' && hit.part !== 'number') {
29
+ if (data.editorType === 'range' && hit.part === 'range') {
23
30
  this.rangeDrag = { hit, data };
24
31
  this.#updateRangeFromEvent(event);
25
32
  window.addEventListener('mousemove', this.onMouseMove);
@@ -47,7 +54,13 @@ export class CellEditorManager {
47
54
  this.controller.updateInspectorValue(node.id, !data.value, 'checkbox');
48
55
  return true;
49
56
  }
50
- if (data.editorType === 'range' && hit.part !== 'number') return true;
57
+ if (data.editorType === 'range') {
58
+ if (hit.part === 'range') return true;
59
+ if (hit.part !== 'number') {
60
+ this.#toggleRangeMinMax(node, data);
61
+ return true;
62
+ }
63
+ }
51
64
  if (this.overlay) return true;
52
65
  this.#showOverlay(node, hit);
53
66
  return true;
@@ -62,7 +75,7 @@ export class CellEditorManager {
62
75
  #showOverlay(node, hit, options = {}) {
63
76
  this.#removeOverlay();
64
77
  const data = node.data;
65
- const rect = this.#overlayRect(hit);
78
+ const rect = this.#clampRectToHost(this.#overlayRect(hit), 4);
66
79
  const hostRect = this.host.getBoundingClientRect();
67
80
  const element = createEditorElement(data);
68
81
  Object.assign(element.style, {
@@ -98,7 +111,7 @@ export class CellEditorManager {
98
111
  ensureOverlayHost(this.host);
99
112
  this.host.append(element);
100
113
  this.overlay = element;
101
- element.focus();
114
+ element.focus({ preventScroll: true });
102
115
  element.select?.();
103
116
  if (options.showPicker && element.showPicker) {
104
117
  requestAnimationFrame(() => {
@@ -113,7 +126,13 @@ export class CellEditorManager {
113
126
 
114
127
  #showHeaderFilterOverlay(hit) {
115
128
  this.#removeOverlay();
116
- const rect = this.controller.getHeaderClientRect(hit);
129
+ const headerRect = this.controller.getHeaderClientRect(hit);
130
+ const rect = this.#clampRectToHost({
131
+ x: headerRect.x + 8,
132
+ y: headerRect.y + 5,
133
+ width: Math.max(24, Math.min(headerRect.width, this.controller.viewport.viewportWidth) - 16),
134
+ height: Math.max(20, headerRect.height - 10),
135
+ }, 0);
117
136
  const hostRect = this.host.getBoundingClientRect();
118
137
  const element = document.createElement('input');
119
138
  element.type = 'search';
@@ -121,12 +140,12 @@ export class CellEditorManager {
121
140
  element.placeholder = 'Filter inspector';
122
141
  Object.assign(element.style, {
123
142
  position: 'absolute',
124
- left: `${rect.x - hostRect.left + 8}px`,
125
- top: `${rect.y - hostRect.top + 5}px`,
126
- width: `${Math.max(24, rect.width - 16)}px`,
127
- height: `${Math.max(20, rect.height - 10)}px`,
143
+ left: `${rect.x - hostRect.left}px`,
144
+ top: `${rect.y - hostRect.top}px`,
145
+ width: `${Math.max(24, rect.width)}px`,
146
+ height: `${Math.max(20, rect.height)}px`,
128
147
  minWidth: '0',
129
- maxWidth: `${Math.max(24, rect.width - 16)}px`,
148
+ maxWidth: `${Math.max(24, rect.width)}px`,
130
149
  zIndex: 20,
131
150
  boxSizing: 'border-box',
132
151
  margin: '0',
@@ -147,7 +166,7 @@ export class CellEditorManager {
147
166
  ensureOverlayHost(this.host);
148
167
  this.host.append(element);
149
168
  this.overlay = element;
150
- element.focus();
169
+ element.focus({ preventScroll: true });
151
170
  element.select();
152
171
  }
153
172
 
@@ -184,8 +203,9 @@ export class CellEditorManager {
184
203
  #overlayRect(hit) {
185
204
  if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
186
205
  const rect = this.controller.getCellClientRect(hit);
187
- const editorLeft = Math.min(Math.max(210, rect.width * 0.42), rect.width - 180);
188
- const editorWidth = Math.max(80, rect.width - editorLeft - 14);
206
+ const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.viewportWidth));
207
+ const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
208
+ const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
189
209
  if (hit.part === 'number') {
190
210
  const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
191
211
  return { x: rect.x + editorLeft + editorWidth - valueWidth - 10, y: rect.y + 4, width: valueWidth, height: rect.height - 8 };
@@ -196,8 +216,9 @@ export class CellEditorManager {
196
216
  #rangeBarRect(hit) {
197
217
  const rect = this.controller.getCellClientRect(hit);
198
218
  if (hit.column?.kind === 'inspectorPane') {
199
- const editorLeft = Math.min(Math.max(210, rect.width * 0.42), rect.width - 180);
200
- const editorWidth = Math.max(80, rect.width - editorLeft - 14);
219
+ const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.viewportWidth));
220
+ const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
221
+ const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
201
222
  const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
202
223
  const barWidth = Math.max(24, editorWidth - 20 - valueWidth - 8);
203
224
  return { x: rect.x + editorLeft + 10, y: rect.y + rect.height / 2 - 4, width: barWidth, height: 8 };
@@ -206,10 +227,31 @@ export class CellEditorManager {
206
227
  return { x: rect.x + 10, y: rect.y + rect.height / 2 - 4, width: Math.max(24, rect.width - 20 - valueWidth - 8), height: 8 };
207
228
  }
208
229
 
230
+ #clampRectToHost(rect, inset = 0) {
231
+ const hostRect = this.host.getBoundingClientRect();
232
+ const minX = hostRect.left + inset;
233
+ const minY = hostRect.top + inset;
234
+ const maxX = hostRect.right - inset;
235
+ const maxY = hostRect.bottom - inset;
236
+ const width = Math.max(24, Math.min(rect.width, maxX - minX));
237
+ const height = Math.max(18, Math.min(rect.height, maxY - minY));
238
+ const x = Math.max(minX, Math.min(rect.x, maxX - width));
239
+ const y = Math.max(minY, Math.min(rect.y, maxY - height));
240
+ return { x, y, width, height };
241
+ }
242
+
209
243
  #removeOverlay() {
210
244
  this.overlay?.remove();
211
245
  this.overlay = null;
212
246
  }
247
+
248
+ #toggleRangeMinMax(node, data) {
249
+ const meta = data.meta ?? {};
250
+ const min = Number.isFinite(meta.min) ? meta.min : 0;
251
+ const max = Number.isFinite(meta.max) ? meta.max : 100;
252
+ const current = typeof data.value === 'number' && Number.isFinite(data.value) ? data.value : min;
253
+ this.controller.updateInspectorValue(node.id, current <= min ? max : min, 'range');
254
+ }
213
255
  }
214
256
 
215
257
  function createEditorElement(data) {
@@ -30,7 +30,8 @@ export class ModelInspectorBuilder {
30
30
  id,
31
31
  parentId,
32
32
  label,
33
- type: inspectorNodeType(valueType, editorType),
33
+ type: rule.type ?? inspectorNodeType(valueType, editorType),
34
+ icon: rule.icon,
34
35
  data: {
35
36
  inspector: true,
36
37
  path,
@@ -47,11 +47,13 @@ export class TreeRowRenderer {
47
47
  ctx.translate(viewport.renderInsetX ?? 0, viewport.renderInsetY ?? 0);
48
48
  this.#drawHeader(ctx);
49
49
  this.#drawRows(ctx);
50
+ this.#drawScrollIndicator(ctx);
50
51
  ctx.restore();
51
52
  }
52
53
 
53
54
  #drawHeader(ctx) {
54
55
  const { viewport, columns, theme, sort, headerFilter, filterQuery } = this.scene;
56
+ if (viewport.headerHeight <= 0) return;
55
57
  const colors = theme.colors;
56
58
  ctx.save();
57
59
  ctx.fillStyle = colors.row;
@@ -65,10 +67,10 @@ export class TreeRowRenderer {
65
67
  this.#drawHeaderFilter(ctx, column, viewport, theme, filterQuery);
66
68
  } else {
67
69
  ctx.fillStyle = colors.textMuted;
68
- ctx.fillText(column.label, column.x + 10, viewport.headerHeight / 2, column.width - 20);
70
+ drawTruncatedText(ctx, column.label, column.x + 10, viewport.headerHeight / 2, column.width - 20);
69
71
  }
70
72
  if (sort?.columnId === column.id && sort.direction) {
71
- ctx.fillText(sort.direction === 'asc' ? '^' : 'v', column.x + column.width - 16, viewport.headerHeight / 2, 12);
73
+ drawTruncatedText(ctx, sort.direction === 'asc' ? '^' : 'v', column.x + column.width - 16, viewport.headerHeight / 2, 12);
72
74
  }
73
75
  ctx.strokeStyle = colors.border;
74
76
  ctx.beginPath();
@@ -89,7 +91,9 @@ export class TreeRowRenderer {
89
91
  const colors = theme.colors;
90
92
  const x = column.x + 8;
91
93
  const y = 5;
92
- const width = Math.max(40, column.width - 16);
94
+ const visibleRight = viewport.scrollX + viewport.viewportWidth;
95
+ const visibleWidth = Math.max(1, Math.min(column.x + column.width, visibleRight) - column.x);
96
+ const width = Math.max(40, visibleWidth - 16);
93
97
  const height = Math.max(18, viewport.headerHeight - 10);
94
98
  ctx.fillStyle = colors.progressTrack;
95
99
  roundRect(ctx, x, y, width, height, 4);
@@ -97,13 +101,16 @@ export class TreeRowRenderer {
97
101
  ctx.strokeStyle = colors.border;
98
102
  ctx.stroke();
99
103
  ctx.fillStyle = filterQuery ? colors.text : colors.textMuted;
100
- ctx.fillText(filterQuery || 'Filter inspector', x + 8, viewport.headerHeight / 2, width - 16);
104
+ drawTruncatedText(ctx, filterQuery || 'Filter inspector', x + 8, viewport.headerHeight / 2, width - 16);
101
105
  }
102
106
 
103
107
  #drawRows(ctx) {
104
108
  const { rows, visibleRange, viewport } = this.scene;
105
109
  this.renderedRows = visibleRange.count;
106
110
  ctx.save();
111
+ ctx.beginPath();
112
+ ctx.rect(0, viewport.headerHeight, viewport.viewportWidth, viewport.rowViewportHeight);
113
+ ctx.clip();
107
114
  ctx.translate(-viewport.scrollX, viewport.headerHeight - viewport.scrollY);
108
115
  for (let i = visibleRange.first; i <= visibleRange.last; i++) {
109
116
  const row = rows[i];
@@ -112,6 +119,36 @@ export class TreeRowRenderer {
112
119
  ctx.restore();
113
120
  }
114
121
 
122
+ #drawScrollIndicator(ctx) {
123
+ const { viewport, theme } = this.scene;
124
+ const maxY = Math.max(0, viewport.contentHeight - viewport.rowViewportHeight);
125
+ if (maxY <= 0 || viewport.viewportHeight <= viewport.headerHeight + 12) return;
126
+
127
+ const trackTop = viewport.headerHeight + 4;
128
+ const trackHeight = Math.max(1, viewport.viewportHeight - viewport.headerHeight - 8);
129
+ const thumbHeight = Math.max(18, Math.min(trackHeight, trackHeight * (viewport.rowViewportHeight / viewport.contentHeight)));
130
+ const thumbY = trackTop + (trackHeight - thumbHeight) * (viewport.scrollY / maxY);
131
+ const x = Math.max(2, viewport.viewportWidth - 4);
132
+
133
+ ctx.save();
134
+ ctx.lineCap = 'round';
135
+ ctx.lineWidth = 2;
136
+ ctx.globalAlpha = 0.28;
137
+ ctx.strokeStyle = theme.colors.guide;
138
+ ctx.beginPath();
139
+ ctx.moveTo(x, trackTop);
140
+ ctx.lineTo(x, trackTop + trackHeight);
141
+ ctx.stroke();
142
+
143
+ ctx.globalAlpha = 0.9;
144
+ ctx.strokeStyle = theme.colors.focus;
145
+ ctx.beginPath();
146
+ ctx.moveTo(x, thumbY);
147
+ ctx.lineTo(x, thumbY + thumbHeight);
148
+ ctx.stroke();
149
+ ctx.restore();
150
+ }
151
+
115
152
  #drawRow(ctx, row) {
116
153
  const { columns, nodes, dynamicState, selection, hoverNodeId, focusNodeId, searchMatches, theme, viewport } = this.scene;
117
154
  const node = nodes[row.nodeIndex];
@@ -123,10 +160,11 @@ export class TreeRowRenderer {
123
160
  const hovered = hoverNodeId === row.nodeId;
124
161
  const focused = focusNodeId === row.nodeId;
125
162
  const y = row.y;
126
- const rowWidth = Math.max(viewport.contentWidth, viewport.scrollX + viewport.viewportWidth);
163
+ const visibleX = viewport.scrollX;
164
+ const visibleWidth = viewport.viewportWidth;
127
165
 
128
166
  ctx.fillStyle = selected ? colors.rowSelected : highlighted ? colors.rowHighlighted : hovered ? colors.rowHover : colors.row;
129
- ctx.fillRect(0, y, rowWidth, row.height);
167
+ ctx.fillRect(visibleX, y, visibleWidth, row.height);
130
168
 
131
169
  this.#drawIndentGuides(ctx, row, colors);
132
170
 
@@ -142,20 +180,15 @@ export class TreeRowRenderer {
142
180
 
143
181
  ctx.strokeStyle = colors.border;
144
182
  ctx.beginPath();
145
- ctx.moveTo(0, y + row.height + 0.5);
146
- ctx.lineTo(rowWidth, y + row.height + 0.5);
183
+ ctx.moveTo(visibleX, y + row.height + 0.5);
184
+ ctx.lineTo(visibleX + visibleWidth, y + row.height + 0.5);
147
185
  ctx.stroke();
148
186
 
149
- if (state.updated && node.data?.inspector) {
150
- ctx.fillStyle = theme.colors.focus;
151
- ctx.beginPath();
152
- ctx.arc(rowWidth - 10, y + row.height / 2, 3, 0, Math.PI * 2);
153
- ctx.fill();
154
- }
187
+ if (state.updated && node.data?.inspector) this.#drawUpdatedMarker(ctx, row, theme);
155
188
 
156
189
  if (focused) {
157
190
  ctx.strokeStyle = colors.focus;
158
- ctx.strokeRect(1.5, y + 2.5, rowWidth - 3, row.height - 5);
191
+ ctx.strokeRect(visibleX + 1.5, y + 2.5, visibleWidth - 3, row.height - 5);
159
192
  }
160
193
  }
161
194
 
@@ -174,25 +207,35 @@ export class TreeRowRenderer {
174
207
  else this.#drawTextCell(ctx, cell);
175
208
  }
176
209
 
177
- #drawInspectorPaneCell(ctx, { node, row, rect, theme }) {
210
+ #drawInspectorPaneCell(ctx, { node, row, rect, theme, style }) {
211
+ const visibleRight = this.scene.viewport.scrollX + this.scene.viewport.viewportWidth;
212
+ rect = { ...rect, width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x) };
178
213
  const data = node.data ?? {};
179
214
  const colors = theme.colors;
180
215
  const indentX = rect.x + row.depth * theme.indentWidth;
181
- const labelX = indentX + 28;
182
216
  const cy = rect.y + rect.height / 2;
183
- this.#drawChevron(ctx, indentX + 10, cy, row, colors);
217
+ let labelX = indentX + 28;
218
+ if (!row.hasChildren && node.icon) {
219
+ this.iconRegistry.draw(ctx, style.icon, indentX + 3, rect.y + 6, 15, style.color);
220
+ labelX = indentX + 24;
221
+ } else if (!row.hasChildren) {
222
+ labelX = indentX + 24;
223
+ } else {
224
+ this.#drawChevron(ctx, indentX + 10, cy, row, colors);
225
+ }
184
226
  ctx.font = theme.font;
185
227
  ctx.textBaseline = 'middle';
186
228
  ctx.textAlign = 'left';
187
229
  ctx.globalAlpha = data.disabled ? 0.45 : 1;
188
230
  ctx.fillStyle = data.valueType === 'object' || data.valueType === 'array' ? colors.text : colors.textMuted;
189
- ctx.fillText(node.label ?? node.id, labelX, cy, 180);
231
+ const layout = inspectorPaneLayout(rect.width, row.depth, theme.indentWidth, data.editorType, this.scene.inspectorPaneLabelEnd);
232
+ drawTruncatedText(ctx, node.label ?? node.id, labelX, cy, Math.max(20, rect.x + layout.editorLeft - labelX - 8));
190
233
 
191
- const editorX = rect.x + Math.min(Math.max(210, rect.width * 0.42), rect.width - 180);
192
- const editorWidth = Math.max(80, rect.x + rect.width - editorX - 14);
234
+ const editorX = rect.x + layout.editorLeft;
235
+ const editorWidth = layout.editorWidth;
193
236
  if (data.valueType === 'array') {
194
237
  ctx.fillStyle = colors.textMuted;
195
- ctx.fillText(data.valueText, editorX, cy, Math.max(20, editorWidth - 58));
238
+ drawTruncatedText(ctx, data.valueText, editorX, cy, Math.max(20, editorWidth - 58));
196
239
  this.#drawSmallButton(ctx, rect.x + rect.width - 54, rect.y + 5, 22, rect.height - 10, '+', theme);
197
240
  this.#drawSmallButton(ctx, rect.x + rect.width - 28, rect.y + 5, 22, rect.height - 10, '-', theme);
198
241
  } else if (data.valueType === 'object') {
@@ -202,12 +245,14 @@ export class TreeRowRenderer {
202
245
  node,
203
246
  rect: { x: editorX, y: rect.y, width: editorWidth, height: rect.height },
204
247
  theme,
248
+ suppressUpdatedMarker: true,
205
249
  });
206
250
  }
251
+ if (data.meta?.updated) this.#drawUpdatedMarker(ctx, row, theme);
207
252
  ctx.globalAlpha = 1;
208
253
  }
209
254
 
210
- #drawInspectorValueCell(ctx, { node, rect, theme }) {
255
+ #drawInspectorValueCell(ctx, { node, rect, theme, suppressUpdatedMarker = false }) {
211
256
  const data = node.data ?? {};
212
257
  const meta = data.meta ?? {};
213
258
  const disabled = data.disabled;
@@ -237,7 +282,7 @@ export class TreeRowRenderer {
237
282
  ctx.fill();
238
283
  ctx.fillStyle = theme.colors.text;
239
284
  ctx.textAlign = 'center';
240
- ctx.fillText(meta.button ?? node.label, x + (meta.fullWidthButton ? width : Math.min(width, 140)) / 2, rect.y + rect.height / 2);
285
+ 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));
241
286
  ctx.textAlign = 'left';
242
287
  } else if (data.editorType === 'select') {
243
288
  ctx.fillStyle = theme.colors.rowHover;
@@ -245,12 +290,12 @@ export class TreeRowRenderer {
245
290
  ctx.fill();
246
291
  this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, Math.min(width, 180) - 22, theme);
247
292
  ctx.fillStyle = theme.colors.chevron;
248
- ctx.fillText('v', x + Math.min(width, 180) - 14, rect.y + rect.height / 2);
293
+ drawTruncatedText(ctx, 'v', x + Math.min(width, 180) - 14, rect.y + rect.height / 2, 10);
249
294
  } else {
250
295
  this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
251
296
  }
252
297
 
253
- if (meta.updated) {
298
+ if (meta.updated && !suppressUpdatedMarker) {
254
299
  ctx.fillStyle = theme.colors.focus;
255
300
  ctx.beginPath();
256
301
  ctx.arc(rect.x + rect.width - 10, rect.y + rect.height / 2, 3, 0, Math.PI * 2);
@@ -259,6 +304,15 @@ export class TreeRowRenderer {
259
304
  ctx.globalAlpha = 1;
260
305
  }
261
306
 
307
+ #drawUpdatedMarker(ctx, row, theme) {
308
+ const x = Math.max(6, row.depth * theme.indentWidth - 8);
309
+ const y = row.y + row.height / 2;
310
+ ctx.fillStyle = theme.colors.focus;
311
+ ctx.beginPath();
312
+ ctx.arc(x, y, 3, 0, Math.PI * 2);
313
+ ctx.fill();
314
+ }
315
+
262
316
  #drawInspectorTypeCell(ctx, { node, rect, theme }) {
263
317
  this.#drawMutedText(ctx, node.data?.valueType ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme);
264
318
  }
@@ -299,7 +353,7 @@ export class TreeRowRenderer {
299
353
  ctx.fill();
300
354
  ctx.fillStyle = theme.colors.text;
301
355
  ctx.textAlign = 'right';
302
- ctx.fillText(String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
356
+ drawTruncatedText(ctx, String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
303
357
  ctx.textAlign = 'left';
304
358
  }
305
359
 
@@ -310,7 +364,7 @@ export class TreeRowRenderer {
310
364
  ctx.fillStyle = theme.colors.text;
311
365
  ctx.textAlign = 'center';
312
366
  ctx.textBaseline = 'middle';
313
- ctx.fillText(label, x + width / 2, y + height / 2, width - 4);
367
+ drawTruncatedText(ctx, label, x + width / 2, y + height / 2, width - 4);
314
368
  ctx.textAlign = 'left';
315
369
  }
316
370
 
@@ -319,7 +373,7 @@ export class TreeRowRenderer {
319
373
  ctx.font = theme.font;
320
374
  ctx.textBaseline = 'middle';
321
375
  ctx.textAlign = 'left';
322
- ctx.fillText(String(text ?? ''), x, y, Math.max(10, width));
376
+ drawTruncatedText(ctx, String(text ?? ''), x, y, Math.max(10, width));
323
377
  }
324
378
 
325
379
  #drawTreeCell(ctx, { node, row, rect, theme, style }) {
@@ -332,7 +386,7 @@ export class TreeRowRenderer {
332
386
  ctx.font = theme.font;
333
387
  ctx.textBaseline = 'middle';
334
388
  ctx.textAlign = 'left';
335
- ctx.fillText(node.label ?? node.id, x + 50, cy, Math.max(40, rect.x + rect.width - x - 56));
389
+ drawTruncatedText(ctx, node.label ?? node.id, x + 50, cy, Math.max(40, rect.x + rect.width - x - 56));
336
390
  }
337
391
 
338
392
  #drawStatusCell(ctx, { rect, style, theme }) {
@@ -346,7 +400,7 @@ export class TreeRowRenderer {
346
400
  ctx.font = '10px system-ui, sans-serif';
347
401
  ctx.textAlign = 'center';
348
402
  ctx.textBaseline = 'middle';
349
- ctx.fillText(style.status.label, x + badgeWidth / 2, y + 8, badgeWidth - 8);
403
+ drawTruncatedText(ctx, style.status.label, x + badgeWidth / 2, y + 8, badgeWidth - 8);
350
404
  ctx.textAlign = 'left';
351
405
  }
352
406
 
@@ -370,7 +424,7 @@ export class TreeRowRenderer {
370
424
  ctx.textBaseline = 'middle';
371
425
  ctx.textAlign = column.align;
372
426
  const x = column.align === 'right' ? rect.x + rect.width - 10 : column.align === 'center' ? rect.x + rect.width / 2 : rect.x + 10;
373
- ctx.fillText(String(value ?? ''), x, rect.y + rect.height / 2, rect.width - 20);
427
+ drawTruncatedText(ctx, String(value ?? ''), x, rect.y + rect.height / 2, rect.width - 20);
374
428
  ctx.textAlign = 'left';
375
429
  }
376
430
 
@@ -421,6 +475,22 @@ function resolveNodeStyle(theme, node, state = {}) {
421
475
  };
422
476
  }
423
477
 
478
+ function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
479
+ const safeWidth = Math.max(1, width);
480
+ const rightPadding = 14;
481
+ if (editorType === 'checkbox') {
482
+ const editorWidth = 34;
483
+ return { editorLeft: Math.max(64, safeWidth - rightPadding - editorWidth), editorWidth };
484
+ }
485
+ const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
486
+ const minLabelEnd = Math.max(88, depth * indentWidth + 104);
487
+ const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
488
+ const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
489
+ const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
490
+ const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
491
+ return { editorLeft, editorWidth };
492
+ }
493
+
424
494
  function clamp01(value) {
425
495
  return Math.max(0, Math.min(1, value));
426
496
  }
@@ -438,6 +508,29 @@ function roundRect(ctx, x, y, width, height, radius) {
438
508
  ctx.quadraticCurveTo(x, y, x + radius, y);
439
509
  }
440
510
 
511
+ function drawTruncatedText(ctx, text, x, y, maxWidth) {
512
+ const value = String(text ?? '');
513
+ const width = Math.max(0, maxWidth);
514
+ if (!value || width <= 0) return;
515
+ ctx.fillText(fitText(ctx, value, width), x, y);
516
+ }
517
+
518
+ function fitText(ctx, text, maxWidth) {
519
+ if (ctx.measureText(text).width <= maxWidth) return text;
520
+ const ellipsis = '...';
521
+ const ellipsisWidth = ctx.measureText(ellipsis).width;
522
+ if (ellipsisWidth > maxWidth) return '';
523
+ let low = 0;
524
+ let high = text.length;
525
+ while (low < high) {
526
+ const mid = Math.ceil((low + high) / 2);
527
+ const candidate = text.slice(0, mid);
528
+ if (ctx.measureText(candidate).width + ellipsisWidth <= maxWidth) low = mid;
529
+ else high = mid - 1;
530
+ }
531
+ return `${text.slice(0, low)}${ellipsis}`;
532
+ }
533
+
441
534
  function formatTime(value) {
442
535
  return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
443
536
  }
@@ -86,17 +86,21 @@ export class TreeViewController {
86
86
  setModel(model, meta = {}, options = {}) {
87
87
  const builder = new ModelInspectorBuilder();
88
88
  const presentation = options.presentation ?? options.mode ?? 'table';
89
+ const previousExpanded = new Set(this.model.expanded);
90
+ const shouldPreserveExpansion = Boolean(this.inspector);
89
91
  const inspectorOptions = {
90
92
  presentation,
91
93
  flatRoot: Boolean(options.flatRoot),
92
94
  enforceMeta: Boolean(options.enforceMeta),
93
95
  filter: Boolean(options.filter),
96
+ markUpdated: options.markUpdated !== false,
94
97
  };
95
98
  const nodes = builder.build(model, meta, inspectorOptions);
96
99
  this.inspector = { model, meta, builder, presentation, options: inspectorOptions };
97
100
  this.setColumns(presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns());
98
101
  this.model.setTree(nodes);
99
- this.expansion.expandToDepth(this.initialExpandDepth);
102
+ if (shouldPreserveExpansion) this.#restoreExpansion(previousExpanded);
103
+ else this.expansion.expandToDepth(this.initialExpandDepth);
100
104
  this.searchIndex.rebuild(this.model);
101
105
  this.#rebuildRows();
102
106
  this.events.emit('modelchange', { model, meta, structural: true });
@@ -113,7 +117,9 @@ export class TreeViewController {
113
117
  data.value = newValue;
114
118
  data.valueType = Array.isArray(newValue) ? 'array' : newValue === null ? 'null' : typeof newValue === 'object' ? 'object' : typeof newValue;
115
119
  data.valueText = formatInspectorValue(newValue, data.meta);
116
- this.setDynamicState([{ id: nodeId, state: { updated: true } }]);
120
+ if (this.inspector.options.markUpdated !== false) {
121
+ this.setDynamicState([{ id: nodeId, state: { updated: true } }]);
122
+ }
117
123
  const detail = { path: data.path, oldValue, newValue, nodeId, editorType };
118
124
  this.events.emit('valuechange', detail);
119
125
  this.events.emit('modelchange', { model: this.inspector.model, path: data.path, oldValue, newValue, nodeId });
@@ -275,6 +281,11 @@ export class TreeViewController {
275
281
  }
276
282
 
277
283
  expand(nodeId) {
284
+ if (this.filterQuery && this.rowModel.expandFilterBranch(nodeId)) {
285
+ this.#rebuildRows();
286
+ this.events.emit('expand', { nodeId, filter: true });
287
+ return true;
288
+ }
278
289
  if (!this.expansion.expand(nodeId)) return false;
279
290
  this.#rebuildRows();
280
291
  this.events.emit('expand', { nodeId });
@@ -282,14 +293,19 @@ export class TreeViewController {
282
293
  }
283
294
 
284
295
  collapse(nodeId) {
285
- if (!this.expansion.collapse(nodeId)) return false;
296
+ const row = this.rowModel.getRowById(nodeId);
297
+ const shouldCollapseFilterBranch = Boolean(this.filterQuery && row?.expanded);
298
+ const changed = this.expansion.collapse(nodeId);
299
+ if (shouldCollapseFilterBranch) this.rowModel.collapseFilterBranch(nodeId);
300
+ if (!changed && !shouldCollapseFilterBranch) return false;
286
301
  this.#rebuildRows();
287
- this.events.emit('collapse', { nodeId });
302
+ this.events.emit('collapse', { nodeId, filter: shouldCollapseFilterBranch });
288
303
  return true;
289
304
  }
290
305
 
291
306
  toggle(nodeId) {
292
- return this.expansion.isExpanded(nodeId) ? this.collapse(nodeId) : this.expand(nodeId);
307
+ const row = this.rowModel.getRowById(nodeId);
308
+ return (row?.expanded ?? this.expansion.isExpanded(nodeId)) ? this.collapse(nodeId) : this.expand(nodeId);
293
309
  }
294
310
 
295
311
  expandAll() {
@@ -573,12 +589,17 @@ export class TreeViewController {
573
589
  searchMatches: this.searchHighlights,
574
590
  sort: this.columnModel.sort,
575
591
  sortValues: this.sortValueSnapshot ? Array.from(this.sortValueSnapshot) : null,
592
+ inspectorPaneLabelEnd: this.#computeInspectorPaneLabelEnd(visibleRange),
576
593
  filterQuery: this.filterQuery,
577
594
  headerFilter: Boolean(this.inspector?.options?.filter),
578
595
  stats: this.getStats(),
579
596
  };
580
597
  }
581
598
 
599
+ closeEditor() {
600
+ this.events.emit('editorclose', {});
601
+ }
602
+
582
603
  hitTest(clientX, clientY) {
583
604
  const localX = clientX - (this.viewport.renderInsetX ?? 0);
584
605
  const localY = clientY - (this.viewport.renderInsetY ?? 0);
@@ -609,7 +630,7 @@ export class TreeViewController {
609
630
  if (localX >= column.width - 54 && localX <= column.width - 32) part = 'arrayAdd';
610
631
  else if (localX >= column.width - 28 && localX <= column.width - 6) part = 'arrayRemove';
611
632
  else {
612
- const editorLeft = Math.min(Math.max(210, column.width * 0.42), column.width - 180);
633
+ const editorLeft = this.getInspectorPaneLayout(this.#visibleInspectorPaneWidth(column), row, node?.data?.editorType).editorLeft;
613
634
  if (localX >= editorLeft) part = 'editor';
614
635
  else {
615
636
  const treeX = row.depth * this.rowModel.indentWidth;
@@ -618,8 +639,8 @@ export class TreeViewController {
618
639
  }
619
640
  return { area: 'row', part, row, column, x, y: rowY };
620
641
  }
621
- const editorLeft = Math.min(Math.max(210, column.width * 0.42), column.width - 180);
622
- if (localX >= editorLeft) part = this.#inspectorEditorPart(row, localX - editorLeft, column.width - editorLeft);
642
+ const layout = this.getInspectorPaneLayout(this.#visibleInspectorPaneWidth(column), row, node?.data?.editorType);
643
+ if (localX >= layout.editorLeft) part = this.#inspectorEditorPart(row, localX - layout.editorLeft, layout.editorWidth);
623
644
  else {
624
645
  const treeX = row.depth * this.rowModel.indentWidth;
625
646
  if (localX >= treeX + 4 && localX <= treeX + 22) part = 'chevron';
@@ -642,13 +663,23 @@ export class TreeViewController {
642
663
  if (!data?.inspector) return 'cell';
643
664
  if (data.editorType === 'checkbox') return 'checkbox';
644
665
  if (data.editorType === 'range') {
645
- const numberLeft = Math.max(50, editorWidth - 64);
666
+ const width = Math.max(24, editorWidth - 20);
667
+ const valueWidth = Math.min(64, Math.max(42, width * 0.28));
668
+ const numberLeft = editorWidth - valueWidth - 10;
646
669
  return localEditorX >= numberLeft ? 'number' : 'range';
647
670
  }
648
671
  if (data.editorType === 'button') return 'button';
649
672
  return 'editor';
650
673
  }
651
674
 
675
+ #visibleInspectorPaneWidth(column) {
676
+ return Math.max(1, Math.min(column.width, this.viewport.scrollX + this.viewport.viewportWidth - column.x));
677
+ }
678
+
679
+ getInspectorPaneLayout(width, row = null, editorType = '') {
680
+ return inspectorPaneLayout(width, row?.depth ?? 0, this.rowModel.indentWidth, editorType, this.#computeInspectorPaneLabelEnd(this.rowModel.getVisibleRange(this.viewport, 4)));
681
+ }
682
+
652
683
  getCellClientRect(hit) {
653
684
  if (!hit?.row || !hit.column || !this.canvas) return { x: 0, y: 0, width: 0, height: 0 };
654
685
  const rect = this.canvas.getBoundingClientRect();
@@ -671,8 +702,57 @@ export class TreeViewController {
671
702
  };
672
703
  }
673
704
 
705
+ getTooltipForHit(hit) {
706
+ if (!hit?.row || !hit.column) return null;
707
+ const node = this.model.nodes[hit.row.nodeIndex];
708
+ if (!node) return null;
709
+ const state = this.model.dynamicState.get(hit.row.nodeId) ?? {};
710
+ const rect = this.getCellClientRect(hit);
711
+ let text = '';
712
+ let width = rect.width;
713
+
714
+ if (hit.column.kind === 'inspectorPane') {
715
+ const data = node.data ?? {};
716
+ const visibleWidth = this.#visibleInspectorPaneWidth(hit.column);
717
+ const layout = this.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
718
+ if (hit.part === 'label' || hit.part === 'chevron') {
719
+ const labelX = hit.row.depth * this.rowModel.indentWidth + (hit.row.hasChildren ? 28 : 24);
720
+ text = node.label ?? node.id;
721
+ width = Math.max(0, layout.editorLeft - labelX - 8);
722
+ } else if (hit.part === 'arrayAdd' || hit.part === 'arrayRemove') {
723
+ return null;
724
+ } else {
725
+ text = inspectorTooltipValue(node);
726
+ width = Math.max(0, layout.editorWidth - 20);
727
+ }
728
+ } else if (hit.column.kind === 'tree') {
729
+ const labelX = hit.row.depth * this.rowModel.indentWidth + 50;
730
+ text = node.label ?? node.id;
731
+ width = Math.max(0, hit.column.width - labelX - 6);
732
+ } else if (hit.column.kind === 'inspectorValue') {
733
+ text = inspectorTooltipValue(node);
734
+ width = Math.max(0, hit.column.width - 20);
735
+ } else if (hit.column.kind === 'inspectorType') {
736
+ text = node.data?.valueType ?? '';
737
+ width = Math.max(0, hit.column.width - 20);
738
+ } else if (hit.column.kind === 'inspectorDescription') {
739
+ text = node.data?.meta?.description ?? '';
740
+ width = Math.max(0, hit.column.width - 20);
741
+ } else if (typeof hit.column.value === 'function') {
742
+ const value = hit.column.value(node, state);
743
+ text = value == null ? '' : String(value);
744
+ width = Math.max(0, hit.column.width - 20);
745
+ }
746
+
747
+ text = String(text ?? '');
748
+ if (!text || !isProbablyTruncated(text, width)) return null;
749
+ return { text, rect, nodeId: node.id, part: hit.part, columnId: hit.column.id };
750
+ }
751
+
674
752
  resize(width, height) {
675
753
  this.viewport.resize(width, height);
754
+ this.#fitInspectorPaneColumn(width);
755
+ this.#syncContentSize();
676
756
  this.events.emit('viewportchange', this.getViewportState());
677
757
  }
678
758
 
@@ -726,6 +806,13 @@ export class TreeViewController {
726
806
  this.setDynamicState(this.patchBatcher.flush());
727
807
  }
728
808
 
809
+ #restoreExpansion(expandedIds) {
810
+ this.model.expanded.clear();
811
+ for (const id of expandedIds) {
812
+ if (this.model.index.getNode(id) && this.expansion.hasChildren(id)) this.model.expanded.add(id);
813
+ }
814
+ }
815
+
729
816
  #focusedRowIndex() {
730
817
  if (this.focusedId) {
731
818
  const row = this.rowModel.getRowById(this.focusedId);
@@ -740,12 +827,41 @@ export class TreeViewController {
740
827
  const height = this.canvas.clientHeight;
741
828
  if (width > 0 && height > 0 && (this.viewport.viewportWidth !== width || this.viewport.viewportHeight !== height)) {
742
829
  this.viewport.resize(width, height);
830
+ this.#fitInspectorPaneColumn(width);
831
+ this.#syncContentSize();
832
+ }
833
+ }
834
+
835
+ #computeInspectorPaneLabelEnd(visibleRange) {
836
+ const column = this.columnModel.columns.find((item) => item.kind === 'inspectorPane');
837
+ if (!column) return 0;
838
+ let labelEnd = 0;
839
+ for (let i = visibleRange.first; i <= visibleRange.last; i++) {
840
+ const row = this.rowModel.rows[i];
841
+ if (!row) continue;
842
+ const node = this.model.nodes[row.nodeIndex];
843
+ if (!node) continue;
844
+ const indentX = row.depth * this.rowModel.indentWidth;
845
+ const labelX = indentX + (row.hasChildren ? 28 : 24);
846
+ const labelWidth = String(node.label ?? node.id ?? '').length * 6.4;
847
+ labelEnd = Math.max(labelEnd, labelX + labelWidth + 12);
743
848
  }
849
+ return Math.min(labelEnd, Math.max(90, this.#visibleInspectorPaneWidth(column) - 72));
850
+ }
851
+
852
+ #fitInspectorPaneColumn(width) {
853
+ const column = this.columnModel.columns.length === 1 ? this.columnModel.columns[0] : null;
854
+ if (column?.kind !== 'inspectorPane' && column?.kind !== 'tree') return;
855
+ if (column.kind === 'inspectorPane' && this.inspector?.options?.presentation !== 'pane') return;
856
+ const nextWidth = Math.max(column.minWidth, Math.floor(width));
857
+ if (Math.abs(column.width - nextWidth) < 1) return;
858
+ this.columnModel.resizeColumn(column.id, nextWidth);
744
859
  }
745
860
 
746
861
  #workerRowOptions(overrides = {}) {
747
862
  return {
748
863
  expandedIds: Array.from(this.expansion.model.expanded),
864
+ filterCollapsedIds: Array.from(this.rowModel.filterCollapsed ?? []),
749
865
  rowHeight: this.rowModel.rowHeight,
750
866
  indentWidth: this.rowModel.indentWidth,
751
867
  sort: this.columnModel.sort,
@@ -777,6 +893,22 @@ export class TreeViewController {
777
893
  }
778
894
  }
779
895
 
896
+ function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
897
+ const safeWidth = Math.max(1, width);
898
+ const rightPadding = 14;
899
+ if (editorType === 'checkbox') {
900
+ const editorWidth = 34;
901
+ return { editorLeft: Math.max(64, safeWidth - rightPadding - editorWidth), editorWidth };
902
+ }
903
+ const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
904
+ const minLabelEnd = Math.max(88, depth * indentWidth + 104);
905
+ const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
906
+ const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
907
+ const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
908
+ const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
909
+ return { editorLeft, editorWidth };
910
+ }
911
+
780
912
  function createDefaultArrayItem(meta = {}) {
781
913
  if (meta.itemFactory) return meta.itemFactory();
782
914
  if (meta.itemType === 'number') return 0;
@@ -820,6 +952,19 @@ function matchesFilter(node, state, path, query) {
820
952
  return values.some((value) => String(value ?? '').toLowerCase().includes(query));
821
953
  }
822
954
 
955
+ function inspectorTooltipValue(node) {
956
+ const data = node.data ?? {};
957
+ if (data.editorType === 'checkbox') return '';
958
+ if (data.editorType === 'button') return data.meta?.button ?? node.label ?? '';
959
+ if (data.valueType === 'object') return '';
960
+ return data.valueText ?? data.value ?? '';
961
+ }
962
+
963
+ function isProbablyTruncated(text, width) {
964
+ if (width <= 0) return Boolean(text);
965
+ return String(text).length * 6.4 > width;
966
+ }
967
+
823
968
  function now() {
824
969
  return globalThis.performance?.now?.() ?? Date.now();
825
970
  }