virtual-tree-canvas 0.1.0 → 0.3.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.
@@ -1,5 +1,9 @@
1
1
  import { IconRegistry } from '../core/icon-registry.js';
2
2
 
3
+ const DISABLED_ALPHA = 0.72;
4
+ const SCROLL_INDICATOR_HOLD_MS = 1600;
5
+ const SCROLL_INDICATOR_FADE_MS = 700;
6
+
3
7
  export class TreeRowRenderer {
4
8
  constructor({ iconRegistry } = {}) {
5
9
  this.canvas = null;
@@ -7,6 +11,8 @@ export class TreeRowRenderer {
7
11
  this.scene = null;
8
12
  this.iconRegistry = iconRegistry ?? new IconRegistry();
9
13
  this.renderedRows = 0;
14
+ this.scrollIndicatorState = null;
15
+ this.scrollIndicatorVisibleUntil = 0;
10
16
  }
11
17
 
12
18
  /** @param {HTMLCanvasElement} canvas */
@@ -24,7 +30,7 @@ export class TreeRowRenderer {
24
30
  // Canvas2D reads the state map directly; patches still stay on the hot path.
25
31
  }
26
32
 
27
- render(scene) {
33
+ render(scene, time = performance.now()) {
28
34
  if (scene?.rows) this.scene = scene;
29
35
  if (!this.canvas || !this.ctx || !this.scene) return;
30
36
  const { viewport, theme } = this.scene;
@@ -47,11 +53,13 @@ export class TreeRowRenderer {
47
53
  ctx.translate(viewport.renderInsetX ?? 0, viewport.renderInsetY ?? 0);
48
54
  this.#drawHeader(ctx);
49
55
  this.#drawRows(ctx);
56
+ this.#drawScrollIndicator(ctx, time);
50
57
  ctx.restore();
51
58
  }
52
59
 
53
60
  #drawHeader(ctx) {
54
61
  const { viewport, columns, theme, sort, headerFilter, filterQuery } = this.scene;
62
+ if (viewport.headerHeight <= 0) return;
55
63
  const colors = theme.colors;
56
64
  ctx.save();
57
65
  ctx.fillStyle = colors.row;
@@ -65,10 +73,10 @@ export class TreeRowRenderer {
65
73
  this.#drawHeaderFilter(ctx, column, viewport, theme, filterQuery);
66
74
  } else {
67
75
  ctx.fillStyle = colors.textMuted;
68
- ctx.fillText(column.label, column.x + 10, viewport.headerHeight / 2, column.width - 20);
76
+ drawTruncatedText(ctx, column.label, column.x + 10, viewport.headerHeight / 2, column.width - 20);
69
77
  }
70
78
  if (sort?.columnId === column.id && sort.direction) {
71
- ctx.fillText(sort.direction === 'asc' ? '^' : 'v', column.x + column.width - 16, viewport.headerHeight / 2, 12);
79
+ drawTruncatedText(ctx, sort.direction === 'asc' ? '^' : 'v', column.x + column.width - 16, viewport.headerHeight / 2, 12);
72
80
  }
73
81
  ctx.strokeStyle = colors.border;
74
82
  ctx.beginPath();
@@ -89,7 +97,9 @@ export class TreeRowRenderer {
89
97
  const colors = theme.colors;
90
98
  const x = column.x + 8;
91
99
  const y = 5;
92
- const width = Math.max(40, column.width - 16);
100
+ const visibleRight = viewport.scrollX + viewport.viewportWidth;
101
+ const visibleWidth = Math.max(1, Math.min(column.x + column.width, visibleRight) - column.x);
102
+ const width = Math.max(40, visibleWidth - 16);
93
103
  const height = Math.max(18, viewport.headerHeight - 10);
94
104
  ctx.fillStyle = colors.progressTrack;
95
105
  roundRect(ctx, x, y, width, height, 4);
@@ -97,13 +107,16 @@ export class TreeRowRenderer {
97
107
  ctx.strokeStyle = colors.border;
98
108
  ctx.stroke();
99
109
  ctx.fillStyle = filterQuery ? colors.text : colors.textMuted;
100
- ctx.fillText(filterQuery || 'Filter inspector', x + 8, viewport.headerHeight / 2, width - 16);
110
+ drawTruncatedText(ctx, filterQuery || 'Filter inspector', x + 8, viewport.headerHeight / 2, width - 16);
101
111
  }
102
112
 
103
113
  #drawRows(ctx) {
104
114
  const { rows, visibleRange, viewport } = this.scene;
105
115
  this.renderedRows = visibleRange.count;
106
116
  ctx.save();
117
+ ctx.beginPath();
118
+ ctx.rect(0, viewport.headerHeight, viewport.viewportWidth, viewport.rowViewportHeight);
119
+ ctx.clip();
107
120
  ctx.translate(-viewport.scrollX, viewport.headerHeight - viewport.scrollY);
108
121
  for (let i = visibleRange.first; i <= visibleRange.last; i++) {
109
122
  const row = rows[i];
@@ -112,6 +125,56 @@ export class TreeRowRenderer {
112
125
  ctx.restore();
113
126
  }
114
127
 
128
+ #drawScrollIndicator(ctx, time) {
129
+ const { viewport, theme, rows } = this.scene;
130
+ const maxY = Math.max(0, viewport.contentHeight - viewport.rowViewportHeight);
131
+ if (maxY <= 0 || viewport.viewportHeight <= viewport.headerHeight + 12) return;
132
+ const alpha = this.#scrollIndicatorAlpha(viewport, rows?.length ?? 0, time);
133
+ if (alpha <= 0) return;
134
+
135
+ const trackTop = viewport.headerHeight + 4;
136
+ const trackHeight = Math.max(1, viewport.viewportHeight - viewport.headerHeight - 8);
137
+ const thumbHeight = Math.max(18, Math.min(trackHeight, trackHeight * (viewport.rowViewportHeight / viewport.contentHeight)));
138
+ const thumbY = trackTop + (trackHeight - thumbHeight) * (viewport.scrollY / maxY);
139
+ const x = Math.max(2, viewport.viewportWidth - 4);
140
+
141
+ ctx.save();
142
+ ctx.lineCap = 'round';
143
+ ctx.lineWidth = 2;
144
+ ctx.globalAlpha = 0.28 * alpha;
145
+ ctx.strokeStyle = theme.colors.guide;
146
+ ctx.beginPath();
147
+ ctx.moveTo(x, trackTop);
148
+ ctx.lineTo(x, trackTop + trackHeight);
149
+ ctx.stroke();
150
+
151
+ ctx.globalAlpha = 0.9 * alpha;
152
+ ctx.strokeStyle = theme.colors.focus;
153
+ ctx.beginPath();
154
+ ctx.moveTo(x, thumbY);
155
+ ctx.lineTo(x, thumbY + thumbHeight);
156
+ ctx.stroke();
157
+ ctx.restore();
158
+ }
159
+
160
+ #scrollIndicatorAlpha(viewport, rowCount, time) {
161
+ const nextState = {
162
+ scrollY: viewport.scrollY,
163
+ contentHeight: viewport.contentHeight,
164
+ rowViewportHeight: viewport.rowViewportHeight,
165
+ viewportHeight: viewport.viewportHeight,
166
+ rowCount,
167
+ };
168
+ if (!sameScrollIndicatorState(this.scrollIndicatorState, nextState)) {
169
+ this.scrollIndicatorState = nextState;
170
+ this.scrollIndicatorVisibleUntil = time + SCROLL_INDICATOR_HOLD_MS;
171
+ return 1;
172
+ }
173
+ if (time <= this.scrollIndicatorVisibleUntil) return 1;
174
+ const fade = 1 - ((time - this.scrollIndicatorVisibleUntil) / SCROLL_INDICATOR_FADE_MS);
175
+ return clamp01(fade);
176
+ }
177
+
115
178
  #drawRow(ctx, row) {
116
179
  const { columns, nodes, dynamicState, selection, hoverNodeId, focusNodeId, searchMatches, theme, viewport } = this.scene;
117
180
  const node = nodes[row.nodeIndex];
@@ -123,10 +186,11 @@ export class TreeRowRenderer {
123
186
  const hovered = hoverNodeId === row.nodeId;
124
187
  const focused = focusNodeId === row.nodeId;
125
188
  const y = row.y;
126
- const rowWidth = Math.max(viewport.contentWidth, viewport.scrollX + viewport.viewportWidth);
189
+ const visibleX = viewport.scrollX;
190
+ const visibleWidth = viewport.viewportWidth;
127
191
 
128
192
  ctx.fillStyle = selected ? colors.rowSelected : highlighted ? colors.rowHighlighted : hovered ? colors.rowHover : colors.row;
129
- ctx.fillRect(0, y, rowWidth, row.height);
193
+ ctx.fillRect(visibleX, y, visibleWidth, row.height);
130
194
 
131
195
  this.#drawIndentGuides(ctx, row, colors);
132
196
 
@@ -142,20 +206,15 @@ export class TreeRowRenderer {
142
206
 
143
207
  ctx.strokeStyle = colors.border;
144
208
  ctx.beginPath();
145
- ctx.moveTo(0, y + row.height + 0.5);
146
- ctx.lineTo(rowWidth, y + row.height + 0.5);
209
+ ctx.moveTo(visibleX, y + row.height + 0.5);
210
+ ctx.lineTo(visibleX + visibleWidth, y + row.height + 0.5);
147
211
  ctx.stroke();
148
212
 
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
- }
213
+ if (state.updated && node.data?.inspector) this.#drawUpdatedMarker(ctx, row, theme);
155
214
 
156
215
  if (focused) {
157
216
  ctx.strokeStyle = colors.focus;
158
- ctx.strokeRect(1.5, y + 2.5, rowWidth - 3, row.height - 5);
217
+ ctx.strokeRect(visibleX + 1.5, y + 2.5, visibleWidth - 3, row.height - 5);
159
218
  }
160
219
  }
161
220
 
@@ -174,40 +233,58 @@ export class TreeRowRenderer {
174
233
  else this.#drawTextCell(ctx, cell);
175
234
  }
176
235
 
177
- #drawInspectorPaneCell(ctx, { node, row, rect, theme }) {
236
+ #drawInspectorPaneCell(ctx, { node, row, rect, theme, style }) {
237
+ const visibleRight = this.scene.viewport.scrollX + this.scene.viewport.viewportWidth;
238
+ rect = { ...rect, width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x) };
178
239
  const data = node.data ?? {};
179
240
  const colors = theme.colors;
180
241
  const indentX = rect.x + row.depth * theme.indentWidth;
181
- const labelX = indentX + 28;
182
242
  const cy = rect.y + rect.height / 2;
183
- this.#drawChevron(ctx, indentX + 10, cy, row, colors);
243
+ let labelX = indentX + 28;
244
+ if (!row.hasChildren && node.icon) {
245
+ this.iconRegistry.draw(ctx, style.icon, indentX + 3, rect.y + 6, 15, style.color);
246
+ labelX = indentX + 24;
247
+ } else if (!row.hasChildren) {
248
+ labelX = indentX + 24;
249
+ } else {
250
+ this.#drawChevron(ctx, indentX + 10, cy, row, colors);
251
+ }
184
252
  ctx.font = theme.font;
185
253
  ctx.textBaseline = 'middle';
186
254
  ctx.textAlign = 'left';
187
- ctx.globalAlpha = data.disabled ? 0.45 : 1;
255
+ ctx.globalAlpha = data.disabled ? DISABLED_ALPHA : 1;
188
256
  ctx.fillStyle = data.valueType === 'object' || data.valueType === 'array' ? colors.text : colors.textMuted;
189
- ctx.fillText(node.label ?? node.id, labelX, cy, 180);
190
257
 
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);
258
+ if (data.valueType === 'object') {
259
+ drawTruncatedText(ctx, node.label ?? node.id, labelX, cy, Math.max(20, rect.x + rect.width - labelX - 8));
260
+ if (data.meta?.updated) this.#drawUpdatedMarker(ctx, row, theme);
261
+ ctx.globalAlpha = 1;
262
+ return;
263
+ }
264
+
265
+ const layout = inspectorPaneLayout(rect.width, row.depth, theme.indentWidth, data.editorType, this.scene.inspectorPaneLabelEnd);
266
+ drawTruncatedText(ctx, node.label ?? node.id, labelX, cy, Math.max(20, rect.x + layout.editorLeft - labelX - 8));
267
+
268
+ const editorX = rect.x + layout.editorLeft;
269
+ const editorWidth = layout.editorWidth;
193
270
  if (data.valueType === 'array') {
194
271
  ctx.fillStyle = colors.textMuted;
195
- ctx.fillText(data.valueText, editorX, cy, Math.max(20, editorWidth - 58));
272
+ drawTruncatedText(ctx, data.valueText, editorX, cy, Math.max(20, editorWidth - 58));
196
273
  this.#drawSmallButton(ctx, rect.x + rect.width - 54, rect.y + 5, 22, rect.height - 10, '+', theme);
197
274
  this.#drawSmallButton(ctx, rect.x + rect.width - 28, rect.y + 5, 22, rect.height - 10, '-', theme);
198
- } else if (data.valueType === 'object') {
199
- // Pane mode uses object rows as folders; the label is enough.
200
275
  } else {
201
276
  this.#drawInspectorValueCell(ctx, {
202
277
  node,
203
278
  rect: { x: editorX, y: rect.y, width: editorWidth, height: rect.height },
204
279
  theme,
280
+ suppressUpdatedMarker: true,
205
281
  });
206
282
  }
