@jupyterlab/codemirror 3.4.3 → 4.0.0-alpha.11
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/editor.js +1 -0
- package/lib/editor.js.map +1 -1
- package/lib/factory.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/mode.js.map +1 -1
- package/lib/searchprovider.d.ts +181 -0
- package/lib/searchprovider.js +685 -0
- package/lib/searchprovider.js.map +1 -0
- package/lib/syntaxstatus.d.ts +1 -2
- package/lib/syntaxstatus.js +1 -1
- package/lib/syntaxstatus.js.map +1 -1
- package/lib/tokens.js +0 -1
- package/lib/tokens.js.map +1 -1
- package/package.json +21 -19
- package/style/base.css +26 -0
- package/style/index.css +3 -1
- package/style/index.js +3 -1
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
// Copyright (c) Jupyter Development Team.
|
|
2
|
+
// Distributed under the terms of the Modified BSD License.
|
|
3
|
+
import { TextSearchEngine } from '@jupyterlab/documentsearch';
|
|
4
|
+
import { JSONExt } from '@lumino/coreutils';
|
|
5
|
+
import { Signal } from '@lumino/signaling';
|
|
6
|
+
import * as CodeMirror from 'codemirror';
|
|
7
|
+
/**
|
|
8
|
+
* CodeMirror search provider for file editor
|
|
9
|
+
*/
|
|
10
|
+
export class CodeMirrorSearchProvider {
|
|
11
|
+
constructor() {
|
|
12
|
+
/**
|
|
13
|
+
* Set to true if the widget under search is read-only, false
|
|
14
|
+
* if it is editable. Will be used to determine whether to show
|
|
15
|
+
* the replace option.
|
|
16
|
+
*/
|
|
17
|
+
this.isReadOnly = false;
|
|
18
|
+
this._matchState = {};
|
|
19
|
+
this._changed = new Signal(this);
|
|
20
|
+
this._disposed = false;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether the search provider is disposed or not.
|
|
24
|
+
*/
|
|
25
|
+
get isDisposed() {
|
|
26
|
+
return this._disposed;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Dispose the search provider
|
|
30
|
+
*/
|
|
31
|
+
dispose() {
|
|
32
|
+
if (this._disposed) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this._disposed = true;
|
|
36
|
+
Signal.clearData(this);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Initialize the search using a CodeMirrorEditor object.
|
|
40
|
+
*
|
|
41
|
+
* @param query the search regular expression
|
|
42
|
+
*/
|
|
43
|
+
async startQuery(query) {
|
|
44
|
+
if (!this.editor) {
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
}
|
|
47
|
+
return this._startQuery(query);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Refresh the search highlight overlay
|
|
51
|
+
*/
|
|
52
|
+
refreshOverlay() {
|
|
53
|
+
this._refreshOverlay();
|
|
54
|
+
}
|
|
55
|
+
async _startQuery(query, refreshOverlay = true) {
|
|
56
|
+
// no point in removing overlay in the middle of the search
|
|
57
|
+
await this.endQuery(false);
|
|
58
|
+
this._query = query;
|
|
59
|
+
CodeMirror.on(this.editor.doc, 'change', this._onDocChanged.bind(this));
|
|
60
|
+
if (refreshOverlay) {
|
|
61
|
+
this._refreshOverlay();
|
|
62
|
+
}
|
|
63
|
+
await this._setInitialMatches(query);
|
|
64
|
+
const matches = this._parseMatchesFromState();
|
|
65
|
+
if (matches.length === 0) {
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
}
|
|
68
|
+
const cursorMatch = this._findNext(false);
|
|
69
|
+
const match = cursorMatch &&
|
|
70
|
+
this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
|
|
71
|
+
this._currentMatch = match;
|
|
72
|
+
return Promise.resolve();
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Clears state of a search provider to prepare for startQuery to be called
|
|
76
|
+
* in order to start a new query or refresh an existing one.
|
|
77
|
+
*
|
|
78
|
+
* @param removeOverlay Whether to remove the search highlight overlay or not.
|
|
79
|
+
*/
|
|
80
|
+
async endQuery(removeOverlay = true) {
|
|
81
|
+
this._matchState = {};
|
|
82
|
+
this._currentMatch = null;
|
|
83
|
+
if (removeOverlay) {
|
|
84
|
+
this.editor.removeOverlay(this._overlay);
|
|
85
|
+
}
|
|
86
|
+
const from = this.editor.getCursor('from');
|
|
87
|
+
const to = this.editor.getCursor('to');
|
|
88
|
+
// Setting a reverse selection to allow search-as-you-type to maintain the
|
|
89
|
+
// current selected match. See comment in _findNext for more details.
|
|
90
|
+
if (from !== to) {
|
|
91
|
+
this.editor.setSelection({
|
|
92
|
+
start: this._toEditorPos(to),
|
|
93
|
+
end: this._toEditorPos(from)
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
CodeMirror.off(this.editor.doc, 'change', this._onDocChanged.bind(this));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Clear currently highlighted match.
|
|
100
|
+
*/
|
|
101
|
+
clearHighlight() {
|
|
102
|
+
this._currentMatch = null;
|
|
103
|
+
const cursor = this.editor.getCursorPosition();
|
|
104
|
+
// Reset cursor position to remove any selection
|
|
105
|
+
this.editor.setCursorPosition(cursor);
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Move the current match indicator to the next match.
|
|
110
|
+
*
|
|
111
|
+
* @param loop Whether to loop within the matches list.
|
|
112
|
+
*
|
|
113
|
+
* @returns A promise that resolves once the action has completed.
|
|
114
|
+
*/
|
|
115
|
+
async highlightNext(loop) {
|
|
116
|
+
const cursorMatch = this._findNext(false);
|
|
117
|
+
if (!cursorMatch) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const match = this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
|
|
121
|
+
this._currentMatch = match;
|
|
122
|
+
return match;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Move the current match indicator to the previous match.
|
|
126
|
+
*
|
|
127
|
+
* @param loop Whether to loop within the matches list.
|
|
128
|
+
*
|
|
129
|
+
* @returns A promise that resolves once the action has completed.
|
|
130
|
+
*/
|
|
131
|
+
async highlightPrevious(loop) {
|
|
132
|
+
const cursorMatch = this._findNext(true);
|
|
133
|
+
if (!cursorMatch) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const match = this._matchState[cursorMatch.from.line][cursorMatch.from.ch];
|
|
137
|
+
this._currentMatch = match;
|
|
138
|
+
return match;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Replace the currently selected match with the provided text
|
|
142
|
+
*
|
|
143
|
+
* @param newText The replacement text
|
|
144
|
+
* @param loop Whether to loop within the matches list.
|
|
145
|
+
*
|
|
146
|
+
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
|
|
147
|
+
*/
|
|
148
|
+
async replaceCurrentMatch(newText, loop) {
|
|
149
|
+
// If the current selection exactly matches the current match,
|
|
150
|
+
// replace it. Otherwise, just select the next match after the cursor.
|
|
151
|
+
let replaceOccurred = false;
|
|
152
|
+
if (this._currentMatchIsSelected()) {
|
|
153
|
+
const cursor = this.editor.getSearchCursor(this._query, this.editor.getCursor('from'), !this._query.ignoreCase);
|
|
154
|
+
if (!cursor.findNext()) {
|
|
155
|
+
return replaceOccurred;
|
|
156
|
+
}
|
|
157
|
+
replaceOccurred = true;
|
|
158
|
+
cursor.replace(newText);
|
|
159
|
+
}
|
|
160
|
+
await this.highlightNext();
|
|
161
|
+
return replaceOccurred;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Replace all matches in the notebook with the provided text
|
|
165
|
+
*
|
|
166
|
+
* @param newText The replacement text
|
|
167
|
+
*
|
|
168
|
+
* @returns A promise that resolves with a boolean indicating whether a replace occurred.
|
|
169
|
+
*/
|
|
170
|
+
async replaceAllMatches(newText) {
|
|
171
|
+
let replaceOccurred = false;
|
|
172
|
+
return new Promise((resolve, _) => {
|
|
173
|
+
this.editor.operation(() => {
|
|
174
|
+
const cursor = this.editor.getSearchCursor(this._query, undefined, !this._query.ignoreCase);
|
|
175
|
+
while (cursor.findNext()) {
|
|
176
|
+
replaceOccurred = true;
|
|
177
|
+
cursor.replace(newText);
|
|
178
|
+
}
|
|
179
|
+
this._matchState = {};
|
|
180
|
+
this._currentMatch = null;
|
|
181
|
+
resolve(replaceOccurred);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The list of matches
|
|
187
|
+
*/
|
|
188
|
+
get matches() {
|
|
189
|
+
return this._parseMatchesFromState();
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The number of matches.
|
|
193
|
+
*/
|
|
194
|
+
get matchesCount() {
|
|
195
|
+
let size = 0;
|
|
196
|
+
for (const line in this._matchState) {
|
|
197
|
+
size += Object.keys(this._matchState[line]).length;
|
|
198
|
+
}
|
|
199
|
+
return size;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The current match
|
|
203
|
+
*/
|
|
204
|
+
get currentMatch() {
|
|
205
|
+
return this._currentMatch;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Signal indicating that something in the search has changed, so the UI should update
|
|
209
|
+
*/
|
|
210
|
+
get stateChanged() {
|
|
211
|
+
return this._changed;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The current index of the selected match.
|
|
215
|
+
*/
|
|
216
|
+
get currentMatchIndex() {
|
|
217
|
+
if (!this._currentMatch) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
// TODO make it more efficient
|
|
221
|
+
return this.matches.indexOf(this._currentMatch);
|
|
222
|
+
}
|
|
223
|
+
_onDocChanged(_, changeObj) {
|
|
224
|
+
var _a, _b;
|
|
225
|
+
// If we get newlines added/removed, the line numbers across the
|
|
226
|
+
// match state are all shifted, so here we need to recalculate it
|
|
227
|
+
if (changeObj.text.length > 1 || ((_b = (_a = changeObj.removed) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 1) {
|
|
228
|
+
this._setInitialMatches(this._query)
|
|
229
|
+
.then(() => {
|
|
230
|
+
this._changed.emit(undefined);
|
|
231
|
+
})
|
|
232
|
+
.catch(reason => {
|
|
233
|
+
console.error(`Fail to reapply search on CodeMirror document change:\n${reason}`);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
_refreshOverlay() {
|
|
238
|
+
this.editor.operation(() => {
|
|
239
|
+
// clear search first
|
|
240
|
+
this.editor.removeOverlay(this._overlay);
|
|
241
|
+
this._overlay = this._getSearchOverlay();
|
|
242
|
+
this.editor.addOverlay(this._overlay);
|
|
243
|
+
this._changed.emit(undefined);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Do a full search on the entire document.
|
|
248
|
+
*
|
|
249
|
+
* This manually constructs the initial match state across the whole
|
|
250
|
+
* document. This must be done manually because the codemirror overlay
|
|
251
|
+
* is lazy-loaded, so it will only tokenize lines that are in or near
|
|
252
|
+
* the viewport. This is sufficient for efficiently maintaining the
|
|
253
|
+
* state when changes are made to the document, as changes occur in or
|
|
254
|
+
* near the viewport, but to scan the whole document, a manual search
|
|
255
|
+
* across the entire content is required.
|
|
256
|
+
*
|
|
257
|
+
* @param query The search term
|
|
258
|
+
*/
|
|
259
|
+
async _setInitialMatches(query) {
|
|
260
|
+
this._matchState = {};
|
|
261
|
+
const content = this.editor.doc.getValue();
|
|
262
|
+
const matches = await TextSearchEngine.search(query, content);
|
|
263
|
+
matches.forEach(match => {
|
|
264
|
+
const { line, ch } = this.editor.doc.posFromIndex(match.position);
|
|
265
|
+
if (!this._matchState[line]) {
|
|
266
|
+
this._matchState[line] = {};
|
|
267
|
+
}
|
|
268
|
+
this._matchState[line][ch] = match;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
_getSearchOverlay() {
|
|
272
|
+
return {
|
|
273
|
+
/**
|
|
274
|
+
* Token function is called when a line needs to be processed -
|
|
275
|
+
* when the overlay is initially created, it's called on all lines;
|
|
276
|
+
* when a line is modified and needs to be re-evaluated, it's called
|
|
277
|
+
* on just that line.
|
|
278
|
+
*
|
|
279
|
+
* This implementation of the token function both constructs/maintains
|
|
280
|
+
* the overlay and keeps track of the match state as the document is
|
|
281
|
+
* updated while a search is active.
|
|
282
|
+
*/
|
|
283
|
+
token: (stream) => {
|
|
284
|
+
const currentPos = stream.pos;
|
|
285
|
+
this._query.lastIndex = currentPos;
|
|
286
|
+
const lineText = stream.string;
|
|
287
|
+
const match = this._query.exec(lineText);
|
|
288
|
+
const line = stream.lineOracle.line;
|
|
289
|
+
// If starting at position 0, the tokenization of this line has just started.
|
|
290
|
+
// Blow away everything on this line in the state so it can be updated.
|
|
291
|
+
if (stream.start === currentPos &&
|
|
292
|
+
currentPos === 0 &&
|
|
293
|
+
!!this._matchState[line]) {
|
|
294
|
+
this._matchState[line] = {};
|
|
295
|
+
}
|
|
296
|
+
if (match && match.index === currentPos) {
|
|
297
|
+
// found match, add it to state
|
|
298
|
+
const matchLength = match[0].length;
|
|
299
|
+
const matchObj = {
|
|
300
|
+
text: lineText.slice(currentPos, currentPos + matchLength),
|
|
301
|
+
position: this.editor.doc.indexFromPos({
|
|
302
|
+
line,
|
|
303
|
+
ch: currentPos
|
|
304
|
+
})
|
|
305
|
+
};
|
|
306
|
+
if (!this._matchState[line]) {
|
|
307
|
+
this._matchState[line] = {};
|
|
308
|
+
}
|
|
309
|
+
this._matchState[line][currentPos] = matchObj;
|
|
310
|
+
// move the stream along and return searching style for the token
|
|
311
|
+
stream.pos += matchLength || 1;
|
|
312
|
+
// if the last thing on the line was a match, make sure we still
|
|
313
|
+
// emit the changed signal so the display can pick up the updates
|
|
314
|
+
if (stream.eol()) {
|
|
315
|
+
this._changed.emit(undefined);
|
|
316
|
+
}
|
|
317
|
+
return 'searching';
|
|
318
|
+
}
|
|
319
|
+
else if (match) {
|
|
320
|
+
// there's a match in the stream, advance the stream to its position
|
|
321
|
+
stream.pos = match.index;
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
// no matches, consume the rest of the stream
|
|
325
|
+
this._changed.emit(undefined);
|
|
326
|
+
stream.skipToEnd();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
_findNext(reverse) {
|
|
332
|
+
return this.editor.operation(() => {
|
|
333
|
+
const caseSensitive = this._query.ignoreCase;
|
|
334
|
+
// In order to support search-as-you-type, we needed a way to allow the first
|
|
335
|
+
// match to be selected when a search is started, but prevent the selected
|
|
336
|
+
// search to move for each new keypress. To do this, when a search is ended,
|
|
337
|
+
// the cursor is reversed, putting the head at the 'from' position. When a new
|
|
338
|
+
// search is started, the cursor we want is at the 'from' position, so that the same
|
|
339
|
+
// match is selected when the next key is entered (if it is still a match).
|
|
340
|
+
//
|
|
341
|
+
// When toggling through a search normally, the cursor is always set in the forward
|
|
342
|
+
// direction, so head is always at the 'to' position. That way, if reverse = false,
|
|
343
|
+
// the search proceeds from the 'to' position during normal toggling. If reverse = true,
|
|
344
|
+
// the search always proceeds from the 'anchor' position, which is at the 'from'.
|
|
345
|
+
const cursorToGet = reverse ? 'anchor' : 'head';
|
|
346
|
+
const lastPosition = this.editor.getCursor(cursorToGet);
|
|
347
|
+
let cursor = this.editor.getSearchCursor(this._query, lastPosition, !caseSensitive);
|
|
348
|
+
if (!cursor.find(reverse)) {
|
|
349
|
+
// if we do want to loop, try searching from the bottom/top
|
|
350
|
+
const startOrEnd = reverse
|
|
351
|
+
? CodeMirror.Pos(this.editor.lastLine())
|
|
352
|
+
: CodeMirror.Pos(this.editor.firstLine(), 0);
|
|
353
|
+
cursor = this.editor.getSearchCursor(this._query, startOrEnd, !caseSensitive);
|
|
354
|
+
if (!cursor.find(reverse)) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const fromPos = cursor.from();
|
|
359
|
+
const toPos = cursor.to();
|
|
360
|
+
const selRange = {
|
|
361
|
+
start: {
|
|
362
|
+
line: fromPos.line,
|
|
363
|
+
column: fromPos.ch
|
|
364
|
+
},
|
|
365
|
+
end: {
|
|
366
|
+
line: toPos.line,
|
|
367
|
+
column: toPos.ch
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
this.editor.setSelection(selRange);
|
|
371
|
+
this.editor.scrollIntoView({
|
|
372
|
+
from: fromPos,
|
|
373
|
+
to: toPos
|
|
374
|
+
}, 100);
|
|
375
|
+
return {
|
|
376
|
+
from: fromPos,
|
|
377
|
+
to: toPos
|
|
378
|
+
};
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
_parseMatchesFromState() {
|
|
382
|
+
// Flatten state map
|
|
383
|
+
const matches = new Array();
|
|
384
|
+
for (const lineKey in this._matchState) {
|
|
385
|
+
const lineMatches = this._matchState[lineKey];
|
|
386
|
+
for (const posKey in lineMatches) {
|
|
387
|
+
matches.push(lineMatches[posKey]);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return matches;
|
|
391
|
+
}
|
|
392
|
+
_toEditorPos(posIn) {
|
|
393
|
+
return {
|
|
394
|
+
line: posIn.line,
|
|
395
|
+
column: posIn.ch
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
_currentMatchIsSelected() {
|
|
399
|
+
if (!this._currentMatch) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
const currentSelection = this.editor.getSelection();
|
|
403
|
+
const currentSelectionLength = currentSelection.end.column - currentSelection.start.column;
|
|
404
|
+
const selectionIsOneLine = currentSelection.start.line === currentSelection.end.line;
|
|
405
|
+
return (selectionIsOneLine &&
|
|
406
|
+
this._currentMatch.text.length === currentSelectionLength &&
|
|
407
|
+
this._currentMatch.position ===
|
|
408
|
+
this.editor.doc.indexFromPos({
|
|
409
|
+
line: currentSelection.start.line,
|
|
410
|
+
ch: currentSelection.start.column
|
|
411
|
+
}));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Helper class to highlight texts in a code mirror editor.
|
|
416
|
+
*
|
|
417
|
+
* Highlighted texts (aka `matches`) must be provided through
|
|
418
|
+
* the `matches` attributes.
|
|
419
|
+
*/
|
|
420
|
+
export class CodeMirrorSearchHighlighter {
|
|
421
|
+
/**
|
|
422
|
+
* Constructor
|
|
423
|
+
*
|
|
424
|
+
* @param editor The CodeMirror editor
|
|
425
|
+
*/
|
|
426
|
+
constructor(editor) {
|
|
427
|
+
this._cm = editor;
|
|
428
|
+
this._matches = new Array();
|
|
429
|
+
this._currentIndex = null;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* The current index of the selected match.
|
|
433
|
+
*/
|
|
434
|
+
get currentIndex() {
|
|
435
|
+
return this._currentIndex;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The list of matches
|
|
439
|
+
*/
|
|
440
|
+
get matches() {
|
|
441
|
+
return this._matches;
|
|
442
|
+
}
|
|
443
|
+
set matches(v) {
|
|
444
|
+
if (!JSONExt.deepEqual(this._matches, v)) {
|
|
445
|
+
this._matches = v;
|
|
446
|
+
}
|
|
447
|
+
this.refresh();
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Clear all highlighted matches
|
|
451
|
+
*/
|
|
452
|
+
clearHighlight() {
|
|
453
|
+
this._currentIndex = null;
|
|
454
|
+
this._highlightCurrentMatch();
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Refresh the highlight matches overlay
|
|
458
|
+
*/
|
|
459
|
+
refresh() {
|
|
460
|
+
this._refreshOverlay();
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Clear the highlighted matches.
|
|
464
|
+
*/
|
|
465
|
+
endQuery() {
|
|
466
|
+
this._currentIndex = null;
|
|
467
|
+
this._matches = [];
|
|
468
|
+
this._cm.removeOverlay('jp-searching');
|
|
469
|
+
this._overlay = null;
|
|
470
|
+
const from = this._cm.getCursor('from');
|
|
471
|
+
const to = this._cm.getCursor('to');
|
|
472
|
+
// Setting a reverse selection to allow search-as-you-type to maintain the
|
|
473
|
+
// current selected match. See comment in _findNext for more details.
|
|
474
|
+
if (from !== to) {
|
|
475
|
+
this._cm.setSelection({
|
|
476
|
+
start: this._toEditorPos(to),
|
|
477
|
+
end: this._toEditorPos(from)
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return Promise.resolve();
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Highlight the next match
|
|
484
|
+
*
|
|
485
|
+
* @returns The next match if available
|
|
486
|
+
*/
|
|
487
|
+
highlightNext() {
|
|
488
|
+
this._currentIndex = this._findNext(false);
|
|
489
|
+
this._highlightCurrentMatch();
|
|
490
|
+
return Promise.resolve(this._currentIndex !== null
|
|
491
|
+
? this._matches[this._currentIndex]
|
|
492
|
+
: undefined);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Highlight the previous match
|
|
496
|
+
*
|
|
497
|
+
* @returns The previous match if available
|
|
498
|
+
*/
|
|
499
|
+
highlightPrevious() {
|
|
500
|
+
this._currentIndex = this._findNext(true);
|
|
501
|
+
this._highlightCurrentMatch();
|
|
502
|
+
return Promise.resolve(this._currentIndex !== null
|
|
503
|
+
? this._matches[this._currentIndex]
|
|
504
|
+
: undefined);
|
|
505
|
+
}
|
|
506
|
+
_highlightCurrentMatch() {
|
|
507
|
+
// Highlight the current index
|
|
508
|
+
if (this._currentIndex !== null) {
|
|
509
|
+
const match = this.matches[this._currentIndex];
|
|
510
|
+
this._cm.operation(() => {
|
|
511
|
+
const start = this._cm.doc.posFromIndex(match.position);
|
|
512
|
+
const from = {
|
|
513
|
+
line: start.line,
|
|
514
|
+
column: start.ch
|
|
515
|
+
};
|
|
516
|
+
const to = {
|
|
517
|
+
// Matches is on the same line
|
|
518
|
+
line: start.line,
|
|
519
|
+
column: start.ch + match.text.length
|
|
520
|
+
};
|
|
521
|
+
// No need to scroll into view this is the default behavior
|
|
522
|
+
this._cm.setSelection({
|
|
523
|
+
start: from,
|
|
524
|
+
end: to
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
// Set cursor to remove any selection
|
|
530
|
+
this._cm.setCursorPosition({ line: 0, column: 0 });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
_refreshOverlay() {
|
|
534
|
+
// clear search first
|
|
535
|
+
this._cm.removeOverlay('jp-searching');
|
|
536
|
+
this._overlay = this._getSearchOverlay();
|
|
537
|
+
this._cm.addOverlay(this._overlay);
|
|
538
|
+
}
|
|
539
|
+
_getSearchOverlay() {
|
|
540
|
+
const token = (stream) => {
|
|
541
|
+
const position = this._cm.doc.indexFromPos({
|
|
542
|
+
line: stream.lineOracle.line,
|
|
543
|
+
ch: stream.pos
|
|
544
|
+
});
|
|
545
|
+
let found = this._matches.length > 0
|
|
546
|
+
? Utils.findNext(this._matches, position, 0, // lastMatchIndex,
|
|
547
|
+
this._matches.length - 1)
|
|
548
|
+
: null;
|
|
549
|
+
if (found !== null) {
|
|
550
|
+
// lastMatchIndex = found;
|
|
551
|
+
const match = this._matches[found];
|
|
552
|
+
if (match.position > position + stream.string.length) {
|
|
553
|
+
// next match not in this stream, consume the rest of the stream
|
|
554
|
+
stream.skipToEnd();
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
if (position === match.position) {
|
|
558
|
+
// move the stream along and return searching style for the token
|
|
559
|
+
stream.pos += match.text.length || 1;
|
|
560
|
+
return 'searching';
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
// Move to the next match
|
|
564
|
+
stream.pos += match.position - position;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
// no matches, consume the rest of the stream
|
|
569
|
+
stream.skipToEnd();
|
|
570
|
+
}
|
|
571
|
+
return null;
|
|
572
|
+
};
|
|
573
|
+
return {
|
|
574
|
+
name: 'jp-searching',
|
|
575
|
+
/**
|
|
576
|
+
* Token function is called when a line needs to be processed -
|
|
577
|
+
* when the overlay is initially created, it's called on all lines;
|
|
578
|
+
* when a line is modified and needs to be re-evaluated, it's called
|
|
579
|
+
* on just that line.
|
|
580
|
+
*
|
|
581
|
+
* This implementation of the token function both constructs/maintains
|
|
582
|
+
* the overlay and keeps track of the match state as the document is
|
|
583
|
+
* updated while a search is active.
|
|
584
|
+
*/
|
|
585
|
+
token: token.bind(this)
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
_findNext(reverse) {
|
|
589
|
+
if (this._matches.length === 0) {
|
|
590
|
+
// No-op
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
// In order to support search-as-you-type, we needed a way to allow the first
|
|
594
|
+
// match to be selected when a search is started, but prevent the selected
|
|
595
|
+
// search to move for each new keypress. To do this, when a search is ended,
|
|
596
|
+
// the cursor is reversed, putting the head at the 'from' position. When a new
|
|
597
|
+
// search is started, the cursor we want is at the 'from' position, so that the same
|
|
598
|
+
// match is selected when the next key is entered (if it is still a match).
|
|
599
|
+
//
|
|
600
|
+
// When toggling through a search normally, the cursor is always set in the forward
|
|
601
|
+
// direction, so head is always at the 'to' position. That way, if reverse = false,
|
|
602
|
+
// the search proceeds from the 'to' position during normal toggling. If reverse = true,
|
|
603
|
+
// the search always proceeds from the 'anchor' position, which is at the 'from'.
|
|
604
|
+
const cursorToGet = reverse ? 'anchor' : 'head';
|
|
605
|
+
let lastPosition = this._cm.getCursor(cursorToGet);
|
|
606
|
+
if (lastPosition.line === 0 &&
|
|
607
|
+
lastPosition.ch === 0 &&
|
|
608
|
+
reverse &&
|
|
609
|
+
this.currentIndex === null) {
|
|
610
|
+
// The default position is (0, 0) but we want to start from the end in that case
|
|
611
|
+
lastPosition = {
|
|
612
|
+
// Go to virtual next line so position got clamp to end
|
|
613
|
+
line: this._cm.lineCount,
|
|
614
|
+
ch: 0
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
const position = this._cm.doc.indexFromPos(lastPosition);
|
|
618
|
+
let found = Utils.findNext(this._matches, position, 0, this._matches.length - 1);
|
|
619
|
+
if (found === null) {
|
|
620
|
+
// Don't loop
|
|
621
|
+
return reverse ? this._matches.length - 1 : null;
|
|
622
|
+
}
|
|
623
|
+
if (reverse) {
|
|
624
|
+
found -= 1;
|
|
625
|
+
if (found < 0) {
|
|
626
|
+
// Don't loop
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return found;
|
|
631
|
+
}
|
|
632
|
+
_toEditorPos(posIn) {
|
|
633
|
+
return {
|
|
634
|
+
line: posIn.line,
|
|
635
|
+
column: posIn.ch
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Helpers namespace
|
|
641
|
+
*/
|
|
642
|
+
var Utils;
|
|
643
|
+
(function (Utils) {
|
|
644
|
+
/**
|
|
645
|
+
* Find the closest match at `position` just after it.
|
|
646
|
+
*
|
|
647
|
+
* #### Notes
|
|
648
|
+
* Search is done using a binary search algorithm
|
|
649
|
+
*
|
|
650
|
+
* @param matches List of matches
|
|
651
|
+
* @param position Searched position
|
|
652
|
+
* @param lowerBound Lower range index
|
|
653
|
+
* @param higherBound High range index
|
|
654
|
+
* @returns The next match or null if none exists
|
|
655
|
+
*/
|
|
656
|
+
function findNext(matches, position, lowerBound = 0, higherBound = Infinity) {
|
|
657
|
+
higherBound = Math.min(matches.length - 1, higherBound);
|
|
658
|
+
while (lowerBound <= higherBound) {
|
|
659
|
+
let middle = Math.floor(0.5 * (lowerBound + higherBound));
|
|
660
|
+
const currentPosition = matches[middle].position;
|
|
661
|
+
if (currentPosition < position) {
|
|
662
|
+
lowerBound = middle + 1;
|
|
663
|
+
if (lowerBound < matches.length &&
|
|
664
|
+
matches[lowerBound].position > position) {
|
|
665
|
+
return lowerBound;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else if (currentPosition > position) {
|
|
669
|
+
higherBound = middle - 1;
|
|
670
|
+
if (higherBound > 0 && matches[higherBound].position < position) {
|
|
671
|
+
return middle;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
return middle;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
// Next could be the first item
|
|
679
|
+
const first = lowerBound > 0 ? lowerBound - 1 : 0;
|
|
680
|
+
const match = matches[first];
|
|
681
|
+
return match.position >= position ? first : null;
|
|
682
|
+
}
|
|
683
|
+
Utils.findNext = findNext;
|
|
684
|
+
})(Utils || (Utils = {}));
|
|
685
|
+
//# sourceMappingURL=searchprovider.js.map
|