@jupyterlab/codemirror 4.0.0-alpha.2 → 4.0.0-alpha.20
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/lib/codemirror-ipythongfm.d.ts +9 -3
- package/lib/codemirror-ipythongfm.js +52 -30
- package/lib/codemirror-ipythongfm.js.map +1 -1
- package/lib/commands.d.ts +13 -0
- package/lib/commands.js +32 -0
- package/lib/commands.js.map +1 -0
- package/lib/editor.d.ts +48 -279
- package/lib/editor.js +220 -818
- package/lib/editor.js.map +1 -1
- package/lib/extension.d.ts +236 -0
- package/lib/extension.js +687 -0
- package/lib/extension.js.map +1 -0
- package/lib/extensions/customStyle.d.ts +25 -0
- package/lib/extensions/customStyle.js +51 -0
- package/lib/extensions/customStyle.js.map +1 -0
- package/lib/extensions/index.d.ts +3 -0
- package/lib/extensions/index.js +8 -0
- package/lib/extensions/index.js.map +1 -0
- package/lib/extensions/rulers.d.ts +8 -0
- package/lib/extensions/rulers.js +85 -0
- package/lib/extensions/rulers.js.map +1 -0
- package/lib/extensions/ybinding.d.ts +125 -0
- package/lib/extensions/ybinding.js +213 -0
- package/lib/extensions/ybinding.js.map +1 -0
- package/lib/factory.d.ts +15 -5
- package/lib/factory.js +53 -24
- package/lib/factory.js.map +1 -1
- package/lib/index.d.ts +7 -19
- package/lib/index.js +7 -23
- package/lib/index.js.map +1 -1
- package/lib/language.d.ts +96 -0
- package/lib/language.js +1690 -0
- package/lib/language.js.map +1 -0
- package/lib/mimetype.d.ts +3 -0
- package/lib/mimetype.js +16 -5
- package/lib/mimetype.js.map +1 -1
- package/lib/searchprovider.d.ts +197 -0
- package/lib/searchprovider.js +550 -0
- package/lib/searchprovider.js.map +1 -0
- package/lib/theme.d.ts +57 -0
- package/lib/theme.js +194 -0
- package/lib/theme.js.map +1 -0
- package/lib/token.d.ts +375 -0
- package/lib/token.js +18 -0
- package/lib/token.js.map +1 -0
- package/package.json +45 -41
- package/src/codemirror-ipythongfm.ts +109 -0
- package/src/commands.ts +34 -0
- package/src/editor.ts +783 -0
- package/src/extension.ts +878 -0
- package/src/extensions/customStyle.ts +79 -0
- package/src/extensions/index.ts +8 -0
- package/src/extensions/rulers.ts +112 -0
- package/src/extensions/ybinding.ts +267 -0
- package/src/factory.ts +100 -0
- package/src/index.ts +17 -0
- package/src/language.ts +1729 -0
- package/src/mimetype.ts +54 -0
- package/src/searchprovider.ts +700 -0
- package/src/theme.ts +219 -0
- package/src/token.ts +436 -0
- package/src/typings.d.ts +13 -0
- package/style/base.css +22 -162
- package/style/index.css +1 -7
- package/style/index.js +1 -7
- package/lib/codemirror-ipython.d.ts +0 -2
- package/lib/codemirror-ipython.js +0 -30
- package/lib/codemirror-ipython.js.map +0 -1
- package/lib/mode.d.ts +0 -81
- package/lib/mode.js +0 -149
- package/lib/mode.js.map +0 -1
- package/lib/syntaxstatus.d.ts +0 -67
- package/lib/syntaxstatus.js +0 -140
- package/lib/syntaxstatus.js.map +0 -1
- package/lib/tokens.d.ts +0 -18
- package/lib/tokens.js +0 -9
- package/lib/tokens.js.map +0 -1
- package/typings/codemirror/codemirror.d.ts +0 -227
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
// Copyright (c) Jupyter Development Team.
|
|
2
|
+
// Distributed under the terms of the Modified BSD License.
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Parts of the implementation of the search in this file were derived from
|
|
6
|
+
CodeMirror's search at:
|
|
7
|
+
https://github.com/codemirror/CodeMirror/blob/c2676685866c571a1c9c82cb25018cc08b4d42b2/addon/search/search.js
|
|
8
|
+
which is licensed with the following license:
|
|
9
|
+
|
|
10
|
+
MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (C) 2017 by Marijn Haverbeke <marijnh@gmail.com> and others
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in
|
|
22
|
+
all copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
30
|
+
THE SOFTWARE.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { ISearchMatch } from '@jupyterlab/documentsearch';
|
|
34
|
+
import { JSONExt } from '@lumino/coreutils';
|
|
35
|
+
import { CodeMirrorEditor } from './editor';
|
|
36
|
+
import { StateEffect, StateEffectType, StateField } from '@codemirror/state';
|
|
37
|
+
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
|
|
38
|
+
import { CodeEditor } from '@jupyterlab/codeeditor';
|
|
39
|
+
import {
|
|
40
|
+
GenericSearchProvider,
|
|
41
|
+
IBaseSearchProvider,
|
|
42
|
+
IFilters,
|
|
43
|
+
IReplaceOptions,
|
|
44
|
+
TextSearchEngine
|
|
45
|
+
} from '@jupyterlab/documentsearch';
|
|
46
|
+
import { ISignal, Signal } from '@lumino/signaling';
|
|
47
|
+
import { ISharedText, SourceChange } from '@jupyter/ydoc';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Search provider for editors.
|
|
51
|
+
*/
|
|
52
|
+
export abstract class EditorSearchProvider<
|
|
53
|
+
T extends CodeEditor.IModel = CodeEditor.IModel
|
|
54
|
+
> implements IBaseSearchProvider
|
|
55
|
+
{
|
|
56
|
+
/**
|
|
57
|
+
* Constructor
|
|
58
|
+
*/
|
|
59
|
+
constructor() {
|
|
60
|
+
this.currentIndex = null;
|
|
61
|
+
this._stateChanged = new Signal<IBaseSearchProvider, void>(this);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* CodeMirror search highlighter
|
|
65
|
+
*/
|
|
66
|
+
protected get cmHandler(): CodeMirrorSearchHighlighter {
|
|
67
|
+
if (!this._cmHandler) {
|
|
68
|
+
this._cmHandler = new CodeMirrorSearchHighlighter(
|
|
69
|
+
this.editor as CodeMirrorEditor
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return this._cmHandler;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Text editor
|
|
77
|
+
*/
|
|
78
|
+
protected abstract get editor(): CodeEditor.IEditor | null;
|
|
79
|
+
/**
|
|
80
|
+
* Editor content model
|
|
81
|
+
*/
|
|
82
|
+
protected abstract get model(): T;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Changed signal to be emitted when search matches change.
|
|
86
|
+
*/
|
|
87
|
+
get stateChanged(): ISignal<IBaseSearchProvider, void> {
|
|
88
|
+
return this._stateChanged;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Current match index
|
|
93
|
+
*/
|
|
94
|
+
get currentMatchIndex(): number | null {
|
|
95
|
+
return this.isActive ? this.currentIndex : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether the cell search is active.
|
|
100
|
+
*
|
|
101
|
+
* This is used when applying search only on selected cells.
|
|
102
|
+
*/
|
|
103
|
+
get isActive(): boolean {
|
|
104
|
+
return this._isActive;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether the search provider is disposed or not.
|
|
109
|
+
*/
|
|
110
|
+
get isDisposed(): boolean {
|
|
111
|
+
return this._isDisposed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Number of matches in the cell.
|
|
116
|
+
*/
|
|
117
|
+
get matchesCount(): number {
|
|
118
|
+
return this.isActive ? this.cmHandler.matches.length : 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Clear currently highlighted match
|
|
123
|
+
*/
|
|
124
|
+
clearHighlight(): Promise<void> {
|
|
125
|
+
this.currentIndex = null;
|
|
126
|
+
this.cmHandler.clearHighlight();
|
|
127
|
+
|
|
128
|
+
return Promise.resolve();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Dispose the search provider
|
|
133
|
+
*/
|
|
134
|
+
dispose(): void {
|
|
135
|
+
if (this._isDisposed) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
this._isDisposed = true;
|
|
139
|
+
Signal.clearData(this);
|
|
140
|
+
if (this.isActive) {
|
|
141
|
+
this.endQuery().catch(reason => {
|
|
142
|
+
console.error(`Failed to end search query on cells.`, reason);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Set `isActive` status.
|
|
149
|
+
*
|
|
150
|
+
* #### Notes
|
|
151
|
+
* It will start or end the search
|
|
152
|
+
*
|
|
153
|
+
* @param v New value
|
|
154
|
+
*/
|
|
155
|
+
async setIsActive(v: boolean): Promise<void> {
|
|
156
|
+
if (this._isActive !== v) {
|
|
157
|
+
this._isActive = v;
|
|
158
|
+
}
|
|
159
|
+
if (this._isActive) {
|
|
160
|
+
if (this.query !== null) {
|
|
161
|
+
await this.startQuery(this.query, this.filters);
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
await this.endQuery();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Initialize the search using the provided options. Should update the UI
|
|
170
|
+
* to highlight all matches and "select" the first match.
|
|
171
|
+
*
|
|
172
|
+
* @param query A RegExp to be use to perform the search
|
|
173
|
+
* @param filters Filter parameters to pass to provider
|
|
174
|
+
*/
|
|
175
|
+
async startQuery(query: RegExp | null, filters?: IFilters): Promise<void> {
|
|
176
|
+
this.query = query;
|
|
177
|
+
this.filters = filters;
|
|
178
|
+
|
|
179
|
+
// Search input
|
|
180
|
+
const content = this.model.sharedModel.getSource();
|
|
181
|
+
await this.updateCodeMirror(content);
|
|
182
|
+
this.model.sharedModel.changed.connect(this.onSharedModelChanged, this);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Stop the search and clean any UI elements.
|
|
187
|
+
*/
|
|
188
|
+
async endQuery(): Promise<void> {
|
|
189
|
+
await this.cmHandler.endQuery();
|
|
190
|
+
this.currentIndex = null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Highlight the next match.
|
|
195
|
+
*
|
|
196
|
+
* @returns The next match if there is one.
|
|
197
|
+
*/
|
|
198
|
+
async highlightNext(loop = true): Promise<ISearchMatch | undefined> {
|
|
199
|
+
if (this.matchesCount === 0 || !this.isActive) {
|
|
200
|
+
this.currentIndex = null;
|
|
201
|
+
} else {
|
|
202
|
+
// This starts from the cursor position
|
|
203
|
+
let match = await this.cmHandler.highlightNext();
|
|
204
|
+
if (match) {
|
|
205
|
+
this.currentIndex = this.cmHandler.currentIndex;
|
|
206
|
+
} else {
|
|
207
|
+
this.currentIndex = loop ? 0 : null;
|
|
208
|
+
}
|
|
209
|
+
return match;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return Promise.resolve(this.getCurrentMatch());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Highlight the previous match.
|
|
217
|
+
*
|
|
218
|
+
* @returns The previous match if there is one.
|
|
219
|
+
*/
|
|
220
|
+
async highlightPrevious(loop = true): Promise<ISearchMatch | undefined> {
|
|
221
|
+
if (this.matchesCount === 0 || !this.isActive) {
|
|
222
|
+
this.currentIndex = null;
|
|
223
|
+
} else {
|
|
224
|
+
// This starts from the cursor position
|
|
225
|
+
let match = await this.cmHandler.highlightPrevious();
|
|
226
|
+
if (match) {
|
|
227
|
+
this.currentIndex = this.cmHandler.currentIndex;
|
|
228
|
+
} else {
|
|
229
|
+
this.currentIndex = loop ? this.matchesCount - 1 : null;
|
|
230
|
+
}
|
|
231
|
+
return match;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return Promise.resolve(this.getCurrentMatch());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Replace the currently selected match with the provided text.
|
|
239
|
+
*
|
|
240
|
+
* If no match is selected, it won't do anything.
|
|
241
|
+
*
|
|
242
|
+
* @param newText The replacement text.
|
|
243
|
+
* @returns Whether a replace occurred.
|
|
244
|
+
*/
|
|
245
|
+
replaceCurrentMatch(
|
|
246
|
+
newText: string,
|
|
247
|
+
loop?: boolean,
|
|
248
|
+
options?: IReplaceOptions
|
|
249
|
+
): Promise<boolean> {
|
|
250
|
+
if (!this.isActive) {
|
|
251
|
+
return Promise.resolve(false);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let occurred = false;
|
|
255
|
+
|
|
256
|
+
if (
|
|
257
|
+
this.currentIndex !== null &&
|
|
258
|
+
this.currentIndex < this.cmHandler.matches.length
|
|
259
|
+
) {
|
|
260
|
+
const editor = this.editor as CodeMirrorEditor;
|
|
261
|
+
const selection = editor.state.sliceDoc(
|
|
262
|
+
editor.state.selection.main.from,
|
|
263
|
+
editor.state.selection.main.to
|
|
264
|
+
);
|
|
265
|
+
const match = this.getCurrentMatch();
|
|
266
|
+
// If cursor is not on a selection, highlight the next match
|
|
267
|
+
if (selection !== match?.text) {
|
|
268
|
+
this.currentIndex = null;
|
|
269
|
+
// The next will be highlighted as a consequence of this returning false
|
|
270
|
+
} else {
|
|
271
|
+
this.cmHandler.matches.splice(this.currentIndex, 1);
|
|
272
|
+
this.currentIndex = null;
|
|
273
|
+
const substitutedText = options?.regularExpression
|
|
274
|
+
? match!.text.replace(this.query!, newText)
|
|
275
|
+
: newText;
|
|
276
|
+
const insertText = options?.preserveCase
|
|
277
|
+
? GenericSearchProvider.preserveCase(match.text, substitutedText)
|
|
278
|
+
: substitutedText;
|
|
279
|
+
this.model.sharedModel.updateSource(
|
|
280
|
+
match!.position,
|
|
281
|
+
match!.position + match!.text.length,
|
|
282
|
+
insertText
|
|
283
|
+
);
|
|
284
|
+
occurred = true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return Promise.resolve(occurred);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Replace all matches in the cell source with the provided text
|
|
293
|
+
*
|
|
294
|
+
* @param newText The replacement text.
|
|
295
|
+
* @returns Whether a replace occurred.
|
|
296
|
+
*/
|
|
297
|
+
replaceAllMatches(
|
|
298
|
+
newText: string,
|
|
299
|
+
options?: IReplaceOptions
|
|
300
|
+
): Promise<boolean> {
|
|
301
|
+
if (!this.isActive) {
|
|
302
|
+
return Promise.resolve(false);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let occurred = this.cmHandler.matches.length > 0;
|
|
306
|
+
let src = this.model.sharedModel.getSource();
|
|
307
|
+
let lastEnd = 0;
|
|
308
|
+
const finalSrc = this.cmHandler.matches.reduce((agg, match) => {
|
|
309
|
+
const start = match.position as number;
|
|
310
|
+
const end = start + match.text.length;
|
|
311
|
+
const substitutedText = options?.regularExpression
|
|
312
|
+
? match!.text.replace(this.query!, newText)
|
|
313
|
+
: newText;
|
|
314
|
+
const insertText = options?.preserveCase
|
|
315
|
+
? GenericSearchProvider.preserveCase(match.text, substitutedText)
|
|
316
|
+
: substitutedText;
|
|
317
|
+
const newStep = `${agg}${src.slice(lastEnd, start)}${insertText}`;
|
|
318
|
+
lastEnd = end;
|
|
319
|
+
return newStep;
|
|
320
|
+
}, '');
|
|
321
|
+
|
|
322
|
+
if (occurred) {
|
|
323
|
+
this.cmHandler.matches = [];
|
|
324
|
+
this.currentIndex = null;
|
|
325
|
+
this.model.sharedModel.setSource(`${finalSrc}${src.slice(lastEnd)}`);
|
|
326
|
+
}
|
|
327
|
+
return Promise.resolve(occurred);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get the current match if it exists.
|
|
332
|
+
*
|
|
333
|
+
* @returns The current match
|
|
334
|
+
*/
|
|
335
|
+
protected getCurrentMatch(): ISearchMatch | undefined {
|
|
336
|
+
if (this.currentIndex === null) {
|
|
337
|
+
return undefined;
|
|
338
|
+
} else {
|
|
339
|
+
let match: ISearchMatch | undefined = undefined;
|
|
340
|
+
if (this.currentIndex < this.cmHandler.matches.length) {
|
|
341
|
+
match = this.cmHandler.matches[this.currentIndex];
|
|
342
|
+
}
|
|
343
|
+
return match;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Callback on source change
|
|
349
|
+
*
|
|
350
|
+
* @param emitter Source of the change
|
|
351
|
+
* @param changes Source change
|
|
352
|
+
*/
|
|
353
|
+
protected async onSharedModelChanged(
|
|
354
|
+
emitter: ISharedText,
|
|
355
|
+
changes: SourceChange
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
if (changes.sourceChange) {
|
|
358
|
+
await this.updateCodeMirror(emitter.getSource());
|
|
359
|
+
this._stateChanged.emit();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Update matches
|
|
365
|
+
*/
|
|
366
|
+
protected async updateCodeMirror(content: string) {
|
|
367
|
+
if (this.query !== null && this.isActive) {
|
|
368
|
+
this.cmHandler.matches = await TextSearchEngine.search(
|
|
369
|
+
this.query,
|
|
370
|
+
content
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
this.cmHandler.matches = [];
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Current match index
|
|
379
|
+
*/
|
|
380
|
+
protected currentIndex: number | null = null;
|
|
381
|
+
/**
|
|
382
|
+
* Current search filters
|
|
383
|
+
*/
|
|
384
|
+
protected filters: IFilters | undefined;
|
|
385
|
+
/**
|
|
386
|
+
* Current search query
|
|
387
|
+
*/
|
|
388
|
+
protected query: RegExp | null = null;
|
|
389
|
+
// Needs to be protected so subclass can emit the signal too.
|
|
390
|
+
protected _stateChanged: Signal<IBaseSearchProvider, void>;
|
|
391
|
+
private _isActive = true;
|
|
392
|
+
private _isDisposed = false;
|
|
393
|
+
private _cmHandler: CodeMirrorSearchHighlighter | null = null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Helper class to highlight texts in a code mirror editor.
|
|
398
|
+
*
|
|
399
|
+
* Highlighted texts (aka `matches`) must be provided through
|
|
400
|
+
* the `matches` attributes.
|
|
401
|
+
*/
|
|
402
|
+
export class CodeMirrorSearchHighlighter {
|
|
403
|
+
/**
|
|
404
|
+
* Constructor
|
|
405
|
+
*
|
|
406
|
+
* @param editor The CodeMirror editor
|
|
407
|
+
*/
|
|
408
|
+
constructor(editor: CodeMirrorEditor | null) {
|
|
409
|
+
this._cm = editor;
|
|
410
|
+
this._matches = new Array<ISearchMatch>();
|
|
411
|
+
this._currentIndex = null;
|
|
412
|
+
|
|
413
|
+
this._highlightEffect = StateEffect.define<{ matches: ISearchMatch[] }>({
|
|
414
|
+
map: (value, mapping) => ({
|
|
415
|
+
matches: value.matches.map(v => ({
|
|
416
|
+
text: v.text,
|
|
417
|
+
position: mapping.mapPos(v.position)
|
|
418
|
+
}))
|
|
419
|
+
})
|
|
420
|
+
});
|
|
421
|
+
this._highlightMark = Decoration.mark({ class: 'cm-searching' });
|
|
422
|
+
|
|
423
|
+
this._highlightField = StateField.define<DecorationSet>({
|
|
424
|
+
create: () => {
|
|
425
|
+
return Decoration.none;
|
|
426
|
+
},
|
|
427
|
+
update: (highlights, transaction) => {
|
|
428
|
+
highlights = highlights.map(transaction.changes);
|
|
429
|
+
for (let ef of transaction.effects) {
|
|
430
|
+
if (ef.is(this._highlightEffect)) {
|
|
431
|
+
const e = ef as StateEffect<{ matches: ISearchMatch[] }>;
|
|
432
|
+
if (e.value.matches.length) {
|
|
433
|
+
highlights = highlights.update({
|
|
434
|
+
add: e.value.matches.map(m =>
|
|
435
|
+
this._highlightMark.range(
|
|
436
|
+
m.position,
|
|
437
|
+
m.position + m.text.length
|
|
438
|
+
)
|
|
439
|
+
),
|
|
440
|
+
// filter out old marks
|
|
441
|
+
filter: () => false
|
|
442
|
+
});
|
|
443
|
+
} else {
|
|
444
|
+
highlights = Decoration.none;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return highlights;
|
|
449
|
+
},
|
|
450
|
+
provide: f => EditorView.decorations.from(f)
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The current index of the selected match.
|
|
456
|
+
*/
|
|
457
|
+
get currentIndex(): number | null {
|
|
458
|
+
return this._currentIndex;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The list of matches
|
|
463
|
+
*/
|
|
464
|
+
get matches(): ISearchMatch[] {
|
|
465
|
+
return this._matches;
|
|
466
|
+
}
|
|
467
|
+
set matches(v: ISearchMatch[]) {
|
|
468
|
+
if (!JSONExt.deepEqual(this._matches as any, v as any)) {
|
|
469
|
+
this._matches = v;
|
|
470
|
+
}
|
|
471
|
+
this._refresh();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Clear all highlighted matches
|
|
476
|
+
*/
|
|
477
|
+
clearHighlight(): void {
|
|
478
|
+
this._currentIndex = null;
|
|
479
|
+
this._highlightCurrentMatch();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Clear the highlighted matches.
|
|
484
|
+
*/
|
|
485
|
+
endQuery(): Promise<void> {
|
|
486
|
+
this._currentIndex = null;
|
|
487
|
+
this._matches = [];
|
|
488
|
+
|
|
489
|
+
if (this._cm) {
|
|
490
|
+
this._cm.editor.dispatch({
|
|
491
|
+
effects: this._highlightEffect.of({ matches: [] })
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const selection = this._cm.state.selection.main;
|
|
495
|
+
const from = selection.from;
|
|
496
|
+
const to = selection.to;
|
|
497
|
+
// Setting a reverse selection to allow search-as-you-type to maintain the
|
|
498
|
+
// current selected match. See comment in _findNext for more details.
|
|
499
|
+
if (from !== to) {
|
|
500
|
+
this._cm.editor.dispatch({ selection: { anchor: to, head: from } });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return Promise.resolve();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Highlight the next match
|
|
509
|
+
*
|
|
510
|
+
* @returns The next match if available
|
|
511
|
+
*/
|
|
512
|
+
highlightNext(): Promise<ISearchMatch | undefined> {
|
|
513
|
+
this._currentIndex = this._findNext(false);
|
|
514
|
+
this._highlightCurrentMatch();
|
|
515
|
+
return Promise.resolve(
|
|
516
|
+
this._currentIndex !== null
|
|
517
|
+
? this._matches[this._currentIndex]
|
|
518
|
+
: undefined
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Highlight the previous match
|
|
524
|
+
*
|
|
525
|
+
* @returns The previous match if available
|
|
526
|
+
*/
|
|
527
|
+
highlightPrevious(): Promise<ISearchMatch | undefined> {
|
|
528
|
+
this._currentIndex = this._findNext(true);
|
|
529
|
+
this._highlightCurrentMatch();
|
|
530
|
+
return Promise.resolve(
|
|
531
|
+
this._currentIndex !== null
|
|
532
|
+
? this._matches[this._currentIndex]
|
|
533
|
+
: undefined
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Set the editor
|
|
539
|
+
*
|
|
540
|
+
* @param editor Editor
|
|
541
|
+
*/
|
|
542
|
+
setEditor(editor: CodeMirrorEditor): void {
|
|
543
|
+
if (this._cm) {
|
|
544
|
+
throw new Error('CodeMirrorEditor already set.');
|
|
545
|
+
} else {
|
|
546
|
+
this._cm = editor;
|
|
547
|
+
this._refresh();
|
|
548
|
+
if (this._currentIndex !== null) {
|
|
549
|
+
this._highlightCurrentMatch();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private _highlightCurrentMatch(): void {
|
|
555
|
+
if (!this._cm) {
|
|
556
|
+
// no-op
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Highlight the current index
|
|
561
|
+
if (this._currentIndex !== null) {
|
|
562
|
+
const match = this.matches[this._currentIndex];
|
|
563
|
+
this._cm.editor.focus();
|
|
564
|
+
this._cm.editor.dispatch({
|
|
565
|
+
selection: {
|
|
566
|
+
anchor: match.position,
|
|
567
|
+
head: match.position + match.text.length
|
|
568
|
+
},
|
|
569
|
+
scrollIntoView: true
|
|
570
|
+
});
|
|
571
|
+
} else {
|
|
572
|
+
// Set cursor to remove any selection
|
|
573
|
+
this._cm.editor.dispatch({ selection: { anchor: 0 } });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private _refresh(): void {
|
|
578
|
+
if (!this._cm) {
|
|
579
|
+
// no-op
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
let effects: StateEffect<unknown>[] = [
|
|
584
|
+
this._highlightEffect.of({ matches: this.matches })
|
|
585
|
+
];
|
|
586
|
+
if (!this._cm!.state.field(this._highlightField, false)) {
|
|
587
|
+
effects.push(StateEffect.appendConfig.of([this._highlightField]));
|
|
588
|
+
}
|
|
589
|
+
this._cm!.editor.dispatch({ effects });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private _findNext(reverse: boolean): number | null {
|
|
593
|
+
if (this._matches.length === 0) {
|
|
594
|
+
// No-op
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
// In order to support search-as-you-type, we needed a way to allow the first
|
|
598
|
+
// match to be selected when a search is started, but prevent the selected
|
|
599
|
+
// search to move for each new keypress. To do this, when a search is ended,
|
|
600
|
+
// the cursor is reversed, putting the head at the 'from' position. When a new
|
|
601
|
+
// search is started, the cursor we want is at the 'from' position, so that the same
|
|
602
|
+
// match is selected when the next key is entered (if it is still a match).
|
|
603
|
+
//
|
|
604
|
+
// When toggling through a search normally, the cursor is always set in the forward
|
|
605
|
+
// direction, so head is always at the 'to' position. That way, if reverse = false,
|
|
606
|
+
// the search proceeds from the 'to' position during normal toggling. If reverse = true,
|
|
607
|
+
// the search always proceeds from the 'anchor' position, which is at the 'from'.
|
|
608
|
+
|
|
609
|
+
const cursor = this._cm!.state.selection.main;
|
|
610
|
+
let lastPosition = reverse ? cursor.anchor : cursor.head;
|
|
611
|
+
if (lastPosition === 0 && reverse && this.currentIndex === null) {
|
|
612
|
+
// The default position is (0, 0) but we want to start from the end in that case
|
|
613
|
+
lastPosition = this._cm!.doc.length;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const position = lastPosition;
|
|
617
|
+
|
|
618
|
+
let found = Utils.findNext(
|
|
619
|
+
this._matches,
|
|
620
|
+
position,
|
|
621
|
+
0,
|
|
622
|
+
this._matches.length - 1
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
if (found === null) {
|
|
626
|
+
// Don't loop
|
|
627
|
+
return reverse ? this._matches.length - 1 : null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (reverse) {
|
|
631
|
+
found -= 1;
|
|
632
|
+
if (found < 0) {
|
|
633
|
+
// Don't loop
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return found;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private _cm: CodeMirrorEditor | null;
|
|
642
|
+
private _currentIndex: number | null;
|
|
643
|
+
private _matches: ISearchMatch[];
|
|
644
|
+
private _highlightEffect: StateEffectType<{ matches: ISearchMatch[] }>;
|
|
645
|
+
private _highlightMark: Decoration;
|
|
646
|
+
private _highlightField: StateField<DecorationSet>;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Helpers namespace
|
|
651
|
+
*/
|
|
652
|
+
namespace Utils {
|
|
653
|
+
/**
|
|
654
|
+
* Find the closest match at `position` just after it.
|
|
655
|
+
*
|
|
656
|
+
* #### Notes
|
|
657
|
+
* Search is done using a binary search algorithm
|
|
658
|
+
*
|
|
659
|
+
* @param matches List of matches
|
|
660
|
+
* @param position Searched position
|
|
661
|
+
* @param lowerBound Lower range index
|
|
662
|
+
* @param higherBound High range index
|
|
663
|
+
* @returns The next match or null if none exists
|
|
664
|
+
*/
|
|
665
|
+
export function findNext(
|
|
666
|
+
matches: ISearchMatch[],
|
|
667
|
+
position: number,
|
|
668
|
+
lowerBound = 0,
|
|
669
|
+
higherBound = Infinity
|
|
670
|
+
): number | null {
|
|
671
|
+
higherBound = Math.min(matches.length - 1, higherBound);
|
|
672
|
+
|
|
673
|
+
while (lowerBound <= higherBound) {
|
|
674
|
+
let middle = Math.floor(0.5 * (lowerBound + higherBound));
|
|
675
|
+
const currentPosition = matches[middle].position;
|
|
676
|
+
|
|
677
|
+
if (currentPosition < position) {
|
|
678
|
+
lowerBound = middle + 1;
|
|
679
|
+
if (
|
|
680
|
+
lowerBound < matches.length &&
|
|
681
|
+
matches[lowerBound].position > position
|
|
682
|
+
) {
|
|
683
|
+
return lowerBound;
|
|
684
|
+
}
|
|
685
|
+
} else if (currentPosition > position) {
|
|
686
|
+
higherBound = middle - 1;
|
|
687
|
+
if (higherBound > 0 && matches[higherBound].position < position) {
|
|
688
|
+
return middle;
|
|
689
|
+
}
|
|
690
|
+
} else {
|
|
691
|
+
return middle;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Next could be the first item
|
|
696
|
+
const first = lowerBound > 0 ? lowerBound - 1 : 0;
|
|
697
|
+
const match = matches[first];
|
|
698
|
+
return match.position >= position ? first : null;
|
|
699
|
+
}
|
|
700
|
+
}
|