283
+ if (data.meta?.updated) this.#drawUpdatedMarker(ctx, row, theme);
207
284
  ctx.globalAlpha = 1;
208
285
  }
209
286
 
210
- #drawInspectorValueCell(ctx, { node, rect, theme }) {
287
+ #drawInspectorValueCell(ctx, { node, rect, theme, suppressUpdatedMarker = false }) {
211
288
  const data = node.data ?? {};
212
289
  const meta = data.meta ?? {};
213
290
  const disabled = data.disabled;
@@ -219,7 +296,7 @@ export class TreeRowRenderer {
219
296
  ctx.font = theme.font;
220
297
  ctx.textBaseline = 'middle';
221
298
  ctx.textAlign = 'left';
222
- ctx.globalAlpha = disabled ? 0.45 : 1;
299
+ ctx.globalAlpha = disabled ? DISABLED_ALPHA : 1;
223
300
 
224
301
  if (data.editorType === 'checkbox') {
225
302
  this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 7, Boolean(data.value), theme);
@@ -237,7 +314,7 @@ export class TreeRowRenderer {
237
314
  ctx.fill();
238
315
  ctx.fillStyle = theme.colors.text;
239
316
  ctx.textAlign = 'center';
240
- ctx.fillText(meta.button ?? node.label, x + (meta.fullWidthButton ? width : Math.min(width, 140)) / 2, rect.y + rect.height / 2);
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));
241
318
  ctx.textAlign = 'left';
