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 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 the 0.6.0 migration notes and additions.
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
- 63 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.
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.
@@ -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
  ]
@@ -1,6 +1,6 @@
1
1
  # Catálogo de iconos
2
2
 
3
- Los 63 iconos incluidos están disponibles en `resources/icons/`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virtual-tree-canvas",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "High-performance Canvas2D virtual tree/table widget for very large hierarchical datasets.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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>
@@ -60,6 +60,8 @@ export const builtinIconNames = Object.freeze([
60
60
  'snow',
61
61
  'space',
62
62
  'star',
63
+ 'star-filled',
64
+ 'trash',
63
65
  'storm',
64
66
  'subsurface',
65
67
  'sun',
@@ -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
@@ -7,6 +7,7 @@
7
7
  * @property {string} [icon]
8
8
  * @property {string} [image]
9
9
  * @property {string[]} [tags]
10
+ * @property {boolean} [reorderable] Allow manual reordering of this node (defaults to true).
10
11
  * @property {any} [data]
11
12
  */
12
13
 
@@ -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
+ }