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.
Files changed (36) hide show
  1. package/README.md +164 -225
  2. package/docs/inspector-demo.png +0 -0
  3. package/docs/tree-demo.png +0 -0
  4. package/package.json +2 -1
  5. package/src/assets/icons/air.svg +1 -0
  6. package/src/assets/icons/aircraft.svg +1 -0
  7. package/src/assets/icons/bus.svg +1 -0
  8. package/src/assets/icons/control.svg +1 -0
  9. package/src/assets/icons/damage.svg +1 -0
  10. package/src/assets/icons/error.svg +1 -0
  11. package/src/assets/icons/folder.svg +1 -0
  12. package/src/assets/icons/ground.svg +1 -0
  13. package/src/assets/icons/inspector-array.svg +1 -0
  14. package/src/assets/icons/inspector-object.svg +1 -0
  15. package/src/assets/icons/inspector-value.svg +1 -0
  16. package/src/assets/icons/munition.svg +1 -0
  17. package/src/assets/icons/placeholder.svg +1 -0
  18. package/src/assets/icons/point.svg +1 -0
  19. package/src/assets/icons/radar.svg +1 -0
  20. package/src/assets/icons/situation.svg +1 -0
  21. package/src/assets/icons/space.svg +1 -0
  22. package/src/assets/icons/subsurface.svg +1 -0
  23. package/src/assets/icons/surface.svg +1 -0
  24. package/src/assets/icons/task.svg +1 -0
  25. package/src/assets/icons/track.svg +1 -0
  26. package/src/assets/icons/warning.svg +1 -0
  27. package/src/assets/icons.js +25 -0
  28. package/src/core/icon-registry.js +173 -495
  29. package/src/core/search-index.js +33 -18
  30. package/src/core/tree-worker-client.js +17 -0
  31. package/src/core/tree-worker-operations.js +39 -20
  32. package/src/core/visible-row-model.js +39 -0
  33. package/src/index.d.ts +15 -2
  34. package/src/renderers/tree-row-renderer.js +64 -0
  35. package/src/tree-view-controller.js +59 -16
  36. package/src/workers/tree-worker.js +22 -8
@@ -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
  }
@@ -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, { 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
@@ -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
- this.workerClient = new TreeWorkerClient(workerUrl);
215
- return this.workerClient.setData(this.model.nodes).then(() => this);
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().toLowerCase();
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
- const normalized = query.trim().toLowerCase();
299
- this.rowModel.setFilterPredicate(normalized ? (node, state) => matchesFilter(node, state, this.model.index.pathById.get(node.id) ?? '', normalized) : null);
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, { limit: options.limit ?? 500, fields: options.fields });
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, { limit: options.limit ?? 500, fields: options.fields });
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) => String(value ?? '').toLowerCase().includes(query));
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 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
+ }