242
319
  } else if (data.editorType === 'select') {
243
320
  ctx.fillStyle = theme.colors.rowHover;
@@ -245,12 +322,12 @@ export class TreeRowRenderer {
245
322
  ctx.fill();
246
323
  this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, Math.min(width, 180) - 22, theme);
247
324
  ctx.fillStyle = theme.colors.chevron;
248
- ctx.fillText('v', x + Math.min(width, 180) - 14, rect.y + rect.height / 2);
325
+ drawTruncatedText(ctx, 'v', x + Math.min(width, 180) - 14, rect.y + rect.height / 2, 10);
249
326
  } else {
250
327
  this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
251
328
  }
252
329
 
253
- if (meta.updated) {
330
+ if (meta.updated && !suppressUpdatedMarker) {
254
331
  ctx.fillStyle = theme.colors.focus;
255
332
  ctx.beginPath();
256
333
  ctx.arc(rect.x + rect.width - 10, rect.y + rect.height / 2, 3, 0, Math.PI * 2);
@@ -259,6 +336,15 @@ export class TreeRowRenderer {
259
336
  ctx.globalAlpha = 1;
260
337
  }
261
338
 
339
+ #drawUpdatedMarker(ctx, row, theme) {
340
+ const x = Math.max(6, row.depth * theme.indentWidth - 8);
341
+ const y = row.y + row.height / 2;
342
+ ctx.fillStyle = theme.colors.focus;
343
+ ctx.beginPath();
344
+ ctx.arc(x, y, 3, 0, Math.PI * 2);
345
+ ctx.fill();
346
+ }
347
+
262
348
  #drawInspectorTypeCell(ctx, { node, rect, theme }) {
263
349
  this.#drawMutedText(ctx, node.data?.valueType ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme);
264
350
  }
