virtual-tree-canvas 0.5.0 → 0.7.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/CHANGELOG.md +65 -0
- package/README.md +181 -27
- package/docs/icon-catalog.html +67 -0
- package/docs/icon-catalog.json +392 -0
- package/docs/icon-catalog.md +171 -0
- package/examples/generate-tree.js +86 -0
- package/examples/reorder-table.html +33 -0
- package/examples/units.html +43 -0
- package/package.json +7 -3
- package/resources/icons/bicycle.svg +1 -0
- package/resources/icons/bird.svg +1 -0
- package/resources/icons/box.svg +1 -0
- package/resources/icons/building.svg +1 -0
- package/resources/icons/calendar.svg +1 -0
- package/resources/icons/car.svg +1 -0
- package/resources/icons/cat.svg +1 -0
- package/resources/icons/child.svg +1 -0
- package/resources/icons/clock.svg +1 -0
- package/resources/icons/cloud.svg +1 -0
- package/resources/icons/data-bus.svg +1 -0
- package/resources/icons/database.svg +1 -0
- package/resources/icons/dog.svg +1 -0
- package/resources/icons/drone.svg +1 -0
- package/resources/icons/fish.svg +1 -0
- package/resources/icons/flower.svg +1 -0
- package/resources/icons/fog.svg +1 -0
- package/resources/icons/globe.svg +1 -0
- package/resources/icons/grip.svg +1 -0
- package/resources/icons/heart.svg +1 -0
- package/resources/icons/helicopter.svg +1 -0
- package/resources/icons/insect.svg +1 -0
- package/resources/icons/leaf.svg +1 -0
- package/resources/icons/link.svg +1 -0
- package/resources/icons/lock.svg +1 -0
- package/resources/icons/moon.svg +1 -0
- package/resources/icons/network.svg +1 -0
- package/resources/icons/people.svg +1 -0
- package/resources/icons/person.svg +1 -0
- package/resources/icons/pin.svg +1 -0
- package/resources/icons/radar.svg +1 -0
- package/resources/icons/rain.svg +1 -0
- package/resources/icons/seedling.svg +1 -0
- package/resources/icons/sensor.svg +1 -0
- package/resources/icons/server.svg +1 -0
- package/resources/icons/ship.svg +1 -0
- package/resources/icons/snow.svg +1 -0
- package/resources/icons/star-filled.svg +1 -0
- package/resources/icons/star.svg +1 -0
- package/resources/icons/storm.svg +1 -0
- package/resources/icons/sun.svg +1 -0
- package/resources/icons/thermometer.svg +1 -0
- package/resources/icons/trash.svg +1 -0
- package/resources/icons/tree.svg +1 -0
- package/resources/icons/wind.svg +1 -0
- package/src/assets/icons.js +75 -25
- package/src/core/icon-registry.js +6 -8
- package/src/core/theme-manager.js +17 -4
- package/src/core/tree-cell-layout.js +9 -0
- package/src/core/tree-column-model.js +27 -3
- package/src/core/tree-model.js +20 -0
- package/src/core/types.js +1 -0
- package/src/core/view-options.js +37 -0
- package/src/core/visible-row-model.js +1 -1
- package/src/index.d.ts +209 -5
- package/src/index.js +3 -0
- package/src/input/index.js +1 -0
- package/src/input/row-actions.js +115 -0
- package/src/input/row-reorder-input.js +168 -0
- package/src/input/tree-filter-bar.js +55 -0
- package/src/input/tree-tooltip.js +55 -0
- package/src/input/tree-view-input-controller.js +10 -3
- package/src/inspector/cell-editor-manager.js +42 -13
- package/src/inspector/pane-layout.js +12 -0
- package/src/renderers/checkbox.js +14 -0
- package/src/renderers/tree-row-renderer.js +103 -62
- package/src/tree-view-controller.js +309 -57
- package/src/tree-view.js +183 -0
- package/src/view/styles.js +14 -0
- package/src/assets/icons/air.svg +0 -1
- package/src/assets/icons/radar.svg +0 -1
- /package/{src/assets → resources}/icons/aircraft.svg +0 -0
- /package/{src/assets/icons/bus.svg → resources/icons/bus-vehicle.svg} +0 -0
- /package/{src/assets → resources}/icons/control.svg +0 -0
- /package/{src/assets → resources}/icons/damage.svg +0 -0
- /package/{src/assets → resources}/icons/error.svg +0 -0
- /package/{src/assets → resources}/icons/folder.svg +0 -0
- /package/{src/assets → resources}/icons/ground.svg +0 -0
- /package/{src/assets → resources}/icons/inspector-array.svg +0 -0
- /package/{src/assets → resources}/icons/inspector-object.svg +0 -0
- /package/{src/assets → resources}/icons/inspector-value.svg +0 -0
- /package/{src/assets → resources}/icons/munition.svg +0 -0
- /package/{src/assets → resources}/icons/placeholder.svg +0 -0
- /package/{src/assets → resources}/icons/point.svg +0 -0
- /package/{src/assets → resources}/icons/situation.svg +0 -0
- /package/{src/assets → resources}/icons/space.svg +0 -0
- /package/{src/assets → resources}/icons/subsurface.svg +0 -0
- /package/{src/assets → resources}/icons/surface.svg +0 -0
- /package/{src/assets → resources}/icons/task.svg +0 -0
- /package/{src/assets → resources}/icons/track.svg +0 -0
- /package/{src/assets → resources}/icons/warning.svg +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** DOM tooltip shared by framework adapters and standalone canvas hosts. */
|
|
2
|
+
export class TreeTooltip {
|
|
3
|
+
constructor({ controller, host = controller.canvas?.parentElement }) {
|
|
4
|
+
this.controller = controller;
|
|
5
|
+
this.canvas = controller.canvas;
|
|
6
|
+
this.host = host;
|
|
7
|
+
if (!this.canvas || !host?.ownerDocument) throw new Error('TreeTooltip requires a mounted canvas and host');
|
|
8
|
+
this.element = host.ownerDocument.createElement('div');
|
|
9
|
+
this.element.className = 'virtual-tree-canvas-tooltip';
|
|
10
|
+
this.element.setAttribute('role', 'tooltip');
|
|
11
|
+
Object.assign(this.element.style, {
|
|
12
|
+
position: 'absolute', display: 'none', pointerEvents: 'none', zIndex: '20',
|
|
13
|
+
maxWidth: 'calc(100% - 16px)', padding: '6px 8px', borderRadius: '5px',
|
|
14
|
+
background: 'var(--vtc-tooltip-background, #111827)', color: 'var(--vtc-tooltip-color, #e5e7eb)',
|
|
15
|
+
border: '1px solid var(--vtc-tooltip-border, #374151)',
|
|
16
|
+
font: '12px/1.35 var(--font-family, ui-monospace, monospace)', overflowWrap: 'anywhere'
|
|
17
|
+
});
|
|
18
|
+
host.appendChild(this.element);
|
|
19
|
+
this.canvas.addEventListener('mousemove', this.onMove);
|
|
20
|
+
this.canvas.addEventListener('mouseleave', this.hide);
|
|
21
|
+
this.canvas.addEventListener('wheel', this.hide);
|
|
22
|
+
this.canvas.addEventListener('pointerdown', this.hide);
|
|
23
|
+
this.stops = ['viewportchange', 'filterchange', 'datachange', 'rowdrag'].map(type => controller.on(type, this.hide));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onMove = event => {
|
|
27
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
28
|
+
const hit = this.controller.hitTest(event.clientX - rect.left, event.clientY - rect.top);
|
|
29
|
+
let text;
|
|
30
|
+
if (['rowDrag', 'rowUp', 'rowDown'].includes(hit?.part)) {
|
|
31
|
+
text = this.controller.canReorderRows()
|
|
32
|
+
? { rowDrag: 'Drag to reorder. Alt + Up/Down also moves the focused row.', rowUp: 'Move up', rowDown: 'Move down' }[hit.part]
|
|
33
|
+
: 'Clear sorting and filtering to reorder rows.';
|
|
34
|
+
} else text = this.controller.getTooltipForHit(hit)?.text;
|
|
35
|
+
if (!text || this.controller.rowReorderInput?.gesture) return this.hide();
|
|
36
|
+
const hostRect = this.host.getBoundingClientRect();
|
|
37
|
+
this.element.textContent = text;
|
|
38
|
+
this.element.style.display = 'block';
|
|
39
|
+
const x = event.clientX - hostRect.left + 12;
|
|
40
|
+
const y = event.clientY - hostRect.top + 14;
|
|
41
|
+
this.element.style.left = `${Math.max(8, Math.min(x, hostRect.width - this.element.offsetWidth - 8))}px`;
|
|
42
|
+
this.element.style.top = `${Math.max(8, Math.min(y, hostRect.height - this.element.offsetHeight - 8))}px`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
hide = () => { this.element.style.display = 'none'; };
|
|
46
|
+
|
|
47
|
+
destroy() {
|
|
48
|
+
this.canvas.removeEventListener('mousemove', this.onMove);
|
|
49
|
+
this.canvas.removeEventListener('mouseleave', this.hide);
|
|
50
|
+
this.canvas.removeEventListener('wheel', this.hide);
|
|
51
|
+
this.canvas.removeEventListener('pointerdown', this.hide);
|
|
52
|
+
for (const stop of this.stops) stop();
|
|
53
|
+
this.element.remove();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -18,7 +18,7 @@ export class TreeViewInputController {
|
|
|
18
18
|
this.canvas.addEventListener('wheel', this.#onWheel, { passive: false });
|
|
19
19
|
this.canvas.addEventListener('mousedown', this.#onMouseDown);
|
|
20
20
|
this.canvas.addEventListener('mousemove', this.#onMouseMove);
|
|
21
|
-
window.addEventListener('mouseup', this.#onMouseUp);
|
|
21
|
+
(this.canvas.ownerDocument.defaultView ?? window).addEventListener('mouseup', this.#onMouseUp);
|
|
22
22
|
this.canvas.addEventListener('mouseleave', this.#onMouseLeave);
|
|
23
23
|
this.canvas.addEventListener('click', this.#onClick);
|
|
24
24
|
this.canvas.addEventListener('dblclick', this.#onDoubleClick);
|
|
@@ -29,7 +29,7 @@ export class TreeViewInputController {
|
|
|
29
29
|
this.canvas.removeEventListener('wheel', this.#onWheel);
|
|
30
30
|
this.canvas.removeEventListener('mousedown', this.#onMouseDown);
|
|
31
31
|
this.canvas.removeEventListener('mousemove', this.#onMouseMove);
|
|
32
|
-
window.removeEventListener('mouseup', this.#onMouseUp);
|
|
32
|
+
(this.canvas.ownerDocument.defaultView ?? window).removeEventListener('mouseup', this.#onMouseUp);
|
|
33
33
|
this.canvas.removeEventListener('mouseleave', this.#onMouseLeave);
|
|
34
34
|
this.canvas.removeEventListener('click', this.#onClick);
|
|
35
35
|
this.canvas.removeEventListener('dblclick', this.#onDoubleClick);
|
|
@@ -43,6 +43,7 @@ export class TreeViewInputController {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
#onMouseMove = (event) => {
|
|
46
|
+
if (this.controller.rowReorderInput?.gesture) return;
|
|
46
47
|
if (this.resizeDrag) {
|
|
47
48
|
const rect = this.canvas.getBoundingClientRect();
|
|
48
49
|
const clientX = event.clientX - rect.left;
|
|
@@ -70,9 +71,10 @@ export class TreeViewInputController {
|
|
|
70
71
|
};
|
|
71
72
|
|
|
72
73
|
#onClick = (event) => {
|
|
73
|
-
this.
|
|
74
|
+
if (this.controller.rowReorderInput?.suppressClick) { this.controller.rowReorderInput.suppressClick = false; event.preventDefault(); return; }
|
|
74
75
|
const hit = this.#hitTest(event);
|
|
75
76
|
if (!hit) return;
|
|
77
|
+
if (['rowDrag', 'rowUp', 'rowDown'].includes(hit.part)) return;
|
|
76
78
|
if (hit.area === 'header') {
|
|
77
79
|
if (hit.part === 'filter' && this.cellEditor?.handleHeaderClick(event, hit)) return;
|
|
78
80
|
if (!this.resizeDrag && hit.part === 'label' && hit.column) this.controller.sortBy(hit.column.id);
|
|
@@ -83,6 +85,7 @@ export class TreeViewInputController {
|
|
|
83
85
|
return;
|
|
84
86
|
}
|
|
85
87
|
if (this.cellEditor?.handleClick(event, hit)) return;
|
|
88
|
+
this.canvas.focus({ preventScroll: true });
|
|
86
89
|
this.controller.clickNode(hit.row.nodeId, {
|
|
87
90
|
shiftKey: event.shiftKey,
|
|
88
91
|
ctrlKey: event.ctrlKey,
|
|
@@ -97,7 +100,9 @@ export class TreeViewInputController {
|
|
|
97
100
|
};
|
|
98
101
|
|
|
99
102
|
#onMouseDown = (event) => {
|
|
103
|
+
if (this.controller.rowReorderInput?.gesture) return;
|
|
100
104
|
const hit = this.#hitTest(event);
|
|
105
|
+
if (['rowDrag', 'rowUp', 'rowDown'].includes(hit?.part)) return;
|
|
101
106
|
this.controller.setActiveHit(hit);
|
|
102
107
|
if (this.cellEditor?.handlePointerDown(event, hit)) {
|
|
103
108
|
event.preventDefault();
|
|
@@ -135,6 +140,8 @@ export class TreeViewInputController {
|
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
function cursorForHit(hit) {
|
|
143
|
+
if (hit?.part === 'rowDrag') return 'grab';
|
|
144
|
+
if (hit?.part === 'rowUp' || hit?.part === 'rowDown') return 'pointer';
|
|
138
145
|
if (hit?.area === 'header' && hit.part === 'resize') return 'col-resize';
|
|
139
146
|
if (hit?.area === 'header' && hit.part === 'filter') return 'text';
|
|
140
147
|
if (hit?.area !== 'row') return '';
|
|
@@ -6,35 +6,58 @@ export class CellEditorManager {
|
|
|
6
6
|
if (!controller.canvas) throw new Error('CellEditorManager requires an initialized TreeViewController canvas');
|
|
7
7
|
this.controller = controller;
|
|
8
8
|
this.canvas = controller.canvas;
|
|
9
|
+
this.view = this.canvas.ownerDocument.defaultView;
|
|
9
10
|
this.host = host ?? controller.canvas.parentElement ?? document.body;
|
|
11
|
+
this.editable = true;
|
|
10
12
|
this.overlay = null;
|
|
11
13
|
this.rangeDrag = null;
|
|
14
|
+
this.overlayKind = null;
|
|
15
|
+
this.overlayNode = null;
|
|
16
|
+
this.stopFilter = controller.on('filterchange', ({ detail }) => {
|
|
17
|
+
if (this.overlayKind === 'filter' && this.overlay) this.overlay.value = detail.query;
|
|
18
|
+
});
|
|
12
19
|
this.onMouseMove = this.#onMouseMove.bind(this);
|
|
13
20
|
this.onMouseUp = this.#onMouseUp.bind(this);
|
|
14
21
|
}
|
|
15
22
|
|
|
23
|
+
setEditable(enabled) {
|
|
24
|
+
this.editable = enabled;
|
|
25
|
+
if (!enabled) this.close();
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
destroy() {
|
|
29
|
+
this.stopFilter();
|
|
17
30
|
this.#removeOverlay();
|
|
18
|
-
|
|
19
|
-
|
|
31
|
+
this.view.removeEventListener('mousemove', this.onMouseMove);
|
|
32
|
+
this.view.removeEventListener('mouseup', this.onMouseUp);
|
|
20
33
|
}
|
|
21
34
|
|
|
22
35
|
close() {
|
|
23
36
|
this.#removeOverlay();
|
|
24
37
|
this.rangeDrag = null;
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
this.view.removeEventListener('mousemove', this.onMouseMove);
|
|
39
|
+
this.view.removeEventListener('mouseup', this.onMouseUp);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keep an in-progress edit while values refresh; cancel if its editor changes. */
|
|
43
|
+
reconcile(nodes) {
|
|
44
|
+
const previous = this.overlayNode ?? this.rangeDrag?.node;
|
|
45
|
+
if (!previous) return;
|
|
46
|
+
const next = nodes.find(node => node.id === previous.id);
|
|
47
|
+
const signature = data => JSON.stringify([data.editorType, data.readonly, data.disabled,
|
|
48
|
+
...['min', 'max', 'step', 'integer', 'options', 'unit'].map(key => data.meta?.[key])]);
|
|
49
|
+
if (!next || signature(previous.data) !== signature(next.data)) this.close();
|
|
27
50
|
}
|
|
28
51
|
|
|
29
52
|
handlePointerDown(event, hit) {
|
|
30
|
-
if (!this.#isEditableHit(hit)) return false;
|
|
53
|
+
if (!this.editable || !this.#isEditableHit(hit)) return false;
|
|
31
54
|
const data = hit.row ? this.controller.model.nodes[hit.row.nodeIndex]?.data : null;
|
|
32
55
|
if (!data || data.readonly || data.disabled) return false;
|
|
33
56
|
if (data.editorType === 'range' && hit.part === 'range') {
|
|
34
|
-
this.rangeDrag = { hit, data };
|
|
57
|
+
this.rangeDrag = { hit, data, node: this.controller.model.nodes[hit.row.nodeIndex] };
|
|
35
58
|
this.#updateRangeFromEvent(event);
|
|
36
|
-
|
|
37
|
-
|
|
59
|
+
this.view.addEventListener('mousemove', this.onMouseMove);
|
|
60
|
+
this.view.addEventListener('mouseup', this.onMouseUp);
|
|
38
61
|
return true;
|
|
39
62
|
}
|
|
40
63
|
if (shouldOpenOverlayOnPointerDown(data, hit)) {
|
|
@@ -46,7 +69,7 @@ export class CellEditorManager {
|
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
handleClick(event, hit) {
|
|
49
|
-
if (!this.#isEditableHit(hit)) return false;
|
|
72
|
+
if (!this.editable || !this.#isEditableHit(hit)) return false;
|
|
50
73
|
const node = this.controller.model.nodes[hit.row.nodeIndex];
|
|
51
74
|
const data = node?.data;
|
|
52
75
|
if (!data || data.disabled) return false;
|
|
@@ -108,7 +131,7 @@ export class CellEditorManager {
|
|
|
108
131
|
});
|
|
109
132
|
let committed = false;
|
|
110
133
|
const commit = () => {
|
|
111
|
-
if (committed) return;
|
|
134
|
+
if (committed || this.overlay !== element) return;
|
|
112
135
|
committed = true;
|
|
113
136
|
const nextValue = parseEditorValue(element, data);
|
|
114
137
|
this.controller.updateInspectorValue(node.id, nextValue, data.editorType);
|
|
@@ -125,6 +148,8 @@ export class CellEditorManager {
|
|
|
125
148
|
ensureOverlayHost(this.host);
|
|
126
149
|
this.host.append(element);
|
|
127
150
|
this.overlay = element;
|
|
151
|
+
this.overlayKind = 'value';
|
|
152
|
+
this.overlayNode = node;
|
|
128
153
|
element.focus({ preventScroll: true });
|
|
129
154
|
element.select?.();
|
|
130
155
|
if (options.showPicker && element.showPicker) {
|
|
@@ -180,6 +205,7 @@ export class CellEditorManager {
|
|
|
180
205
|
ensureOverlayHost(this.host);
|
|
181
206
|
this.host.append(element);
|
|
182
207
|
this.overlay = element;
|
|
208
|
+
this.overlayKind = 'filter';
|
|
183
209
|
element.focus({ preventScroll: true });
|
|
184
210
|
element.select();
|
|
185
211
|
}
|
|
@@ -206,8 +232,8 @@ export class CellEditorManager {
|
|
|
206
232
|
|
|
207
233
|
#onMouseUp() {
|
|
208
234
|
this.rangeDrag = null;
|
|
209
|
-
|
|
210
|
-
|
|
235
|
+
this.view.removeEventListener('mousemove', this.onMouseMove);
|
|
236
|
+
this.view.removeEventListener('mouseup', this.onMouseUp);
|
|
211
237
|
}
|
|
212
238
|
|
|
213
239
|
#isEditableHit(hit) {
|
|
@@ -257,8 +283,11 @@ export class CellEditorManager {
|
|
|
257
283
|
}
|
|
258
284
|
|
|
259
285
|
#removeOverlay() {
|
|
260
|
-
this.overlay
|
|
286
|
+
const overlay = this.overlay;
|
|
261
287
|
this.overlay = null;
|
|
288
|
+
this.overlayKind = null;
|
|
289
|
+
this.overlayNode = null;
|
|
290
|
+
overlay?.remove();
|
|
262
291
|
}
|
|
263
292
|
|
|
264
293
|
#toggleRangeMinMax(node, data) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
|
|
2
|
+
const safeWidth = Math.max(1, width);
|
|
3
|
+
const rightPadding = 14;
|
|
4
|
+
const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
|
|
5
|
+
const minLabelEnd = Math.max(88, depth * indentWidth + 104);
|
|
6
|
+
const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
|
|
7
|
+
const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
|
|
8
|
+
const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
|
|
9
|
+
const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
|
|
10
|
+
return { editorLeft, editorWidth };
|
|
11
|
+
}
|
|
12
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function drawCheckbox(ctx, x, y, checked, theme) {
|
|
2
|
+
ctx.beginPath();
|
|
3
|
+
ctx.roundRect(x, y, 16, 16, 3);
|
|
4
|
+
ctx.fillStyle = theme.colors.progressTrack;
|
|
5
|
+
ctx.fill();
|
|
6
|
+
ctx.strokeStyle = checked ? theme.colors.progressFill : theme.colors.textMuted;
|
|
7
|
+
ctx.stroke();
|
|
8
|
+
if (!checked) return;
|
|
9
|
+
ctx.fillStyle = theme.colors.progressFill;
|
|
10
|
+
ctx.beginPath();
|
|
11
|
+
ctx.roundRect(x + 4, y + 4, 8, 8, 1.5);
|
|
12
|
+
ctx.fill();
|
|
13
|
+
}
|
|
14
|
+
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { drawCheckbox } from './checkbox.js';
|
|
2
|
+
import { treeCellLayout } from '../core/tree-cell-layout.js';
|
|
3
|
+
import { formatInspectorValue } from '../inspector/editor-resolver.js';
|
|
4
|
+
import { inspectorPaneLayout } from '../inspector/pane-layout.js';
|
|
1
5
|
import { numericLayout } from '../inspector/numeric-layout.js';
|
|
2
6
|
import { IconRegistry } from '../core/icon-registry.js';
|
|
3
7
|
|
|
@@ -10,7 +14,8 @@ export class TreeRowRenderer {
|
|
|
10
14
|
this.scene = null;
|
|
11
15
|
this.iconRegistry = iconRegistry ?? new IconRegistry();
|
|
12
16
|
this.renderedRows = 0;
|
|
13
|
-
this.
|
|
17
|
+
this.iconRenderFrame = null;
|
|
18
|
+
this.invalidate = null;
|
|
14
19
|
this.preparedIconThemeKey = null;
|
|
15
20
|
this.stopIconListener = this.iconRegistry.onChange?.(() => this.#scheduleIconRender()) ?? null;
|
|
16
21
|
}
|
|
@@ -26,7 +31,15 @@ export class TreeRowRenderer {
|
|
|
26
31
|
this.scene = scene;
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
setInvalidationHandler(invalidate) { this.invalidate = invalidate; }
|
|
35
|
+
|
|
29
36
|
destroy() {
|
|
37
|
+
if (this.iconRenderFrame !== null) {
|
|
38
|
+
const view = this.canvas?.ownerDocument?.defaultView ?? globalThis;
|
|
39
|
+
(view.cancelAnimationFrame?.bind(view) ?? clearTimeout)(this.iconRenderFrame);
|
|
40
|
+
}
|
|
41
|
+
this.iconRenderFrame = null;
|
|
42
|
+
this.invalidate = null;
|
|
30
43
|
this.stopIconListener?.();
|
|
31
44
|
this.stopIconListener = null;
|
|
32
45
|
this.canvas = null;
|
|
@@ -42,7 +55,7 @@ export class TreeRowRenderer {
|
|
|
42
55
|
if (scene?.rows) this.scene = scene;
|
|
43
56
|
if (!this.canvas || !this.ctx || !this.scene) return;
|
|
44
57
|
const { viewport, theme } = this.scene;
|
|
45
|
-
const dpr = Math.max(1,
|
|
58
|
+
const dpr = Math.max(1, this.canvas.ownerDocument?.defaultView?.devicePixelRatio || 1);
|
|
46
59
|
const viewportWidth = Math.max(0, viewport.viewportWidth);
|
|
47
60
|
const viewportHeight = Math.max(0, viewport.viewportHeight);
|
|
48
61
|
if (viewportWidth <= 0 || viewportHeight <= 0) return;
|
|
@@ -65,15 +78,18 @@ export class TreeRowRenderer {
|
|
|
65
78
|
this.#drawHeader(ctx);
|
|
66
79
|
this.#drawRows(ctx);
|
|
67
80
|
this.#drawStickyRows(ctx);
|
|
81
|
+
this.#drawRowDrop(ctx);
|
|
68
82
|
ctx.restore();
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
#scheduleIconRender() {
|
|
72
|
-
if (this.
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
if (!this.canvas) return;
|
|
87
|
+
if (this.invalidate) return this.invalidate();
|
|
88
|
+
if (this.iconRenderFrame !== null) return;
|
|
89
|
+
const view = this.canvas.ownerDocument?.defaultView ?? globalThis;
|
|
90
|
+
const schedule = view.requestAnimationFrame?.bind(view) ?? (callback => setTimeout(callback, 0));
|
|
91
|
+
this.iconRenderFrame = schedule(() => {
|
|
92
|
+
this.iconRenderFrame = null;
|
|
77
93
|
this.render();
|
|
78
94
|
});
|
|
79
95
|
}
|
|
@@ -107,7 +123,7 @@ export class TreeRowRenderer {
|
|
|
107
123
|
ctx.textBaseline = 'middle';
|
|
108
124
|
|
|
109
125
|
for (const column of columns) {
|
|
110
|
-
if (headerFilter && column.kind
|
|
126
|
+
if (headerFilter && column === columns.find(item => item.kind !== 'rowOrder' && item.id !== '__vtc_actions')) {
|
|
111
127
|
this.#drawHeaderFilter(ctx, column, viewport, theme, filterQuery);
|
|
112
128
|
} else {
|
|
113
129
|
ctx.fillStyle = colors.textMuted;
|
|
@@ -145,7 +161,7 @@ export class TreeRowRenderer {
|
|
|
145
161
|
ctx.strokeStyle = colors.border;
|
|
146
162
|
ctx.stroke();
|
|
147
163
|
ctx.fillStyle = filterQuery ? colors.text : colors.textMuted;
|
|
148
|
-
drawTruncatedText(ctx, filterQuery || 'Filter
|
|
164
|
+
drawTruncatedText(ctx, filterQuery || 'Filter rows', x + 8, viewport.headerHeight / 2, width - 16);
|
|
149
165
|
}
|
|
150
166
|
|
|
151
167
|
#drawSortIndicator(ctx, x, y, direction, theme) {
|
|
@@ -200,6 +216,7 @@ export class TreeRowRenderer {
|
|
|
200
216
|
}
|
|
201
217
|
ctx.restore();
|
|
202
218
|
|
|
219
|
+
if (!stickyRows.some(row => row.stickyY > row.y - viewport.scrollY)) return;
|
|
203
220
|
const bottom = viewport.headerHeight + height;
|
|
204
221
|
ctx.strokeStyle = theme.colors.border;
|
|
205
222
|
ctx.beginPath();
|
|
@@ -222,7 +239,8 @@ export class TreeRowRenderer {
|
|
|
222
239
|
const visibleX = viewport.scrollX;
|
|
223
240
|
const visibleWidth = viewport.contentViewportWidth ?? viewport.viewportWidth;
|
|
224
241
|
|
|
225
|
-
|
|
242
|
+
const background = selected ? colors.rowSelected : highlighted ? colors.rowHighlighted : hovered ? colors.rowHover : colors.row;
|
|
243
|
+
ctx.fillStyle = background;
|
|
226
244
|
ctx.fillRect(visibleX, y, visibleWidth, row.height);
|
|
227
245
|
|
|
228
246
|
this.#drawIndentGuides(ctx, row, colors);
|
|
@@ -249,6 +267,12 @@ export class TreeRowRenderer {
|
|
|
249
267
|
ctx.stroke();
|
|
250
268
|
}
|
|
251
269
|
|
|
270
|
+
const actions = columns.find(column => column.id === '__vtc_actions');
|
|
271
|
+
if (actions) {
|
|
272
|
+
ctx.fillStyle = background;
|
|
273
|
+
ctx.fillRect(visibleX + Math.max(0, visibleWidth - actions.width), y, actions.width, row.height);
|
|
274
|
+
}
|
|
275
|
+
|
|
252
276
|
ctx.strokeStyle = colors.border;
|
|
253
277
|
ctx.beginPath();
|
|
254
278
|
ctx.moveTo(visibleX, y + row.height + 0.5);
|
|
@@ -268,7 +292,8 @@ export class TreeRowRenderer {
|
|
|
268
292
|
cell.column.render(ctx, cell);
|
|
269
293
|
return;
|
|
270
294
|
}
|
|
271
|
-
if (cell.column.kind === '
|
|
295
|
+
if (cell.column.kind === 'rowOrder') this.#drawRowOrder(ctx, cell);
|
|
296
|
+
else if (cell.column.kind === 'tree') this.#drawTreeCell(ctx, cell);
|
|
272
297
|
else if (cell.column.kind === 'inspectorPane') this.#drawInspectorPaneCell(ctx, cell);
|
|
273
298
|
else if (cell.column.kind === 'inspectorValue') this.#drawInspectorValueCell(ctx, cell);
|
|
274
299
|
else if (cell.column.kind === 'inspectorType') this.#drawInspectorTypeCell(ctx, cell);
|
|
@@ -278,6 +303,43 @@ export class TreeRowRenderer {
|
|
|
278
303
|
else this.#drawTextCell(ctx, cell);
|
|
279
304
|
}
|
|
280
305
|
|
|
306
|
+
#drawRowOrder(ctx, { node, row, rect, theme }) {
|
|
307
|
+
if (node.reorderable === false) return;
|
|
308
|
+
const siblings = this.scene.childrenByParent?.get(node.parentId ?? null) ?? [];
|
|
309
|
+
const enabled = this.scene.canReorderRows && siblings.length > 1;
|
|
310
|
+
const cy = rect.y + row.height / 2;
|
|
311
|
+
ctx.save();
|
|
312
|
+
ctx.strokeStyle = theme.colors.textMuted;
|
|
313
|
+
ctx.fillStyle = theme.colors.textMuted;
|
|
314
|
+
ctx.lineWidth = 1.5;
|
|
315
|
+
ctx.globalAlpha = enabled || this.scene.rowDrag?.(node, this.scene.dynamicState.get(node.id) ?? {}) != null ? 1 : 0.3;
|
|
316
|
+
for (const dx of [10, 14]) for (const dy of [-4, 0, 4]) ctx.fillRect(rect.x + dx, cy + dy, 1.5, 1.5);
|
|
317
|
+
for (const [center, direction, available] of [[36, -1, siblings[0] !== node.id], [60, 1, siblings.at(-1) !== node.id]]) {
|
|
318
|
+
ctx.globalAlpha = enabled && available ? 1 : 0.3;
|
|
319
|
+
ctx.beginPath();
|
|
320
|
+
ctx.moveTo(rect.x + center - 4, cy - direction * 2);
|
|
321
|
+
ctx.lineTo(rect.x + center, cy + direction * 2);
|
|
322
|
+
ctx.lineTo(rect.x + center + 4, cy - direction * 2);
|
|
323
|
+
ctx.stroke();
|
|
324
|
+
}
|
|
325
|
+
ctx.restore();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#drawRowDrop(ctx) {
|
|
329
|
+
const { rowDrop, viewport, theme } = this.scene;
|
|
330
|
+
if (!rowDrop) return;
|
|
331
|
+
const y = viewport.headerHeight + rowDrop.y - viewport.scrollY;
|
|
332
|
+
if (y < viewport.headerHeight || y > viewport.headerHeight + viewport.rowViewportHeight) return;
|
|
333
|
+
ctx.save();
|
|
334
|
+
ctx.strokeStyle = theme.colors.focus;
|
|
335
|
+
ctx.lineWidth = 2;
|
|
336
|
+
ctx.beginPath();
|
|
337
|
+
ctx.moveTo(3, y);
|
|
338
|
+
ctx.lineTo(viewport.contentViewportWidth - 3, y);
|
|
339
|
+
ctx.stroke();
|
|
340
|
+
ctx.restore();
|
|
341
|
+
}
|
|
342
|
+
|
|
281
343
|
#drawInspectorPaneCell(ctx, { node, row, rect, theme, style, hovered, hoverPart, activePart }) {
|
|
282
344
|
const visibleRight = this.scene.viewport.scrollX + (this.scene.viewport.contentViewportWidth ?? this.scene.viewport.viewportWidth);
|
|
283
345
|
rect = { ...rect, width: Math.max(1, Math.min(rect.x + rect.width, visibleRight) - rect.x) };
|
|
@@ -343,6 +405,7 @@ export class TreeRowRenderer {
|
|
|
343
405
|
const meta = data.meta ?? {};
|
|
344
406
|
const disabled = data.disabled;
|
|
345
407
|
const readonly = data.readonly;
|
|
408
|
+
const valueColor = resolveValueColor(theme, data.value, data.editorType === 'select' ? 'enum' : data.valueType);
|
|
346
409
|
const x = rect.x + 10;
|
|
347
410
|
const y = rect.y + 5;
|
|
348
411
|
const fullWidth = Math.max(24, rect.width - 20);
|
|
@@ -362,7 +425,7 @@ export class TreeRowRenderer {
|
|
|
362
425
|
}
|
|
363
426
|
|
|
364
427
|
if (data.editorType === 'checkbox') {
|
|
365
|
-
|
|
428
|
+
drawCheckbox(ctx, x, rect.y + rect.height / 2 - 8, Boolean(data.value), theme);
|
|
366
429
|
} else if (data.editorType === 'range') {
|
|
367
430
|
this.#drawInspectorRange(ctx, x, rect.y + rect.height / 2 - 4, width, data, theme, {
|
|
368
431
|
hoveredNumber: hovered && hoverPart === 'number',
|
|
@@ -373,7 +436,7 @@ export class TreeRowRenderer {
|
|
|
373
436
|
ctx.fillRect(x, y + 2, 28, height - 4);
|
|
374
437
|
ctx.strokeStyle = theme.colors.border;
|
|
375
438
|
ctx.strokeRect(x + 0.5, y + 2.5, 28, height - 4);
|
|
376
|
-
this.#drawMutedText(ctx, String(data.value ?? ''), x + 38, rect.y + rect.height / 2, width - 38, theme);
|
|
439
|
+
this.#drawMutedText(ctx, String(data.value ?? ''), x + 38, rect.y + rect.height / 2, width - 38, theme, false, null, valueColor);
|
|
377
440
|
} else if (data.editorType === 'button') {
|
|
378
441
|
const buttonWidth = meta.fullWidthButton ? width : Math.min(width, 140);
|
|
379
442
|
this.#drawControlSurface(ctx, x, y, buttonWidth, height, theme, {
|
|
@@ -392,17 +455,17 @@ export class TreeRowRenderer {
|
|
|
392
455
|
active: activePart === 'editor',
|
|
393
456
|
disabled: readonly || disabled,
|
|
394
457
|
});
|
|
395
|
-
this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme);
|
|
458
|
+
this.#drawMutedText(ctx, data.valueText, x + 8, rect.y + rect.height / 2, selectWidth - 30, theme, false, null, valueColor);
|
|
396
459
|
this.#drawSelectChevron(ctx, x + selectWidth - 18, rect.y + rect.height / 2, theme, disabled);
|
|
397
460
|
} else {
|
|
398
461
|
if (numeric.unit) {
|
|
399
462
|
ctx.font = theme.monoFont ?? theme.font;
|
|
400
|
-
ctx.fillStyle =
|
|
463
|
+
ctx.fillStyle = valueColor;
|
|
401
464
|
ctx.textAlign = 'right';
|
|
402
465
|
drawTruncatedText(ctx, data.valueText, x + width, rect.y + rect.height / 2, width);
|
|
403
466
|
ctx.textAlign = 'left';
|
|
404
467
|
} else {
|
|
405
|
-
this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme,
|
|
468
|
+
this.#drawMutedText(ctx, data.valueText, x, rect.y + rect.height / 2, width, theme, false, null, valueColor);
|
|
406
469
|
}
|
|
407
470
|
}
|
|
408
471
|
|
|
@@ -432,17 +495,6 @@ export class TreeRowRenderer {
|
|
|
432
495
|
this.#drawMutedText(ctx, node.data?.meta?.description ?? '', rect.x + 10, rect.y + rect.height / 2, rect.width - 20, theme);
|
|
433
496
|
}
|
|
434
497
|
|
|
435
|
-
#drawCheckbox(ctx, x, y, checked, theme) {
|
|
436
|
-
roundRect(ctx, x, y, 16, 16, 3);
|
|
437
|
-
ctx.fillStyle = theme.colors.progressTrack;
|
|
438
|
-
ctx.fill();
|
|
439
|
-
ctx.strokeStyle = checked ? theme.colors.progressFill : theme.colors.textMuted;
|
|
440
|
-
ctx.stroke();
|
|
441
|
-
if (!checked) return;
|
|
442
|
-
ctx.fillStyle = theme.colors.progressFill;
|
|
443
|
-
roundRect(ctx, x + 4, y + 4, 8, 8, 1.5);
|
|
444
|
-
ctx.fill();
|
|
445
|
-
}
|
|
446
498
|
|
|
447
499
|
#drawInspectorRange(ctx, x, y, width, data, theme, state = {}) {
|
|
448
500
|
const meta = data.meta ?? {};
|
|
@@ -457,7 +509,7 @@ export class TreeRowRenderer {
|
|
|
457
509
|
hovered: Boolean(state.hoveredNumber),
|
|
458
510
|
active: Boolean(state.activeNumber),
|
|
459
511
|
});
|
|
460
|
-
ctx.fillStyle = theme.
|
|
512
|
+
ctx.fillStyle = resolveValueColor(theme, data.value);
|
|
461
513
|
ctx.font = theme.monoFont ?? theme.font;
|
|
462
514
|
ctx.textAlign = 'right';
|
|
463
515
|
drawTruncatedText(ctx, String(data.valueText ?? ''), x + barWidth + gap + valueWidth - 6, y + 4, valueWidth - 10);
|
|
@@ -499,8 +551,8 @@ export class TreeRowRenderer {
|
|
|
499
551
|
ctx.fill();
|
|
500
552
|
}
|
|
501
553
|
|
|
502
|
-
#drawMutedText(ctx, text, x, y, width, theme, readonly = false, font = null) {
|
|
503
|
-
ctx.fillStyle = readonly ? theme.colors.textMuted : theme.colors.text;
|
|
554
|
+
#drawMutedText(ctx, text, x, y, width, theme, readonly = false, font = null, color = null) {
|
|
555
|
+
ctx.fillStyle = color ?? (readonly ? theme.colors.textMuted : theme.colors.text);
|
|
504
556
|
ctx.font = font ?? theme.font;
|
|
505
557
|
ctx.textBaseline = 'middle';
|
|
506
558
|
ctx.textAlign = 'left';
|
|
@@ -509,19 +561,20 @@ export class TreeRowRenderer {
|
|
|
509
561
|
|
|
510
562
|
#drawTreeCell(ctx, { node, row, rect, theme, style }) {
|
|
511
563
|
const colors = theme.colors;
|
|
512
|
-
const
|
|
564
|
+
const layout = treeCellLayout(row.depth, theme.indentWidth, this.scene.childrenByParent.size > 1);
|
|
513
565
|
const cy = rect.y + rect.height / 2;
|
|
514
|
-
this.#drawChevron(ctx, x +
|
|
515
|
-
this.iconRegistry.draw(ctx, style.icon, x +
|
|
566
|
+
if (row.hasChildren) this.#drawChevron(ctx, rect.x + layout.chevronX, cy, row, colors);
|
|
567
|
+
this.iconRegistry.draw(ctx, style.icon, rect.x + layout.iconX, rect.y + 6, 15, style.color);
|
|
516
568
|
ctx.fillStyle = colors.text;
|
|
517
569
|
ctx.font = theme.font;
|
|
518
570
|
ctx.textBaseline = 'middle';
|
|
519
571
|
ctx.textAlign = 'left';
|
|
520
|
-
drawTruncatedText(ctx, node.label ?? node.id, x +
|
|
572
|
+
drawTruncatedText(ctx, node.label ?? node.id, rect.x + layout.labelX, cy, Math.max(0, rect.width - layout.labelX - 6));
|
|
521
573
|
}
|
|
522
574
|
|
|
523
575
|
#drawStatusCell(ctx, { rect, style, theme }) {
|
|
524
|
-
|
|
576
|
+
ctx.font = theme.font;
|
|
577
|
+
const badgeWidth = Math.max(0, Math.min(ctx.measureText(style.status.label).width + 18, rect.width - 12));
|
|
525
578
|
const x = rect.x + (rect.width - badgeWidth) / 2;
|
|
526
579
|
const y = rect.y + (rect.height - 17) / 2;
|
|
527
580
|
const statusColor = style.status.color;
|
|
@@ -532,7 +585,7 @@ export class TreeRowRenderer {
|
|
|
532
585
|
ctx.strokeStyle = mixColor(theme.colors.border, statusColor, 0.42);
|
|
533
586
|
ctx.stroke();
|
|
534
587
|
ctx.fillStyle = mixColor(theme.colors.text, statusColor, 0.36);
|
|
535
|
-
ctx.font =
|
|
588
|
+
ctx.font = theme.font;
|
|
536
589
|
ctx.textAlign = 'center';
|
|
537
590
|
ctx.textBaseline = 'middle';
|
|
538
591
|
drawTruncatedText(ctx, style.status.label, x + badgeWidth / 2, y + 8.5, badgeWidth - 8);
|
|
@@ -574,10 +627,14 @@ export class TreeRowRenderer {
|
|
|
574
627
|
|
|
575
628
|
#drawTextCell(ctx, { node, state, rect, column, theme }) {
|
|
576
629
|
let value = column.value(node, state);
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
630
|
+
const numeric = typeof value === 'number';
|
|
631
|
+
const valueType = typeof column.valueType === 'function' ? column.valueType(value, node, state) : column.valueType;
|
|
632
|
+
const valueColor = resolveValueColor(theme, value, valueType);
|
|
633
|
+
if (column.format) value = column.format(value, node, state);
|
|
634
|
+
else if (column.kind === 'updated' && numeric) value = formatTime(value);
|
|
635
|
+
else if (numeric) value = formatInspectorValue(value);
|
|
636
|
+
ctx.fillStyle = column.kind === 'type' ? resolveNodeStyle(theme, node, state).color : column.kind === 'value' ? valueColor : theme.colors.textMuted;
|
|
637
|
+
ctx.font = column.kind === 'type' || column.kind === 'updated' || numeric ? theme.monoFont ?? theme.font : theme.font;
|
|
581
638
|
ctx.textBaseline = 'middle';
|
|
582
639
|
ctx.textAlign = column.align;
|
|
583
640
|
const x = column.align === 'right' ? rect.x + rect.width - 10 : column.align === 'center' ? rect.x + rect.width / 2 : rect.x + 10;
|
|
@@ -599,13 +656,8 @@ export class TreeRowRenderer {
|
|
|
599
656
|
}
|
|
600
657
|
|
|
601
658
|
#drawChevron(ctx, x, y, row, colors) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
ctx.beginPath();
|
|
605
|
-
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
|
|
606
|
-
ctx.fill();
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
659
|
+
if (!row.hasChildren) return;
|
|
660
|
+
ctx.fillStyle = colors.chevron;
|
|
609
661
|
ctx.beginPath();
|
|
610
662
|
if (row.expanded) {
|
|
611
663
|
ctx.moveTo(x - 5, y - 2);
|
|
@@ -632,21 +684,6 @@ function resolveNodeStyle(theme, node, state = {}) {
|
|
|
632
684
|
};
|
|
633
685
|
}
|
|
634
686
|
|
|
635
|
-
function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
|
|
636
|
-
const safeWidth = Math.max(1, width);
|
|
637
|
-
const rightPadding = 14;
|
|
638
|
-
if (editorType === 'checkbox') {
|
|
639
|
-
const editorWidth = 34;
|
|
640
|
-
return { editorLeft: Math.max(64, safeWidth - rightPadding - editorWidth), editorWidth };
|
|
641
|
-
}
|
|
642
|
-
const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
|
|
643
|
-
const minLabelEnd = Math.max(88, depth * indentWidth + 104);
|
|
644
|
-
const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
|
|
645
|
-
const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
|
|
646
|
-
const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
|
|
647
|
-
const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
|
|
648
|
-
return { editorLeft, editorWidth };
|
|
649
|
-
}
|
|
650
687
|
|
|
651
688
|
function clamp01(value) {
|
|
652
689
|
return Math.max(0, Math.min(1, value));
|
|
@@ -744,3 +781,7 @@ function formatTime(value) {
|
|
|
744
781
|
if (!Number.isFinite(time)) return '';
|
|
745
782
|
return timeFormatter.format(time);
|
|
746
783
|
}
|
|
784
|
+
|
|
785
|
+
function resolveValueColor(theme, value, type) {
|
|
786
|
+
return theme.valueColors?.[type ?? (value === null ? 'null' : typeof value)] ?? theme.colors.text;
|
|
787
|
+
}
|