virtual-tree-canvas 0.6.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 +23 -0
- package/README.md +79 -3
- package/docs/icon-catalog.json +12 -0
- package/docs/icon-catalog.md +5 -1
- package/package.json +1 -1
- package/resources/icons/star-filled.svg +1 -0
- package/resources/icons/trash.svg +1 -0
- package/src/assets/icons.js +2 -0
- package/src/core/theme-manager.js +3 -0
- package/src/core/tree-cell-layout.js +9 -0
- package/src/core/tree-column-model.js +13 -2
- package/src/core/types.js +1 -0
- package/src/core/view-options.js +10 -3
- package/src/core/visible-row-model.js +1 -1
- package/src/index.d.ts +48 -1
- package/src/input/row-actions.js +115 -0
- package/src/input/row-reorder-input.js +34 -9
- package/src/input/tree-view-input-controller.js +5 -3
- package/src/inspector/cell-editor-manager.js +23 -9
- package/src/inspector/pane-layout.js +12 -0
- package/src/renderers/checkbox.js +14 -0
- package/src/renderers/tree-row-renderer.js +47 -55
- package/src/tree-view-controller.js +67 -41
- package/src/tree-view.js +6 -4
- package/src/view/styles.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 — 2026-09-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Configurable right-aligned row actions with icons, checkboxes, tooltips, disabled and pressed states, and keyboard activation.
|
|
8
|
+
- Optional row data dragging with start, move, end and cancel events for application drop targets.
|
|
9
|
+
- `preloadIcons` prepares action icon variants before the first interaction. Includes new trash and filled-star icons.
|
|
10
|
+
- `showHeader` hides column headings independently of the filter.
|
|
11
|
+
- `reorderable: false` prevents manual movement of individual rows while allowing their parent subtree to move.
|
|
12
|
+
- Value formatting and semantic type colors for table columns, plus configurable status pills.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Row actions preserve selection backgrounds and remain available on partially visible rows. Hover highlights the icon without covering the row.
|
|
17
|
+
- Action icons render at their displayed size and current screen pixel density.
|
|
18
|
+
- Canvas resizing repaints immediately after measurement; keyboard focus uses a subtle outer border.
|
|
19
|
+
- Inspector checkboxes align with other value controls. Sliders and text inputs remain editable when row dragging is enabled.
|
|
20
|
+
- Live refreshes preserve active editors, row drags, expansion state and action-button focus.
|
|
21
|
+
- Flat lists no longer reserve unnecessary leaf-marker space. Sticky ancestor separators remain hidden before scrolling.
|
|
22
|
+
- Focusing a row during dragging no longer scrolls ancestor panels.
|
|
23
|
+
- Sorted and filtered tables can export rows while keeping manual reordering disabled.
|
|
24
|
+
- Pointer cancellation and disposal release drag state.
|
|
25
|
+
|
|
3
26
|
## 0.6.0 — 2026-09-10
|
|
4
27
|
|
|
5
28
|
### Added
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ grow.
|
|
|
19
19
|
- **Easy to style.** Built-in dark, light and tactical themes, type-based styling, and editable SVG icons.
|
|
20
20
|
- **Ready for inspectors.** Turn plain JavaScript objects into compact editable forms or property tables.
|
|
21
21
|
|
|
22
|
-
See [the changelog](./CHANGELOG.md) for
|
|
22
|
+
See [the changelog](./CHANGELOG.md) for migration notes and additions.
|
|
23
23
|
|
|
24
24
|
## Install
|
|
25
25
|
|
|
@@ -135,8 +135,16 @@ and may rebuild the visible row list. For very large datasets,
|
|
|
135
135
|
`enableWorkers()` moves search and filtered row rebuilds off the main thread
|
|
136
136
|
when Workers are available.
|
|
137
137
|
|
|
138
|
+
Row actions can also use checkboxes styled like inspector controls. Set `kind: 'checkbox'` and provide
|
|
139
|
+
`checked` as a boolean or a function of `(node, state)`. The `rowaction` event
|
|
140
|
+
includes `checked`; update your application state in response. Checkbox actions
|
|
141
|
+
support `visible` and `disabled` and do not select or drag the row.
|
|
142
|
+
|
|
138
143
|
## Columns
|
|
139
144
|
|
|
145
|
+
Use `showHeader: false` to hide column headings while keeping the filter available.
|
|
146
|
+
The configured `headerHeight` is preserved when the header is shown again.
|
|
147
|
+
|
|
140
148
|
```js
|
|
141
149
|
tree.setColumns([
|
|
142
150
|
{
|
|
@@ -171,6 +179,11 @@ Available column kinds are `tree`, `status`, `value`, `progress`, `type`,
|
|
|
171
179
|
Canvas2D content. Import `builtInColumns` or `defaultTreeTableColumns` to start
|
|
172
180
|
from the default set.
|
|
173
181
|
|
|
182
|
+
Text columns accept `format(value, node, state)` to customize displayed text and
|
|
183
|
+
tooltips while sorting by the original `value`. For example,
|
|
184
|
+
`format: value => value.toFixed(2)` displays two decimal places. By default,
|
|
185
|
+
numeric text uses the same precision as inspector values (up to three decimals).
|
|
186
|
+
|
|
174
187
|
## Themes and icons
|
|
175
188
|
|
|
176
189
|
Three themes are included:
|
|
@@ -181,6 +194,18 @@ import { darkTheme, lightTheme, tacticalTheme } from 'virtual-tree-canvas';
|
|
|
181
194
|
tree.setTheme(tacticalTheme);
|
|
182
195
|
```
|
|
183
196
|
|
|
197
|
+
Inspector values and columns with `kind: 'value'` use `theme.valueColors` to
|
|
198
|
+
distinguish numbers, strings, booleans, enums and empty values. Read-only values
|
|
199
|
+
retain their contrast; disabled controls remain dimmed. Override individual
|
|
200
|
+
colors, for example `tree.setTheme({ valueColors: { number: '#8bd5ee' } })`.
|
|
201
|
+
A value column can specify `valueType: 'enum'` or a
|
|
202
|
+
`valueType(value, node, state)` callback when its semantic type differs from the
|
|
203
|
+
raw value. Formatting and sorting are unaffected.
|
|
204
|
+
|
|
205
|
+
Columns with `kind: 'status'` display compact pills using `theme.statuses`, for
|
|
206
|
+
example `{ Live: { label: 'Live', color: '#56ba95' } }`. Their width adapts to
|
|
207
|
+
the label within the column.
|
|
208
|
+
|
|
184
209
|
Themes can map domain types to a colour and icon:
|
|
185
210
|
|
|
186
211
|
```js
|
|
@@ -328,7 +353,7 @@ npm test
|
|
|
328
353
|
|
|
329
354
|
## Manual row order
|
|
330
355
|
|
|
331
|
-
`rowReorder: true` adds a dedicated handle and Up/Down controls to a data table. Dragging commits only on drop; Escape or dropping outside cancels. Alt+Up / Alt+Down moves the focused row. Edge scrolling works while dragging.
|
|
356
|
+
`rowReorder: true` adds a dedicated handle and Up/Down controls to a data table. Set `reorderable: false` on derived or fixed nodes to hide their ordering controls and prevent moving them manually; their parent can still move with its subtree. Dragging commits only on drop; Escape or dropping outside cancels. Alt+Up / Alt+Down moves the focused row. Edge scrolling works while dragging.
|
|
332
357
|
|
|
333
358
|
```js
|
|
334
359
|
const tree = new TreeViewController({
|
|
@@ -359,4 +384,55 @@ Open [the reorder demo](./examples/reorder-table.html) through a local HTTP serv
|
|
|
359
384
|
|
|
360
385
|
## Icon catalogue
|
|
361
386
|
|
|
362
|
-
|
|
387
|
+
65 SVGs are included, with a [visual sheet](./docs/icon-catalog.html) and a [reference of available icons](./docs/icon-catalog.md). `builtinIconNames` exposes available IDs.
|
|
388
|
+
|
|
389
|
+
## Row actions and data dragging
|
|
390
|
+
|
|
391
|
+
Actions are optional accessible buttons on the right of visible rows. Their icon names use
|
|
392
|
+
`IconRegistry`, including registered custom icons. The host handles the action;
|
|
393
|
+
clicking a button does not select or expand the row.
|
|
394
|
+
|
|
395
|
+
```js
|
|
396
|
+
const favorites = new Set();
|
|
397
|
+
const view = new TreeView(host, {
|
|
398
|
+
nodes,
|
|
399
|
+
rowActions: [{
|
|
400
|
+
id: 'favorite', preloadIcons: ['star', 'star-filled'], icon: node => favorites.has(node.id) ? 'star-filled' : 'star', label: 'Toggle favorite',
|
|
401
|
+
visible: node => !node.data?.group,
|
|
402
|
+
disabled: node => node.data?.unavailable === true,
|
|
403
|
+
pressed: node => favorites.has(node.id)
|
|
404
|
+
}],
|
|
405
|
+
rowDrag: node => node.data?.reference ?? null
|
|
406
|
+
});
|
|
407
|
+
view.on('rowaction', ({detail}) => {
|
|
408
|
+
if (favorites.has(detail.nodeId)) favorites.delete(detail.nodeId);
|
|
409
|
+
else favorites.add(detail.nodeId);
|
|
410
|
+
view.controller.requestRender();
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
`rowActions` accepts unique `id` and accessible `label` strings. `icon`, `visible`,
|
|
415
|
+
`disabled` and `pressed` may be functions receiving `(node, dynamicState)`.
|
|
416
|
+
`visible`, `disabled` and `pressed` also accept booleans. Buttons remain focusable
|
|
417
|
+
with Tab and work with Enter/Space. Only visible-row buttons are mounted; live
|
|
418
|
+
value updates preserve their identity and focus.
|
|
419
|
+
|
|
420
|
+
`rowDrag(node, dynamicState)` returns a payload, or `null` to disable dragging
|
|
421
|
+
that row. Drag from a row label or the row-order handle. Editors, checkboxes,
|
|
422
|
+
action buttons and expand/collapse controls retain their normal behavior.
|
|
423
|
+
|
|
424
|
+
The controller emits `rowdragstart`, `rowdragmove`, `rowdragend` and
|
|
425
|
+
`rowdragcancel`. Details contain `nodeId`, `payload`, `label` and, except on
|
|
426
|
+
cancellation, `originalEvent` with client coordinates. Connect these events to
|
|
427
|
+
your application's drop manager to highlight destinations and handle the drop.
|
|
428
|
+
The payload is captured at pointer-down; use a stable reference instead of a
|
|
429
|
+
snapshot of a live value. The library never moves or deletes exported data.
|
|
430
|
+
|
|
431
|
+
Dragging uses Pointer Events within a document. Escape, pointer cancellation,
|
|
432
|
+
loss of pointer capture, removal of the source row and destruction cancel the
|
|
433
|
+
gesture. Live value refreshes preserve an ongoing drag. Sorting and filtering
|
|
434
|
+
disable manual row reordering but still allow exporting rows. Clear the filter
|
|
435
|
+
and cycle the column header through ascending, descending and unsorted to
|
|
436
|
+
restore manual ordering. With `rowReorder: true`, Alt+Up/Down also reorders rows.
|
|
437
|
+
|
|
438
|
+
For actions whose `icon` callback changes between variants, supply `preloadIcons: ['star', 'star-filled']`. The visible action layer prepares these icons in both normal and pressed colors at the current pixel ratio before interaction.
|
package/docs/icon-catalog.json
CHANGED
|
@@ -376,5 +376,17 @@
|
|
|
376
376
|
"label": "Munición",
|
|
377
377
|
"category": "Tiempo y simulación",
|
|
378
378
|
"available": true
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
"id": "trash",
|
|
382
|
+
"label": "Eliminar",
|
|
383
|
+
"category": "Interfaz",
|
|
384
|
+
"available": true
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
"id": "star-filled",
|
|
388
|
+
"label": "Favorito activo",
|
|
389
|
+
"category": "Interfaz",
|
|
390
|
+
"available": true
|
|
379
391
|
}
|
|
380
392
|
]
|
package/docs/icon-catalog.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Catálogo de iconos
|
|
2
2
|
|
|
3
|
-
Los
|
|
3
|
+
Los 65 iconos incluidos están disponibles en `resources/icons/`.
|
|
4
4
|
Consulta la [galería visual](./icon-catalog.html) para verlos sobre fondos claros
|
|
5
5
|
u oscuros y comparar tamaños. `builtinIconNames` permite enumerar sus IDs desde JavaScript.
|
|
6
6
|
|
|
@@ -165,3 +165,7 @@ view.setData([{ id: 'operator-1', label: 'Operador', icon: 'person' }]);
|
|
|
165
165
|
| `clock` | Reloj |
|
|
166
166
|
| `calendar` | Calendario |
|
|
167
167
|
| `munition` | Munición |
|
|
168
|
+
|
|
169
|
+
- `trash`: eliminar un elemento o quitarlo de una lista.
|
|
170
|
+
|
|
171
|
+
- `star-filled`: favorito activo; combina con `star` para el estado inactivo.
|
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m12 2 3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M9 6V3h6v3M6 6l1 15h10l1-15M10 10v7m4-7v7"/></svg>
|
package/src/assets/icons.js
CHANGED
|
@@ -32,6 +32,7 @@ export const darkTheme = {
|
|
|
32
32
|
borderStrong: '#2b3b52',
|
|
33
33
|
shadow: 'rgba(0,0,0,.24)',
|
|
34
34
|
},
|
|
35
|
+
valueColors: { number: '#8bd5ee', bigint: '#8bd5ee', string: '#dde5ef', boolean: '#86d6b5', enum: '#c4b5fd', null: '#9aa9bc', undefined: '#9aa9bc' },
|
|
35
36
|
types: {
|
|
36
37
|
root: { icon: 'folder', color: '#38bdf8' },
|
|
37
38
|
system: { icon: 'folder', color: '#818cf8' },
|
|
@@ -68,6 +69,7 @@ export const darkTheme = {
|
|
|
68
69
|
|
|
69
70
|
export const lightTheme = {
|
|
70
71
|
...darkTheme,
|
|
72
|
+
valueColors: { number: '#075985', bigint: '#075985', string: '#182438', boolean: '#176249', enum: '#6b3fa0', null: '#576579', undefined: '#576579' },
|
|
71
73
|
colors: {
|
|
72
74
|
background: '#f8fafc',
|
|
73
75
|
row: '#ffffff',
|
|
@@ -228,6 +230,7 @@ function mergeTheme(base, override) {
|
|
|
228
230
|
...base,
|
|
229
231
|
...override,
|
|
230
232
|
colors: { ...base.colors, ...(override.colors ?? {}) },
|
|
233
|
+
valueColors: { ...base.valueColors, ...(override.valueColors ?? {}) },
|
|
231
234
|
types: { ...base.types, ...(override.types ?? {}) },
|
|
232
235
|
statuses: { ...base.statuses, ...(override.statuses ?? {}) },
|
|
233
236
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Share tree-cell positions between painting, hit testing and tooltips. */
|
|
2
|
+
export function treeCellLayout(depth, indentWidth, hasHierarchy) {
|
|
3
|
+
const indent = depth * indentWidth;
|
|
4
|
+
return {
|
|
5
|
+
chevronX: hasHierarchy ? indent + 10 : null,
|
|
6
|
+
iconX: indent + (hasHierarchy ? 27 : 8),
|
|
7
|
+
labelX: indent + (hasHierarchy ? 50 : 31),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
@@ -69,6 +69,7 @@ export class TreeColumnModel {
|
|
|
69
69
|
if (!this.columns.some((column) => column.kind === 'tree' || column.kind === 'inspectorPane')) {
|
|
70
70
|
this.columns.unshift(normalizeColumn(builtInColumns.tree, 0));
|
|
71
71
|
}
|
|
72
|
+
this.setRowActions(this.rowActionCount ?? 0);
|
|
72
73
|
this.setRowReorder(this.rowReorder);
|
|
73
74
|
this.#layout();
|
|
74
75
|
}
|
|
@@ -83,6 +84,14 @@ export class TreeColumnModel {
|
|
|
83
84
|
this.#layout();
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
setRowActions(count) {
|
|
88
|
+
this.rowActionCount = count;
|
|
89
|
+
this.columns = this.columns.filter(column => column.id !== '__vtc_actions');
|
|
90
|
+
if (count) this.columns.push(normalizeColumn({ id: '__vtc_actions', label: '', kind: 'text',
|
|
91
|
+
width: count * 28, minWidth: count * 28, sortable: false, resizable: false, value: () => '' }, 0));
|
|
92
|
+
this.#layout();
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
/** @param {number} x */
|
|
87
96
|
getColumnAt(x) {
|
|
88
97
|
return this.columns.find((column) => x >= column.x && x < column.x + column.width) ?? null;
|
|
@@ -101,11 +110,11 @@ export class TreeColumnModel {
|
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
moveColumn(id, targetIndex) {
|
|
104
|
-
if (id === '__vtc_row_order') return false;
|
|
113
|
+
if (id === '__vtc_row_order' || id === '__vtc_actions') return false;
|
|
105
114
|
const currentIndex = this.columns.findIndex((column) => column.id === id);
|
|
106
115
|
if (currentIndex === -1) return false;
|
|
107
116
|
const [column] = this.columns.splice(currentIndex, 1);
|
|
108
|
-
const nextIndex = Math.max(this.rowReorder ? 1 : 0, Math.min(this.columns.length, targetIndex));
|
|
117
|
+
const nextIndex = Math.max(this.rowReorder ? 1 : 0, Math.min(this.columns.length - (this.rowActionCount ? 1 : 0), targetIndex));
|
|
109
118
|
this.columns.splice(nextIndex, 0, column);
|
|
110
119
|
if (!this.columns.some((item) => item.kind === 'tree')) this.columns.unshift(normalizeColumn(builtInColumns.tree, 0));
|
|
111
120
|
this.#layout();
|
|
@@ -158,6 +167,8 @@ function normalizeColumn(column, index) {
|
|
|
158
167
|
sortable: source.sortable ?? true,
|
|
159
168
|
resizable: source.resizable ?? true,
|
|
160
169
|
value: source.value ?? ((node) => node[source.id] ?? ''),
|
|
170
|
+
format: source.format,
|
|
171
|
+
valueType: source.valueType,
|
|
161
172
|
render: source.render,
|
|
162
173
|
x: 0,
|
|
163
174
|
};
|
package/src/core/types.js
CHANGED
package/src/core/view-options.js
CHANGED
|
@@ -4,14 +4,21 @@ export const treeViewDefaults = Object.freeze({
|
|
|
4
4
|
mode: 'tree', presentation: 'pane', nodes: Object.freeze([]), model: null, meta: null,
|
|
5
5
|
columns: null, theme: 'dark', flatRoot: true, enforceMeta: false,
|
|
6
6
|
filter: true, filterPlacement: 'auto', markUpdated: true, editable: true,
|
|
7
|
-
rowReorder: false, initialExpandDepth: 1, rowHeight: undefined,
|
|
8
|
-
indentWidth: undefined, headerHeight: 28, iconResolver: null, fontFamily: null,
|
|
7
|
+
rowActions: Object.freeze([]), rowDrag: null, rowReorder: false, initialExpandDepth: 1, rowHeight: undefined,
|
|
8
|
+
indentWidth: undefined, showHeader: true, headerHeight: 28, iconResolver: null, fontFamily: null,
|
|
9
9
|
});
|
|
10
10
|
export const treeViewOptionNames = Object.freeze(Object.keys(treeViewDefaults));
|
|
11
11
|
|
|
12
12
|
export function validateTreeViewOptions(options) {
|
|
13
13
|
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Options must be an object');
|
|
14
14
|
for (const [key, value] of Object.entries(options)) {
|
|
15
|
+
if (key === 'rowActions' && (!Array.isArray(value) || value.some(action => !action || typeof action.id !== 'string' || !action.id || typeof action.label !== 'string') || new Set(value.map(action => action.id)).size !== value.length)) throw new TypeError('rowActions requires unique ids and labels');
|
|
16
|
+
if (key === 'rowActions') for (const action of value) {
|
|
17
|
+
if (action.kind !== undefined && !['button','checkbox'].includes(action.kind)) throw new TypeError('row action kind must be button or checkbox');
|
|
18
|
+
for (const property of ['visible', 'disabled', 'pressed', 'checked']) if (action[property] !== undefined && !['boolean', 'function'].includes(typeof action[property])) throw new TypeError(`${property} must be boolean or a function`);
|
|
19
|
+
if (action.icon !== undefined && !['string', 'function'].includes(typeof action.icon)) throw new TypeError('action icon must be a name or a function');
|
|
20
|
+
}
|
|
21
|
+
if (key === 'rowDrag' && value !== null && typeof value !== 'function') throw new TypeError('rowDrag must be a function or null');
|
|
15
22
|
if (key === 'theme') resolveTheme(value);
|
|
16
23
|
if (key === 'mode' && !['tree', 'inspector'].includes(value)) throw new TypeError('mode must be tree or inspector');
|
|
17
24
|
if (key === 'presentation' && !['pane', 'table'].includes(value)) throw new TypeError('presentation must be pane or table');
|
|
@@ -25,6 +32,6 @@ export function validateTreeViewOptions(options) {
|
|
|
25
32
|
if (key === 'initialExpandDepth' && (!Number.isInteger(value) || value < 0)) throw new TypeError('initialExpandDepth must be a non-negative integer');
|
|
26
33
|
if (key === 'iconResolver' && value !== null && typeof value !== 'function') throw new TypeError('iconResolver must be a function or null');
|
|
27
34
|
if (key === 'fontFamily' && value !== null && typeof value !== 'string') throw new TypeError('fontFamily must be a string or null');
|
|
28
|
-
if (['flatRoot', 'enforceMeta', 'filter', 'markUpdated', 'editable', 'rowReorder'].includes(key) && typeof value !== 'boolean') throw new TypeError(`${key} must be boolean`);
|
|
35
|
+
if (['flatRoot', 'enforceMeta', 'filter', 'markUpdated', 'editable', 'rowReorder', 'showHeader'].includes(key) && typeof value !== 'boolean') throw new TypeError(`${key} must be boolean`);
|
|
29
36
|
}
|
|
30
37
|
}
|
|
@@ -151,7 +151,7 @@ export class VisibleRowModel extends EventTarget {
|
|
|
151
151
|
/** @param {import('./tree-view-viewport.js').TreeViewViewport} viewport */
|
|
152
152
|
getStickyRows(viewport) {
|
|
153
153
|
const rowViewportHeight = viewport.rowViewportHeight ?? viewport.viewportHeight;
|
|
154
|
-
if (rowViewportHeight < this.rowHeight * 2) return [];
|
|
154
|
+
if (viewport.scrollY <= 0 || rowViewportHeight < this.rowHeight * 2) return [];
|
|
155
155
|
const max = Math.max(0, Math.min(6, Math.floor(rowViewportHeight / this.rowHeight) - 1));
|
|
156
156
|
const first = Math.max(0, Math.floor(viewport.scrollY / this.rowHeight));
|
|
157
157
|
let row = this.getRow(first);
|
package/src/index.d.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
export interface RowAction {
|
|
2
|
+
kind?: 'button' | 'checkbox';
|
|
3
|
+
checked?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
icon?: string | ((node: TreeNode, state: Record<string, any>) => string);
|
|
7
|
+
/** Icon variants to rasterize before the first action. */
|
|
8
|
+
preloadIcons?: string[];
|
|
9
|
+
visible?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
|
|
10
|
+
disabled?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
|
|
11
|
+
pressed?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
|
|
12
|
+
}
|
|
13
|
+
export type RowDragResolver = ((node: TreeNode, state: Record<string, any>) => any | null) | null;
|
|
1
14
|
export const builtinIconNames: readonly string[];
|
|
2
15
|
export interface RowReorderDetail {
|
|
3
16
|
nodeId: string;
|
|
@@ -14,6 +27,8 @@ export interface TreeViewOptions {
|
|
|
14
27
|
iconsBaseUrl?: string | URL;
|
|
15
28
|
iconRegistry?: IconRegistry;
|
|
16
29
|
rowReorder?: boolean;
|
|
30
|
+
rowActions?: RowAction[];
|
|
31
|
+
rowDrag?: RowDragResolver;
|
|
17
32
|
autoRender?: boolean;
|
|
18
33
|
tooltip?: boolean;
|
|
19
34
|
canvas?: HTMLCanvasElement;
|
|
@@ -49,6 +64,7 @@ export type TreeNode = {
|
|
|
49
64
|
icon?: string;
|
|
50
65
|
image?: string;
|
|
51
66
|
tags?: string[];
|
|
67
|
+
reorderable?: boolean;
|
|
52
68
|
data?: any;
|
|
53
69
|
};
|
|
54
70
|
|
|
@@ -89,8 +105,12 @@ export type Column = {
|
|
|
89
105
|
minWidth?: number;
|
|
90
106
|
align?: 'left' | 'center' | 'right';
|
|
91
107
|
kind?: string;
|
|
108
|
+
/** Optional semantic type for value-column coloring; defaults to the raw value type. */
|
|
109
|
+
valueType?: string | ((value: any, node: TreeNode, state: Record<string, any>) => string);
|
|
92
110
|
sortable?: boolean;
|
|
93
111
|
value?: (node: TreeNode, state: Record<string, any>) => string | number | boolean;
|
|
112
|
+
/** Formats displayed text and tooltips without changing sorting values. */
|
|
113
|
+
format?: (value: any, node: TreeNode, state: Record<string, any>) => string;
|
|
94
114
|
render?: (ctx: CanvasRenderingContext2D, cell: any) => void;
|
|
95
115
|
};
|
|
96
116
|
|
|
@@ -128,6 +148,10 @@ export class TreeViewController {
|
|
|
128
148
|
model: any;
|
|
129
149
|
inspector: any;
|
|
130
150
|
columnModel: any;
|
|
151
|
+
setRowActions(actions: RowAction[]): void;
|
|
152
|
+
setRowDrag(resolver: RowDragResolver): void;
|
|
153
|
+
sortBy(id: string, direction?: string): boolean;
|
|
154
|
+
clearSort(): void;
|
|
131
155
|
setEditable(enabled: boolean): void;
|
|
132
156
|
setInitialExpandDepth(depth: number): void;
|
|
133
157
|
setIconResolver(resolver: TreeViewConfiguration['iconResolver']): void;
|
|
@@ -141,7 +165,7 @@ export class TreeViewController {
|
|
|
141
165
|
nextSearchResult(): string | null;
|
|
142
166
|
previousSearchResult(): string | null;
|
|
143
167
|
setRowReorder(enabled: boolean): void;
|
|
144
|
-
canReorderRows(): boolean;
|
|
168
|
+
canReorderRows(nodeId?: string): boolean;
|
|
145
169
|
getRowOrder(parentId?: string | null): string[];
|
|
146
170
|
moveRow(nodeId: string, targetIndex: number, options?: {source?: RowReorderDetail["source"]}): boolean;
|
|
147
171
|
moveRowBy(nodeId: string, offset: number, options?: {source?: RowReorderDetail["source"]}): boolean;
|
|
@@ -176,6 +200,7 @@ export class TreeViewController {
|
|
|
176
200
|
getSelection(): string[];
|
|
177
201
|
setSelection(ids: string[]): void;
|
|
178
202
|
clearSelection(): void;
|
|
203
|
+
toggle(nodeId: string): boolean;
|
|
179
204
|
expandAll(): void;
|
|
180
205
|
collapseAll(): void;
|
|
181
206
|
}
|
|
@@ -208,9 +233,13 @@ export interface TreeViewConfiguration {
|
|
|
208
233
|
markUpdated?: boolean;
|
|
209
234
|
editable?: boolean;
|
|
210
235
|
rowReorder?: boolean;
|
|
236
|
+
rowActions?: RowAction[];
|
|
237
|
+
rowDrag?: RowDragResolver;
|
|
211
238
|
initialExpandDepth?: number;
|
|
212
239
|
rowHeight?: number;
|
|
213
240
|
indentWidth?: number;
|
|
241
|
+
/** Hide column headings without hiding the filter bar. Defaults to true; inspector panes may hide headings automatically. */
|
|
242
|
+
showHeader?: boolean;
|
|
214
243
|
headerHeight?: number;
|
|
215
244
|
iconResolver?: ((node: TreeNode) => string | Partial<TreeNode> | null | undefined) | null;
|
|
216
245
|
fontFamily?: string | null;
|
|
@@ -273,7 +302,18 @@ export interface TreeSearchState {
|
|
|
273
302
|
current: string | null;
|
|
274
303
|
count: number;
|
|
275
304
|
}
|
|
305
|
+
export interface RowDragDetail {
|
|
306
|
+
nodeId: string;
|
|
307
|
+
payload: any;
|
|
308
|
+
label: string;
|
|
309
|
+
originalEvent: PointerEvent;
|
|
310
|
+
}
|
|
276
311
|
export interface TreeViewEvents {
|
|
312
|
+
rowaction: {checked?: boolean; actionId: string; nodeId: string; node: TreeNode; originalEvent: MouseEvent};
|
|
313
|
+
rowdragstart: RowDragDetail;
|
|
314
|
+
rowdragmove: RowDragDetail;
|
|
315
|
+
rowdragend: RowDragDetail;
|
|
316
|
+
rowdragcancel: Omit<RowDragDetail, 'originalEvent'>;
|
|
277
317
|
valuechange: InspectorValueChange;
|
|
278
318
|
modelchange: {model: any; meta?: Record<string, MetaRule>; path?: string; structural?: boolean; action?: string; value?: any} & Partial<InspectorValueChange>;
|
|
279
319
|
action: {path: string; label: string; nodeId: string; model: any; source: string};
|
|
@@ -288,3 +328,10 @@ export interface TreeViewEvents {
|
|
|
288
328
|
rowreorderchange: {enabled: boolean};
|
|
289
329
|
}
|
|
290
330
|
export type TreeViewEvent<K extends keyof TreeViewEvents> = {type: K; detail: TreeViewEvents[K]};
|
|
331
|
+
|
|
332
|
+
/** Formats an inspector value using its options and precision. */
|
|
333
|
+
export function formatInspectorValue(value: any, meta?: MetaRule): string;
|
|
334
|
+
|
|
335
|
+
export class ModelInspectorBuilder {
|
|
336
|
+
build(model: any, meta?: Record<string, MetaRule>, options?: {flatRoot?: boolean; enforceMeta?: boolean}): TreeNode[];
|
|
337
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { drawCheckbox } from '../renderers/checkbox.js';
|
|
2
|
+
/** Accessible buttons for visible rows. Actions and their meaning belong to the host. */
|
|
3
|
+
export class RowActions {
|
|
4
|
+
constructor(controller) {
|
|
5
|
+
this.controller = controller;
|
|
6
|
+
this.buttons = new Map();
|
|
7
|
+
this.preparedIcons = new Set();
|
|
8
|
+
this.element = controller.canvas.ownerDocument.createElement('div');
|
|
9
|
+
Object.assign(this.element.style, { position: 'absolute', overflow: 'hidden', pointerEvents: 'none', zIndex: '2' });
|
|
10
|
+
const style = controller.canvas.ownerDocument.createElement('style');
|
|
11
|
+
style.textContent = `
|
|
12
|
+
.vtc-row-action { appearance:none; display:flex; align-items:center; justify-content:center;
|
|
13
|
+
background:transparent; color:inherit; outline:none; }
|
|
14
|
+
.vtc-row-action:hover:not(:disabled) canvas { filter:brightness(1.45) drop-shadow(0 0 2px var(--vtc-action-focus)); }
|
|
15
|
+
.vtc-row-action:focus-visible { outline:1px solid var(--vtc-action-focus); outline-offset:-2px; }
|
|
16
|
+
`;
|
|
17
|
+
this.element.append(style);
|
|
18
|
+
controller.canvas.parentElement?.append(this.element);
|
|
19
|
+
this.element.addEventListener('pointerdown', event => event.stopPropagation());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
render(scene) {
|
|
23
|
+
const { viewport, theme, rows, visibleRange, stickyRows } = scene;
|
|
24
|
+
const ratio = Math.max(1, this.element.ownerDocument.defaultView.devicePixelRatio || 1);
|
|
25
|
+
for (const action of this.controller.rowActions) {
|
|
26
|
+
const icons = action.preloadIcons ?? (typeof action.icon === 'string' ? [action.icon] : []);
|
|
27
|
+
for (const icon of icons) for (const color of [theme.colors.text, theme.colors.textMuted]) {
|
|
28
|
+
const key = JSON.stringify([icon, color, ratio]);
|
|
29
|
+
if (this.preparedIcons.has(key)) continue;
|
|
30
|
+
this.preparedIcons.add(key);
|
|
31
|
+
void this.controller.iconRegistry.prepare({icons:[icon],size:16,color,pixelRatio:ratio});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const column = scene.columns.find(c => c.id === '__vtc_actions');
|
|
35
|
+
const used = new Set();
|
|
36
|
+
const width = column?.width ?? 0;
|
|
37
|
+
this.element.style.setProperty('--vtc-action-focus', theme.colors.focus);
|
|
38
|
+
Object.assign(this.element.style, { background:'transparent', left: `${viewport.renderInsetX + Math.max(0, viewport.contentViewportWidth - width)}px`, top: `${viewport.renderInsetY + viewport.headerHeight}px`,
|
|
39
|
+
width: `${width}px`, height: `${viewport.rowViewportHeight}px` });
|
|
40
|
+
if (column) {
|
|
41
|
+
const visible = new Map();
|
|
42
|
+
const stickyBottom = stickyRows.reduce((end, row) => Math.max(end, row.stickyY + row.height), 0);
|
|
43
|
+
for (let i = visibleRange.first; i <= visibleRange.last; i++) {
|
|
44
|
+
const row = rows[i];
|
|
45
|
+
if (row && row.y + row.height - viewport.scrollY > stickyBottom) visible.set(row.nodeId, {row, y:row.y - viewport.scrollY});
|
|
46
|
+
}
|
|
47
|
+
for (const row of stickyRows) visible.set(row.nodeId, {row, y:row.stickyY, sticky:true});
|
|
48
|
+
for (const {row, y, sticky} of visible.values()) {
|
|
49
|
+
if (y + row.height <= 0 || y >= viewport.rowViewportHeight) continue;
|
|
50
|
+
const node = this.controller.model.index.getNode(row.nodeId);
|
|
51
|
+
const state = this.controller.model.dynamicState.get(node.id) ?? {};
|
|
52
|
+
this.controller.rowActions.forEach((action, index) => {
|
|
53
|
+
const resolve = value => typeof value === 'function' ? value(node, state) : value;
|
|
54
|
+
if (resolve(action.visible) === false) return;
|
|
55
|
+
const key = JSON.stringify([node.id, action.id, action.kind]);
|
|
56
|
+
used.add(key);
|
|
57
|
+
let button = this.buttons.get(key);
|
|
58
|
+
if (!button) {
|
|
59
|
+
button = this.element.ownerDocument.createElement('button');
|
|
60
|
+
button.type = 'button';
|
|
61
|
+
if (action.kind === 'checkbox') button.setAttribute('role','checkbox');
|
|
62
|
+
button.className = 'vtc-row-action';
|
|
63
|
+
button.dataset.nodeId = node.id;
|
|
64
|
+
button.dataset.action = action.id;
|
|
65
|
+
Object.assign(button.style, { position:'absolute', pointerEvents:'auto', width:'26px', padding:'3px', border:'0', borderRadius:'3px', cursor:'pointer' });
|
|
66
|
+
const icon = this.element.ownerDocument.createElement('canvas');
|
|
67
|
+
Object.assign(icon.style, {width:'16px',height:'16px',display:'block'});
|
|
68
|
+
button.append(icon);
|
|
69
|
+
button.onclick = event => {
|
|
70
|
+
event.stopPropagation();
|
|
71
|
+
const current = this.controller.model.index.getNode(button.dataset.nodeId);
|
|
72
|
+
const spec = this.controller.rowActions.find(item => item.id === button.dataset.action);
|
|
73
|
+
const currentState = this.controller.model.dynamicState.get(current?.id) ?? {};
|
|
74
|
+
if (!current || !spec || (typeof spec.disabled === 'function' ? spec.disabled(current, currentState) : spec.disabled)) return;
|
|
75
|
+
this.controller.events.emit('rowaction', { actionId: spec.id, nodeId: current.id, node: current, ...(spec.kind === 'checkbox' ? {checked:!(typeof spec.checked === 'function' ? spec.checked(current,currentState) : spec.checked)} : {}), originalEvent: event });
|
|
76
|
+
};
|
|
77
|
+
this.buttons.set(key, button);
|
|
78
|
+
this.element.append(button);
|
|
79
|
+
}
|
|
80
|
+
button.title = action.label;
|
|
81
|
+
button.setAttribute('aria-label', `${action.label}: ${node.label ?? node.id}`);
|
|
82
|
+
if (action.pressed !== undefined) button.setAttribute('aria-pressed', String(Boolean(resolve(action.pressed))));
|
|
83
|
+
else button.removeAttribute('aria-pressed');
|
|
84
|
+
button.disabled = Boolean(resolve(action.disabled));
|
|
85
|
+
Object.assign(button.style, { left:`${index * 28 + 1}px`, top:`${y + 1}px`, height:`${row.height - 2}px`,
|
|
86
|
+
clipPath:sticky ? 'none' : `inset(${Math.max(0, stickyBottom - y - 1)}px 0 0)`, opacity:button.disabled ? '0.3':'1' });
|
|
87
|
+
const canvas = button.firstChild;
|
|
88
|
+
const ratio = Math.max(1, this.element.ownerDocument.defaultView.devicePixelRatio || 1);
|
|
89
|
+
const size = action.kind === 'checkbox' ? 18 : 16;
|
|
90
|
+
canvas.style.width = canvas.style.height = `${size}px`;
|
|
91
|
+
const pixels = Math.round(size * ratio);
|
|
92
|
+
if (canvas.width !== pixels || canvas.height !== pixels) canvas.width = canvas.height = pixels;
|
|
93
|
+
const ctx = canvas.getContext('2d');
|
|
94
|
+
ctx.setTransform(pixels / size, 0, 0, pixels / size, 0, 0);
|
|
95
|
+
ctx.clearRect(0,0,size,size);
|
|
96
|
+
if (action.kind === 'checkbox') {
|
|
97
|
+
const checked = Boolean(resolve(action.checked));
|
|
98
|
+
button.setAttribute('aria-checked', String(checked));
|
|
99
|
+
drawCheckbox(ctx,1,1,checked,theme);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.controller.iconRegistry.draw(ctx, resolve(action.icon) ?? 'star', 0,0,16,resolve(action.pressed) ? theme.colors.text : theme.colors.textMuted);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let refocus = false;
|
|
107
|
+
for (const [key, button] of this.buttons) if (!used.has(key)) {
|
|
108
|
+
refocus ||= this.element.getRootNode().activeElement === button;
|
|
109
|
+
button.remove(); this.buttons.delete(key);
|
|
110
|
+
}
|
|
111
|
+
if (refocus) (this.buttons.values().next().value ?? this.controller.canvas).focus({preventScroll:true});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
destroy() { this.element.remove(); this.buttons.clear(); }
|
|
115
|
+
}
|