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.
- package/CHANGELOG.md +15 -0
- package/README.md +12 -1
- package/docs/icon-catalog.html +68 -66
- package/docs/icon-catalog.md +111 -111
- package/docs/performance/dynamic-updates-0.7.1.md +38 -0
- package/package.json +3 -2
- package/src/assets/icons.js +10 -4
- package/src/core/dynamic-state.js +19 -29
- package/src/core/patch-batcher.js +4 -0
- package/src/core/theme-manager.js +238 -64
- package/src/core/tree-cell-layout.js +4 -2
- package/src/core/tree-column-model.js +72 -24
- package/src/core/tree-model.js +4 -2
- package/src/core/view-options.js +98 -25
- package/src/core/visible-row-model.js +50 -20
- package/src/index.d.ts +22 -0
- package/src/input/row-actions.js +224 -83
- package/src/input/row-reorder-input.js +112 -39
- package/src/input/tree-tooltip.js +39 -5
- package/src/input/tree-view-input-controller.js +33 -10
- package/src/inspector/cell-editor-manager.js +110 -31
- package/src/inspector/pane-layout.js +11 -3
- package/src/renderers/checkbox.js +12 -13
- package/src/renderers/tree-row-renderer.js +325 -68
- package/src/tree-view-controller.js +919 -213
- package/src/tree-view.js +215 -53
- package/src/view/styles.js +83 -12
package/src/core/view-options.js
CHANGED
|
@@ -1,37 +1,110 @@
|
|
|
1
1
|
import { resolveTheme } from './theme-manager.js';
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Supported TreeView configuration options and defaults.
|
|
5
|
+
*/
|
|
3
6
|
export const treeViewDefaults = Object.freeze({
|
|
4
|
-
mode: 'tree',
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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))
|
|
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 (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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))
|
|
24
|
-
|
|
25
|
-
if (key === '
|
|
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))
|
|
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 (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (
|
|
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
|
-
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @type {TreeRow[]}
|
|
33
|
+
*/
|
|
30
34
|
this.rows = [];
|
|
31
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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) =>
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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(
|
|
148
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
174
|
-
.
|
|
175
|
-
|
|
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
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* @param {TreeRow} row
|
|
207
|
+
*/
|
|
181
208
|
#getStickyPath(row) {
|
|
182
|
-
const ancestors = this.model.index
|
|
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;
|
package/src/input/row-actions.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
35
|
-
const
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
42
|
-
|
|
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
|
|
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)
|
|
48
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
}
|