@difizen/libro-search 0.0.2-alpha.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/LICENSE +21 -0
- package/README.md +1 -0
- package/es/abstract-search-provider.d.ts +113 -0
- package/es/abstract-search-provider.d.ts.map +1 -0
- package/es/abstract-search-provider.js +126 -0
- package/es/index.d.ts +10 -0
- package/es/index.d.ts.map +1 -0
- package/es/index.js +9 -0
- package/es/index.less +107 -0
- package/es/libro-cell-search-provider.d.ts +10 -0
- package/es/libro-cell-search-provider.d.ts.map +1 -0
- package/es/libro-cell-search-provider.js +54 -0
- package/es/libro-search-engine-html.d.ts +3 -0
- package/es/libro-search-engine-html.d.ts.map +1 -0
- package/es/libro-search-engine-html.js +59 -0
- package/es/libro-search-engine-text.d.ts +10 -0
- package/es/libro-search-engine-text.d.ts.map +1 -0
- package/es/libro-search-engine-text.js +32 -0
- package/es/libro-search-generic-provider.d.ts +107 -0
- package/es/libro-search-generic-provider.d.ts.map +1 -0
- package/es/libro-search-generic-provider.js +467 -0
- package/es/libro-search-manager.d.ts +22 -0
- package/es/libro-search-manager.d.ts.map +1 -0
- package/es/libro-search-manager.js +102 -0
- package/es/libro-search-model.d.ts +111 -0
- package/es/libro-search-model.d.ts.map +1 -0
- package/es/libro-search-model.js +395 -0
- package/es/libro-search-protocol.d.ts +176 -0
- package/es/libro-search-protocol.d.ts.map +1 -0
- package/es/libro-search-protocol.js +36 -0
- package/es/libro-search-provider.d.ts +138 -0
- package/es/libro-search-provider.d.ts.map +1 -0
- package/es/libro-search-provider.js +759 -0
- package/es/libro-search-utils.d.ts +25 -0
- package/es/libro-search-utils.d.ts.map +1 -0
- package/es/libro-search-utils.js +85 -0
- package/es/libro-search-view.d.ts +59 -0
- package/es/libro-search-view.d.ts.map +1 -0
- package/es/libro-search-view.js +455 -0
- package/es/module.d.ts +4 -0
- package/es/module.d.ts.map +1 -0
- package/es/module.js +35 -0
- package/package.json +66 -0
- package/src/abstract-search-provider.ts +160 -0
- package/src/index.less +107 -0
- package/src/index.ts +9 -0
- package/src/libro-cell-search-provider.ts +39 -0
- package/src/libro-search-engine-html.ts +74 -0
- package/src/libro-search-engine-text.ts +34 -0
- package/src/libro-search-generic-provider.ts +303 -0
- package/src/libro-search-manager.ts +86 -0
- package/src/libro-search-model.ts +266 -0
- package/src/libro-search-protocol.ts +209 -0
- package/src/libro-search-provider.ts +507 -0
- package/src/libro-search-utils.spec.ts +37 -0
- package/src/libro-search-utils.ts +83 -0
- package/src/libro-search-view.tsx +404 -0
- package/src/module.ts +59 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-loop-func */
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
3
|
+
import type { View } from '@difizen/mana-app';
|
|
4
|
+
import { prop } from '@difizen/mana-app';
|
|
5
|
+
import { inject, transient } from '@difizen/mana-app';
|
|
6
|
+
|
|
7
|
+
import { AbstractSearchProvider } from './abstract-search-provider.js';
|
|
8
|
+
import { searchInHTML } from './libro-search-engine-html.js';
|
|
9
|
+
import type { HTMLSearchMatch } from './libro-search-protocol.js';
|
|
10
|
+
import {
|
|
11
|
+
LIBRO_SEARCH_FOUND_CLASSES,
|
|
12
|
+
LIBRO_SEARCH_SELECTED_CLASSES,
|
|
13
|
+
SearchProviderOption,
|
|
14
|
+
} from './libro-search-protocol.js';
|
|
15
|
+
|
|
16
|
+
export type GenericSearchProviderFactory = (
|
|
17
|
+
option: SearchProviderOption,
|
|
18
|
+
) => GenericSearchProvider;
|
|
19
|
+
export const GenericSearchProviderFactory = Symbol('GenericSearchProviderFactory');
|
|
20
|
+
/**
|
|
21
|
+
* Generic DOM tree search provider.
|
|
22
|
+
*/
|
|
23
|
+
@transient()
|
|
24
|
+
export class GenericSearchProvider extends AbstractSearchProvider {
|
|
25
|
+
protected _query: RegExp | null;
|
|
26
|
+
protected _currentMatchIndex: number;
|
|
27
|
+
@prop() protected _matches: HTMLSearchMatch[] = [];
|
|
28
|
+
protected _mutationObserver: MutationObserver = new MutationObserver(
|
|
29
|
+
this._onWidgetChanged.bind(this),
|
|
30
|
+
);
|
|
31
|
+
protected _markNodes = new Array<HTMLSpanElement>();
|
|
32
|
+
/**
|
|
33
|
+
* Report whether or not this provider has the ability to search on the given object
|
|
34
|
+
*/
|
|
35
|
+
static isApplicable(domain: View): boolean {
|
|
36
|
+
return !!domain.container?.current;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The current index of the selected match.
|
|
41
|
+
*/
|
|
42
|
+
override get currentMatchIndex(): number | undefined {
|
|
43
|
+
return this._currentMatchIndex >= 0 ? this._currentMatchIndex : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The current match
|
|
48
|
+
*/
|
|
49
|
+
get currentMatch(): HTMLSearchMatch | undefined {
|
|
50
|
+
return this._matches[this._currentMatchIndex] ?? undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The current matches
|
|
55
|
+
*/
|
|
56
|
+
get matches(): HTMLSearchMatch[] {
|
|
57
|
+
// Ensure that no other fn can overwrite matches index property
|
|
58
|
+
// We shallow clone each node
|
|
59
|
+
return this._matches
|
|
60
|
+
? this._matches.map((m) => Object.assign({}, m))
|
|
61
|
+
: this._matches;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The number of matches.
|
|
66
|
+
*/
|
|
67
|
+
override get matchesCount(): number | undefined {
|
|
68
|
+
return this._matches.length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Set to true if the widget under search is read-only, false
|
|
73
|
+
* if it is editable. Will be used to determine whether to show
|
|
74
|
+
* the replace option.
|
|
75
|
+
*/
|
|
76
|
+
readonly isReadOnly = true;
|
|
77
|
+
|
|
78
|
+
constructor(@inject(SearchProviderOption) option: SearchProviderOption) {
|
|
79
|
+
super(option);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Clear currently highlighted match.
|
|
83
|
+
*/
|
|
84
|
+
clearHighlight(): Promise<void> {
|
|
85
|
+
if (this._currentMatchIndex >= 0) {
|
|
86
|
+
const hit = this._markNodes[this._currentMatchIndex];
|
|
87
|
+
hit.classList.remove(...LIBRO_SEARCH_SELECTED_CLASSES);
|
|
88
|
+
}
|
|
89
|
+
this._currentMatchIndex = -1;
|
|
90
|
+
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Dispose of the resources held by the search provider.
|
|
96
|
+
*
|
|
97
|
+
* #### Notes
|
|
98
|
+
* If the object's `dispose` method is called more than once, all
|
|
99
|
+
* calls made after the first will be a no-op.
|
|
100
|
+
*
|
|
101
|
+
* #### Undefined Behavior
|
|
102
|
+
* It is undefined behavior to use any functionality of the object
|
|
103
|
+
* after it has been disposed unless otherwise explicitly noted.
|
|
104
|
+
*/
|
|
105
|
+
override dispose(): void {
|
|
106
|
+
if (this.isDisposed) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.endQuery().catch((reason) => {
|
|
111
|
+
console.error(`Failed to end search query.`, reason);
|
|
112
|
+
});
|
|
113
|
+
super.dispose();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Move the current match indicator to the next match.
|
|
118
|
+
*
|
|
119
|
+
* @param loop Whether to loop within the matches list.
|
|
120
|
+
*
|
|
121
|
+
* @returns A promise that resolves once the action has completed.
|
|
122
|
+
*/
|
|
123
|
+
async highlightNext(loop?: boolean): Promise<HTMLSearchMatch | undefined> {
|
|
124
|
+
return this._highlightNext(false, loop ?? true) ?? undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Move the current match indicator to the previous match.
|
|
129
|
+
*
|
|
130
|
+
* @param loop Whether to loop within the matches list.
|
|
131
|
+
*
|
|
132
|
+
* @returns A promise that resolves once the action has completed.
|
|
133
|
+
*/
|
|
134
|
+
async highlightPrevious(loop?: boolean): Promise<HTMLSearchMatch | undefined> {
|
|
135
|
+
return this._highlightNext(true, loop ?? true) ?? undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Replace the currently selected match with the provided text
|
|
140
|
+
*
|
|
141
|
+
* @param newText The replacement text
|
|
142
|
+
* @param loop Whether to loop within the matches list.
|
|
143
|
+
*
|
|
144
|
+
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
|
|
145
|
+
*/
|
|
146
|
+
async replaceCurrentMatch(_newText: string, _loop?: boolean): Promise<boolean> {
|
|
147
|
+
return Promise.resolve(false);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Replace all matches in the notebook with the provided text
|
|
152
|
+
*
|
|
153
|
+
* @param newText The replacement text
|
|
154
|
+
*
|
|
155
|
+
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
|
|
156
|
+
*/
|
|
157
|
+
async replaceAllMatches(_newText: string): Promise<boolean> {
|
|
158
|
+
// This is read only, but we could loosen this in theory for input boxes...
|
|
159
|
+
return Promise.resolve(false);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Initialize the search using the provided options. Should update the UI
|
|
164
|
+
* to highlight all matches and "select" whatever the first match should be.
|
|
165
|
+
*
|
|
166
|
+
* @param query A RegExp to be use to perform the search
|
|
167
|
+
* @param filters Filter parameters to pass to provider
|
|
168
|
+
*/
|
|
169
|
+
startQuery = async (query: RegExp | null, _filters = {}): Promise<void> => {
|
|
170
|
+
await this.endQuery();
|
|
171
|
+
this._query = query;
|
|
172
|
+
|
|
173
|
+
if (query === null) {
|
|
174
|
+
return Promise.resolve();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const matches = this.view.container?.current
|
|
178
|
+
? await searchInHTML(query, this.view.container?.current)
|
|
179
|
+
: [];
|
|
180
|
+
|
|
181
|
+
// Transform the DOM
|
|
182
|
+
let nodeIdx = 0;
|
|
183
|
+
while (nodeIdx < matches.length) {
|
|
184
|
+
const activeNode = matches[nodeIdx].node;
|
|
185
|
+
const parent = activeNode.parentNode!;
|
|
186
|
+
|
|
187
|
+
const subMatches = [matches[nodeIdx]];
|
|
188
|
+
while (++nodeIdx < matches.length && matches[nodeIdx].node === activeNode) {
|
|
189
|
+
subMatches.unshift(matches[nodeIdx]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const markedNodes = subMatches.map((match) => {
|
|
193
|
+
// TODO: support tspan for svg when svg support is added
|
|
194
|
+
const markedNode = document.createElement('mark');
|
|
195
|
+
markedNode.classList.add(...LIBRO_SEARCH_FOUND_CLASSES);
|
|
196
|
+
markedNode.textContent = match.text;
|
|
197
|
+
|
|
198
|
+
const newNode = activeNode.splitText(match.position);
|
|
199
|
+
newNode.textContent = newNode.textContent!.slice(match.text.length);
|
|
200
|
+
parent.insertBefore(markedNode, newNode);
|
|
201
|
+
return markedNode;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Insert node in reverse order as we replace from last to first
|
|
205
|
+
// to maintain match position.
|
|
206
|
+
for (let i = markedNodes.length - 1; i >= 0; i--) {
|
|
207
|
+
this._markNodes.push(markedNodes[i]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (this.view.container?.current) {
|
|
211
|
+
// Watch for future changes:
|
|
212
|
+
this._mutationObserver.observe(
|
|
213
|
+
this.view.container?.current,
|
|
214
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit
|
|
215
|
+
{
|
|
216
|
+
attributes: false,
|
|
217
|
+
characterData: true,
|
|
218
|
+
childList: true,
|
|
219
|
+
subtree: true,
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
this._matches = matches;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Clear the highlighted matches and any internal state.
|
|
228
|
+
*/
|
|
229
|
+
async endQuery(): Promise<void> {
|
|
230
|
+
this._mutationObserver.disconnect();
|
|
231
|
+
this._markNodes.forEach((el) => {
|
|
232
|
+
const parent = el.parentNode!;
|
|
233
|
+
parent.replaceChild(document.createTextNode(el.textContent!), el);
|
|
234
|
+
parent.normalize();
|
|
235
|
+
});
|
|
236
|
+
this._markNodes = [];
|
|
237
|
+
this._matches = [];
|
|
238
|
+
this._currentMatchIndex = -1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
protected _highlightNext(reverse: boolean, loop: boolean): HTMLSearchMatch | null {
|
|
242
|
+
if (this._matches.length === 0) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
if (this._currentMatchIndex === -1) {
|
|
246
|
+
this._currentMatchIndex = reverse ? this.matches.length - 1 : 0;
|
|
247
|
+
} else {
|
|
248
|
+
const hit = this._markNodes[this._currentMatchIndex];
|
|
249
|
+
hit.classList.remove(...LIBRO_SEARCH_SELECTED_CLASSES);
|
|
250
|
+
|
|
251
|
+
this._currentMatchIndex = reverse
|
|
252
|
+
? this._currentMatchIndex - 1
|
|
253
|
+
: this._currentMatchIndex + 1;
|
|
254
|
+
if (
|
|
255
|
+
loop &&
|
|
256
|
+
(this._currentMatchIndex < 0 || this._currentMatchIndex >= this._matches.length)
|
|
257
|
+
) {
|
|
258
|
+
// Cheap way to make this a circular buffer
|
|
259
|
+
this._currentMatchIndex =
|
|
260
|
+
(this._currentMatchIndex + this._matches.length) % this._matches.length;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
this._currentMatchIndex >= 0 &&
|
|
266
|
+
this._currentMatchIndex < this._matches.length
|
|
267
|
+
) {
|
|
268
|
+
const hit = this._markNodes[this._currentMatchIndex];
|
|
269
|
+
hit.classList.add(...LIBRO_SEARCH_SELECTED_CLASSES);
|
|
270
|
+
// If not in view, scroll just enough to see it
|
|
271
|
+
if (!elementInViewport(hit)) {
|
|
272
|
+
hit.scrollIntoView(reverse);
|
|
273
|
+
}
|
|
274
|
+
hit.focus();
|
|
275
|
+
|
|
276
|
+
return this._matches[this._currentMatchIndex];
|
|
277
|
+
} else {
|
|
278
|
+
this._currentMatchIndex = -1;
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
protected async _onWidgetChanged(
|
|
284
|
+
_mutations: MutationRecord[],
|
|
285
|
+
_observer: MutationObserver,
|
|
286
|
+
) {
|
|
287
|
+
this._currentMatchIndex = -1;
|
|
288
|
+
// This is typically cheap, but we do not control the rate of change or size of the output
|
|
289
|
+
await this.startQuery(this._query);
|
|
290
|
+
this._stateChanged.fire();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function elementInViewport(el: HTMLElement): boolean {
|
|
294
|
+
const boundingClientRect = el.getBoundingClientRect();
|
|
295
|
+
return (
|
|
296
|
+
boundingClientRect.top >= 0 &&
|
|
297
|
+
boundingClientRect.bottom <=
|
|
298
|
+
(window.innerHeight || document.documentElement.clientHeight) &&
|
|
299
|
+
boundingClientRect.left >= 0 &&
|
|
300
|
+
boundingClientRect.right <=
|
|
301
|
+
(window.innerWidth || document.documentElement.clientWidth)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
import {
|
|
3
|
+
LibroCommandRegister,
|
|
4
|
+
LibroExtensionSlotContribution,
|
|
5
|
+
} from '@difizen/libro-core';
|
|
6
|
+
import type {
|
|
7
|
+
LibroExtensionSlotFactory,
|
|
8
|
+
LibroSlot,
|
|
9
|
+
LibroView,
|
|
10
|
+
} from '@difizen/libro-core';
|
|
11
|
+
import type { CommandRegistry, KeybindingRegistry } from '@difizen/mana-app';
|
|
12
|
+
import {
|
|
13
|
+
ViewManager,
|
|
14
|
+
CommandContribution,
|
|
15
|
+
KeybindingContribution,
|
|
16
|
+
} from '@difizen/mana-app';
|
|
17
|
+
import { inject, singleton } from '@difizen/mana-app';
|
|
18
|
+
|
|
19
|
+
import { LibroSearchView } from './libro-search-view.js';
|
|
20
|
+
|
|
21
|
+
export const LibroSearchToggleCommand = {
|
|
22
|
+
ShowLibroSearch: {
|
|
23
|
+
id: 'libro-search:toggle',
|
|
24
|
+
keybind: 'ctrlcmd+F',
|
|
25
|
+
},
|
|
26
|
+
// LibroSearchToggle: {
|
|
27
|
+
// id: 'libro-search:toogle',
|
|
28
|
+
// keybind: 'ctrlcmd+F',
|
|
29
|
+
// },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
@singleton({
|
|
33
|
+
contrib: [
|
|
34
|
+
CommandContribution,
|
|
35
|
+
KeybindingContribution,
|
|
36
|
+
LibroExtensionSlotContribution,
|
|
37
|
+
],
|
|
38
|
+
})
|
|
39
|
+
export class LibroSearchManager
|
|
40
|
+
implements
|
|
41
|
+
CommandContribution,
|
|
42
|
+
KeybindingContribution,
|
|
43
|
+
LibroExtensionSlotContribution
|
|
44
|
+
{
|
|
45
|
+
@inject(ViewManager) viewManager: ViewManager;
|
|
46
|
+
@inject(LibroCommandRegister) libroCommandRegister: LibroCommandRegister;
|
|
47
|
+
protected viewMap: Map<string, LibroSearchView> = new Map();
|
|
48
|
+
|
|
49
|
+
public readonly slot: LibroSlot = 'container';
|
|
50
|
+
registerKeybindings(keybindings: KeybindingRegistry): void {
|
|
51
|
+
this.libroCommandRegister.registerKeybinds(
|
|
52
|
+
keybindings,
|
|
53
|
+
LibroSearchToggleCommand,
|
|
54
|
+
false,
|
|
55
|
+
false,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
registerCommands(commands: CommandRegistry) {
|
|
59
|
+
this.libroCommandRegister.registerLibroCommand(
|
|
60
|
+
commands,
|
|
61
|
+
LibroSearchToggleCommand.ShowLibroSearch,
|
|
62
|
+
{
|
|
63
|
+
execute: (_cell, libro, _position) => {
|
|
64
|
+
if (libro) {
|
|
65
|
+
this.showSearchView(libro);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
factory: LibroExtensionSlotFactory = async (libro: LibroView) => {
|
|
72
|
+
const view = await this.viewManager.getOrCreateView(LibroSearchView, {
|
|
73
|
+
parentId: libro.id,
|
|
74
|
+
});
|
|
75
|
+
view.libro = libro;
|
|
76
|
+
this.viewMap.set(libro.id, view);
|
|
77
|
+
view.onDisposed(() => {
|
|
78
|
+
this.viewMap.delete(libro.id);
|
|
79
|
+
});
|
|
80
|
+
return view;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
showSearchView = (libro: LibroView) => {
|
|
84
|
+
this.viewMap.get(libro.id)?.show();
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { Disposable } from '@difizen/mana-app';
|
|
2
|
+
import { DisposableCollection, Emitter } from '@difizen/mana-app';
|
|
3
|
+
import { inject, singleton } from '@difizen/mana-app';
|
|
4
|
+
import debounce from 'lodash.debounce';
|
|
5
|
+
|
|
6
|
+
import type { SearchProvider } from './libro-search-protocol.js';
|
|
7
|
+
import type { SearchFilter, SearchFilters } from './libro-search-protocol.js';
|
|
8
|
+
import { LibroSearchUtils } from './libro-search-utils.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Search in a document model.
|
|
12
|
+
*/
|
|
13
|
+
@singleton()
|
|
14
|
+
export class LibroSearchModel implements Disposable {
|
|
15
|
+
utils: LibroSearchUtils;
|
|
16
|
+
protected _disposed?: boolean = false;
|
|
17
|
+
protected _caseSensitive = false;
|
|
18
|
+
protected parsingError = '';
|
|
19
|
+
protected _filters: SearchFilters = {
|
|
20
|
+
searchCellOutput: true,
|
|
21
|
+
onlySearchSelectedCells: false,
|
|
22
|
+
};
|
|
23
|
+
protected _replaceText = '';
|
|
24
|
+
protected searchDebouncer: any;
|
|
25
|
+
protected _searchExpression = '';
|
|
26
|
+
protected _useRegex = false;
|
|
27
|
+
protected disposedEmitter = new Emitter<void>();
|
|
28
|
+
protected searchProvider: SearchProvider;
|
|
29
|
+
protected toDispose = new DisposableCollection();
|
|
30
|
+
get onDisposed() {
|
|
31
|
+
return this.disposedEmitter.event;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get disposed() {
|
|
35
|
+
return !!this._disposed;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Search document model
|
|
39
|
+
* @param searchProvider Provider for the current document
|
|
40
|
+
* @param searchDebounceTime Debounce search time
|
|
41
|
+
*/
|
|
42
|
+
constructor(
|
|
43
|
+
@inject(LibroSearchUtils) utils: LibroSearchUtils,
|
|
44
|
+
searchProvider: SearchProvider,
|
|
45
|
+
searchDebounceTime: number,
|
|
46
|
+
) {
|
|
47
|
+
this.utils = utils;
|
|
48
|
+
this.searchProvider = searchProvider;
|
|
49
|
+
// this._filters = {};
|
|
50
|
+
// if (this.searchProvider.getFilters) {
|
|
51
|
+
// const filters = this.searchProvider.getFilters();
|
|
52
|
+
// for (const filter in filters) {
|
|
53
|
+
// this._filters[filter] = filters[filter].default;
|
|
54
|
+
// }
|
|
55
|
+
// }
|
|
56
|
+
|
|
57
|
+
this.toDispose.push(searchProvider.stateChanged(this.refresh));
|
|
58
|
+
|
|
59
|
+
this.searchDebouncer = debounce(() => {
|
|
60
|
+
this.updateSearch().catch((reason) => {
|
|
61
|
+
console.error('Failed to update search on document.', reason);
|
|
62
|
+
});
|
|
63
|
+
}, searchDebounceTime);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the search is case sensitive or not.
|
|
68
|
+
*/
|
|
69
|
+
get caseSensitive(): boolean {
|
|
70
|
+
return this._caseSensitive;
|
|
71
|
+
}
|
|
72
|
+
set caseSensitive(v: boolean) {
|
|
73
|
+
if (this._caseSensitive !== v) {
|
|
74
|
+
this._caseSensitive = v;
|
|
75
|
+
this.refresh();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Current highlighted match index.
|
|
81
|
+
*/
|
|
82
|
+
get currentIndex(): number | undefined {
|
|
83
|
+
return this.searchProvider.currentMatchIndex;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Filter values.
|
|
88
|
+
*/
|
|
89
|
+
get filters(): SearchFilters {
|
|
90
|
+
return this._filters;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Filter definitions for the current provider.
|
|
95
|
+
*/
|
|
96
|
+
get filtersDefinition(): Record<string, SearchFilter> {
|
|
97
|
+
return this.searchProvider.getFilters?.() ?? {};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The initial query string.
|
|
102
|
+
*/
|
|
103
|
+
get initialQuery(): string {
|
|
104
|
+
if (!this.searchProvider.getInitialQuery) {
|
|
105
|
+
return this._searchExpression;
|
|
106
|
+
}
|
|
107
|
+
return this._searchExpression || this.searchProvider.getInitialQuery();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether the document is read-only or not.
|
|
112
|
+
*/
|
|
113
|
+
get isReadOnly(): boolean {
|
|
114
|
+
return this.searchProvider.isReadOnly;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Replacement expression
|
|
119
|
+
*/
|
|
120
|
+
get replaceText(): string {
|
|
121
|
+
return this._replaceText;
|
|
122
|
+
}
|
|
123
|
+
set replaceText(v: string) {
|
|
124
|
+
if (this._replaceText !== v) {
|
|
125
|
+
this._replaceText = v;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Search expression
|
|
131
|
+
*/
|
|
132
|
+
get searchExpression(): string {
|
|
133
|
+
return this._searchExpression;
|
|
134
|
+
}
|
|
135
|
+
set searchExpression(v: string) {
|
|
136
|
+
if (this._searchExpression !== v) {
|
|
137
|
+
this._searchExpression = v;
|
|
138
|
+
this.refresh();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Total number of matches.
|
|
144
|
+
*/
|
|
145
|
+
get totalMatches(): number | undefined {
|
|
146
|
+
return this.searchProvider.matchesCount;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether to use regular expression or not.
|
|
151
|
+
*/
|
|
152
|
+
get useRegex(): boolean {
|
|
153
|
+
return this._useRegex;
|
|
154
|
+
}
|
|
155
|
+
set useRegex(v: boolean) {
|
|
156
|
+
if (this._useRegex !== v) {
|
|
157
|
+
this._useRegex = v;
|
|
158
|
+
this.refresh();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Dispose the model.
|
|
164
|
+
*/
|
|
165
|
+
dispose(): void {
|
|
166
|
+
if (this.disposed) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (this._searchExpression) {
|
|
170
|
+
this.endQuery().catch((reason) => {
|
|
171
|
+
console.error(`Failed to end query '${this._searchExpression}.`, reason);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
this.toDispose.dispose();
|
|
175
|
+
this.searchDebouncer.dispose();
|
|
176
|
+
this._disposed = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* End the query.
|
|
181
|
+
*/
|
|
182
|
+
async endQuery(): Promise<void> {
|
|
183
|
+
await this.searchProvider.endQuery();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Highlight the next match.
|
|
188
|
+
*/
|
|
189
|
+
async highlightNext(): Promise<void> {
|
|
190
|
+
await this.searchProvider.highlightNext();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Highlight the previous match
|
|
195
|
+
*/
|
|
196
|
+
async highlightPrevious(): Promise<void> {
|
|
197
|
+
await this.searchProvider.highlightPrevious();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Refresh search
|
|
202
|
+
*/
|
|
203
|
+
refresh(): void {
|
|
204
|
+
this.searchDebouncer.invoke().catch((reason: any) => {
|
|
205
|
+
console.error('Failed to invoke search document debouncer.', reason);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Replace all matches.
|
|
211
|
+
*/
|
|
212
|
+
async replaceAllMatches(): Promise<void> {
|
|
213
|
+
await this.searchProvider.replaceAllMatches(this._replaceText);
|
|
214
|
+
// Emit state change as the index needs to be updated
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Replace the current match.
|
|
219
|
+
*/
|
|
220
|
+
async replaceCurrentMatch(): Promise<void> {
|
|
221
|
+
await this.searchProvider.replaceCurrentMatch(this._replaceText);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Set the value of a given filter.
|
|
226
|
+
*
|
|
227
|
+
* @param name Filter name
|
|
228
|
+
* @param v Filter value
|
|
229
|
+
*/
|
|
230
|
+
async setFilter(_name: string, _v: boolean): Promise<void> {
|
|
231
|
+
// if (this._filters[name] !== v) {
|
|
232
|
+
// if (this.searchProvider.validateFilter) {
|
|
233
|
+
// this._filters[name] = await this.searchProvider.validateFilter(name, v);
|
|
234
|
+
// // If the value was changed
|
|
235
|
+
// if (this._filters[name] === v) {
|
|
236
|
+
// this.refresh();
|
|
237
|
+
// }
|
|
238
|
+
// } else {
|
|
239
|
+
// this._filters[name] = v;
|
|
240
|
+
// this.refresh();
|
|
241
|
+
// }
|
|
242
|
+
// }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
protected async updateSearch(): Promise<void> {
|
|
246
|
+
if (this.parsingError) {
|
|
247
|
+
this.parsingError = '';
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const query = this.searchExpression
|
|
251
|
+
? this.utils.parseQuery(
|
|
252
|
+
this.searchExpression,
|
|
253
|
+
this.caseSensitive,
|
|
254
|
+
this.useRegex,
|
|
255
|
+
)
|
|
256
|
+
: null;
|
|
257
|
+
if (query) {
|
|
258
|
+
await this.searchProvider.startQuery(query, this._filters);
|
|
259
|
+
// Emit state change as the index needs to be updated
|
|
260
|
+
}
|
|
261
|
+
} catch (reason: any) {
|
|
262
|
+
this.parsingError = reason.toString();
|
|
263
|
+
console.error(`Failed to parse expression ${this.searchExpression}`, reason);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|