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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virtual-tree-canvas",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "High-performance Canvas2D virtual tree/table widget for very large hierarchical datasets.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -13,12 +13,12 @@ export class TreeSearchIndex {
13
13
  const searchId = searchableNodeId(node);
14
14
  const record = {
15
15
  id: node.id,
16
- searchId: normalize(searchId),
17
- label: normalize(node.label ?? ''),
18
- path: normalize(path),
19
- tags: normalize((node.tags ?? []).join(' ')),
20
- type: normalize(node.type ?? ''),
21
- value: normalize(searchableNodeValue(node)),
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().toLowerCase();
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.includes(q)) results.push(record.id);
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).includes(q)) {
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 || normalize(record.id);
91
- return normalize(record[field] ?? '');
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 normalize([
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 normalize(`${record.searchId} ${record.label} ${record.path} ${record.tags} ${record.type}`);
117
+ return `${record.searchId} ${record.label} ${record.path} ${record.tags} ${record.type}`;
118
118
  }
119
119
 
120
- function normalize(value) {
121
- return String(value ?? '').toLowerCase();
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, { fields = ['label', 'id', 'path', 'tags', 'type'], limit = 500 } = {}) {
48
- const q = normalize(query);
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 ? record.searchText.includes(q) : fields.some((field) => searchFieldValue(record, field).includes(q));
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 query = normalize(options.filterQuery ?? '');
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 = normalize(query);
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).includes(q)) continue;
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 normalize(value) {
157
- return String(value ?? '').toLowerCase();
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 normalize([
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 normalize(`${record.searchId} ${record.label} ${record.path} ${record.tags} ${record.type}`);
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 normalize([
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().toLowerCase();
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
- const normalized = query.trim().toLowerCase();
299
- this.rowModel.setFilterPredicate(normalized ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', normalized) : null);
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, { limit: options.limit ?? 500, fields: options.fields });
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, { limit: options.limit ?? 500, fields: options.fields });
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 scrollX = this.viewport.scrollX;
896
- const scrollY = this.viewport.scrollY;
911
+ const scrollAnchor = this.#captureScrollAnchor();
897
912
  this.rowModel.rebuild();
898
913
  this.#syncContentSize();
899
- this.viewport.scrollTo(scrollX, scrollY);
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 scrollX = this.viewport.scrollX;
1153
- const scrollY = this.viewport.scrollY;
1168
+ const scrollAnchor = this.#captureScrollAnchor();
1154
1169
  this.rowModel.applyRows(result);
1155
1170
  this.#syncContentSize();
1156
- this.viewport.scrollTo(scrollX, scrollY);
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) => String(value ?? '').toLowerCase().includes(query));
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 query = String(payload.filterQuery ?? '').trim().toLowerCase();
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
- let cached = filterCache.get(query);
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(query, cached);
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 [cachedQuery, cached] of filterCache) {
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 = { query: cachedQuery, ...cached };
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
+ }