virtual-tree-canvas 0.3.8 → 0.4.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/README.md +164 -225
- package/docs/inspector-demo.png +0 -0
- package/docs/tree-demo.png +0 -0
- package/package.json +2 -1
- package/src/assets/icons/air.svg +1 -0
- package/src/assets/icons/aircraft.svg +1 -0
- package/src/assets/icons/bus.svg +1 -0
- package/src/assets/icons/control.svg +1 -0
- package/src/assets/icons/damage.svg +1 -0
- package/src/assets/icons/error.svg +1 -0
- package/src/assets/icons/folder.svg +1 -0
- package/src/assets/icons/ground.svg +1 -0
- package/src/assets/icons/inspector-array.svg +1 -0
- package/src/assets/icons/inspector-object.svg +1 -0
- package/src/assets/icons/inspector-value.svg +1 -0
- package/src/assets/icons/munition.svg +1 -0
- package/src/assets/icons/placeholder.svg +1 -0
- package/src/assets/icons/point.svg +1 -0
- package/src/assets/icons/radar.svg +1 -0
- package/src/assets/icons/situation.svg +1 -0
- package/src/assets/icons/space.svg +1 -0
- package/src/assets/icons/subsurface.svg +1 -0
- package/src/assets/icons/surface.svg +1 -0
- package/src/assets/icons/task.svg +1 -0
- package/src/assets/icons/track.svg +1 -0
- package/src/assets/icons/warning.svg +1 -0
- package/src/assets/icons.js +25 -0
- package/src/core/icon-registry.js +173 -495
- package/src/core/search-index.js +33 -18
- package/src/core/tree-worker-client.js +17 -0
- package/src/core/tree-worker-operations.js +39 -20
- package/src/core/visible-row-model.js +39 -0
- package/src/index.d.ts +15 -2
- package/src/renderers/tree-row-renderer.js +64 -0
- package/src/tree-view-controller.js +59 -16
- package/src/workers/tree-worker.js +22 -8
package/src/core/search-index.js
CHANGED
|
@@ -13,12 +13,12 @@ export class TreeSearchIndex {
|
|
|
13
13
|
const searchId = searchableNodeId(node);
|
|
14
14
|
const record = {
|
|
15
15
|
id: node.id,
|
|
16
|
-
searchId
|
|
17
|
-
label:
|
|
18
|
-
path
|
|
19
|
-
tags:
|
|
20
|
-
type:
|
|
21
|
-
value:
|
|
16
|
+
searchId,
|
|
17
|
+
label: node.label ?? '',
|
|
18
|
+
path,
|
|
19
|
+
tags: (node.tags ?? []).join(' '),
|
|
20
|
+
type: node.type ?? '',
|
|
21
|
+
value: searchableNodeValue(node),
|
|
22
22
|
};
|
|
23
23
|
record.searchText = defaultSearchText(node, record);
|
|
24
24
|
return record;
|
|
@@ -27,10 +27,10 @@ export class TreeSearchIndex {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* @param {string} query
|
|
30
|
-
* @param {{ fields?: string[], limit?: number }} options
|
|
30
|
+
* @param {{ fields?: string[], limit?: number, caseSensitive?: boolean, wholeWord?: boolean }} options
|
|
31
31
|
*/
|
|
32
32
|
search(query, options = {}) {
|
|
33
|
-
const q = query.trim()
|
|
33
|
+
const q = normalizeSearchValue(query.trim(), options);
|
|
34
34
|
this.lastQuery = query;
|
|
35
35
|
if (!q) {
|
|
36
36
|
this.results = [];
|
|
@@ -43,10 +43,10 @@ export class TreeSearchIndex {
|
|
|
43
43
|
const defaultFields = isDefaultSearchFields(fields);
|
|
44
44
|
for (const record of this.records) {
|
|
45
45
|
if (defaultFields) {
|
|
46
|
-
if (record.searchText.
|
|
46
|
+
if (matchesSearch(normalizeSearchValue(record.searchText, options), q, options.wholeWord)) results.push(record.id);
|
|
47
47
|
} else {
|
|
48
48
|
for (const field of fields) {
|
|
49
|
-
if (searchFieldValue(record, field).
|
|
49
|
+
if (matchesSearch(searchFieldValue(record, field, options), q, options.wholeWord)) {
|
|
50
50
|
results.push(record.id);
|
|
51
51
|
break;
|
|
52
52
|
}
|
|
@@ -86,9 +86,9 @@ function isDefaultSearchFields(fields) {
|
|
|
86
86
|
return fields.length === 5 && fields.includes('label') && fields.includes('id') && fields.includes('path') && fields.includes('tags') && fields.includes('type');
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function searchFieldValue(record, field) {
|
|
90
|
-
if (field === 'id') return record.searchId ||
|
|
91
|
-
return
|
|
89
|
+
function searchFieldValue(record, field, options) {
|
|
90
|
+
if (field === 'id') return normalizeSearchValue(record.searchId || record.id, options);
|
|
91
|
+
return normalizeSearchValue(record[field] ?? '', options);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
function searchableNodeId(node) {
|
|
@@ -104,7 +104,7 @@ function searchableNodeValue(node) {
|
|
|
104
104
|
function defaultSearchText(node, record) {
|
|
105
105
|
if (node?.data?.inspector) {
|
|
106
106
|
const data = node.data;
|
|
107
|
-
return
|
|
107
|
+
return [
|
|
108
108
|
record.searchId,
|
|
109
109
|
node.label ?? '',
|
|
110
110
|
data.key ?? '',
|
|
@@ -112,11 +112,26 @@ function defaultSearchText(node, record) {
|
|
|
112
112
|
data.valueType ?? '',
|
|
113
113
|
record.tags,
|
|
114
114
|
record.type,
|
|
115
|
-
].join(' ')
|
|
115
|
+
].join(' ');
|
|
116
116
|
}
|
|
117
|
-
return
|
|
117
|
+
return `${record.searchId} ${record.label} ${record.path} ${record.tags} ${record.type}`;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
function
|
|
121
|
-
|
|
120
|
+
function normalizeSearchValue(value, options = {}) {
|
|
121
|
+
const text = String(value ?? '');
|
|
122
|
+
return options.caseSensitive ? text : text.toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function matchesSearch(text, query, wholeWord = false) {
|
|
126
|
+
if (!wholeWord) return text.includes(query);
|
|
127
|
+
let index = text.indexOf(query);
|
|
128
|
+
while (index !== -1) {
|
|
129
|
+
if (!isWordChar(text[index - 1]) && !isWordChar(text[index + query.length])) return true;
|
|
130
|
+
index = text.indexOf(query, index + query.length);
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isWordChar(char) {
|
|
136
|
+
return typeof char === 'string' && /[\p{L}\p{N}_]/u.test(char);
|
|
122
137
|
}
|
|
@@ -6,6 +6,8 @@ export class TreeWorkerClient {
|
|
|
6
6
|
this.pending = new Map();
|
|
7
7
|
this.ready = Promise.resolve();
|
|
8
8
|
this.worker.addEventListener('message', this.#onMessage);
|
|
9
|
+
this.worker.addEventListener('error', this.#onError);
|
|
10
|
+
this.worker.addEventListener('messageerror', this.#onMessageError);
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
setData(nodes) {
|
|
@@ -27,6 +29,8 @@ export class TreeWorkerClient {
|
|
|
27
29
|
for (const { reject } of this.pending.values()) reject(new Error('Tree worker destroyed'));
|
|
28
30
|
this.pending.clear();
|
|
29
31
|
this.worker.removeEventListener('message', this.#onMessage);
|
|
32
|
+
this.worker.removeEventListener('error', this.#onError);
|
|
33
|
+
this.worker.removeEventListener('messageerror', this.#onMessageError);
|
|
30
34
|
this.worker.terminate();
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -47,4 +51,17 @@ export class TreeWorkerClient {
|
|
|
47
51
|
if (ok) pending.resolve(result);
|
|
48
52
|
else pending.reject(new Error(error || 'Tree worker request failed'));
|
|
49
53
|
};
|
|
54
|
+
|
|
55
|
+
#onError = (event) => {
|
|
56
|
+
this.#rejectPending(new Error(event.message || 'Tree worker failed'));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
#onMessageError = () => {
|
|
60
|
+
this.#rejectPending(new Error('Tree worker returned an unreadable message'));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
#rejectPending(error) {
|
|
64
|
+
for (const { reject } of this.pending.values()) reject(error);
|
|
65
|
+
this.pending.clear();
|
|
66
|
+
}
|
|
50
67
|
}
|
|
@@ -44,14 +44,17 @@ export function createWorkerTreeState(nodes) {
|
|
|
44
44
|
return { nodes, idToIndex, childrenByParent, parentById, roots, pathById, records };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function searchWorkerTree(state, query,
|
|
48
|
-
const
|
|
47
|
+
export function searchWorkerTree(state, query, options = {}) {
|
|
48
|
+
const { fields = ['label', 'id', 'path', 'tags', 'type'], limit = 500 } = options;
|
|
49
|
+
const q = normalizeSearchValue(String(query ?? '').trim(), options);
|
|
49
50
|
if (!q) return [];
|
|
50
51
|
const results = [];
|
|
51
52
|
const defaultFields = fields.length === 5 && fields.includes('label') && fields.includes('id') && fields.includes('path') && fields.includes('tags') && fields.includes('type');
|
|
52
53
|
|
|
53
54
|
for (const record of state.records) {
|
|
54
|
-
const matches = defaultFields
|
|
55
|
+
const matches = defaultFields
|
|
56
|
+
? matchesSearch(normalizeSearchValue(record.searchText, options), q, options.wholeWord)
|
|
57
|
+
: fields.some((field) => matchesSearch(searchFieldValue(record, field, options), q, options.wholeWord));
|
|
55
58
|
if (!matches) continue;
|
|
56
59
|
results.push(record.id);
|
|
57
60
|
if (results.length >= limit) break;
|
|
@@ -64,10 +67,11 @@ export function rebuildWorkerRows(state, options = {}) {
|
|
|
64
67
|
const indentWidth = options.indentWidth ?? 18;
|
|
65
68
|
const expanded = new Set(options.expandedIds ?? []);
|
|
66
69
|
const filterCollapsed = new Set(options.filterCollapsedIds ?? []);
|
|
67
|
-
const
|
|
70
|
+
const filterOptions = options.filterOptions ?? {};
|
|
71
|
+
const query = normalizeSearchValue(options.filterQuery ?? '', filterOptions);
|
|
68
72
|
const sort = options.sort ?? { columnId: null, direction: null };
|
|
69
73
|
const sortValues = options.sortValues ? new Map(options.sortValues) : null;
|
|
70
|
-
const includedIds = options.includedIds ? new Set(options.includedIds) : query ? getIncludedIdsForQuery(state, query).includedIds : null;
|
|
74
|
+
const includedIds = options.includedIds ? new Set(options.includedIds) : query ? getIncludedIdsForQuery(state, query, null, filterOptions).includedIds : null;
|
|
71
75
|
const rows = [];
|
|
72
76
|
let maxDepth = 0;
|
|
73
77
|
|
|
@@ -112,14 +116,14 @@ export function rebuildWorkerRows(state, options = {}) {
|
|
|
112
116
|
};
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
export function getIncludedIdsForQuery(state, query, candidateIds = null) {
|
|
116
|
-
const q =
|
|
119
|
+
export function getIncludedIdsForQuery(state, query, candidateIds = null, options = {}) {
|
|
120
|
+
const q = normalizeSearchValue(query, options);
|
|
117
121
|
const includedIds = new Set();
|
|
118
122
|
const matchingIds = [];
|
|
119
123
|
const records = candidateIds ? idsToRecords(state, candidateIds) : state.records;
|
|
120
124
|
|
|
121
125
|
for (const record of records) {
|
|
122
|
-
if (!(record.filterText ?? record.searchText).
|
|
126
|
+
if (!matchesSearch(normalizeSearchValue(record.filterText ?? record.searchText, options), q, options.wholeWord)) continue;
|
|
123
127
|
matchingIds.push(record.id);
|
|
124
128
|
let id = record.id;
|
|
125
129
|
while (id !== null && id !== undefined && !includedIds.has(id)) {
|
|
@@ -153,13 +157,9 @@ function columnValue(node, columnId) {
|
|
|
153
157
|
return node[columnId] ?? '';
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
function searchFieldValue(record, field) {
|
|
161
|
-
if (field === 'id') return normalize(record.searchId || record.id);
|
|
162
|
-
return normalize(record[field]);
|
|
160
|
+
function searchFieldValue(record, field, options) {
|
|
161
|
+
if (field === 'id') return normalizeSearchValue(record.searchId || record.id, options);
|
|
162
|
+
return normalizeSearchValue(record[field], options);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
function searchableNodeId(node) {
|
|
@@ -175,7 +175,7 @@ function searchableNodeValue(node) {
|
|
|
175
175
|
function defaultSearchText(node, record) {
|
|
176
176
|
if (node?.data?.inspector) {
|
|
177
177
|
const data = node.data;
|
|
178
|
-
return
|
|
178
|
+
return [
|
|
179
179
|
record.searchId,
|
|
180
180
|
node.label ?? '',
|
|
181
181
|
data.key ?? '',
|
|
@@ -183,15 +183,15 @@ function defaultSearchText(node, record) {
|
|
|
183
183
|
data.valueType ?? '',
|
|
184
184
|
record.tags,
|
|
185
185
|
record.type,
|
|
186
|
-
].join(' ')
|
|
186
|
+
].join(' ');
|
|
187
187
|
}
|
|
188
|
-
return
|
|
188
|
+
return `${record.searchId} ${record.label} ${record.path} ${record.tags} ${record.type}`;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
function defaultFilterText(node, record) {
|
|
192
192
|
if (node?.data?.inspector) {
|
|
193
193
|
const data = node.data;
|
|
194
|
-
return
|
|
194
|
+
return [
|
|
195
195
|
node.label ?? '',
|
|
196
196
|
node.type ?? '',
|
|
197
197
|
data.path ?? '',
|
|
@@ -199,7 +199,26 @@ function defaultFilterText(node, record) {
|
|
|
199
199
|
data.valueText ?? '',
|
|
200
200
|
data.meta?.description ?? '',
|
|
201
201
|
...Object.keys(data.meta?.options ?? {}),
|
|
202
|
-
].join(' ')
|
|
202
|
+
].join(' ');
|
|
203
203
|
}
|
|
204
204
|
return record.searchText;
|
|
205
205
|
}
|
|
206
|
+
|
|
207
|
+
function normalizeSearchValue(value, options = {}) {
|
|
208
|
+
const text = String(value ?? '');
|
|
209
|
+
return options.caseSensitive ? text : text.toLowerCase();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function matchesSearch(text, query, wholeWord = false) {
|
|
213
|
+
if (!wholeWord) return text.includes(query);
|
|
214
|
+
let index = text.indexOf(query);
|
|
215
|
+
while (index !== -1) {
|
|
216
|
+
if (!isWordChar(text[index - 1]) && !isWordChar(text[index + query.length])) return true;
|
|
217
|
+
index = text.indexOf(query, index + query.length);
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isWordChar(char) {
|
|
223
|
+
return typeof char === 'string' && /[\p{L}\p{N}_]/u.test(char);
|
|
224
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @property {number} height
|
|
9
9
|
* @property {boolean} expanded
|
|
10
10
|
* @property {boolean} hasChildren
|
|
11
|
+
* @property {number} [stickyY]
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
export class VisibleRowModel extends EventTarget {
|
|
@@ -146,4 +147,42 @@ export class VisibleRowModel extends EventTarget {
|
|
|
146
147
|
const last = Math.min(this.rows.length - 1, Math.ceil((viewport.scrollY + rowViewportHeight) / this.rowHeight) + overscan);
|
|
147
148
|
return { first, last, count: last >= first ? last - first + 1 : 0 };
|
|
148
149
|
}
|
|
150
|
+
|
|
151
|
+
/** @param {import('./tree-view-viewport.js').TreeViewViewport} viewport */
|
|
152
|
+
getStickyRows(viewport) {
|
|
153
|
+
const rowViewportHeight = viewport.rowViewportHeight ?? viewport.viewportHeight;
|
|
154
|
+
if (rowViewportHeight < this.rowHeight * 2) return [];
|
|
155
|
+
const max = Math.max(0, Math.min(6, Math.floor(rowViewportHeight / this.rowHeight) - 1));
|
|
156
|
+
const first = Math.max(0, Math.floor(viewport.scrollY / this.rowHeight));
|
|
157
|
+
let row = this.getRow(first);
|
|
158
|
+
if (!row) return [];
|
|
159
|
+
let sticky = this.#getStickyPath(row);
|
|
160
|
+
for (let i = row.rowIndex + 1; i < this.rows.length; i++) {
|
|
161
|
+
const candidate = this.rows[i];
|
|
162
|
+
if (candidate.y - viewport.scrollY >= Math.min(sticky.length, max) * this.rowHeight) break;
|
|
163
|
+
const replaceAt = sticky.findIndex((ancestor) => ancestor.depth >= candidate.depth);
|
|
164
|
+
if (replaceAt === -1) {
|
|
165
|
+
if (candidate.hasChildren && sticky.length < max) sticky = [...sticky, candidate];
|
|
166
|
+
} else if (!candidate.hasChildren) {
|
|
167
|
+
sticky = sticky.slice(0, replaceAt);
|
|
168
|
+
} else {
|
|
169
|
+
sticky = [...sticky.slice(0, replaceAt), candidate];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return sticky
|
|
173
|
+
.slice(-max)
|
|
174
|
+
.map((ancestor, index) => ({
|
|
175
|
+
...ancestor,
|
|
176
|
+
stickyY: Math.max(ancestor.y - viewport.scrollY, index * this.rowHeight),
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {TreeRow} row */
|
|
181
|
+
#getStickyPath(row) {
|
|
182
|
+
const ancestors = this.model.index.getAncestors(row.nodeId).reverse()
|
|
183
|
+
.map((id) => this.getRowById(id))
|
|
184
|
+
.filter((ancestor) => ancestor && ancestor.rowIndex < row.rowIndex);
|
|
185
|
+
if (row.hasChildren) ancestors.push(row);
|
|
186
|
+
return ancestors;
|
|
187
|
+
}
|
|
149
188
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
export type TreeViewAlign = 'start' | 'center' | 'end' | 'nearest';
|
|
2
2
|
|
|
3
|
+
export type IconDrawFunction = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) => void;
|
|
4
|
+
export type IconSource = string | CanvasImageSource | IconDrawFunction;
|
|
5
|
+
|
|
6
|
+
export class IconRegistry {
|
|
7
|
+
constructor(options?: { pixelRatio?: number });
|
|
8
|
+
register(name: string, icon: IconSource): any;
|
|
9
|
+
get(name: string): any;
|
|
10
|
+
onChange(listener: () => void): () => void;
|
|
11
|
+
prepare(options?: { icons?: Iterable<string>; size?: number; color?: string; pixelRatio?: number }): Promise<any[]>;
|
|
12
|
+
draw(ctx: CanvasRenderingContext2D, name: string, x: number, y: number, size: number, color: string): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
export type TreeNode = {
|
|
4
16
|
id: string;
|
|
5
17
|
parentId?: string | null;
|
|
@@ -86,13 +98,14 @@ export class TreeViewController {
|
|
|
86
98
|
setDynamicState(patches: DynamicPatch[]): void;
|
|
87
99
|
setTheme(theme: any): void;
|
|
88
100
|
setLayoutMetrics(options?: { rowHeight?: number; indentWidth?: number; headerHeight?: number }): void;
|
|
101
|
+
registerIcon(name: string, icon: IconSource): any;
|
|
89
102
|
resize(width: number, height: number): void;
|
|
90
103
|
render(time?: number): void;
|
|
91
104
|
renderMeasured(time?: number): any;
|
|
92
105
|
hitTest(clientX: number, clientY: number): any;
|
|
93
106
|
getTooltipForHit(hit: any): any;
|
|
94
|
-
search(query: string, options?: Record<string, any>): any;
|
|
95
|
-
setFilter(queryOrPredicate?: string | ((node: any, state: any) => boolean)): void;
|
|
107
|
+
search(query: string, options?: Record<string, any> & { caseSensitive?: boolean; wholeWord?: boolean }): any;
|
|
108
|
+
setFilter(queryOrPredicate?: string | ((node: any, state: any) => boolean), options?: { caseSensitive?: boolean; wholeWord?: boolean }): void;
|
|
96
109
|
clearFilter(): void;
|
|
97
110
|
focusNode(nodeId: string, options?: Record<string, any>): boolean;
|
|
98
111
|
scrollToNode(nodeId: string, align?: TreeViewAlign): boolean;
|
|
@@ -9,6 +9,9 @@ export class TreeRowRenderer {
|
|
|
9
9
|
this.scene = null;
|
|
10
10
|
this.iconRegistry = iconRegistry ?? new IconRegistry();
|
|
11
11
|
this.renderedRows = 0;
|
|
12
|
+
this.iconRenderQueued = false;
|
|
13
|
+
this.preparedIconThemeKey = null;
|
|
14
|
+
this.stopIconListener = this.iconRegistry.onChange?.(() => this.#scheduleIconRender()) ?? null;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/** @param {HTMLCanvasElement} canvas */
|
|
@@ -22,6 +25,14 @@ export class TreeRowRenderer {
|
|
|
22
25
|
this.scene = scene;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
destroy() {
|
|
29
|
+
this.stopIconListener?.();
|
|
30
|
+
this.stopIconListener = null;
|
|
31
|
+
this.canvas = null;
|
|
32
|
+
this.ctx = null;
|
|
33
|
+
this.scene = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
updateDynamicState(_patches) {
|
|
26
37
|
// Canvas2D reads the state map directly; patches still stay on the hot path.
|
|
27
38
|
}
|
|
@@ -43,6 +54,7 @@ export class TreeRowRenderer {
|
|
|
43
54
|
|
|
44
55
|
const ctx = this.ctx;
|
|
45
56
|
const colors = theme.colors;
|
|
57
|
+
this.#prepareThemeIcons(theme, dpr);
|
|
46
58
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
47
59
|
ctx.fillStyle = colors.background;
|
|
48
60
|
ctx.fillRect(0, 0, viewportWidth, viewportHeight);
|
|
@@ -51,9 +63,33 @@ export class TreeRowRenderer {
|
|
|
51
63
|
ctx.translate(viewport.renderInsetX ?? 0, viewport.renderInsetY ?? 0);
|
|
52
64
|
this.#drawHeader(ctx);
|
|
53
65
|
this.#drawRows(ctx);
|
|
66
|
+
this.#drawStickyRows(ctx);
|
|
54
67
|
ctx.restore();
|
|
55
68
|
}
|
|
56
69
|
|
|
70
|
+
#scheduleIconRender() {
|
|
71
|
+
if (this.iconRenderQueued) return;
|
|
72
|
+
this.iconRenderQueued = true;
|
|
73
|
+
const schedule = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0));
|
|
74
|
+
schedule(() => {
|
|
75
|
+
this.iconRenderQueued = false;
|
|
76
|
+
this.render();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#prepareThemeIcons(theme, pixelRatio) {
|
|
81
|
+
const iconColors = new Map([['placeholder', theme.colors.textMuted]]);
|
|
82
|
+
for (const style of Object.values(theme.types ?? {})) {
|
|
83
|
+
if (style?.icon) iconColors.set(style.icon, style.color ?? theme.colors.progressFill);
|
|
84
|
+
}
|
|
85
|
+
const key = `${pixelRatio}|${[...iconColors].map(([icon, color]) => `${icon}:${color}`).join(',')}`;
|
|
86
|
+
if (key === this.preparedIconThemeKey) return;
|
|
87
|
+
this.preparedIconThemeKey = key;
|
|
88
|
+
for (const [icon, color] of iconColors) {
|
|
89
|
+
this.iconRegistry.prepare?.({ icons: [icon], size: 15, color, pixelRatio });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
57
93
|
#drawHeader(ctx) {
|
|
58
94
|
const { viewport, columns, theme, sort, headerFilter, filterQuery } = this.scene;
|
|
59
95
|
if (viewport.headerHeight <= 0) return;
|
|
@@ -143,6 +179,34 @@ export class TreeRowRenderer {
|
|
|
143
179
|
ctx.restore();
|
|
144
180
|
}
|
|
145
181
|
|
|
182
|
+
#drawStickyRows(ctx) {
|
|
183
|
+
const { stickyRows = [], viewport, theme } = this.scene;
|
|
184
|
+
if (!stickyRows.length) return;
|
|
185
|
+
const visibleWidth = viewport.contentViewportWidth ?? viewport.viewportWidth;
|
|
186
|
+
const height = Math.min(
|
|
187
|
+
viewport.rowViewportHeight,
|
|
188
|
+
stickyRows.reduce((bottom, row, index) => Math.max(bottom, (row.stickyY ?? index * row.height) + row.height), 0)
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
ctx.save();
|
|
192
|
+
ctx.beginPath();
|
|
193
|
+
ctx.rect(0, viewport.headerHeight, visibleWidth, height);
|
|
194
|
+
ctx.clip();
|
|
195
|
+
ctx.translate(-viewport.scrollX, viewport.headerHeight);
|
|
196
|
+
for (let i = 0; i < stickyRows.length; i++) {
|
|
197
|
+
const row = stickyRows[i];
|
|
198
|
+
this.#drawRow(ctx, { ...row, y: row.stickyY ?? i * row.height });
|
|
199
|
+
}
|
|
200
|
+
ctx.restore();
|
|
201
|
+
|
|
202
|
+
const bottom = viewport.headerHeight + height;
|
|
203
|
+
ctx.strokeStyle = theme.colors.border;
|
|
204
|
+
ctx.beginPath();
|
|
205
|
+
ctx.moveTo(0, bottom + 0.5);
|
|
206
|
+
ctx.lineTo(visibleWidth, bottom + 0.5);
|
|
207
|
+
ctx.stroke();
|
|
208
|
+
}
|
|
209
|
+
|
|
146
210
|
#drawRow(ctx, row) {
|
|
147
211
|
const { columns, nodes, dynamicState, selection, hoverNodeId, hoverPart, activeNodeId, activePart, focusNodeId, searchMatches, theme, viewport } = this.scene;
|
|
148
212
|
const node = nodes[row.nodeIndex];
|
|
@@ -46,6 +46,7 @@ export class TreeViewController {
|
|
|
46
46
|
this.initialExpandDepth = options.initialExpandDepth ?? 1;
|
|
47
47
|
this.searchHighlights = new Set();
|
|
48
48
|
this.filterQuery = '';
|
|
49
|
+
this.filterOptions = { caseSensitive: false, wholeWord: false };
|
|
49
50
|
this.hoverId = null;
|
|
50
51
|
this.hoverPart = null;
|
|
51
52
|
this.activeId = null;
|
|
@@ -112,6 +113,7 @@ export class TreeViewController {
|
|
|
112
113
|
this.#nativeScroll = null;
|
|
113
114
|
this.inputController = null;
|
|
114
115
|
this.cellEditor = null;
|
|
116
|
+
this.renderer?.destroy?.();
|
|
115
117
|
this.canvas = null;
|
|
116
118
|
}
|
|
117
119
|
|
|
@@ -211,8 +213,14 @@ export class TreeViewController {
|
|
|
211
213
|
|
|
212
214
|
enableWorkers(workerUrl) {
|
|
213
215
|
if (this.workerClient) return Promise.resolve(this);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
+
const client = new TreeWorkerClient(workerUrl);
|
|
217
|
+
this.workerClient = client;
|
|
218
|
+
return client.setData(this.model.nodes)
|
|
219
|
+
.then(() => this)
|
|
220
|
+
.catch((error) => {
|
|
221
|
+
if (this.workerClient === client) this.disableWorkers();
|
|
222
|
+
throw error;
|
|
223
|
+
});
|
|
216
224
|
}
|
|
217
225
|
|
|
218
226
|
disableWorkers() {
|
|
@@ -271,38 +279,42 @@ export class TreeViewController {
|
|
|
271
279
|
this.events.emit('sortchange', { columnId: null, direction: null });
|
|
272
280
|
}
|
|
273
281
|
|
|
274
|
-
setFilter(queryOrPredicate = '') {
|
|
282
|
+
setFilter(queryOrPredicate = '', options = {}) {
|
|
275
283
|
this.filterQuery = typeof queryOrPredicate === 'string' ? queryOrPredicate : '';
|
|
284
|
+
this.filterOptions = typeof queryOrPredicate === 'string'
|
|
285
|
+
? { caseSensitive: Boolean(options.caseSensitive), wholeWord: Boolean(options.wholeWord) }
|
|
286
|
+
: { caseSensitive: false, wholeWord: false };
|
|
276
287
|
if (typeof queryOrPredicate === 'function') {
|
|
277
288
|
this.rowModel.setFilterPredicate(queryOrPredicate);
|
|
278
289
|
} else {
|
|
279
|
-
const query = queryOrPredicate.trim().
|
|
280
|
-
this.rowModel.setFilterPredicate(query ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', query) : null);
|
|
290
|
+
const query = normalizeFilterValue(queryOrPredicate.trim(), this.filterOptions);
|
|
291
|
+
this.rowModel.setFilterPredicate(query ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', query, this.filterOptions) : null);
|
|
281
292
|
}
|
|
282
293
|
this.#rebuildRows();
|
|
283
|
-
this.events.emit('filterchange', { query: this.filterQuery, visibleRows: this.rowModel.rows.length });
|
|
294
|
+
this.events.emit('filterchange', { query: this.filterQuery, options: this.filterOptions, visibleRows: this.rowModel.rows.length });
|
|
284
295
|
}
|
|
285
296
|
|
|
286
297
|
clearFilter() {
|
|
287
298
|
this.setFilter('');
|
|
288
299
|
}
|
|
289
300
|
|
|
290
|
-
async setFilterAsync(query = '') {
|
|
301
|
+
async setFilterAsync(query = '', options = {}) {
|
|
291
302
|
if (!this.workerClient || typeof query !== 'string') {
|
|
292
|
-
this.setFilter(query);
|
|
303
|
+
this.setFilter(query, options);
|
|
293
304
|
return this.rowModel.rows;
|
|
294
305
|
}
|
|
295
306
|
const totalStart = now();
|
|
296
307
|
const revision = ++this.workerRevision;
|
|
297
308
|
this.filterQuery = query;
|
|
298
|
-
|
|
299
|
-
|
|
309
|
+
this.filterOptions = { caseSensitive: Boolean(options.caseSensitive), wholeWord: Boolean(options.wholeWord) };
|
|
310
|
+
const normalized = normalizeFilterValue(query.trim(), this.filterOptions);
|
|
311
|
+
this.rowModel.setFilterPredicate(normalized ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', normalized, this.filterOptions) : null);
|
|
300
312
|
const workerStart = now();
|
|
301
|
-
const result = await this.workerClient.rebuildRows(this.#workerRowOptions({ filterQuery: query }));
|
|
313
|
+
const result = await this.workerClient.rebuildRows(this.#workerRowOptions({ filterQuery: query, filterOptions: this.filterOptions }));
|
|
302
314
|
const workerMs = now() - workerStart;
|
|
303
315
|
if (revision !== this.workerRevision) return this.rowModel.rows;
|
|
304
316
|
this.#applyWorkerRows(result);
|
|
305
|
-
this.events.emit('filterchange', { query: this.filterQuery, visibleRows: this.rowModel.rows.length, worker: true, workerMs, totalMs: now() - totalStart });
|
|
317
|
+
this.events.emit('filterchange', { query: this.filterQuery, options: this.filterOptions, visibleRows: this.rowModel.rows.length, worker: true, workerMs, totalMs: now() - totalStart });
|
|
306
318
|
return this.rowModel.rows;
|
|
307
319
|
}
|
|
308
320
|
|
|
@@ -393,7 +405,12 @@ export class TreeViewController {
|
|
|
393
405
|
|
|
394
406
|
search(query, options = {}) {
|
|
395
407
|
for (const id of this.searchHighlights) this.patchBatcher.set(id, { highlighted: false });
|
|
396
|
-
const results = this.searchIndex.search(query, {
|
|
408
|
+
const results = this.searchIndex.search(query, {
|
|
409
|
+
limit: options.limit ?? 500,
|
|
410
|
+
fields: options.fields,
|
|
411
|
+
caseSensitive: options.caseSensitive,
|
|
412
|
+
wholeWord: options.wholeWord,
|
|
413
|
+
});
|
|
397
414
|
this.searchHighlights = new Set(results);
|
|
398
415
|
const expandedSizeBefore = this.expansion.model.expanded.size;
|
|
399
416
|
for (const id of results) {
|
|
@@ -419,7 +436,12 @@ export class TreeViewController {
|
|
|
419
436
|
const revision = ++this.workerRevision;
|
|
420
437
|
for (const id of this.searchHighlights) this.patchBatcher.set(id, { highlighted: false });
|
|
421
438
|
const searchStart = now();
|
|
422
|
-
const results = await this.workerClient.search(query, {
|
|
439
|
+
const results = await this.workerClient.search(query, {
|
|
440
|
+
limit: options.limit ?? 500,
|
|
441
|
+
fields: options.fields,
|
|
442
|
+
caseSensitive: options.caseSensitive,
|
|
443
|
+
wholeWord: options.wholeWord,
|
|
444
|
+
});
|
|
423
445
|
const searchMs = now() - searchStart;
|
|
424
446
|
if (revision !== this.workerRevision) return this.searchIndex.results;
|
|
425
447
|
this.searchIndex.lastQuery = query;
|
|
@@ -671,6 +693,7 @@ export class TreeViewController {
|
|
|
671
693
|
return {
|
|
672
694
|
rows: this.rowModel.rows,
|
|
673
695
|
visibleRange,
|
|
696
|
+
stickyRows: this.rowModel.getStickyRows(this.viewport),
|
|
674
697
|
viewport: this.viewport,
|
|
675
698
|
columns: this.columnModel.columns,
|
|
676
699
|
theme: this.themeManager.get(),
|
|
@@ -1143,6 +1166,7 @@ export class TreeViewController {
|
|
|
1143
1166
|
indentWidth: this.rowModel.indentWidth,
|
|
1144
1167
|
sort: this.columnModel.sort,
|
|
1145
1168
|
filterQuery: this.filterQuery,
|
|
1169
|
+
filterOptions: this.filterOptions,
|
|
1146
1170
|
...overrides,
|
|
1147
1171
|
};
|
|
1148
1172
|
}
|
|
@@ -1366,7 +1390,7 @@ function compareColumnValues(column, a, b, dynamicState, snapshot = null) {
|
|
|
1366
1390
|
return String(aValue ?? '').localeCompare(String(bValue ?? ''), undefined, { numeric: true, sensitivity: 'base' });
|
|
1367
1391
|
}
|
|
1368
1392
|
|
|
1369
|
-
function matchesFilter(node, state, path, query) {
|
|
1393
|
+
function matchesFilter(node, state, path, query, options = {}) {
|
|
1370
1394
|
const inspector = node.data?.inspector;
|
|
1371
1395
|
const values = inspector
|
|
1372
1396
|
? [
|
|
@@ -1387,7 +1411,26 @@ function matchesFilter(node, state, path, query) {
|
|
|
1387
1411
|
state.status,
|
|
1388
1412
|
state.value,
|
|
1389
1413
|
];
|
|
1390
|
-
return values.some((value) =>
|
|
1414
|
+
return values.some((value) => matchesSearch(normalizeFilterValue(value, options), query, options.wholeWord));
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function normalizeFilterValue(value, options = {}) {
|
|
1418
|
+
const text = String(value ?? '');
|
|
1419
|
+
return options.caseSensitive ? text : text.toLowerCase();
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function matchesSearch(text, query, wholeWord = false) {
|
|
1423
|
+
if (!wholeWord) return text.includes(query);
|
|
1424
|
+
let index = text.indexOf(query);
|
|
1425
|
+
while (index !== -1) {
|
|
1426
|
+
if (!isWordChar(text[index - 1]) && !isWordChar(text[index + query.length])) return true;
|
|
1427
|
+
index = text.indexOf(query, index + query.length);
|
|
1428
|
+
}
|
|
1429
|
+
return false;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function isWordChar(char) {
|
|
1433
|
+
return typeof char === 'string' && /[\p{L}\p{N}_]/u.test(char);
|
|
1391
1434
|
}
|
|
1392
1435
|
|
|
1393
1436
|
function inspectorTooltipValue(node) {
|
|
@@ -25,28 +25,37 @@ self.addEventListener('message', (event) => {
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
function rebuildRowsIncremental(payload) {
|
|
28
|
-
const
|
|
28
|
+
const options = {
|
|
29
|
+
caseSensitive: Boolean(payload.filterOptions?.caseSensitive),
|
|
30
|
+
wholeWord: Boolean(payload.filterOptions?.wholeWord),
|
|
31
|
+
};
|
|
32
|
+
const query = normalizeFilterValue(payload.filterQuery ?? '', options);
|
|
29
33
|
if (!query) return rebuildWorkerRows(state, payload);
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
const cacheKey = `${options.caseSensitive ? 1 : 0}:${options.wholeWord ? 1 : 0}:${query}`;
|
|
36
|
+
let cached = filterCache.get(cacheKey);
|
|
32
37
|
if (!cached) {
|
|
33
|
-
const base = findBestPrefixCache(query);
|
|
34
|
-
const computed = getIncludedIdsForQuery(state, query, base?.matchingIds ?? null);
|
|
38
|
+
const base = findBestPrefixCache(query, options);
|
|
39
|
+
const computed = getIncludedIdsForQuery(state, query, base?.matchingIds ?? null, options);
|
|
35
40
|
cached = {
|
|
41
|
+
query,
|
|
42
|
+
options,
|
|
36
43
|
matchingIds: computed.matchingIds,
|
|
37
44
|
includedIds: Array.from(computed.includedIds),
|
|
38
45
|
};
|
|
39
|
-
filterCache.set(
|
|
46
|
+
filterCache.set(cacheKey, cached);
|
|
40
47
|
trimFilterCache();
|
|
41
48
|
}
|
|
42
49
|
return rebuildWorkerRows(state, { ...payload, includedIds: cached.includedIds });
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
function findBestPrefixCache(query) {
|
|
52
|
+
function findBestPrefixCache(query, options = {}) {
|
|
46
53
|
let best = null;
|
|
47
|
-
for (const
|
|
54
|
+
for (const cached of filterCache.values()) {
|
|
55
|
+
if (cached.options.caseSensitive !== Boolean(options.caseSensitive) || cached.options.wholeWord !== Boolean(options.wholeWord)) continue;
|
|
56
|
+
const cachedQuery = cached.query;
|
|
48
57
|
if (!cachedQuery || cachedQuery === query || !query.startsWith(cachedQuery)) continue;
|
|
49
|
-
if (!best || cachedQuery.length > best.query.length) best =
|
|
58
|
+
if (!best || cachedQuery.length > best.query.length) best = cached;
|
|
50
59
|
}
|
|
51
60
|
return best;
|
|
52
61
|
}
|
|
@@ -57,3 +66,8 @@ function trimFilterCache(limit = 8) {
|
|
|
57
66
|
filterCache.delete(oldest);
|
|
58
67
|
}
|
|
59
68
|
}
|
|
69
|
+
|
|
70
|
+
function normalizeFilterValue(value, options = {}) {
|
|
71
|
+
const text = String(value ?? '').trim();
|
|
72
|
+
return options.caseSensitive ? text : text.toLowerCase();
|
|
73
|
+
}
|