virtual-tree-canvas 0.3.7 → 0.3.9
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/package.json +1 -1
- package/src/core/search-index.js +33 -18
- package/src/core/tree-worker-operations.js +39 -20
- package/src/core/visible-row-model.js +39 -0
- package/src/index.d.ts +2 -2
- package/src/renderers/tree-row-renderer.js +29 -0
- package/src/tree-view-controller.js +74 -20
- package/src/workers/tree-worker.js +22 -8
package/package.json
CHANGED
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
|
}
|
|
@@ -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
|
@@ -91,8 +91,8 @@ export class TreeViewController {
|
|
|
91
91
|
renderMeasured(time?: number): any;
|
|
92
92
|
hitTest(clientX: number, clientY: number): any;
|
|
93
93
|
getTooltipForHit(hit: any): any;
|
|
94
|
-
search(query: string, options?: Record<string, any>): any;
|
|
95
|
-
setFilter(queryOrPredicate?: string | ((node: any, state: any) => boolean)): void;
|
|
94
|
+
search(query: string, options?: Record<string, any> & { caseSensitive?: boolean; wholeWord?: boolean }): any;
|
|
95
|
+
setFilter(queryOrPredicate?: string | ((node: any, state: any) => boolean), options?: { caseSensitive?: boolean; wholeWord?: boolean }): void;
|
|
96
96
|
clearFilter(): void;
|
|
97
97
|
focusNode(nodeId: string, options?: Record<string, any>): boolean;
|
|
98
98
|
scrollToNode(nodeId: string, align?: TreeViewAlign): boolean;
|
|
@@ -51,6 +51,7 @@ export class TreeRowRenderer {
|
|
|
51
51
|
ctx.translate(viewport.renderInsetX ?? 0, viewport.renderInsetY ?? 0);
|
|
52
52
|
this.#drawHeader(ctx);
|
|
53
53
|
this.#drawRows(ctx);
|
|
54
|
+
this.#drawStickyRows(ctx);
|
|
54
55
|
ctx.restore();
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -143,6 +144,34 @@ export class TreeRowRenderer {
|
|
|
143
144
|
ctx.restore();
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
#drawStickyRows(ctx) {
|
|
148
|
+
const { stickyRows = [], viewport, theme } = this.scene;
|
|
149
|
+
if (!stickyRows.length) return;
|
|
150
|
+
const visibleWidth = viewport.contentViewportWidth ?? viewport.viewportWidth;
|
|
151
|
+
const height = Math.min(
|
|
152
|
+
viewport.rowViewportHeight,
|
|
153
|
+
stickyRows.reduce((bottom, row, index) => Math.max(bottom, (row.stickyY ?? index * row.height) + row.height), 0)
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
ctx.save();
|
|
157
|
+
ctx.beginPath();
|
|
158
|
+
ctx.rect(0, viewport.headerHeight, visibleWidth, height);
|
|
159
|
+
ctx.clip();
|
|
160
|
+
ctx.translate(-viewport.scrollX, viewport.headerHeight);
|
|
161
|
+
for (let i = 0; i < stickyRows.length; i++) {
|
|
162
|
+
const row = stickyRows[i];
|
|
163
|
+
this.#drawRow(ctx, { ...row, y: row.stickyY ?? i * row.height });
|
|
164
|
+
}
|
|
165
|
+
ctx.restore();
|
|
166
|
+
|
|
167
|
+
const bottom = viewport.headerHeight + height;
|
|
168
|
+
ctx.strokeStyle = theme.colors.border;
|
|
169
|
+
ctx.beginPath();
|
|
170
|
+
ctx.moveTo(0, bottom + 0.5);
|
|
171
|
+
ctx.lineTo(visibleWidth, bottom + 0.5);
|
|
172
|
+
ctx.stroke();
|
|
173
|
+
}
|
|
174
|
+
|
|
146
175
|
#drawRow(ctx, row) {
|
|
147
176
|
const { columns, nodes, dynamicState, selection, hoverNodeId, hoverPart, activeNodeId, activePart, focusNodeId, searchMatches, theme, viewport } = this.scene;
|
|
148
177
|
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;
|
|
@@ -271,38 +272,42 @@ export class TreeViewController {
|
|
|
271
272
|
this.events.emit('sortchange', { columnId: null, direction: null });
|
|
272
273
|
}
|
|
273
274
|
|
|
274
|
-
setFilter(queryOrPredicate = '') {
|
|
275
|
+
setFilter(queryOrPredicate = '', options = {}) {
|
|
275
276
|
this.filterQuery = typeof queryOrPredicate === 'string' ? queryOrPredicate : '';
|
|
277
|
+
this.filterOptions = typeof queryOrPredicate === 'string'
|
|
278
|
+
? { caseSensitive: Boolean(options.caseSensitive), wholeWord: Boolean(options.wholeWord) }
|
|
279
|
+
: { caseSensitive: false, wholeWord: false };
|
|
276
280
|
if (typeof queryOrPredicate === 'function') {
|
|
277
281
|
this.rowModel.setFilterPredicate(queryOrPredicate);
|
|
278
282
|
} 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);
|
|
283
|
+
const query = normalizeFilterValue(queryOrPredicate.trim(), this.filterOptions);
|
|
284
|
+
this.rowModel.setFilterPredicate(query ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', query, this.filterOptions) : null);
|
|
281
285
|
}
|
|
282
286
|
this.#rebuildRows();
|
|
283
|
-
this.events.emit('filterchange', { query: this.filterQuery, visibleRows: this.rowModel.rows.length });
|
|
287
|
+
this.events.emit('filterchange', { query: this.filterQuery, options: this.filterOptions, visibleRows: this.rowModel.rows.length });
|
|
284
288
|
}
|
|
285
289
|
|
|
286
290
|
clearFilter() {
|
|
287
291
|
this.setFilter('');
|
|
288
292
|
}
|
|
289
293
|
|
|
290
|
-
async setFilterAsync(query = '') {
|
|
294
|
+
async setFilterAsync(query = '', options = {}) {
|
|
291
295
|
if (!this.workerClient || typeof query !== 'string') {
|
|
292
|
-
this.setFilter(query);
|
|
296
|
+
this.setFilter(query, options);
|
|
293
297
|
return this.rowModel.rows;
|
|
294
298
|
}
|
|
295
299
|
const totalStart = now();
|
|
296
300
|
const revision = ++this.workerRevision;
|
|
297
301
|
this.filterQuery = query;
|
|
298
|
-
|
|
299
|
-
|
|
302
|
+
this.filterOptions = { caseSensitive: Boolean(options.caseSensitive), wholeWord: Boolean(options.wholeWord) };
|
|
303
|
+
const normalized = normalizeFilterValue(query.trim(), this.filterOptions);
|
|
304
|
+
this.rowModel.setFilterPredicate(normalized ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', normalized, this.filterOptions) : null);
|
|
300
305
|
const workerStart = now();
|
|
301
|
-
const result = await this.workerClient.rebuildRows(this.#workerRowOptions({ filterQuery: query }));
|
|
306
|
+
const result = await this.workerClient.rebuildRows(this.#workerRowOptions({ filterQuery: query, filterOptions: this.filterOptions }));
|
|
302
307
|
const workerMs = now() - workerStart;
|
|
303
308
|
if (revision !== this.workerRevision) return this.rowModel.rows;
|
|
304
309
|
this.#applyWorkerRows(result);
|
|
305
|
-
this.events.emit('filterchange', { query: this.filterQuery, visibleRows: this.rowModel.rows.length, worker: true, workerMs, totalMs: now() - totalStart });
|
|
310
|
+
this.events.emit('filterchange', { query: this.filterQuery, options: this.filterOptions, visibleRows: this.rowModel.rows.length, worker: true, workerMs, totalMs: now() - totalStart });
|
|
306
311
|
return this.rowModel.rows;
|
|
307
312
|
}
|
|
308
313
|
|
|
@@ -393,7 +398,12 @@ export class TreeViewController {
|
|
|
393
398
|
|
|
394
399
|
search(query, options = {}) {
|
|
395
400
|
for (const id of this.searchHighlights) this.patchBatcher.set(id, { highlighted: false });
|
|
396
|
-
const results = this.searchIndex.search(query, {
|
|
401
|
+
const results = this.searchIndex.search(query, {
|
|
402
|
+
limit: options.limit ?? 500,
|
|
403
|
+
fields: options.fields,
|
|
404
|
+
caseSensitive: options.caseSensitive,
|
|
405
|
+
wholeWord: options.wholeWord,
|
|
406
|
+
});
|
|
397
407
|
this.searchHighlights = new Set(results);
|
|
398
408
|
const expandedSizeBefore = this.expansion.model.expanded.size;
|
|
399
409
|
for (const id of results) {
|
|
@@ -419,7 +429,12 @@ export class TreeViewController {
|
|
|
419
429
|
const revision = ++this.workerRevision;
|
|
420
430
|
for (const id of this.searchHighlights) this.patchBatcher.set(id, { highlighted: false });
|
|
421
431
|
const searchStart = now();
|
|
422
|
-
const results = await this.workerClient.search(query, {
|
|
432
|
+
const results = await this.workerClient.search(query, {
|
|
433
|
+
limit: options.limit ?? 500,
|
|
434
|
+
fields: options.fields,
|
|
435
|
+
caseSensitive: options.caseSensitive,
|
|
436
|
+
wholeWord: options.wholeWord,
|
|
437
|
+
});
|
|
423
438
|
const searchMs = now() - searchStart;
|
|
424
439
|
if (revision !== this.workerRevision) return this.searchIndex.results;
|
|
425
440
|
this.searchIndex.lastQuery = query;
|
|
@@ -671,6 +686,7 @@ export class TreeViewController {
|
|
|
671
686
|
return {
|
|
672
687
|
rows: this.rowModel.rows,
|
|
673
688
|
visibleRange,
|
|
689
|
+
stickyRows: this.rowModel.getStickyRows(this.viewport),
|
|
674
690
|
viewport: this.viewport,
|
|
675
691
|
columns: this.columnModel.columns,
|
|
676
692
|
theme: this.themeManager.get(),
|
|
@@ -892,11 +908,10 @@ export class TreeViewController {
|
|
|
892
908
|
}
|
|
893
909
|
|
|
894
910
|
#rebuildRows() {
|
|
895
|
-
const
|
|
896
|
-
const scrollY = this.viewport.scrollY;
|
|
911
|
+
const scrollAnchor = this.#captureScrollAnchor();
|
|
897
912
|
this.rowModel.rebuild();
|
|
898
913
|
this.#syncContentSize();
|
|
899
|
-
this
|
|
914
|
+
this.#restoreScrollAnchor(scrollAnchor);
|
|
900
915
|
this.rebuildCount++;
|
|
901
916
|
}
|
|
902
917
|
|
|
@@ -1144,19 +1159,39 @@ export class TreeViewController {
|
|
|
1144
1159
|
indentWidth: this.rowModel.indentWidth,
|
|
1145
1160
|
sort: this.columnModel.sort,
|
|
1146
1161
|
filterQuery: this.filterQuery,
|
|
1162
|
+
filterOptions: this.filterOptions,
|
|
1147
1163
|
...overrides,
|
|
1148
1164
|
};
|
|
1149
1165
|
}
|
|
1150
1166
|
|
|
1151
1167
|
#applyWorkerRows(result) {
|
|
1152
|
-
const
|
|
1153
|
-
const scrollY = this.viewport.scrollY;
|
|
1168
|
+
const scrollAnchor = this.#captureScrollAnchor();
|
|
1154
1169
|
this.rowModel.applyRows(result);
|
|
1155
1170
|
this.#syncContentSize();
|
|
1156
|
-
this
|
|
1171
|
+
this.#restoreScrollAnchor(scrollAnchor);
|
|
1157
1172
|
this.rebuildCount++;
|
|
1158
1173
|
}
|
|
1159
1174
|
|
|
1175
|
+
#captureScrollAnchor() {
|
|
1176
|
+
const scrollX = this.viewport.scrollX;
|
|
1177
|
+
const scrollY = this.viewport.scrollY;
|
|
1178
|
+
const rowIndex = Math.max(0, Math.floor(scrollY / this.rowModel.rowHeight));
|
|
1179
|
+
const row = this.rowModel.getRow(rowIndex);
|
|
1180
|
+
if (!row) return { scrollX, scrollY, nodeId: null, offsetY: 0 };
|
|
1181
|
+
return {
|
|
1182
|
+
scrollX,
|
|
1183
|
+
scrollY,
|
|
1184
|
+
nodeId: row.nodeId,
|
|
1185
|
+
offsetY: Math.max(0, scrollY - row.y),
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
#restoreScrollAnchor(anchor) {
|
|
1190
|
+
const row = anchor.nodeId ? this.rowModel.getRowById(anchor.nodeId) : null;
|
|
1191
|
+
const scrollY = row ? row.y + anchor.offsetY : anchor.scrollY;
|
|
1192
|
+
this.viewport.scrollTo(anchor.scrollX, scrollY);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1160
1195
|
#rebuildInspectorModel(focusPath = '') {
|
|
1161
1196
|
if (!this.inspector) return;
|
|
1162
1197
|
const expanded = new Set(this.expansion.model.expanded);
|
|
@@ -1348,7 +1383,7 @@ function compareColumnValues(column, a, b, dynamicState, snapshot = null) {
|
|
|
1348
1383
|
return String(aValue ?? '').localeCompare(String(bValue ?? ''), undefined, { numeric: true, sensitivity: 'base' });
|
|
1349
1384
|
}
|
|
1350
1385
|
|
|
1351
|
-
function matchesFilter(node, state, path, query) {
|
|
1386
|
+
function matchesFilter(node, state, path, query, options = {}) {
|
|
1352
1387
|
const inspector = node.data?.inspector;
|
|
1353
1388
|
const values = inspector
|
|
1354
1389
|
? [
|
|
@@ -1369,7 +1404,26 @@ function matchesFilter(node, state, path, query) {
|
|
|
1369
1404
|
state.status,
|
|
1370
1405
|
state.value,
|
|
1371
1406
|
];
|
|
1372
|
-
return values.some((value) =>
|
|
1407
|
+
return values.some((value) => matchesSearch(normalizeFilterValue(value, options), query, options.wholeWord));
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function normalizeFilterValue(value, options = {}) {
|
|
1411
|
+
const text = String(value ?? '');
|
|
1412
|
+
return options.caseSensitive ? text : text.toLowerCase();
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function matchesSearch(text, query, wholeWord = false) {
|
|
1416
|
+
if (!wholeWord) return text.includes(query);
|
|
1417
|
+
let index = text.indexOf(query);
|
|
1418
|
+
while (index !== -1) {
|
|
1419
|
+
if (!isWordChar(text[index - 1]) && !isWordChar(text[index + query.length])) return true;
|
|
1420
|
+
index = text.indexOf(query, index + query.length);
|
|
1421
|
+
}
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function isWordChar(char) {
|
|
1426
|
+
return typeof char === 'string' && /[\p{L}\p{N}_]/u.test(char);
|
|
1373
1427
|
}
|
|
1374
1428
|
|
|
1375
1429
|
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
|
+
}
|