@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.
Files changed (78) hide show
  1. package/lib/codemirror-ipythongfm.d.ts +9 -3
  2. package/lib/codemirror-ipythongfm.js +52 -30
  3. package/lib/codemirror-ipythongfm.js.map +1 -1
  4. package/lib/commands.d.ts +13 -0
  5. package/lib/commands.js +32 -0
  6. package/lib/commands.js.map +1 -0
  7. package/lib/editor.d.ts +48 -279
  8. package/lib/editor.js +220 -818
  9. package/lib/editor.js.map +1 -1
  10. package/lib/extension.d.ts +236 -0
  11. package/lib/extension.js +687 -0
  12. package/lib/extension.js.map +1 -0
  13. package/lib/extensions/customStyle.d.ts +25 -0
  14. package/lib/extensions/customStyle.js +51 -0
  15. package/lib/extensions/customStyle.js.map +1 -0
  16. package/lib/extensions/index.d.ts +3 -0
  17. package/lib/extensions/index.js +8 -0
  18. package/lib/extensions/index.js.map +1 -0
  19. package/lib/extensions/rulers.d.ts +8 -0
  20. package/lib/extensions/rulers.js +85 -0
  21. package/lib/extensions/rulers.js.map +1 -0
  22. package/lib/extensions/ybinding.d.ts +125 -0
  23. package/lib/extensions/ybinding.js +213 -0
  24. package/lib/extensions/ybinding.js.map +1 -0
  25. package/lib/factory.d.ts +15 -5
  26. package/lib/factory.js +53 -24
  27. package/lib/factory.js.map +1 -1
  28. package/lib/index.d.ts +7 -19
  29. package/lib/index.js +7 -23
  30. package/lib/index.js.map +1 -1
  31. package/lib/language.d.ts +96 -0
  32. package/lib/language.js +1690 -0
  33. package/lib/language.js.map +1 -0
  34. package/lib/mimetype.d.ts +3 -0
  35. package/lib/mimetype.js +16 -5
  36. package/lib/mimetype.js.map +1 -1
  37. package/lib/searchprovider.d.ts +197 -0
  38. package/lib/searchprovider.js +550 -0
  39. package/lib/searchprovider.js.map +1 -0
  40. package/lib/theme.d.ts +57 -0
  41. package/lib/theme.js +194 -0
  42. package/lib/theme.js.map +1 -0
  43. package/lib/token.d.ts +375 -0
  44. package/lib/token.js +18 -0
  45. package/lib/token.js.map +1 -0
  46. package/package.json +45 -41
  47. package/src/codemirror-ipythongfm.ts +109 -0
  48. package/src/commands.ts +34 -0
  49. package/src/editor.ts +783 -0
  50. package/src/extension.ts +878 -0
  51. package/src/extensions/customStyle.ts +79 -0
  52. package/src/extensions/index.ts +8 -0
  53. package/src/extensions/rulers.ts +112 -0
  54. package/src/extensions/ybinding.ts +267 -0
  55. package/src/factory.ts +100 -0
  56. package/src/index.ts +17 -0
  57. package/src/language.ts +1729 -0
  58. package/src/mimetype.ts +54 -0
  59. package/src/searchprovider.ts +700 -0
  60. package/src/theme.ts +219 -0
  61. package/src/token.ts +436 -0
  62. package/src/typings.d.ts +13 -0
  63. package/style/base.css +22 -162
  64. package/style/index.css +1 -7
  65. package/style/index.js +1 -7
  66. package/lib/codemirror-ipython.d.ts +0 -2
  67. package/lib/codemirror-ipython.js +0 -30
  68. package/lib/codemirror-ipython.js.map +0 -1
  69. package/lib/mode.d.ts +0 -81
  70. package/lib/mode.js +0 -149
  71. package/lib/mode.js.map +0 -1
  72. package/lib/syntaxstatus.d.ts +0 -67
  73. package/lib/syntaxstatus.js +0 -140
  74. package/lib/syntaxstatus.js.map +0 -1
  75. package/lib/tokens.d.ts +0 -18
  76. package/lib/tokens.js +0 -9
  77. package/lib/tokens.js.map +0 -1
  78. 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
+ }