@@ -299,7 +385,7 @@ export class TreeRowRenderer {
299
385
  ctx.fill();
300
386
  ctx.fillStyle = theme.colors.text;
301
387
  ctx.textAlign = 'right';
302
- ctx.fillText(String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
388
+ drawTruncatedText(ctx, String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
303
389
  ctx.textAlign = 'left';
304
390
  }
305
391
 
@@ -310,7 +396,7 @@ export class TreeRowRenderer {
310
396
  ctx.fillStyle = theme.colors.text;
311
397
  ctx.textAlign = 'center';
312
398
  ctx.textBaseline = 'middle';
313
- ctx.fillText(label, x + width / 2, y + height / 2, width - 4);
399
+ drawTruncatedText(ctx, label, x + width / 2, y + height / 2, width - 4);
314
400
  ctx.textAlign = 'left';
315
401
  }
316
402
 
@@ -319,7 +405,7 @@ export class TreeRowRenderer {
319
405
  ctx.font = theme.font;
320
406
  ctx.textBaseline = 'middle';
321
407
  ctx.textAlign = 'left';
322
- ctx.fillText(String(text ?? ''), x, y, Math.max(10, width));
408
+ drawTruncatedText(ctx, String(text ?? ''), x, y, Math.max(10, width));
323
409
  }
324
410
 
325
411
  #drawTreeCell(ctx, { node, row, rect, theme, style }) {
@@ -332,7 +418,7 @@ export class TreeRowRenderer {
332
418
  ctx.font = theme.font;
333
419
  ctx.textBaseline = 'middle';
334
420
  ctx.textAlign = 'left';
335
- ctx.fillText(node.label ?? node.id, x + 50, cy, Math.max(40, rect.x + rect.width - x - 56));
421
+ drawTruncatedText(ctx, node.label ?? node.id, x + 50, cy, Math.max(40, rect.x + rect.width - x - 56));
336
422
  }
337
423
 
338
424
  #drawStatusCell(ctx, { rect, style, theme }) {
@@ -346,7 +432,7 @@ export class TreeRowRenderer {
346
432
  ctx.font = '10px system-ui, sans-serif';
347
433
  ctx.textAlign = 'center';
348
434
  ctx.textBaseline = 'middle';
349
- ctx.fillText(style.status.label, x + badgeWidth / 2, y + 8, badgeWidth - 8);
435
+ drawTruncatedText(ctx, style.status.label, x + badgeWidth / 2, y + 8, badgeWidth - 8);
350
436
  ctx.textAlign = 'left';
351
437
  }
352
438
 
@@ -370,7 +456,7 @@ export class TreeRowRenderer {
370
456
  ctx.textBaseline = 'middle';
371
457
  ctx.textAlign = column.align;
372
458
  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);
459
+ drawTruncatedText(ctx, String(value ?? ''), x, rect.y + rect.height / 2, rect.width - 20);
374
460
  ctx.textAlign = 'left';
375
461
  }
376
462
 
@@ -421,6 +507,31 @@ function resolveNodeStyle(theme, node, state = {}) {
421
507
  };
422
508
  }
