virtual-tree-canvas 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,37 +1,110 @@
1
1
  import { resolveTheme } from './theme-manager.js';
2
- /** Supported TreeView configuration options and defaults. */
2
+
3
+ /**
4
+ * Supported TreeView configuration options and defaults.
5
+ */
3
6
  export const treeViewDefaults = Object.freeze({
4
- mode: 'tree', presentation: 'pane', nodes: Object.freeze([]), model: null, meta: null,
5
- columns: null, theme: 'dark', flatRoot: true, enforceMeta: false,
6
- filter: true, filterPlacement: 'auto', markUpdated: true, editable: true,
7
- rowActions: Object.freeze([]), rowDrag: null, rowReorder: false, initialExpandDepth: 1, rowHeight: undefined,
8
- indentWidth: undefined, showHeader: true, headerHeight: 28, iconResolver: null, fontFamily: null,
7
+ mode: 'tree',
8
+ presentation: 'pane',
9
+ nodes: Object.freeze([]),
10
+ model: null,
11
+ meta: null,
12
+ columns: null,
13
+ theme: 'dark',
14
+ flatRoot: true,
15
+ enforceMeta: false,
16
+ filter: true,
17
+ filterPlacement: 'auto',
18
+ markUpdated: true,
19
+ editable: true,
20
+ rowActions: Object.freeze([]),
21
+ rowDrag: null,
22
+ rowReorder: false,
23
+ initialExpandDepth: 1,
24
+ rowHeight: undefined,
25
+ indentWidth: undefined,
26
+ showHeader: true,
27
+ headerHeight: 28,
28
+ iconResolver: null,
29
+ fontFamily: null
9
30
  });
10
31
  export const treeViewOptionNames = Object.freeze(Object.keys(treeViewDefaults));
11
32
 
