@difizen/libro-search-code-cell 0.1.2
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 +3 -0
- package/es/code-cell-search-protocol.d.ts +26 -0
- package/es/code-cell-search-protocol.d.ts.map +1 -0
- package/es/code-cell-search-protocol.js +3 -0
- package/es/code-cell-search-provider-contribution.d.ts +18 -0
- package/es/code-cell-search-provider-contribution.d.ts.map +1 -0
- package/es/code-cell-search-provider-contribution.js +64 -0
- package/es/code-cell-search-provider.d.ts +52 -0
- package/es/code-cell-search-provider.d.ts.map +1 -0
- package/es/code-cell-search-provider.js +423 -0
- package/es/code-editor-cell-search-provider.d.ts +138 -0
- package/es/code-editor-cell-search-provider.d.ts.map +1 -0
- package/es/code-editor-cell-search-provider.js +563 -0
- package/es/index.d.ts +5 -0
- package/es/index.d.ts.map +1 -0
- package/es/index.js +4 -0
- package/es/module.d.ts +3 -0
- package/es/module.d.ts.map +1 -0
- package/es/module.js +30 -0
- package/es/search-highlighter.d.ts +58 -0
- package/es/search-highlighter.d.ts.map +1 -0
- package/es/search-highlighter.js +241 -0
- package/package.json +57 -0
- package/src/code-cell-search-protocol.ts +40 -0
- package/src/code-cell-search-provider-contribution.ts +39 -0
- package/src/code-cell-search-provider.ts +227 -0
- package/src/code-editor-cell-search-provider.ts +377 -0
- package/src/index.spec.ts +10 -0
- package/src/index.ts +4 -0
- package/src/module.ts +46 -0
- package/src/search-highlighter.ts +207 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import type { LibroCodeCellView } from '@difizen/libro-code-cell';
|
|
2
|
+
import type { IPosition, SearchMatch } from '@difizen/libro-code-editor';
|
|
3
|
+
import type { BaseSearchProvider, SearchFilters } from '@difizen/libro-search';
|
|
4
|
+
import { searchText } from '@difizen/libro-search';
|
|
5
|
+
import type { Event } from '@difizen/mana-app';
|
|
6
|
+
import { prop } from '@difizen/mana-app';
|
|
7
|
+
import { DisposableCollection, Emitter } from '@difizen/mana-app';
|
|
8
|
+
import { watch } from '@difizen/mana-app';
|
|
9
|
+
import { inject, transient } from '@difizen/mana-app';
|
|
10
|
+
|
|
11
|
+
import type { CodeEditorSearchHighlighter } from './code-cell-search-protocol.js';
|
|
12
|
+
import { CodeEditorSearchHighlighterFactory } from './code-cell-search-protocol.js';
|
|
13
|
+
/**
|
|
14
|
+
* Search provider for cells.
|
|
15
|
+
*/
|
|
16
|
+
@transient()
|
|
17
|
+
export class CodeEditorCellSearchProvider implements BaseSearchProvider {
|
|
18
|
+
protected toDispose = new DisposableCollection();
|
|
19
|
+
/**
|
|
20
|
+
* CodeMirror search highlighter
|
|
21
|
+
*/
|
|
22
|
+
@prop() protected editorHighlighter: CodeEditorSearchHighlighter;
|
|
23
|
+
/**
|
|
24
|
+
* Current match index
|
|
25
|
+
*/
|
|
26
|
+
@prop() protected currentIndex: number | undefined = undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Current search filters
|
|
29
|
+
*/
|
|
30
|
+
@prop() protected filters: SearchFilters | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Current search query
|
|
33
|
+
*/
|
|
34
|
+
protected query: RegExp | null = null;
|
|
35
|
+
// Needs to be protected so subclass can emit the signal too.
|
|
36
|
+
protected stateChangedEmitter: Emitter<void>;
|
|
37
|
+
protected _isActive = true;
|
|
38
|
+
protected _isDisposed = false;
|
|
39
|
+
protected lastReplacementPosition: IPosition | null = null;
|
|
40
|
+
protected highlighterFactory: CodeEditorSearchHighlighterFactory;
|
|
41
|
+
protected cell: LibroCodeCellView;
|
|
42
|
+
/**
|
|
43
|
+
* Constructor
|
|
44
|
+
*
|
|
45
|
+
* @param cell Cell widget
|
|
46
|
+
*/
|
|
47
|
+
constructor(
|
|
48
|
+
@inject(CodeEditorSearchHighlighterFactory)
|
|
49
|
+
highlighterFactory: CodeEditorSearchHighlighterFactory,
|
|
50
|
+
cell: LibroCodeCellView,
|
|
51
|
+
) {
|
|
52
|
+
this.cell = cell;
|
|
53
|
+
this.highlighterFactory = highlighterFactory;
|
|
54
|
+
this.currentIndex = undefined;
|
|
55
|
+
this.stateChangedEmitter = new Emitter<void>();
|
|
56
|
+
this.toDispose.push(this.stateChangedEmitter);
|
|
57
|
+
this.editorHighlighter = this.highlighterFactory(this.cell.editor);
|
|
58
|
+
|
|
59
|
+
this.toDispose.push(watch(this.cell.model, 'value', this.updateMatches));
|
|
60
|
+
this.toDispose.push(
|
|
61
|
+
watch(this.cell, 'editor', async () => {
|
|
62
|
+
await this.cell.editorReady;
|
|
63
|
+
if (this.cell.hasInputHidden === true) {
|
|
64
|
+
this.endQuery();
|
|
65
|
+
} else {
|
|
66
|
+
this.startQuery(this.query, this.filters);
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get an initial query value if applicable so that it can be entered
|
|
74
|
+
* into the search box as an initial query
|
|
75
|
+
*
|
|
76
|
+
* @returns Initial value used to populate the search box.
|
|
77
|
+
*/
|
|
78
|
+
getInitialQuery(): string {
|
|
79
|
+
const selection = this.cell?.editor?.getSelectionValue();
|
|
80
|
+
// if there are newlines, just return empty string
|
|
81
|
+
return selection?.search(/\r?\n|\r/g) === -1 ? selection : '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get disposed() {
|
|
85
|
+
return this._isDisposed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
protected async setEditor() {
|
|
89
|
+
await this.cell.editorReady;
|
|
90
|
+
if (this.cell.editor) {
|
|
91
|
+
this.editorHighlighter.setEditor(this.cell.editor);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Changed signal to be emitted when search matches change.
|
|
97
|
+
*/
|
|
98
|
+
get stateChanged(): Event<void> {
|
|
99
|
+
return this.stateChangedEmitter.event;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Current match index
|
|
104
|
+
*/
|
|
105
|
+
get currentMatchIndex(): number | undefined {
|
|
106
|
+
return this.isActive ? this.currentIndex : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether the cell search is active.
|
|
111
|
+
*
|
|
112
|
+
* This is used when applying search only on selected cells.
|
|
113
|
+
*/
|
|
114
|
+
get isActive(): boolean {
|
|
115
|
+
return this._isActive;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether the search provider is disposed or not.
|
|
120
|
+
*/
|
|
121
|
+
get isDisposed(): boolean {
|
|
122
|
+
return this._isDisposed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Number of matches in the cell.
|
|
127
|
+
*/
|
|
128
|
+
get matchesCount(): number {
|
|
129
|
+
return this.isActive ? this.editorHighlighter.matches.length : 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get isCellSelected(): boolean {
|
|
133
|
+
return this.cell.parent.isSelected(this.cell);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Clear currently highlighted match
|
|
138
|
+
*/
|
|
139
|
+
clearHighlight(): Promise<void> {
|
|
140
|
+
this.currentIndex = undefined;
|
|
141
|
+
this.editorHighlighter.clearHighlight();
|
|
142
|
+
|
|
143
|
+
return Promise.resolve();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Dispose the search provider
|
|
148
|
+
*/
|
|
149
|
+
dispose(): void {
|
|
150
|
+
if (this.isDisposed) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.toDispose.dispose();
|
|
154
|
+
this._isDisposed = true;
|
|
155
|
+
if (this.isActive) {
|
|
156
|
+
this.endQuery().catch((reason) => {
|
|
157
|
+
console.error(`Failed to end search query on cells.`, reason);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Set `isActive` status.
|
|
164
|
+
*
|
|
165
|
+
* #### Notes
|
|
166
|
+
* It will start or end the search
|
|
167
|
+
*
|
|
168
|
+
* @param v New value
|
|
169
|
+
*/
|
|
170
|
+
async setIsActive(v: boolean): Promise<void> {
|
|
171
|
+
if (this.isActive !== v) {
|
|
172
|
+
this._isActive = v;
|
|
173
|
+
}
|
|
174
|
+
if (this.isActive) {
|
|
175
|
+
if (this.query !== null) {
|
|
176
|
+
await this.startQuery(this.query, this.filters);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
await this.endQuery();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Initialize the search using the provided options. Should update the UI
|
|
185
|
+
* to highlight all matches and "select" the first match.
|
|
186
|
+
*
|
|
187
|
+
* @param query A RegExp to be use to perform the search
|
|
188
|
+
* @param filters Filter parameters to pass to provider
|
|
189
|
+
*/
|
|
190
|
+
async startQuery(query: RegExp | null, filters?: SearchFilters): Promise<void> {
|
|
191
|
+
this.query = query;
|
|
192
|
+
this.filters = filters;
|
|
193
|
+
if (this.cell.hasInputHidden) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
await this.setEditor();
|
|
197
|
+
// Search input
|
|
198
|
+
await this.updateMatches();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Stop the search and clean any UI elements.
|
|
203
|
+
*/
|
|
204
|
+
async endQuery(): Promise<void> {
|
|
205
|
+
this.currentIndex = undefined;
|
|
206
|
+
this.query = null;
|
|
207
|
+
await this.editorHighlighter.endQuery();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Highlight the next match.
|
|
212
|
+
*
|
|
213
|
+
* @returns The next match if there is one.
|
|
214
|
+
*/
|
|
215
|
+
async highlightNext(): Promise<SearchMatch | undefined> {
|
|
216
|
+
if (this.matchesCount === 0 || !this.isActive) {
|
|
217
|
+
this.currentIndex = undefined;
|
|
218
|
+
} else {
|
|
219
|
+
if (this.lastReplacementPosition) {
|
|
220
|
+
this.cell.editor?.setCursorPosition(this.lastReplacementPosition);
|
|
221
|
+
this.lastReplacementPosition = null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// This starts from the cursor position
|
|
225
|
+
const match = await this.editorHighlighter.highlightNext();
|
|
226
|
+
|
|
227
|
+
if (match) {
|
|
228
|
+
this.currentIndex = this.editorHighlighter.currentIndex;
|
|
229
|
+
} else {
|
|
230
|
+
this.currentIndex = undefined;
|
|
231
|
+
}
|
|
232
|
+
return match;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return Promise.resolve(this.getCurrentMatch());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Highlight the previous match.
|
|
240
|
+
*
|
|
241
|
+
* @returns The previous match if there is one.
|
|
242
|
+
*/
|
|
243
|
+
async highlightPrevious(): Promise<SearchMatch | undefined> {
|
|
244
|
+
if (this.matchesCount === 0 || !this.isActive) {
|
|
245
|
+
this.currentIndex = undefined;
|
|
246
|
+
} else {
|
|
247
|
+
// This starts from the cursor position
|
|
248
|
+
const match = await this.editorHighlighter.highlightPrevious();
|
|
249
|
+
if (match) {
|
|
250
|
+
this.currentIndex = this.editorHighlighter.currentIndex;
|
|
251
|
+
} else {
|
|
252
|
+
this.currentIndex = undefined;
|
|
253
|
+
}
|
|
254
|
+
return match;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return Promise.resolve(this.getCurrentMatch());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Replace the currently selected match with the provided text.
|
|
262
|
+
*
|
|
263
|
+
* If no match is selected, it won't do anything.
|
|
264
|
+
*
|
|
265
|
+
* @param newText The replacement text.
|
|
266
|
+
* @returns Whether a replace occurred.
|
|
267
|
+
*/
|
|
268
|
+
replaceCurrentMatch(newText: string): Promise<boolean> {
|
|
269
|
+
if (!this.isActive) {
|
|
270
|
+
return Promise.resolve(false);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let occurred = false;
|
|
274
|
+
|
|
275
|
+
if (
|
|
276
|
+
this.currentIndex !== undefined &&
|
|
277
|
+
this.currentIndex < this.editorHighlighter.matches.length
|
|
278
|
+
) {
|
|
279
|
+
const editor = this.cell.editor;
|
|
280
|
+
const selection = editor?.getSelectionValue();
|
|
281
|
+
const match = this.getCurrentMatch();
|
|
282
|
+
if (!match) {
|
|
283
|
+
return Promise.resolve(occurred);
|
|
284
|
+
}
|
|
285
|
+
// If cursor is not on a selection, highlight the next match
|
|
286
|
+
if (selection !== match?.text) {
|
|
287
|
+
this.currentIndex = undefined;
|
|
288
|
+
// The next will be highlighted as a consequence of this returning false
|
|
289
|
+
} else {
|
|
290
|
+
this.editorHighlighter.matches.splice(this.currentIndex, 1);
|
|
291
|
+
this.currentIndex = undefined;
|
|
292
|
+
// Store the current position to highlight properly the next search hit
|
|
293
|
+
this.lastReplacementPosition = editor?.getCursorPosition() ?? null;
|
|
294
|
+
editor?.replaceSelection(newText, {
|
|
295
|
+
start: editor.getPositionAt(match.position)!,
|
|
296
|
+
end: editor.getPositionAt(match.position + match.text.length)!,
|
|
297
|
+
});
|
|
298
|
+
occurred = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return Promise.resolve(occurred);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Replace all matches in the cell source with the provided text
|
|
306
|
+
*
|
|
307
|
+
* @param newText The replacement text.
|
|
308
|
+
* @returns Whether a replace occurred.
|
|
309
|
+
*/
|
|
310
|
+
replaceAllMatches = (newText: string): Promise<boolean> => {
|
|
311
|
+
if (!this.isActive) {
|
|
312
|
+
return Promise.resolve(false);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const occurred = this.editorHighlighter.matches.length > 0;
|
|
316
|
+
// const src = this.cell.model.value;
|
|
317
|
+
// let lastEnd = 0;
|
|
318
|
+
// const finalSrc = this.cmHandler.matches.reduce((agg, match) => {
|
|
319
|
+
// const start = match.position as number;
|
|
320
|
+
// const end = start + match.text.length;
|
|
321
|
+
// const newStep = `${agg}${src.slice(lastEnd, start)}${newText}`;
|
|
322
|
+
// lastEnd = end;
|
|
323
|
+
// return newStep;
|
|
324
|
+
// }, '');
|
|
325
|
+
|
|
326
|
+
const editor = this.cell.editor;
|
|
327
|
+
if (occurred && editor) {
|
|
328
|
+
const changes = this.editorHighlighter.matches.map((match) => ({
|
|
329
|
+
range: {
|
|
330
|
+
start: editor.getPositionAt(match.position)!,
|
|
331
|
+
end: editor.getPositionAt(match.position + match.text.length)!,
|
|
332
|
+
},
|
|
333
|
+
text: newText,
|
|
334
|
+
}));
|
|
335
|
+
editor?.replaceSelections(changes);
|
|
336
|
+
this.editorHighlighter.matches = [];
|
|
337
|
+
this.currentIndex = undefined;
|
|
338
|
+
// this.cell.model.setSource(`${finalSrc}${src.slice(lastEnd)}`);
|
|
339
|
+
}
|
|
340
|
+
return Promise.resolve(occurred);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get the current match if it exists.
|
|
345
|
+
*
|
|
346
|
+
* @returns The current match
|
|
347
|
+
*/
|
|
348
|
+
protected getCurrentMatch(): SearchMatch | undefined {
|
|
349
|
+
if (this.currentIndex === undefined) {
|
|
350
|
+
return undefined;
|
|
351
|
+
} else {
|
|
352
|
+
let match: SearchMatch | undefined = undefined;
|
|
353
|
+
if (this.currentIndex < this.editorHighlighter.matches.length) {
|
|
354
|
+
match = this.editorHighlighter.matches[this.currentIndex];
|
|
355
|
+
}
|
|
356
|
+
return match;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
protected updateMatches = async () => {
|
|
361
|
+
if (this.query !== null) {
|
|
362
|
+
if (this.isActive) {
|
|
363
|
+
const matches = await searchText(this.query, this.cell.model.value);
|
|
364
|
+
this.editorHighlighter.matches = matches;
|
|
365
|
+
if (this.isCellSelected) {
|
|
366
|
+
const cursorOffset = this.cell.editor!.getOffsetAt(
|
|
367
|
+
this.cell.editor?.getCursorPosition() ?? { column: 0, line: 0 },
|
|
368
|
+
);
|
|
369
|
+
const index = matches.findIndex((item) => item.position >= cursorOffset);
|
|
370
|
+
this.currentIndex = index;
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
this.editorHighlighter.matches = [];
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}
|
package/src/index.ts
ADDED
package/src/module.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { IEditor } from '@difizen/libro-code-editor';
|
|
2
|
+
import { LibroSearchModule } from '@difizen/libro-search';
|
|
3
|
+
import { ManaModule } from '@difizen/mana-app';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CodeCellSearchOption,
|
|
7
|
+
CodeCellSearchProviderFactory,
|
|
8
|
+
CodeEditorSearchHighlighterFactory,
|
|
9
|
+
} from './code-cell-search-protocol.js';
|
|
10
|
+
import { CodeCellSearchProviderContribution } from './code-cell-search-provider-contribution.js';
|
|
11
|
+
import { CodeCellSearchProvider } from './code-cell-search-provider.js';
|
|
12
|
+
import { GenericSearchHighlighter } from './search-highlighter.js';
|
|
13
|
+
|
|
14
|
+
export const SearchCodeCellModule = ManaModule.create()
|
|
15
|
+
.register(
|
|
16
|
+
CodeCellSearchProvider,
|
|
17
|
+
CodeCellSearchProviderContribution,
|
|
18
|
+
CodeCellSearchProviderFactory,
|
|
19
|
+
GenericSearchHighlighter,
|
|
20
|
+
{
|
|
21
|
+
token: CodeEditorSearchHighlighterFactory,
|
|
22
|
+
useFactory: (ctx) => {
|
|
23
|
+
return (editor: IEditor) => {
|
|
24
|
+
const child = ctx.container.createChild();
|
|
25
|
+
const highlighter = child.get(GenericSearchHighlighter);
|
|
26
|
+
highlighter.setEditor(editor);
|
|
27
|
+
return highlighter;
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
token: CodeCellSearchProviderFactory,
|
|
33
|
+
useFactory: (ctx) => {
|
|
34
|
+
return (options: CodeCellSearchOption) => {
|
|
35
|
+
const child = ctx.container.createChild();
|
|
36
|
+
child.register({
|
|
37
|
+
token: CodeCellSearchOption,
|
|
38
|
+
useValue: options,
|
|
39
|
+
});
|
|
40
|
+
const model = child.get(CodeCellSearchProvider);
|
|
41
|
+
return model;
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
.dependOn(LibroSearchModule);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/* eslint-disable no-param-reassign */
|
|
2
|
+
import type { IEditor, SearchMatch } from '@difizen/libro-code-editor';
|
|
3
|
+
import { deepEqual } from '@difizen/libro-common';
|
|
4
|
+
import { LibroSearchUtils } from '@difizen/libro-search';
|
|
5
|
+
import { prop } from '@difizen/mana-app';
|
|
6
|
+
import { inject, transient } from '@difizen/mana-app';
|
|
7
|
+
|
|
8
|
+
import type { CodeEditorSearchHighlighter } from './code-cell-search-protocol.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Helper class to highlight texts in a code mirror editor.
|
|
12
|
+
*
|
|
13
|
+
* Highlighted texts (aka `matches`) must be provided through
|
|
14
|
+
* the `matches` attributes.
|
|
15
|
+
*/
|
|
16
|
+
@transient()
|
|
17
|
+
export class GenericSearchHighlighter implements CodeEditorSearchHighlighter {
|
|
18
|
+
@inject(LibroSearchUtils) utils: LibroSearchUtils;
|
|
19
|
+
|
|
20
|
+
protected editor: IEditor | undefined;
|
|
21
|
+
@prop() _currentIndex: number | undefined;
|
|
22
|
+
@prop() protected _matches: SearchMatch[];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The list of matches
|
|
26
|
+
*/
|
|
27
|
+
get matches(): SearchMatch[] {
|
|
28
|
+
return this._matches;
|
|
29
|
+
}
|
|
30
|
+
set matches(v: SearchMatch[]) {
|
|
31
|
+
if (!deepEqual(this._matches as any, v as any)) {
|
|
32
|
+
this._matches = v;
|
|
33
|
+
}
|
|
34
|
+
this.refresh();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get currentIndex(): number | undefined {
|
|
38
|
+
return this._currentIndex;
|
|
39
|
+
}
|
|
40
|
+
set currentIndex(v: number | undefined) {
|
|
41
|
+
this._currentIndex = v;
|
|
42
|
+
this.refresh();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Constructor
|
|
47
|
+
*
|
|
48
|
+
* @param editor The CodeMirror editor
|
|
49
|
+
*/
|
|
50
|
+
constructor() {
|
|
51
|
+
this._matches = new Array<SearchMatch>();
|
|
52
|
+
this.currentIndex = undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Clear all highlighted matches
|
|
57
|
+
*/
|
|
58
|
+
clearHighlight(): void {
|
|
59
|
+
this.currentIndex = undefined;
|
|
60
|
+
this._highlightCurrentMatch();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Clear the highlighted matches.
|
|
65
|
+
*/
|
|
66
|
+
endQuery(): Promise<void> {
|
|
67
|
+
this._currentIndex = undefined;
|
|
68
|
+
this._matches = [];
|
|
69
|
+
|
|
70
|
+
if (this.editor) {
|
|
71
|
+
this.editor.highlightMatches([], undefined);
|
|
72
|
+
|
|
73
|
+
const selection = this.editor.getSelection();
|
|
74
|
+
|
|
75
|
+
const start = this.editor.getOffsetAt(selection.start);
|
|
76
|
+
const end = this.editor.getOffsetAt(selection.end);
|
|
77
|
+
|
|
78
|
+
// Setting a reverse selection to allow search-as-you-type to maintain the
|
|
79
|
+
// current selected match. See comment in _findNext for more details.
|
|
80
|
+
if (start !== end) {
|
|
81
|
+
this.editor.setSelection(selection);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Promise.resolve();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Highlight the next match
|
|
90
|
+
*
|
|
91
|
+
* @returns The next match if available
|
|
92
|
+
*/
|
|
93
|
+
highlightNext(): Promise<SearchMatch | undefined> {
|
|
94
|
+
this.currentIndex = this._findNext(false);
|
|
95
|
+
this._highlightCurrentMatch();
|
|
96
|
+
return Promise.resolve(
|
|
97
|
+
this.currentIndex !== undefined ? this._matches[this.currentIndex] : undefined,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Highlight the previous match
|
|
103
|
+
*
|
|
104
|
+
* @returns The previous match if available
|
|
105
|
+
*/
|
|
106
|
+
highlightPrevious(): Promise<SearchMatch | undefined> {
|
|
107
|
+
this.currentIndex = this._findNext(true);
|
|
108
|
+
this._highlightCurrentMatch();
|
|
109
|
+
return Promise.resolve(
|
|
110
|
+
this.currentIndex !== undefined ? this._matches[this.currentIndex] : undefined,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Set the editor
|
|
116
|
+
*
|
|
117
|
+
* @param editor Editor
|
|
118
|
+
*/
|
|
119
|
+
setEditor(editor: IEditor): void {
|
|
120
|
+
this.editor = editor;
|
|
121
|
+
this.refresh();
|
|
122
|
+
if (this.currentIndex !== undefined) {
|
|
123
|
+
this._highlightCurrentMatch();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected _highlightCurrentMatch(): void {
|
|
128
|
+
if (!this.editor) {
|
|
129
|
+
// no-op
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Highlight the current index
|
|
134
|
+
if (this.currentIndex !== undefined) {
|
|
135
|
+
const match = this.matches[this.currentIndex];
|
|
136
|
+
// this.cm.editor.focus();
|
|
137
|
+
const start = this.editor.getPositionAt(match.position);
|
|
138
|
+
const end = this.editor.getPositionAt(match.position + match.text.length);
|
|
139
|
+
if (start && end) {
|
|
140
|
+
this.editor.setSelection({ start, end });
|
|
141
|
+
this.editor.revealSelection({ start, end });
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
const start = this.editor.getPositionAt(0)!;
|
|
145
|
+
const end = this.editor.getPositionAt(0)!;
|
|
146
|
+
// Set cursor to remove any selection
|
|
147
|
+
this.editor.setSelection({ start, end });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
protected refresh(): void {
|
|
152
|
+
if (!this.editor) {
|
|
153
|
+
// no-op
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.editor.highlightMatches(this.matches, this.currentIndex);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
protected _findNext(reverse: boolean): number | undefined {
|
|
160
|
+
if (this.matches.length === 0) {
|
|
161
|
+
// No-op
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
if (!this.editor) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// In order to support search-as-you-type, we needed a way to allow the first
|
|
168
|
+
// match to be selected when a search is started, but prevent the selected
|
|
169
|
+
// search to move for each new keypress. To do this, when a search is ended,
|
|
170
|
+
// the cursor is reversed, putting the head at the 'from' position. When a new
|
|
171
|
+
// search is started, the cursor we want is at the 'from' position, so that the same
|
|
172
|
+
// match is selected when the next key is entered (if it is still a match).
|
|
173
|
+
//
|
|
174
|
+
// When toggling through a search normally, the cursor is always set in the forward
|
|
175
|
+
// direction, so head is always at the 'to' position. That way, if reverse = false,
|
|
176
|
+
// the search proceeds from the 'to' position during normal toggling. If reverse = true,
|
|
177
|
+
// the search always proceeds from the 'anchor' position, which is at the 'from'.
|
|
178
|
+
|
|
179
|
+
const selection = this.editor?.getSelection();
|
|
180
|
+
|
|
181
|
+
const start = this.editor?.getOffsetAt(selection.start);
|
|
182
|
+
const end = this.editor.getOffsetAt(selection.end);
|
|
183
|
+
let lastPosition = reverse ? start : end;
|
|
184
|
+
if (lastPosition === 0 && reverse && this.currentIndex === undefined) {
|
|
185
|
+
// The default position is (0, 0) but we want to start from the end in that case
|
|
186
|
+
lastPosition = this.editor.model.value.length;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const position = lastPosition;
|
|
190
|
+
|
|
191
|
+
let found = this.utils.findNext(this.matches, position, 0, this.matches.length - 1);
|
|
192
|
+
if (found === undefined) {
|
|
193
|
+
// Don't loop
|
|
194
|
+
return reverse ? this.matches.length - 1 : undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (reverse) {
|
|
198
|
+
found -= 1;
|
|
199
|
+
if (found < 0) {
|
|
200
|
+
// Don't loop
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return found;
|
|
206
|
+
}
|
|
207
|
+
}
|