423
509
 
510
+ function sameScrollIndicatorState(a, b) {
511
+ return Boolean(a) &&
512
+ a.scrollY === b.scrollY &&
513
+ a.contentHeight === b.contentHeight &&
514
+ a.rowViewportHeight === b.rowViewportHeight &&
515
+ a.viewportHeight === b.viewportHeight &&
516
+ a.rowCount === b.rowCount;
517
+ }
518
+
519
+ function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
520
+ const safeWidth = Math.max(1, width);
521
+ const rightPadding = 14;
522
+ if (editorType === 'checkbox') {
523
+ const editorWidth = 34;
524
+ return { editorLeft: Math.max(64, safeWidth - rightPadding - editorWidth), editorWidth };
525
+ }
526
+ const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
527
+ const minLabelEnd = Math.max(88, depth * indentWidth + 104);
528
+ const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
529
+ const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
530
+ const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
531
+ const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
532
+ return { editorLeft, editorWidth };
533
+ }
534
+
424
535
  function clamp01(value) {
425
536
  return Math.max(0, Math.min(1, value));
426
537
  }
@@ -438,6 +549,29 @@ function roundRect(ctx, x, y, width, height, radius) {
438
549
  ctx.quadraticCurveTo(x, y, x + radius, y);
439
550
  }
440
551
 
552
+ function drawTruncatedText(ctx, text, x, y, maxWidth) {
553
+ const value = String(text ?? '');
554
+ const width = Math.max(0, maxWidth);
555
+ if (!value || width <= 0) return;
556
+ ctx.fillText(fitText(ctx, value, width), x, y);
557
+ }
558
+
559
+ function fitText(ctx, text, maxWidth) {
560
+ if (ctx.measureText(text).width <= maxWidth) return text;
561
+ const ellipsis = '...';
562
+ const ellipsisWidth = ctx.measureText(ellipsis).width;
563
+ if (ellipsisWidth > maxWidth) return '';
564
+ let low = 0;
565
+ let high = text.length;
566
+ while (low < high) {
567
+ const mid = Math.ceil((low + high) / 2);
568
+ const candidate = text.slice(0, mid);
569
+ if (ctx.measureText(candidate).width + ellipsisWidth <= maxWidth) low = mid;
570
+ else high = mid - 1;
571
+ }
572
+ return `${text.slice(0, low)}${ellipsis}`;
573
+ }
574
+
441
575
  function formatTime(value) {
442
576
  return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
443
577
  }