12
33
  export function validateTreeViewOptions(options) {
13
- if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Options must be an object');
34
+ if (!options || typeof options !== 'object' || Array.isArray(options))
35
+ throw new TypeError('Options must be an object');
14
36
  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');
37
+ if (
38
+ key === 'rowActions' &&
39
+ (!Array.isArray(value) ||
40
+ value.some(
41
+ (action) =>
42
+ !action ||
43
+ typeof action.id !== 'string' ||
44
+ !action.id ||
45
+ typeof action.label !== 'string'
46
+ ) ||
47
+ new Set(value.map((action) => action.id)).size !== value.length)
48
+ )
49
+ throw new TypeError('rowActions requires unique ids and labels');
50
+ if (key === 'rowActions')
51
+ for (const action of value) {
52
+ if (action.kind !== undefined && !['button', 'checkbox'].includes(action.kind))
53
+ throw new TypeError('row action kind must be button or checkbox');
54
+ for (const property of ['visible', 'disabled', 'pressed', 'checked'])
55
+ if (
56
+ action[property] !== undefined &&
57
+ !['boolean', 'function'].includes(typeof action[property])
58
+ )
59
+ throw new TypeError(`${property} must be boolean or a function`);
60
+ if (action.icon !== undefined && !['string', 'function'].includes(typeof action.icon))
61
+ throw new TypeError('action icon must be a name or a function');
62
+ }
63
+ if (key === 'rowDrag' && value !== null && typeof value !== 'function')
64
+ throw new TypeError('rowDrag must be a function or null');
22
65
  if (key === 'theme') resolveTheme(value);
23
- if (key === 'mode' && !['tree', 'inspector'].includes(value)) throw new TypeError('mode must be tree or inspector');
24
- if (key === 'presentation' && !['pane', 'table'].includes(value)) throw new TypeError('presentation must be pane or table');
25
- if (key === 'filterPlacement' && !['auto', 'bar', 'header'].includes(value)) throw new TypeError('filterPlacement must be auto, bar or header');
66
+ if (key === 'mode' && !['tree', 'inspector'].includes(value))
67
+ throw new TypeError('mode must be tree or inspector');
68
+ if (key === 'presentation' && !['pane', 'table'].includes(value))
69
+ throw new TypeError('presentation must be pane or table');
70
+ if (key === 'filterPlacement' && !['auto', 'bar', 'header'].includes(value))
71
+ throw new TypeError('filterPlacement must be auto, bar or header');
26
72
  if (key === 'nodes' && !Array.isArray(value)) throw new TypeError('nodes must be an array');
27
- if (key === 'columns' && value !== null && !Array.isArray(value)) throw new TypeError('columns must be an array or null');
73
+ if (key === 'columns' && value !== null && !Array.isArray(value))
74
+ throw new TypeError('columns must be an array or null');
28
75
  if (key === 'model' && value === undefined) throw new TypeError('model must not be undefined');
29
- if (key === 'meta' && value !== null && (!value || typeof value !== 'object' || Array.isArray(value))) throw new TypeError('meta must be an object or null');
30
- if (['rowHeight', 'indentWidth'].includes(key) && value !== undefined && (!Number.isFinite(value) || value <= 0)) throw new TypeError(`${key} must be a positive number`);
31
- if (key === 'headerHeight' && (!Number.isFinite(value) || value < 0)) throw new TypeError('headerHeight must be non-negative');
32
- if (key === 'initialExpandDepth' && (!Number.isInteger(value) || value < 0)) throw new TypeError('initialExpandDepth must be a non-negative integer');
33
- if (key === 'iconResolver' && value !== null && typeof value !== 'function') throw new TypeError('iconResolver must be a function or null');
34
- if (key === 'fontFamily' && value !== null && typeof value !== 'string') throw new TypeError('fontFamily must be a string or null');
35
- if (['flatRoot', 'enforceMeta', 'filter', 'markUpdated', 'editable', 'rowReorder', 'showHeader'].includes(key) && typeof value !== 'boolean') throw new TypeError(`${key} must be boolean`);
76
+ if (
77
+ key === 'meta' &&
78
+ value !== null &&
79
+ (!value || typeof value !== 'object' || Array.isArray(value))
80
+ )
81
+ throw new TypeError('meta must be an object or null');
82
+ if (
83
+ ['rowHeight', 'indentWidth'].includes(key) &&
84
+ value !== undefined &&
85
+ (!Number.isFinite(value) || value <= 0)
86
+ )
87
+ throw new TypeError(`${key} must be a positive number`);
88
+ if (key === 'headerHeight' && (!Number.isFinite(value) || value < 0))
89
+ throw new TypeError('headerHeight must be non-negative');
90
+ if (key === 'initialExpandDepth' && (!Number.isInteger(value) || value < 0))
91
+ throw new TypeError('initialExpandDepth must be a non-negative integer');
92
+ if (key === 'iconResolver' && value !== null && typeof value !== 'function')
93
+ throw new TypeError('iconResolver must be a function or null');
94
+ if (key === 'fontFamily' && value !== null && typeof value !== 'string')
95
+ throw new TypeError('fontFamily must be a string or null');
96
+ if (
97
+ [
98
+ 'flatRoot',
99
+ 'enforceMeta',
100
+ 'filter',
101
+ 'markUpdated',
102
+ 'editable',
103
+ 'rowReorder',
104
+ 'showHeader'
105
+ ].includes(key) &&
106
+ typeof value !== 'boolean'
107
+ )
108
+ throw new TypeError(`${key} must be boolean`);
36
109
  }
