@jupyterlab/codemirror 4.0.0-alpha.9 → 4.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 -273
  8. package/lib/editor.js +220 -819
  9. package/lib/editor.js.map +1 -1
  10. package/lib/extension.d.ts +236 -0
  11. package/lib/extension.js +696 -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 +129 -0
  23. package/lib/extensions/ybinding.js +229 -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 +1687 -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 +219 -0
  38. package/lib/searchprovider.js +640 -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 +890 -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 +295 -0
  55. package/src/factory.ts +100 -0
  56. package/src/index.ts +17 -0
  57. package/src/language.ts +1726 -0
  58. package/src/mimetype.ts +54 -0
  59. package/src/searchprovider.ts +813 -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 +33 -187
  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 -82
  70. package/lib/mode.js +0 -150
  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 -8
  77. package/lib/tokens.js.map +0 -1
  78. package/typings/codemirror/codemirror.d.ts +0 -227
@@ -0,0 +1,813 @@
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 { CodeMirrorEditor } from './editor';
35
+ import { StateEffect, StateEffectType, StateField } from '@codemirror/state';
36
+ import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
37
+ import { CodeEditor } from '@jupyterlab/codeeditor';
38
+ import {
39
+ GenericSearchProvider,
40
+ IBaseSearchProvider,
41
+ IFilters,
42
+ IReplaceOptions,
43
+ TextSearchEngine
44
+ } from '@jupyterlab/documentsearch';
45
+ import { ISignal, Signal } from '@lumino/signaling';
46
+ import { ISharedText, SourceChange } from '@jupyter/ydoc';
47
+
48
+ /**
49
+ * Search provider for editors.
50
+ */
51
+ export abstract class EditorSearchProvider<
52
+ T extends CodeEditor.IModel = CodeEditor.IModel
53
+ > implements IBaseSearchProvider
54
+ {
55
+ /**
56
+ * Constructor
57
+ */
58
+ constructor() {
59
+ this.currentIndex = null;
60
+ this._stateChanged = new Signal<IBaseSearchProvider, void>(this);
61
+ }
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
+ return;
158
+ }
159
+ this._isActive = v;
160
+ if (this._isActive) {
161
+ if (this.query !== null) {
162
+ await this.startQuery(this.query, this.filters);
163
+ }
164
+ } else {
165
+ await this.endQuery();
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Set whether search should be limitted to specified selection.
171
+ */
172
+ async setSearchSelection(selection: CodeEditor.IRange | null): Promise<void> {
173
+ if (this._inSelection === selection) {
174
+ return;
175
+ }
176
+ this._inSelection = selection;
177
+ await this.updateCodeMirror(this.model.sharedModel.getSource());
178
+ this._stateChanged.emit();
179
+ }
180
+
181
+ /**
182
+ * Initialize the search using the provided options. Should update the UI
183
+ * to highlight all matches and "select" the first match.
184
+ *
185
+ * @param query A RegExp to be use to perform the search
186
+ * @param filters Filter parameters to pass to provider
187
+ */
188
+ async startQuery(query: RegExp | null, filters?: IFilters): Promise<void> {
189
+ this.query = query;
190
+ this.filters = filters;
191
+
192
+ // Search input
193
+ const content = this.model.sharedModel.getSource();
194
+ await this.updateCodeMirror(content);
195
+ this.model.sharedModel.changed.connect(this.onSharedModelChanged, this);
196
+ }
197
+
198
+ /**
199
+ * Stop the search and clean any UI elements.
200
+ */
201
+ async endQuery(): Promise<void> {
202
+ await this.clearHighlight();
203
+ await this.cmHandler.endQuery();
204
+ this.currentIndex = null;
205
+ }
206
+
207
+ /**
208
+ * Highlight the next match.
209
+ *
210
+ * @returns The next match if there is one.
211
+ */
212
+ async highlightNext(
213
+ loop = true,
214
+ fromCursor = false
215
+ ): Promise<ISearchMatch | undefined> {
216
+ if (this.matchesCount === 0 || !this.isActive) {
217
+ this.currentIndex = null;
218
+ } else {
219
+ // This starts from the cursor position if `fromCursor` is true
220
+ let match = await this.cmHandler.highlightNext(fromCursor);
221
+ if (match) {
222
+ this.currentIndex = this.cmHandler.currentIndex;
223
+ } else {
224
+ // Note: the loop logic is only used in single-editor (e.g. file editor)
225
+ // provider sub-classes, notebook has it's own loop logic and ignores
226
+ // `currentIndex` as set here.
227
+ this.currentIndex = loop ? 0 : null;
228
+ }
229
+ return match;
230
+ }
231
+
232
+ return Promise.resolve(this.getCurrentMatch());
233
+ }
234
+
235
+ /**
236
+ * Highlight the previous match.
237
+ *
238
+ * @returns The previous match if there is one.
239
+ */
240
+ async highlightPrevious(
241
+ loop = true,
242
+ fromCursor = false
243
+ ): Promise<ISearchMatch | undefined> {
244
+ if (this.matchesCount === 0 || !this.isActive) {
245
+ this.currentIndex = null;
246
+ } else {
247
+ // This starts from the cursor position if `fromCursor` is true
248
+ let match = await this.cmHandler.highlightPrevious(fromCursor);
249
+ if (match) {
250
+ this.currentIndex = this.cmHandler.currentIndex;
251
+ } else {
252
+ this.currentIndex = loop ? this.matchesCount - 1 : null;
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
+ * The caller of this method is expected to call `highlightNext` if after
266
+ * calling `replaceCurrentMatch()` attribute `this.currentIndex` is null.
267
+ * It is necesary to let the caller handle highlighting because this
268
+ * method is used in composition pattern (search engine of notebook cells)
269
+ * and highligthing on the composer (notebook) level needs to switch to next
270
+ * engine (cell) with matches.
271
+ *
272
+ * @param newText The replacement text.
273
+ * @returns Whether a replace occurred.
274
+ */
275
+ replaceCurrentMatch(
276
+ newText: string,
277
+ loop?: boolean,
278
+ options?: IReplaceOptions
279
+ ): Promise<boolean> {
280
+ if (!this.isActive) {
281
+ return Promise.resolve(false);
282
+ }
283
+
284
+ let occurred = false;
285
+
286
+ if (
287
+ this.currentIndex !== null &&
288
+ this.currentIndex < this.cmHandler.matches.length
289
+ ) {
290
+ const match = this.getCurrentMatch();
291
+ // If cursor there is no match selected, highlight the next match
292
+ if (!match) {
293
+ this.currentIndex = null;
294
+ } else {
295
+ this.cmHandler.matches.splice(this.currentIndex, 1);
296
+ this.currentIndex =
297
+ this.currentIndex < this.cmHandler.matches.length
298
+ ? Math.max(this.currentIndex - 1, 0)
299
+ : null;
300
+ const substitutedText = options?.regularExpression
301
+ ? match!.text.replace(this.query!, newText)
302
+ : newText;
303
+ const insertText = options?.preserveCase
304
+ ? GenericSearchProvider.preserveCase(match.text, substitutedText)
305
+ : substitutedText;
306
+ this.model.sharedModel.updateSource(
307
+ match!.position,
308
+ match!.position + match!.text.length,
309
+ insertText
310
+ );
311
+ occurred = true;
312
+ }
313
+ }
314
+
315
+ return Promise.resolve(occurred);
316
+ }
317
+
318
+ /**
319
+ * Replace all matches in the cell source with the provided text
320
+ *
321
+ * @param newText The replacement text.
322
+ * @returns Whether a replace occurred.
323
+ */
324
+ replaceAllMatches(
325
+ newText: string,
326
+ options?: IReplaceOptions
327
+ ): Promise<boolean> {
328
+ if (!this.isActive) {
329
+ return Promise.resolve(false);
330
+ }
331
+
332
+ let occurred = this.cmHandler.matches.length > 0;
333
+ let src = this.model.sharedModel.getSource();
334
+ let lastEnd = 0;
335
+ const finalSrc = this.cmHandler.matches.reduce((agg, match) => {
336
+ const start = match.position as number;
337
+ const end = start + match.text.length;
338
+ const substitutedText = options?.regularExpression
339
+ ? match!.text.replace(this.query!, newText)
340
+ : newText;
341
+ const insertText = options?.preserveCase
342
+ ? GenericSearchProvider.preserveCase(match.text, substitutedText)
343
+ : substitutedText;
344
+ const newStep = `${agg}${src.slice(lastEnd, start)}${insertText}`;
345
+ lastEnd = end;
346
+ return newStep;
347
+ }, '');
348
+
349
+ if (occurred) {
350
+ this.cmHandler.matches = [];
351
+ this.currentIndex = null;
352
+ this.model.sharedModel.setSource(`${finalSrc}${src.slice(lastEnd)}`);
353
+ }
354
+ return Promise.resolve(occurred);
355
+ }
356
+
357
+ /**
358
+ * Get the current match if it exists.
359
+ *
360
+ * @returns The current match
361
+ */
362
+ getCurrentMatch(): ISearchMatch | undefined {
363
+ if (this.currentIndex === null) {
364
+ return undefined;
365
+ } else {
366
+ let match: ISearchMatch | undefined = undefined;
367
+ if (this.currentIndex < this.cmHandler.matches.length) {
368
+ match = this.cmHandler.matches[this.currentIndex];
369
+ }
370
+ return match;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Callback on source change
376
+ *
377
+ * @param emitter Source of the change
378
+ * @param changes Source change
379
+ */
380
+ protected async onSharedModelChanged(
381
+ emitter: ISharedText,
382
+ changes: SourceChange
383
+ ): Promise<void> {
384
+ if (changes.sourceChange) {
385
+ await this.updateCodeMirror(emitter.getSource());
386
+ this._stateChanged.emit();
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Update matches
392
+ */
393
+ protected async updateCodeMirror(content: string) {
394
+ if (this.query !== null && this.isActive) {
395
+ const allMatches = await TextSearchEngine.search(this.query, content);
396
+ if (this._inSelection) {
397
+ const editor = this.editor!;
398
+ const start = editor.getOffsetAt(this._inSelection.start);
399
+ const end = editor.getOffsetAt(this._inSelection.end);
400
+ this.cmHandler.matches = allMatches.filter(
401
+ match => match.position >= start && match.position <= end
402
+ );
403
+ // A special case to always have a current match when in line selection mode.
404
+ if (
405
+ this.cmHandler.currentIndex === null &&
406
+ this.cmHandler.matches.length > 0
407
+ ) {
408
+ await this.cmHandler.highlightNext(true, false);
409
+ }
410
+ this.currentIndex = this.cmHandler.currentIndex;
411
+ } else {
412
+ this.cmHandler.matches = allMatches;
413
+ }
414
+ } else {
415
+ this.cmHandler.matches = [];
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Current match index
421
+ */
422
+ protected currentIndex: number | null = null;
423
+ /**
424
+ * Current search filters
425
+ */
426
+ protected filters: IFilters | undefined;
427
+ /**
428
+ * Current search query
429
+ */
430
+ protected query: RegExp | null = null;
431
+ // Needs to be protected so subclass can emit the signal too.
432
+ protected _stateChanged: Signal<IBaseSearchProvider, void>;
433
+ private _isActive = true;
434
+ private _inSelection: CodeEditor.IRange | null = null;
435
+ private _isDisposed = false;
436
+ private _cmHandler: CodeMirrorSearchHighlighter | null = null;
437
+ }
438
+
439
+ /**
440
+ * Matches to be highlighted.
441
+ */
442
+ interface IEffectValue {
443
+ matches: ISearchMatch[];
444
+ currentMatch: ISearchMatch | null;
445
+ }
446
+
447
+ /**
448
+ * Helper class to highlight texts in a code mirror editor.
449
+ *
450
+ * Highlighted texts (aka `matches`) must be provided through
451
+ * the `matches` attributes.
452
+ *
453
+ * **NOTES:**
454
+ * - to retain the selection visibility `drawSelection` extension is needed.
455
+ * - highlighting starts from the cursor (if editor is focused, cursor moved,
456
+ * or `fromCursor` argument is set to `true`), or from last "current" match
457
+ * otherwise.
458
+ * - `currentIndex` is the (readonly) source of truth for the current match.
459
+ */
460
+ export class CodeMirrorSearchHighlighter {
461
+ /**
462
+ * Constructor
463
+ *
464
+ * @param editor The CodeMirror editor
465
+ */
466
+ constructor(editor: CodeMirrorEditor | null) {
467
+ this._cm = editor;
468
+ this._matches = new Array<ISearchMatch>();
469
+ this._currentIndex = null;
470
+
471
+ this._highlightEffect = StateEffect.define<IEffectValue>({
472
+ map: (value, mapping) => {
473
+ const transform = (v: ISearchMatch) => ({
474
+ text: v.text,
475
+ position: mapping.mapPos(v.position)
476
+ });
477
+ return {
478
+ matches: value.matches.map(transform),
479
+ currentMatch: value.currentMatch
480
+ ? transform(value.currentMatch)
481
+ : null
482
+ };
483
+ }
484
+ });
485
+ this._highlightMark = Decoration.mark({ class: 'cm-searching' });
486
+ this._currentMark = Decoration.mark({ class: 'jp-current-match' });
487
+
488
+ this._highlightField = StateField.define<DecorationSet>({
489
+ create: () => {
490
+ return Decoration.none;
491
+ },
492
+ update: (highlights, transaction) => {
493
+ highlights = highlights.map(transaction.changes);
494
+ for (let ef of transaction.effects) {
495
+ if (ef.is(this._highlightEffect)) {
496
+ const e = ef as StateEffect<IEffectValue>;
497
+ if (e.value.matches.length) {
498
+ highlights = highlights.update({
499
+ add: e.value.matches.map(m =>
500
+ this._highlightMark.range(
501
+ m.position,
502
+ m.position + m.text.length
503
+ )
504
+ ),
505
+ // filter out old marks
506
+ filter: () => false
507
+ });
508
+ highlights = highlights.update({
509
+ add: e.value.currentMatch
510
+ ? [
511
+ this._currentMark.range(
512
+ e.value.currentMatch.position,
513
+ e.value.currentMatch.position +
514
+ e.value.currentMatch.text.length
515
+ )
516
+ ]
517
+ : []
518
+ });
519
+ } else {
520
+ highlights = Decoration.none;
521
+ }
522
+ }
523
+ }
524
+ return highlights;
525
+ },
526
+ provide: f => EditorView.decorations.from(f)
527
+ });
528
+ }
529
+
530
+ /**
531
+ * The current index of the selected match.
532
+ */
533
+ get currentIndex(): number | null {
534
+ return this._currentIndex;
535
+ }
536
+
537
+ /**
538
+ * The list of matches
539
+ */
540
+ get matches(): ISearchMatch[] {
541
+ return this._matches;
542
+ }
543
+ set matches(v: ISearchMatch[]) {
544
+ this._matches = v;
545
+ if (
546
+ this._currentIndex !== null &&
547
+ this._currentIndex > this._matches.length
548
+ ) {
549
+ this._currentIndex = this._matches.length > 0 ? 0 : null;
550
+ }
551
+ this._highlightCurrentMatch(true);
552
+ }
553
+
554
+ private _current: ISearchMatch | null = null;
555
+
556
+ /**
557
+ * Clear all highlighted matches
558
+ */
559
+ clearHighlight(): void {
560
+ this._currentIndex = null;
561
+ this._highlightCurrentMatch();
562
+ }
563
+
564
+ /**
565
+ * Clear the highlighted matches.
566
+ */
567
+ endQuery(): Promise<void> {
568
+ this._currentIndex = null;
569
+ this._matches = [];
570
+
571
+ if (this._cm) {
572
+ this._cm.editor.dispatch({
573
+ effects: this._highlightEffect.of({ matches: [], currentMatch: null })
574
+ });
575
+ }
576
+
577
+ return Promise.resolve();
578
+ }
579
+
580
+ /**
581
+ * Highlight the next match
582
+ *
583
+ * @returns The next match if available
584
+ */
585
+ highlightNext(
586
+ fromCursor = false,
587
+ doNotModifySelection = false
588
+ ): Promise<ISearchMatch | undefined> {
589
+ this._currentIndex = this._findNext(false, fromCursor);
590
+ this._highlightCurrentMatch(doNotModifySelection);
591
+ return Promise.resolve(
592
+ this._currentIndex !== null
593
+ ? this._matches[this._currentIndex]
594
+ : undefined
595
+ );
596
+ }
597
+
598
+ /**
599
+ * Highlight the previous match
600
+ *
601
+ * @returns The previous match if available
602
+ */
603
+ highlightPrevious(fromCursor = false): Promise<ISearchMatch | undefined> {
604
+ this._currentIndex = this._findNext(true, fromCursor);
605
+ this._highlightCurrentMatch();
606
+ return Promise.resolve(
607
+ this._currentIndex !== null
608
+ ? this._matches[this._currentIndex]
609
+ : undefined
610
+ );
611
+ }
612
+
613
+ /**
614
+ * Set the editor
615
+ *
616
+ * @param editor Editor
617
+ */
618
+ setEditor(editor: CodeMirrorEditor): void {
619
+ if (this._cm) {
620
+ throw new Error('CodeMirrorEditor already set.');
621
+ } else {
622
+ this._cm = editor;
623
+ if (this._currentIndex !== null) {
624
+ this._highlightCurrentMatch();
625
+ }
626
+ this._refresh();
627
+ }
628
+ }
629
+
630
+ private _selectCurrentMatch(): void {
631
+ const match = this._current;
632
+ if (!match) {
633
+ return;
634
+ }
635
+ if (!this._cm) {
636
+ return;
637
+ }
638
+ const selection = this._cm.editor.state.selection.main;
639
+ if (
640
+ selection.from === match.position &&
641
+ selection.to === match.position + match.text.length
642
+ ) {
643
+ return;
644
+ }
645
+ const cursor = {
646
+ anchor: match.position,
647
+ head: match.position + match.text.length
648
+ };
649
+ this._cm.editor.dispatch({
650
+ selection: cursor,
651
+ scrollIntoView: true
652
+ });
653
+ }
654
+
655
+ private _highlightCurrentMatch(doNotModifySelection = false): void {
656
+ if (!this._cm) {
657
+ // no-op
658
+ return;
659
+ }
660
+
661
+ // Highlight the current index
662
+ if (this._currentIndex !== null) {
663
+ const match = this.matches[this._currentIndex];
664
+ this._current = match;
665
+ // Do not change selection/scroll if user is selecting
666
+ if (!doNotModifySelection) {
667
+ if (this._cm.hasFocus()) {
668
+ // If editor is focused we actually set the cursor on the match.
669
+ this._selectCurrentMatch();
670
+ } else {
671
+ // otherwise we just scroll to preserve the selection.
672
+ this._cm.editor.dispatch({
673
+ effects: EditorView.scrollIntoView(match.position)
674
+ });
675
+ }
676
+ }
677
+ } else {
678
+ this._current = null;
679
+ }
680
+ this._refresh();
681
+ }
682
+
683
+ private _refresh(): void {
684
+ if (!this._cm) {
685
+ // no-op
686
+ return;
687
+ }
688
+ let effects: StateEffect<unknown>[] = [
689
+ this._highlightEffect.of({
690
+ matches: this.matches,
691
+ currentMatch: this._current
692
+ })
693
+ ];
694
+
695
+ if (!this._cm!.state.field(this._highlightField, false)) {
696
+ effects.push(StateEffect.appendConfig.of([this._highlightField]));
697
+ // set cursor on active match when editor gets focused
698
+ const focusExtension = EditorView.domEventHandlers({
699
+ focus: () => {
700
+ this._selectCurrentMatch();
701
+ }
702
+ });
703
+ effects.push(StateEffect.appendConfig.of([focusExtension]));
704
+ }
705
+ this._cm!.editor.dispatch({ effects });
706
+ }
707
+
708
+ private _findNext(reverse: boolean, fromCursor = false): number | null {
709
+ if (this._matches.length === 0) {
710
+ // No-op
711
+ return null;
712
+ }
713
+
714
+ let lastPosition = 0;
715
+ if (this._cm!.hasFocus() || fromCursor) {
716
+ const cursor = this._cm!.state.selection.main;
717
+ lastPosition = reverse ? cursor.anchor : cursor.head;
718
+ } else if (this._current) {
719
+ lastPosition = reverse
720
+ ? this._current.position
721
+ : this._current.position + this._current.text.length;
722
+ }
723
+ if (lastPosition === 0 && reverse && this.currentIndex === null) {
724
+ // The default position is (0, 0) but we want to start from the end in that case
725
+ lastPosition = this._cm!.doc.length;
726
+ }
727
+
728
+ const position = lastPosition;
729
+
730
+ let found = Utils.findNext(
731
+ this._matches,
732
+ position,
733
+ 0,
734
+ this._matches.length - 1
735
+ );
736
+
737
+ if (found === null) {
738
+ // Don't loop
739
+ return reverse ? this._matches.length - 1 : null;
740
+ }
741
+
742
+ if (reverse) {
743
+ found -= 1;
744
+ if (found < 0) {
745
+ // Don't loop
746
+ return null;
747
+ }
748
+ }
749
+
750
+ return found;
751
+ }
752
+
753
+ private _cm: CodeMirrorEditor | null;
754
+ private _currentIndex: number | null;
755
+ private _matches: ISearchMatch[];
756
+ private _highlightEffect: StateEffectType<IEffectValue>;
757
+ private _highlightMark: Decoration;
758
+ private _currentMark: Decoration;
759
+ private _highlightField: StateField<DecorationSet>;
760
+ }
761
+
762
+ /**
763
+ * Helpers namespace
764
+ */
765
+ namespace Utils {
766
+ /**
767
+ * Find the closest match at `position` just after it.
768
+ *
769
+ * #### Notes
770
+ * Search is done using a binary search algorithm
771
+ *
772
+ * @param matches List of matches
773
+ * @param position Searched position
774
+ * @param lowerBound Lower range index
775
+ * @param higherBound High range index
776
+ * @returns The next match or null if none exists
777
+ */
778
+ export function findNext(
779
+ matches: ISearchMatch[],
780
+ position: number,
781
+ lowerBound = 0,
782
+ higherBound = Infinity
783
+ ): number | null {
784
+ higherBound = Math.min(matches.length - 1, higherBound);
785
+
786
+ while (lowerBound <= higherBound) {
787
+ let middle = Math.floor(0.5 * (lowerBound + higherBound));
788
+ const currentPosition = matches[middle].position;
789
+
790
+ if (currentPosition < position) {
791
+ lowerBound = middle + 1;
792
+ if (
793
+ lowerBound < matches.length &&
794
+ matches[lowerBound].position > position
795
+ ) {
796
+ return lowerBound;
797
+ }
798
+ } else if (currentPosition > position) {
799
+ higherBound = middle - 1;
800
+ if (higherBound > 0 && matches[higherBound].position < position) {
801
+ return middle;
802
+ }
803
+ } else {
804
+ return middle;
805
+ }
806
+ }
807
+
808
+ // Next could be the first item
809
+ const first = lowerBound > 0 ? lowerBound - 1 : 0;
810
+ const match = matches[first];
811
+ return match.position >= position ? first : null;
812
+ }
813
+ }