virtual-tree-canvas 0.4.0 → 0.5.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/README.md +10 -1
- package/package.json +1 -1
- package/src/core/theme-manager.js +1 -0
- package/src/index.d.ts +4 -0
- package/src/inspector/cell-editor-manager.js +23 -18
- package/src/inspector/editor-resolver.js +6 -1
- package/src/inspector/numeric-layout.js +16 -0
- package/src/renderers/tree-row-renderer.js +21 -4
- package/src/tree-view-controller.js +4 -3
package/README.md
CHANGED
|
@@ -197,7 +197,7 @@ tree.setModel(
|
|
|
197
197
|
{
|
|
198
198
|
'sensor.range': { min: 0, max: 120, step: 1, integer: true },
|
|
199
199
|
'sensor.mode': { options: { Search: 'search', Track: 'track' } },
|
|
200
|
-
'tracks.*.speed': { min: 0, max: 900, step: 5 },
|
|
200
|
+
'tracks.*.speed': { min: 0, max: 900, step: 5, unit: 'm/s', precision: 2 },
|
|
201
201
|
'tracks.*.id': { readonly: true },
|
|
202
202
|
},
|
|
203
203
|
{ presentation: 'pane', flatRoot: true },
|
|
@@ -209,6 +209,15 @@ Choose `presentation: 'pane'` for a compact folder-and-controls view, or
|
|
|
209
209
|
Editors are inferred from data and metadata: checkbox, range, number, text,
|
|
210
210
|
select, colour, button, object and array.
|
|
211
211
|
|
|
212
|
+
Numeric metadata supports `unit` (a display-only string suffix) and `precision`
|
|
213
|
+
(decimal places, 0–20). The unit has a reserved slot beside the number in both
|
|
214
|
+
pane and table presentations, including range sliders. It stays visible while
|
|
215
|
+
editing; the editor receives the full original numeric value and emits a number.
|
|
216
|
+
Precision changes display only, not stored values, limits or step size. Omit both
|
|
217
|
+
options to keep the existing formatting. Invalid precision values are ignored.
|
|
218
|
+
Customize the suffix with `theme.unitFont` and `theme.colors.unit` (defaults to
|
|
219
|
+
`textMuted`). Long units are truncated to fit the available cell width.
|
|
220
|
+
|
|
212
221
|
Listen for user edits and actions with `valuechange`, `modelchange` and
|
|
213
222
|
`action` events.
|
|
214
223
|
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ export const darkTheme = {
|
|
|
2
2
|
rowHeight: 28,
|
|
3
3
|
indentWidth: 18,
|
|
4
4
|
font: '12px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
5
|
+
unitFont: '11px ui-monospace, SFMono-Regular, Consolas, monospace',
|
|
5
6
|
monoFont: '12px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace',
|
|
6
7
|
colors: {
|
|
7
8
|
background: '#0a0f1c',
|
package/src/index.d.ts
CHANGED
|
@@ -33,6 +33,10 @@ export type MetaRule = {
|
|
|
33
33
|
max?: number;
|
|
34
34
|
step?: number;
|
|
35
35
|
integer?: boolean;
|
|
36
|
+
/** Display-only suffix for numeric values. Never appended to the model value. */
|
|
37
|
+
unit?: string;
|
|
38
|
+
/** Display decimal places (0–20). Editing retains the original precision. */
|
|
39
|
+
precision?: number;
|
|
36
40
|
readonly?: boolean;
|
|
37
41
|
disabled?: boolean;
|
|
38
42
|
updated?: boolean;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { numericLayout } from './numeric-layout.js';
|
|
2
|
+
|
|
1
3
|
export class CellEditorManager {
|
|
2
4
|
constructor({ controller, host = null }) {
|
|
3
5
|
if (!controller) throw new TypeError('CellEditorManager requires a TreeViewController');
|
|
@@ -81,6 +83,7 @@ export class CellEditorManager {
|
|
|
81
83
|
const hostRect = this.host.getBoundingClientRect();
|
|
82
84
|
const element = createEditorElement(data);
|
|
83
85
|
element.className = `vtc-editor vtc-editor-${data.editorType}`;
|
|
86
|
+
element.setAttribute('aria-label', [node.label, numericLayout(rect.width, data).unit].filter(Boolean).join(' '));
|
|
84
87
|
Object.assign(element.style, {
|
|
85
88
|
position: 'absolute',
|
|
86
89
|
left: `${rect.x - hostRect.left}px`,
|
|
@@ -208,34 +211,36 @@ export class CellEditorManager {
|
|
|
208
211
|
}
|
|
209
212
|
|
|
210
213
|
#isEditableHit(hit) {
|
|
211
|
-
return hit?.area === 'row' && (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane');
|
|
214
|
+
return hit?.area === 'row' && hit.part !== 'unit' && (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane');
|
|
212
215
|
}
|
|
213
216
|
|
|
214
|
-
#
|
|
215
|
-
if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
|
|
217
|
+
#editorContentRect(hit) {
|
|
216
218
|
const rect = this.controller.getCellClientRect(hit);
|
|
219
|
+
if (hit.column?.kind !== 'inspectorPane') {
|
|
220
|
+
return { x: rect.x + 10, y: rect.y + 4, width: Math.max(24, rect.width - 20), height: rect.height - 8 };
|
|
221
|
+
}
|
|
217
222
|
const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.contentViewportWidth));
|
|
218
223
|
const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
|
|
219
224
|
const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
225
|
+
return { x: rect.x + editorLeft + 10, y: rect.y + 4, width: Math.max(24, editorWidth - 20), height: rect.height - 8 };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#overlayRect(hit) {
|
|
229
|
+
const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
|
|
230
|
+
const rect = this.#editorContentRect(hit);
|
|
231
|
+
const layout = numericLayout(rect.width, data);
|
|
232
|
+
if (hit.part === 'number' && data.editorType === 'range') {
|
|
233
|
+
return { ...rect, x: rect.x + layout.numberLeft, width: layout.valueWidth };
|
|
223
234
|
}
|
|
224
|
-
|
|
235
|
+
if (layout.unit) return { ...rect, width: layout.contentWidth };
|
|
236
|
+
if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
|
|
237
|
+
return rect;
|
|
225
238
|
}
|
|
226
239
|
|
|
227
240
|
#rangeBarRect(hit) {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
|
|
232
|
-
const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
|
|
233
|
-
const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
|
|
234
|
-
const barWidth = Math.max(24, editorWidth - 20 - valueWidth - 8);
|
|
235
|
-
return { x: rect.x + editorLeft + 10, y: rect.y + rect.height / 2 - 4, width: barWidth, height: 8 };
|
|
236
|
-
}
|
|
237
|
-
const valueWidth = Math.min(64, Math.max(42, (rect.width - 20) * 0.28));
|
|
238
|
-
return { x: rect.x + 10, y: rect.y + rect.height / 2 - 4, width: Math.max(24, rect.width - 20 - valueWidth - 8), height: 8 };
|
|
241
|
+
const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
|
|
242
|
+
const rect = this.#editorContentRect(hit);
|
|
243
|
+
return { ...rect, y: rect.y + rect.height / 2 - 4, width: numericLayout(rect.width, data).barWidth, height: 8 };
|
|
239
244
|
}
|
|
240
245
|
|
|
241
246
|
#clampRectToHost(rect, inset = 0) {
|
|
@@ -28,6 +28,11 @@ export function formatInspectorValue(value, meta = {}) {
|
|
|
28
28
|
if (value === null) return 'null';
|
|
29
29
|
if (value === undefined) return 'undefined';
|
|
30
30
|
if (typeof value === 'object') return Array.isArray(value) ? `Array(${value.length})` : 'Object';
|
|
31
|
-
if (typeof value === 'number')
|
|
31
|
+
if (typeof value === 'number') {
|
|
32
|
+
if (Number.isFinite(value) && Number.isInteger(meta.precision) && meta.precision >= 0 && meta.precision <= 20) {
|
|
33
|
+
return value.toFixed(meta.precision);
|
|
34
|
+
}
|
|
35
|
+
return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000);
|
|
36
|
+
}
|
|
32
37
|
return String(value);
|
|
33
38
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Shared geometry for canvas drawing, hit testing and the numeric editor. */
|
|
2
|
+
export function numericLayout(width, data = {}) {
|
|
3
|
+
width = Math.max(0, width);
|
|
4
|
+
const numeric = typeof data.value === 'number' && !data.meta?.options && !data.meta?.button;
|
|
5
|
+
const unit = numeric && typeof data.meta?.unit === 'string' ? data.meta.unit.trim() : '';
|
|
6
|
+
// Reserve a stable slot: changes to the number must not move the unit.
|
|
7
|
+
const gap = unit ? 6 : 0;
|
|
8
|
+
const unitWidth = unit ? Math.min(Math.max(24, [...unit].length * 7), 96, Math.max(0, width - 48)) : 0;
|
|
9
|
+
const contentWidth = Math.max(0, width - unitWidth - gap);
|
|
10
|
+
const valueWidth = Math.min(64, Math.max(42, contentWidth * 0.28), Math.max(0, contentWidth - 32));
|
|
11
|
+
return {
|
|
12
|
+
unit, unitWidth, unitLeft: contentWidth + gap, contentWidth,
|
|
13
|
+
valueWidth, numberLeft: contentWidth - valueWidth,
|
|
14
|
+
barWidth: Math.max(0, contentWidth - valueWidth - 8),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { numericLayout } from '../inspector/numeric-layout.js';
|
|
1
2
|
import { IconRegistry } from '../core/icon-registry.js';
|
|
2
3
|
|
|
3
4
|
const DISABLED_ALPHA = 0.72;
|
|
@@ -344,13 +345,22 @@ export class TreeRowRenderer {
|
|
|
344
345
|
const readonly = data.readonly;
|
|
345
346
|
const x = rect.x + 10;
|
|
346
347
|
const y = rect.y + 5;
|
|
347
|
-
const
|
|
348
|
+
const fullWidth = Math.max(24, rect.width - 20);
|
|
349
|
+
const numeric = numericLayout(fullWidth, data);
|
|
350
|
+
const width = numeric.contentWidth;
|
|
348
351
|
const height = rect.height - 10;
|
|
349
352
|
ctx.font = theme.font;
|
|
350
353
|
ctx.textBaseline = 'middle';
|
|
351
354
|
ctx.textAlign = 'left';
|
|
352
355
|
ctx.globalAlpha = disabled ? DISABLED_ALPHA : 1;
|
|
353
356
|
|
|
357
|
+
if (numeric.unit && numeric.unitWidth) {
|
|
358
|
+
ctx.font = theme.unitFont ?? theme.monoFont ?? theme.font;
|
|
359
|
+
ctx.fillStyle = theme.colors.unit ?? theme.colors.textMuted;
|
|
360
|
+
drawTruncatedText(ctx, numeric.unit, x + numeric.unitLeft, rect.y + rect.height / 2, numeric.unitWidth);
|
|
361
|
+
ctx.font = theme.font;
|
|
362
|
+
}
|
|
363
|
+
|
|
354
364
|
if (data.editorType === 'checkbox') {
|
|
355
365
|
this.#drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
|
|
356
366
|
} else if (data.editorType === 'range') {
|
|
@@ -385,7 +395,15 @@ export class TreeRowRenderer {
|
|
|
385
395
|
this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme);
|
|
386
396
|
this.#drawSelectChevron(ctx, x + selectWidth - 18, rect.y + rect.height / 2, theme, disabled);
|
|
387
397
|
} else {
|
|
388
|
-
|
|
398
|
+
if (numeric.unit) {
|
|
399
|
+
ctx.font = theme.monoFont ?? theme.font;
|
|
400
|
+
ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
|
|
401
|
+
ctx.textAlign = 'right';
|
|
402
|
+
drawTruncatedText(ctx, data.valueText, x + width, rect.y + rect.height / 2, width);
|
|
403
|
+
ctx.textAlign = 'left';
|
|
404
|
+
} else {
|
|
405
|
+
this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, readonly);
|
|
406
|
+
}
|
|
389
407
|
}
|
|
390
408
|
|
|
391
409
|
if (meta.updated && !suppressUpdatedMarker) {
|
|
@@ -432,9 +450,8 @@ export class TreeRowRenderer {
|
|
|
432
450
|
const max = meta.max ?? 100;
|
|
433
451
|
const value = typeof data.value === 'number' ? data.value : min;
|
|
434
452
|
const ratio = max === min ? 0 : clamp01((value - min) / (max - min));
|
|
435
|
-
const valueWidth =
|
|
453
|
+
const { valueWidth, barWidth } = numericLayout(width);
|
|
436
454
|
const gap = 8;
|
|
437
|
-
const barWidth = Math.max(24, width - valueWidth - gap);
|
|
438
455
|
this.#drawMeterBar(ctx, x, y + 0.5, barWidth, 7, ratio, theme.colors.progressFill, theme);
|
|
439
456
|
this.#drawControlSurface(ctx, x + barWidth + gap, y - 6, valueWidth, 20, theme, {
|
|
440
457
|
hovered: Boolean(state.hoveredNumber),
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
TreeViewViewport,
|
|
13
13
|
VisibleRowModel,
|
|
14
14
|
} from './core/index.js';
|
|
15
|
+
import { numericLayout } from './inspector/numeric-layout.js';
|
|
15
16
|
import { CellEditorManager } from './inspector/cell-editor-manager.js';
|
|
16
17
|
import { formatInspectorValue, getAtPath, inspectorColumns, inspectorPaneColumns, ModelInspectorBuilder, setAtPath } from './inspector/index.js';
|
|
17
18
|
import { TreeViewInputController } from './input/tree-view-input-controller.js';
|
|
@@ -789,11 +790,11 @@ export class TreeViewController {
|
|
|
789
790
|
const node = this.model.nodes[row.nodeIndex];
|
|
790
791
|
const data = node?.data;
|
|
791
792
|
if (!data?.inspector) return 'cell';
|
|
793
|
+
const numeric = numericLayout(Math.max(24, editorWidth - 20), data);
|
|
794
|
+
if (numeric.unit && localEditorX >= 10 + numeric.unitLeft) return 'unit';
|
|
792
795
|
if (data.editorType === 'checkbox') return 'checkbox';
|
|
793
796
|
if (data.editorType === 'range') {
|
|
794
|
-
const
|
|
795
|
-
const valueWidth = Math.min(64, Math.max(42, width * 0.28));
|
|
796
|
-
const numberLeft = editorWidth - valueWidth - 10;
|
|
797
|
+
const numberLeft = 10 + numeric.numberLeft;
|
|
797
798
|
return localEditorX >= numberLeft ? 'number' : 'range';
|
|
798
799
|
}
|
|
799
800
|
if (data.editorType === 'button') return 'button';
|