37
110
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  export class VisibleRowModel extends EventTarget {
15
+
15
16
  /**
16
17
  * @param {{
17
18
  * model: import('./tree-model.js').TreeModel,
@@ -26,9 +27,15 @@ export class VisibleRowModel extends EventTarget {
26
27
  this.expansion = expansion;
27
28
  this.rowHeight = rowHeight;
28
29
  this.indentWidth = indentWidth;
29
- /** @type {TreeRow[]} */
30
+
31
+ /**
32
+ * @type {TreeRow[]}
33
+ */
30
34
  this.rows = [];
31
- /** @type {Map<string, number>} */
35
+
36
+ /**
37
+ * @type {Map<string, number>}
38
+ */
32
39
  this.rowIndexById = new Map();
33
40
  this.contentWidth = 0;
34
41
  this.contentHeight = 0;
@@ -75,14 +82,18 @@ export class VisibleRowModel extends EventTarget {
75
82
  const node = this.model.index.getNode(id);
76
83
  const state = this.model.dynamicState.get(id) ?? {};
77
84
  const ownMatch = node ? this.filterPredicate(node, state) : false;
78
- const childMatch = this.model.index.getChildren(id).some((childId) => subtreeIncluded(childId));
85
+ const childMatch = this.model.index
86
+ .getChildren(id)
87
+ .some((childId) => subtreeIncluded(childId));
79
88
  const included = ownMatch || childMatch;
80
89
  includeCache.set(id, included);
81
90
  return included;
82
91
  };
83
92
 
84
93
  const sortedChildren = (id) => {
85
- const children = this.model.index.getChildren(id).filter((childId) => subtreeIncluded(childId));
94
+ const children = this.model.index
95
+ .getChildren(id)
96
+ .filter((childId) => subtreeIncluded(childId));
86
97
  if (!this.sortComparator) return children;
87
98
  return children.slice().sort((aId, bId) => {
88
99
  const a = this.model.index.getNode(aId);
@@ -98,7 +109,8 @@ export class VisibleRowModel extends EventTarget {
98
109
  const children = sortedChildren(id);
99
110
  const hasChildren = this.expansion.hasChildren(id);
100
111
  const autoExpanded = Boolean(this.filterPredicate && children.length > 0);
101
- const expanded = (this.expansion.isExpanded(id) || autoExpanded) && !this.filterCollapsed.has(id);
112
+ const expanded =
113
+ (this.expansion.isExpanded(id) || autoExpanded) && !this.filterCollapsed.has(id);
102
114
  const rowIndex = this.rows.length;
103
115
  this.rows.push({
104
116
  nodeId: id,
@@ -108,7 +120,7 @@ export class VisibleRowModel extends EventTarget {
108
120
  y: rowIndex * this.rowHeight,
109
121
  height: this.rowHeight,
110
122
  expanded,
111
- hasChildren,
123
+ hasChildren
112
124
  });
113
125
  this.rowIndexById.set(id, rowIndex);
114
126
  maxDepth = Math.max(maxDepth, depth);
@@ -118,7 +130,9 @@ export class VisibleRowModel extends EventTarget {
118
130
 
119
131
  const roots = this.model.index.roots.filter((rootId) => subtreeIncluded(rootId));
120
132
  if (this.sortComparator) {
121
- roots.sort((aId, bId) => this.sortComparator(this.model.index.getNode(aId), this.model.index.getNode(bId), aId, bId));
133
+ roots.sort((aId, bId) =>
134
+ this.sortComparator(this.model.index.getNode(aId), this.model.index.getNode(bId), aId, bId)
135
+ );
122
136
  }
123
137
  for (const rootId of roots) visit(rootId, 0);
124
138
  this.contentHeight = this.rows.length * this.rowHeight;
@@ -126,13 +140,17 @@ export class VisibleRowModel extends EventTarget {
126
140
  this.dispatchEvent(new Event('change'));
127
141
  }
128
142
 
129
- /** @param {string} id */
143
+ /**
144
+ * @param {string} id
145
+ */
130
146
  getRowById(id) {
131
147
  const rowIndex = this.rowIndexById.get(id);
132
148
  return rowIndex === undefined ? null : this.rows[rowIndex];
133
149
  }
134
150
 
135
- /** @param {number} rowIndex */
151
+ /**
152
+ * @param {number} rowIndex
153
+ */
136
154
  getRow(rowIndex) {
137
155
  return this.rows[rowIndex] ?? null;
138
156
  }
@@ -144,11 +162,20 @@ export class VisibleRowModel extends EventTarget {
144
162
  getVisibleRange(viewport, overscan = 6) {
145
163
  const rowViewportHeight = viewport.rowViewportHeight ?? viewport.viewportHeight;
146
164
  const first = Math.max(0, Math.floor(viewport.scrollY / this.rowHeight) - overscan);
147
- const last = Math.min(this.rows.length - 1, Math.ceil((viewport.scrollY + rowViewportHeight) / this.rowHeight) + overscan);
148
- return { first, last, count: last >= first ? last - first + 1 : 0 };
165
+ const last = Math.min(
166
+ this.rows.length - 1,
167
+ Math.ceil((viewport.scrollY + rowViewportHeight) / this.rowHeight) + overscan
168
+ );
169
+ return {
170
+ first,
171
+ last,
172
+ count: last >= first ? last - first + 1 : 0
173
+ };
149
174
  }
150
175
 
151
- /** @param {import('./tree-view-viewport.js').TreeViewViewport} viewport */
176
+ /**
177
+ * @param {import('./tree-view-viewport.js').TreeViewViewport} viewport
178
+ */
152
179
  getStickyRows(viewport) {
153
180
  const rowViewportHeight = viewport.rowViewportHeight ?? viewport.viewportHeight;
154
181
  if (viewport.scrollY <= 0 || rowViewportHeight < this.rowHeight * 2) return [];
@@ -169,20 +196,23 @@ export class VisibleRowModel extends EventTarget {
169
196
  sticky = [...sticky.slice(0, replaceAt), candidate];
170
197
  }
171
198
  }
172
- return sticky
173
- .slice(-max)
174
- .map((ancestor, index) => ({
175
- ...ancestor,
176
- stickyY: Math.max(ancestor.y - viewport.scrollY, index * this.rowHeight),
177
- }));
199
+ return sticky.slice(-max).map((ancestor, index) => ({
200
+ ...ancestor,
201
+ stickyY: Math.max(ancestor.y - viewport.scrollY, index * this.rowHeight)
202
+ }));
178
203
  }
179
204
 
180
- /** @param {TreeRow} row */
205
+ /**
206
+ * @param {TreeRow} row
207
+ */
181
208
  #getStickyPath(row) {
182
- const ancestors = this.model.index.getAncestors(row.nodeId).reverse()
209
+ const ancestors = this.model.index
210
+ .getAncestors(row.nodeId)
211
+ .reverse()
183
212
  .map((id) => this.getRowById(id))
184
213
  .filter((ancestor) => ancestor && ancestor.rowIndex < row.rowIndex);
185
214
  if (row.hasChildren) ancestors.push(row);
186
215
  return ancestors;
187
216
  }
217
+
188
218
  }
package/src/index.d.ts CHANGED
@@ -183,12 +183,14 @@ export class TreeViewController {
183
183
  setModel(model: any, meta?: Record<string, MetaRule>, options?: Record<string, any>): void;
184
184
  setColumns(columns: Column[] | null): void;
185
185
  setDynamicState(patches: DynamicPatch[]): void;
186
+ flushDynamicState(): Set<string>;
186
187
  setTheme(theme: any): void;
187
188
  setLayoutMetrics(options?: { rowHeight?: number; indentWidth?: number; headerHeight?: number }): void;
188
189
  registerIcon(name: string, icon: IconSource): any;
189
190
  resize(width: number, height: number): void;
190
191
  render(time?: number): void;
191
192
  renderMeasured(time?: number): any;
193
+ getStats(): TreeViewStats;
192
194
  hitTest(clientX: number, clientY: number): any;
193
195
  getTooltipForHit(hit: any): any;
194
196
  search(query: string, options?: Record<string, any> & { caseSensitive?: boolean; wholeWord?: boolean }): any;
@@ -205,6 +207,26 @@ export class TreeViewController {
205
207
  collapseAll(): void;
206
208
  }
207
209
 
210
+ export interface TreeViewStats {
211
+ totalNodes: number;
212
+ visibleRows: number;
213
+ renderedRows: number;
214
+ patchesFrame: number;
215
+ dirtyNodes: number;
216
+ selectedCount: number;
217
+ rebuildCount: number;
218
+ setDynamicStateCalls: number;
219
+ patchesReceived: number;
220
+ uniqueNodesReceived: number;
221
+ nodesChanged: number;
222
+ rendersRequested: number;
223
+ rendersExecuted: number;
224
+ rendersAvoidedNoChanges: number;
225
+ rendersAvoidedOffscreen: number;
226
+ rowActionsFullUpdates: number;
227
+ rowActionsIncrementalUpdates: number;
228
+ }
229
+
208
230
  export class TreeViewInputController {
209
231
  constructor(options: { controller: TreeViewController; cellEditor?: CellEditorManager | null });
210
232
  destroy(): void;
@@ -1,12 +1,28 @@
1
1
  import { drawCheckbox } from '../renderers/checkbox.js';
2
+
2
3
  /** Accessible buttons for visible rows. Actions and their meaning belong to the host. */
3
4
  export class RowActions {
4
5
  constructor(controller) {
5
6
  this.controller = controller;
6
7
  this.buttons = new Map();
8
+ this.buttonsByNodeId = new Map();
9
+ this.actionsById = new Map();
10
+ this.visibleRowsById = new Map();
7
11
  this.preparedIcons = new Set();
12
+ this.fullUpdates = 0;
13
+ this.incrementalUpdates = 0;
14
+ this.themeToken = 0;
15
+ this.theme = null;
16
+ this.stopIconListener = this.controller.iconRegistry.onChange?.(() => {
17
+ for (const button of this.buttons.values()) {
18
+ if (button._vtcKind !== 'checkbox') button.firstChild._vtcDrawKey = null;
19
+ }
20
+ this.controller.requestRender(true);
21
+ }) ?? null;
8
22
  this.element = controller.canvas.ownerDocument.createElement('div');
9
- Object.assign(this.element.style, { position: 'absolute', overflow: 'hidden', pointerEvents: 'none', zIndex: '2' });
23
+ Object.assign(this.element.style, {
24
+ position: 'absolute', overflow: 'hidden', pointerEvents: 'none', zIndex: '2'
25
+ });
10
26
  const style = controller.canvas.ownerDocument.createElement('style');
11
27
  style.textContent = `
12
28
  .vtc-row-action { appearance:none; display:flex; align-items:center; justify-content:center;
@@ -16,100 +32,225 @@ export class RowActions {
16
32
  `;
17
33
  this.element.append(style);
18
34
  controller.canvas.parentElement?.append(this.element);
19
- this.element.addEventListener('pointerdown', event => event.stopPropagation());
35
+ this.element.addEventListener('pointerdown', (event) => event.stopPropagation());
20
36
  }
21
37
 
22
- render(scene) {
38
+ /** @param {object} scene @param {{full?: boolean, dirtyNodeIds?: Set<string>}} invalidation */
39
+ render(scene, invalidation = { full: true }) {
40
+ if (!invalidation.full && invalidation.dirtyNodeIds?.size) {
41
+ this.incrementalUpdates++;
42
+ for (const id of invalidation.dirtyNodeIds) this.#syncDynamicRow(id, scene);
43
+ return;
44
+ }
45
+ this.fullUpdates++;
46
+ this.#syncAll(scene);
47
+ }
48
+
49
+ #syncAll(scene) {
23
50
  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
- }
51
+ if (this.theme !== theme) {
52
+ this.theme = theme;
53
+ this.themeToken++;
33
54
  }
34
- const column = scene.columns.find(c => c.id === '__vtc_actions');
35
- const used = new Set();
55
+ this.actionsById.clear();
56
+ for (const action of this.controller.rowActions) this.actionsById.set(action.id, action);
57
+ const ratio = Math.max(1, this.element.ownerDocument.defaultView.devicePixelRatio || 1);
58
+ this.#prepareIcons(theme, ratio);
59
+ const column = scene.columns.find((column) => column.id === '__vtc_actions');
36
60
  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` });
61
+ setStyle(this.element, '--vtc-action-focus', theme.colors.focus);
62
+ setStyle(this.element, 'background', 'transparent');
63
+ setStyle(this.element, 'left', `${viewport.renderInsetX + Math.max(0, viewport.contentViewportWidth - width)}px`);
64
+ setStyle(this.element, 'top', `${viewport.renderInsetY + viewport.headerHeight}px`);
65
+ setStyle(this.element, 'width', `${width}px`);
66
+ setStyle(this.element, 'height', `${viewport.rowViewportHeight}px`);
67
+
68
+ const used = new Set();
69
+ this.visibleRowsById.clear();
40
70
  if (column) {
41
- const visible = new Map();
42
- const stickyBottom = stickyRows.reduce((end, row) => Math.max(end, row.stickyY + row.height), 0);
71
+ const stickyBottom = stickyRows.reduce(
72
+ (end, row) => Math.max(end, row.stickyY + row.height), 0
73
+ );
43
74
  for (let i = visibleRange.first; i <= visibleRange.last; i++) {
44
75
  const row = rows[i];
45
- if (row && row.y + row.height - viewport.scrollY > stickyBottom) visible.set(row.nodeId, {row, y:row.y - viewport.scrollY});
76
+ if (!row || row.y + row.height - viewport.scrollY <= stickyBottom) continue;
77
+ this.#rememberVisibleRow(row, row.y - viewport.scrollY, false, stickyBottom, viewport);
46
78
  }
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
- });
79
+ for (const row of stickyRows) {
80
+ this.#rememberVisibleRow(row, row.stickyY, true, stickyBottom, viewport);
104
81
  }
82
+ for (const info of this.visibleRowsById.values()) this.#syncRow(info, scene, used, ratio);
105
83
  }
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);
84
+ for (const [key, button] of this.buttons) {
85
+ if (!used.has(button)) this.#removeButton(key, button, true);
86
+ }
87
+ }
88
+
89
+ #rememberVisibleRow(row, y, sticky, stickyBottom, viewport) {
90
+ if (y + row.height <= 0 || y >= viewport.rowViewportHeight) return;
91
+ this.visibleRowsById.set(row.nodeId, { row, y, sticky, stickyBottom });
92
+ }
93
+
94
+ #syncDynamicRow(id, scene) {
95
+ const info = this.visibleRowsById.get(id);
96
+ if (!info) return;
97
+ const ratio = Math.max(1, this.element.ownerDocument.defaultView.devicePixelRatio || 1);
98
+ this.#syncRow(info, scene, null, ratio);
99
+ }
100
+
101
+ #syncRow(info, scene, used, ratio) {
102
+ const { row, y, sticky, stickyBottom } = info;
103
+ const node = this.controller.model.index.getNode(row.nodeId);
104
+ if (!node) return;
105
+ const state = this.controller.model.dynamicState.get(node.id) ?? {};
106
+ this.controller.rowActions.forEach((action, index) => {
107
+ const visible = resolve(action.visible, node, state) !== false;
108
+ const key = actionKey(node.id, action.id);
109
+ let button = this.buttons.get(key);
110
+ if (!visible) {
111
+ if (button) this.#removeButton(key, button, false);
112
+ return;
113
+ }
114
+ if (button?._vtcKind !== action.kind) {
115
+ this.#removeButton(key, button, false);
116
+ button = null;
117
+ }
118
+ if (!button) button = this.#createButton(key, node, action);
119
+ used?.add(button);
120
+ this.#syncButton(button, node, state, action, index, row, y, sticky, stickyBottom, scene, ratio);
121
+ });
122
+ }
123
+
124
+ #createButton(key, node, action) {
125
+ const button = this.element.ownerDocument.createElement('button');
126
+ button.type = 'button';
127
+ button._vtcKind = action.kind;
128
+ if (action.kind === 'checkbox') button.setAttribute('role', 'checkbox');
129
+ button.className = 'vtc-row-action';
130
+ button.dataset.nodeId = node.id;
131
+ button.dataset.action = action.id;
132
+ Object.assign(button.style, {
133
+ position: 'absolute', pointerEvents: 'auto', width: '26px', padding: '3px',
134
+ border: '0', borderRadius: '3px', cursor: 'pointer'
135
+ });
136
+ const icon = this.element.ownerDocument.createElement('canvas');
137
+ Object.assign(icon.style, { width: '16px', height: '16px', display: 'block' });
138
+ button.append(icon);
139
+ button.onclick = (event) => this.#activate(button, event);
140
+ this.buttons.set(key, button);
141
+ let nodeButtons = this.buttonsByNodeId.get(node.id);
142
+ if (!nodeButtons) this.buttonsByNodeId.set(node.id, (nodeButtons = new Map()));
143
+ nodeButtons.set(action.id, button);
144
+ this.element.append(button);
145
+ return button;
146
+ }
147
+
148
+ #syncButton(button, node, state, action, index, row, y, sticky, stickyBottom, scene, ratio) {
149
+ setProperty(button, 'title', action.label);
150
+ setAttribute(button, 'aria-label', `${action.label}: ${node.label ?? node.id}`);
151
+ const pressed = action.pressed === undefined ? null : Boolean(resolve(action.pressed, node, state));
152
+ setAttribute(button, 'aria-pressed', pressed === null ? null : String(pressed));
153
+ const disabled = Boolean(resolve(action.disabled, node, state));
154
+ setProperty(button, 'disabled', disabled);
155
+ setStyle(button, 'left', `${index * 28 + 1}px`);
156
+ setStyle(button, 'top', `${y + 1}px`);
157
+ setStyle(button, 'height', `${row.height - 2}px`);
158
+ setStyle(button, 'clipPath', sticky ? 'none' : `inset(${Math.max(0, stickyBottom - y - 1)}px 0 0)`);
159
+ setStyle(button, 'opacity', disabled ? '0.3' : '1');
160
+
161
+ const canvas = button.firstChild;
162
+ const size = action.kind === 'checkbox' ? 18 : 16;
163
+ const pixels = Math.round(size * ratio);
164
+ setStyle(canvas, 'width', `${size}px`);
165
+ setStyle(canvas, 'height', `${size}px`);
166
+ const checked = action.kind === 'checkbox' && Boolean(resolve(action.checked, node, state));
167
+ if (action.kind === 'checkbox') setAttribute(button, 'aria-checked', String(checked));
168
+ const icon = action.kind === 'checkbox' ? '' : resolve(action.icon, node, state) ?? 'star';
169
+ const color = pressed ? scene.theme.colors.text : scene.theme.colors.textMuted;
170
+ const drawKey = `${this.themeToken}\0${action.kind ?? ''}\0${icon}\0${color}\0${checked}\0${pixels}`;
171
+ if (canvas._vtcDrawKey === drawKey) return;
172
+ canvas._vtcDrawKey = drawKey;
173
+ if (canvas.width !== pixels) canvas.width = pixels;
174
+ if (canvas.height !== pixels) canvas.height = pixels;
175
+ const ctx = canvas.getContext('2d');
176
+ ctx.setTransform(pixels / size, 0, 0, pixels / size, 0, 0);
177
+ ctx.clearRect(0, 0, size, size);
178
+ if (action.kind === 'checkbox') drawCheckbox(ctx, 1, 1, checked, scene.theme);
179
+ else this.controller.iconRegistry.draw(ctx, icon, 0, 0, 16, color);
180
+ }
181
+
182
+ #activate(button, event) {
183
+ event.stopPropagation();
184
+ this.controller.flushDynamicState();
185
+ const current = this.controller.model.index.getNode(button.dataset.nodeId);
186
+ const spec = this.actionsById.get(button.dataset.action);
187
+ const state = this.controller.model.dynamicState.get(current?.id) ?? {};
188
+ if (!current || !spec || resolve(spec.disabled, current, state)) return;
189
+ this.controller.events.emit('rowaction', {
190
+ actionId: spec.id, nodeId: current.id, node: current,
191
+ ...(spec.kind === 'checkbox'
192
+ ? { checked: !Boolean(resolve(spec.checked, current, state)) }
193
+ : {}),
194
+ originalEvent: event
195
+ });
196
+ }
197
+
198
+ #prepareIcons(theme, ratio) {
199
+ for (const action of this.controller.rowActions) {
200
+ const icons = action.preloadIcons ?? (typeof action.icon === 'string' ? [action.icon] : []);
201
+ for (const icon of icons) for (const color of [theme.colors.text, theme.colors.textMuted]) {
202
+ const key = `${icon}\0${color}\0${ratio}`;
203
+ if (this.preparedIcons.has(key)) continue;
204
+ this.preparedIcons.add(key);
205
+ void this.controller.iconRegistry.prepare({ icons: [icon], size: 16, color, pixelRatio: ratio });
206
+ }
110
207
  }
111
- if (refocus) (this.buttons.values().next().value ?? this.controller.canvas).focus({preventScroll:true});
112
208
  }
113
209
 
114
- destroy() { this.element.remove(); this.buttons.clear(); }
210
+ #removeButton(key, button, preserveFocus) {
211
+ if (!button) return;
212
+ const focused = this.element.getRootNode().activeElement === button;
213
+ button.remove();
214
+ this.buttons.delete(key);
215
+ const nodeButtons = this.buttonsByNodeId.get(button.dataset.nodeId);
216
+ nodeButtons?.delete(button.dataset.action);
217
+ if (!nodeButtons?.size) this.buttonsByNodeId.delete(button.dataset.nodeId);
218
+ if (focused && preserveFocus) {
219
+ (this.buttons.values().next().value ?? this.controller.canvas).focus({ preventScroll: true });
220
+ } else if (focused) this.controller.canvas.focus({ preventScroll: true });
221
+ }
222
+
223
+ destroy() {
224
+ this.stopIconListener?.();
225
+ this.stopIconListener = null;
226
+ this.element.remove();
227
+ this.buttons.clear();
228
+ this.buttonsByNodeId.clear();
229
+ this.actionsById.clear();
230
+ this.visibleRowsById.clear();
231
+ }
232
+ }
233
+
234
+ function resolve(value, node, state) {
235
+ return typeof value === 'function' ? value(node, state) : value;
236
+ }
237
+
238
+ function actionKey(nodeId, actionId) {
239
+ return `${nodeId.length}:${nodeId}${actionId}`;
240
+ }
241
+
242
+ function setProperty(target, name, value) {
243
+ if (!Object.is(target[name], value)) target[name] = value;
244
+ }
245
+
246
+ function setAttribute(target, name, value) {
247
+ if (value === null) {
248
+ if (target.hasAttribute(name)) target.removeAttribute(name);
249
+ } else if (target.getAttribute(name) !== value) target.setAttribute(name, value);
250
+ }
251
+
252
+ function setStyle(target, name, value) {
253
+ if (name.startsWith('--')) {
254
+ if (target.style.getPropertyValue(name) !== value) target.style.setProperty(name, value);
255
+ } else if (target.style[name] !== value) target.style[name] = value;
115
256
  }