@jupyterlab/codemirror 4.0.0-alpha.20 → 4.0.0-alpha.21

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.
@@ -31,7 +31,6 @@
31
31
  */
32
32
 
33
33
  import { ISearchMatch } from '@jupyterlab/documentsearch';
34
- import { JSONExt } from '@lumino/coreutils';
35
34
  import { CodeMirrorEditor } from './editor';
36
35
  import { StateEffect, StateEffectType, StateField } from '@codemirror/state';
37
36
  import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
@@ -60,6 +59,7 @@ export abstract class EditorSearchProvider<
60
59
  this.currentIndex = null;
61
60
  this._stateChanged = new Signal<IBaseSearchProvider, void>(this);
62
61
  }
62
+
63
63
  /**
64
64
  * CodeMirror search highlighter
65
65
  */
@@ -153,9 +153,10 @@ export abstract class EditorSearchProvider<
153
153
  * @param v New value
154
154
  */
155
155
  async setIsActive(v: boolean): Promise<void> {
156
- if (this._isActive !== v) {
157
- this._isActive = v;
156
+ if (this._isActive === v) {
157
+ return;
158
158
  }
159
+ this._isActive = v;
159
160
  if (this._isActive) {
160
161
  if (this.query !== null) {
161
162
  await this.startQuery(this.query, this.filters);
@@ -165,6 +166,18 @@ export abstract class EditorSearchProvider<
165
166
  }
166
167
  }
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
+
168
181
  /**
169
182
  * Initialize the search using the provided options. Should update the UI
170
183
  * to highlight all matches and "select" the first match.
@@ -186,6 +199,7 @@ export abstract class EditorSearchProvider<
186
199
  * Stop the search and clean any UI elements.
187
200
  */
188
201
  async endQuery(): Promise<void> {
202
+ await this.clearHighlight();
189
203
  await this.cmHandler.endQuery();
190
204
  this.currentIndex = null;
191
205
  }
@@ -195,15 +209,21 @@ export abstract class EditorSearchProvider<
195
209
  *
196
210
  * @returns The next match if there is one.
197
211
  */
198
- async highlightNext(loop = true): Promise<ISearchMatch | undefined> {
212
+ async highlightNext(
213
+ loop = true,
214
+ fromCursor = false
215
+ ): Promise<ISearchMatch | undefined> {
199
216
  if (this.matchesCount === 0 || !this.isActive) {
200
217
  this.currentIndex = null;
201
218
  } else {
202
- // This starts from the cursor position
203
- let match = await this.cmHandler.highlightNext();
219
+ // This starts from the cursor position if `fromCursor` is true
220
+ let match = await this.cmHandler.highlightNext(fromCursor);
204
221
  if (match) {
205
222
  this.currentIndex = this.cmHandler.currentIndex;
206
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.
207
227
  this.currentIndex = loop ? 0 : null;
208
228
  }
209
229
  return match;
@@ -217,12 +237,15 @@ export abstract class EditorSearchProvider<
217
237
  *
218
238
  * @returns The previous match if there is one.
219
239
  */
220
- async highlightPrevious(loop = true): Promise<ISearchMatch | undefined> {
240
+ async highlightPrevious(
241
+ loop = true,
242
+ fromCursor = false
243
+ ): Promise<ISearchMatch | undefined> {
221
244
  if (this.matchesCount === 0 || !this.isActive) {
222
245
  this.currentIndex = null;
223
246
  } else {
224
- // This starts from the cursor position
225
- let match = await this.cmHandler.highlightPrevious();
247
+ // This starts from the cursor position if `fromCursor` is true
248
+ let match = await this.cmHandler.highlightPrevious(fromCursor);
226
249
  if (match) {
227
250
  this.currentIndex = this.cmHandler.currentIndex;
228
251
  } else {
@@ -239,6 +262,13 @@ export abstract class EditorSearchProvider<
239
262
  *
240
263
  * If no match is selected, it won't do anything.
241
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
+ *
242
272
  * @param newText The replacement text.
243
273
  * @returns Whether a replace occurred.
244
274
  */
@@ -257,19 +287,16 @@ export abstract class EditorSearchProvider<
257
287
  this.currentIndex !== null &&
258
288
  this.currentIndex < this.cmHandler.matches.length
259
289
  ) {
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
290
  const match = this.getCurrentMatch();
266
- // If cursor is not on a selection, highlight the next match
267
- if (selection !== match?.text) {
291
+ // If cursor there is no match selected, highlight the next match
292
+ if (!match) {
268
293
  this.currentIndex = null;
269
- // The next will be highlighted as a consequence of this returning false
270
294
  } else {
271
295
  this.cmHandler.matches.splice(this.currentIndex, 1);
272
- this.currentIndex = null;
296
+ this.currentIndex =
297
+ this.currentIndex < this.cmHandler.matches.length
298
+ ? Math.max(this.currentIndex - 1, 0)
299
+ : null;
273
300
  const substitutedText = options?.regularExpression
274
301
  ? match!.text.replace(this.query!, newText)
275
302
  : newText;
@@ -332,7 +359,7 @@ export abstract class EditorSearchProvider<
332
359
  *
333
360
  * @returns The current match
334
361
  */
335
- protected getCurrentMatch(): ISearchMatch | undefined {
362
+ getCurrentMatch(): ISearchMatch | undefined {
336
363
  if (this.currentIndex === null) {
337
364
  return undefined;
338
365
  } else {
@@ -365,10 +392,25 @@ export abstract class EditorSearchProvider<
365
392
  */
366
393
  protected async updateCodeMirror(content: string) {
367
394
  if (this.query !== null && this.isActive) {
368
- this.cmHandler.matches = await TextSearchEngine.search(
369
- this.query,
370
- content
371
- );
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
+ }
372
414
  } else {
373
415
  this.cmHandler.matches = [];
374
416
  }
@@ -389,15 +431,31 @@ export abstract class EditorSearchProvider<
389
431
  // Needs to be protected so subclass can emit the signal too.
390
432
  protected _stateChanged: Signal<IBaseSearchProvider, void>;
391
433
  private _isActive = true;
434
+ private _inSelection: CodeEditor.IRange | null = null;
392
435
  private _isDisposed = false;
393
436
  private _cmHandler: CodeMirrorSearchHighlighter | null = null;
394
437
  }
395
438
 
439
+ /**
440
+ * Matches to be highlighted.
441
+ */
442
+ interface IEffectValue {
443
+ matches: ISearchMatch[];
444
+ currentMatch: ISearchMatch | null;
445
+ }
446
+
396
447
  /**
397
448
  * Helper class to highlight texts in a code mirror editor.
398
449
  *
399
450
  * Highlighted texts (aka `matches`) must be provided through
400
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.
401
459
  */
402
460
  export class CodeMirrorSearchHighlighter {
403
461
  /**
@@ -410,15 +468,22 @@ export class CodeMirrorSearchHighlighter {
410
468
  this._matches = new Array<ISearchMatch>();
411
469
  this._currentIndex = null;
412
470
 
413
- this._highlightEffect = StateEffect.define<{ matches: ISearchMatch[] }>({
414
- map: (value, mapping) => ({
415
- matches: value.matches.map(v => ({
471
+ this._highlightEffect = StateEffect.define<IEffectValue>({
472
+ map: (value, mapping) => {
473
+ const transform = (v: ISearchMatch) => ({
416
474
  text: v.text,
417
475
  position: mapping.mapPos(v.position)
418
- }))
419
- })
476
+ });
477
+ return {
478
+ matches: value.matches.map(transform),
479
+ currentMatch: value.currentMatch
480
+ ? transform(value.currentMatch)
481
+ : null
482
+ };
483
+ }
420
484
  });
421
485
  this._highlightMark = Decoration.mark({ class: 'cm-searching' });
486
+ this._currentMark = Decoration.mark({ class: 'jp-current-match' });
422
487
 
423
488
  this._highlightField = StateField.define<DecorationSet>({
424
489
  create: () => {
@@ -428,7 +493,7 @@ export class CodeMirrorSearchHighlighter {
428
493
  highlights = highlights.map(transaction.changes);
429
494
  for (let ef of transaction.effects) {
430
495
  if (ef.is(this._highlightEffect)) {
431
- const e = ef as StateEffect<{ matches: ISearchMatch[] }>;
496
+ const e = ef as StateEffect<IEffectValue>;
432
497
  if (e.value.matches.length) {
433
498
  highlights = highlights.update({
434
499
  add: e.value.matches.map(m =>
@@ -440,6 +505,17 @@ export class CodeMirrorSearchHighlighter {
440
505
  // filter out old marks
441
506
  filter: () => false
442
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
+ });
443
519
  } else {
444
520
  highlights = Decoration.none;
445
521
  }
@@ -465,12 +541,18 @@ export class CodeMirrorSearchHighlighter {
465
541
  return this._matches;
466
542
  }
467
543
  set matches(v: ISearchMatch[]) {
468
- if (!JSONExt.deepEqual(this._matches as any, v as any)) {
469
- this._matches = v;
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;
470
550
  }
471
- this._refresh();
551
+ this._highlightCurrentMatch(true);
472
552
  }
473
553
 
554
+ private _current: ISearchMatch | null = null;
555
+
474
556
  /**
475
557
  * Clear all highlighted matches
476
558
  */
@@ -488,17 +570,8 @@ export class CodeMirrorSearchHighlighter {
488
570
 
489
571
  if (this._cm) {
490
572
  this._cm.editor.dispatch({
491
- effects: this._highlightEffect.of({ matches: [] })
573
+ effects: this._highlightEffect.of({ matches: [], currentMatch: null })
492
574
  });
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
575
  }
503
576
 
504
577
  return Promise.resolve();
@@ -509,9 +582,12 @@ export class CodeMirrorSearchHighlighter {
509
582
  *
510
583
  * @returns The next match if available
511
584
  */
512
- highlightNext(): Promise<ISearchMatch | undefined> {
513
- this._currentIndex = this._findNext(false);
514
- this._highlightCurrentMatch();
585
+ highlightNext(
586
+ fromCursor = false,
587
+ doNotModifySelection = false
588
+ ): Promise<ISearchMatch | undefined> {
589
+ this._currentIndex = this._findNext(false, fromCursor);
590
+ this._highlightCurrentMatch(doNotModifySelection);
515
591
  return Promise.resolve(
516
592
  this._currentIndex !== null
517
593
  ? this._matches[this._currentIndex]
@@ -524,8 +600,8 @@ export class CodeMirrorSearchHighlighter {
524
600
  *
525
601
  * @returns The previous match if available
526
602
  */
527
- highlightPrevious(): Promise<ISearchMatch | undefined> {
528
- this._currentIndex = this._findNext(true);
603
+ highlightPrevious(fromCursor = false): Promise<ISearchMatch | undefined> {
604
+ this._currentIndex = this._findNext(true, fromCursor);
529
605
  this._highlightCurrentMatch();
530
606
  return Promise.resolve(
531
607
  this._currentIndex !== null
@@ -544,14 +620,39 @@ export class CodeMirrorSearchHighlighter {
544
620
  throw new Error('CodeMirrorEditor already set.');
545
621
  } else {
546
622
  this._cm = editor;
547
- this._refresh();
548
623
  if (this._currentIndex !== null) {
549
624
  this._highlightCurrentMatch();
550
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;
551
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
+ });
552
653
  }
553
654
 
554
- private _highlightCurrentMatch(): void {
655
+ private _highlightCurrentMatch(doNotModifySelection = false): void {
555
656
  if (!this._cm) {
556
657
  // no-op
557
658
  return;
@@ -560,18 +661,23 @@ export class CodeMirrorSearchHighlighter {
560
661
  // Highlight the current index
561
662
  if (this._currentIndex !== null) {
562
663
  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
- });
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
+ }
571
677
  } else {
572
- // Set cursor to remove any selection
573
- this._cm.editor.dispatch({ selection: { anchor: 0 } });
678
+ this._current = null;
574
679
  }
680
+ this._refresh();
575
681
  }
576
682
 
577
683
  private _refresh(): void {
@@ -579,35 +685,41 @@ export class CodeMirrorSearchHighlighter {
579
685
  // no-op
580
686
  return;
581
687
  }
582
-
583
688
  let effects: StateEffect<unknown>[] = [
584
- this._highlightEffect.of({ matches: this.matches })
689
+ this._highlightEffect.of({
690
+ matches: this.matches,
691
+ currentMatch: this._current
692
+ })
585
693
  ];
694
+
586
695
  if (!this._cm!.state.field(this._highlightField, false)) {
587
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]));
588
704
  }
589
705
  this._cm!.editor.dispatch({ effects });
590
706
  }
591
707
 
592
- private _findNext(reverse: boolean): number | null {
708
+ private _findNext(reverse: boolean, fromCursor = false): number | null {
593
709
  if (this._matches.length === 0) {
594
710
  // No-op
595
711
  return null;
596
712
  }
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;
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
+ }
611
723
  if (lastPosition === 0 && reverse && this.currentIndex === null) {
612
724
  // The default position is (0, 0) but we want to start from the end in that case
613
725
  lastPosition = this._cm!.doc.length;
@@ -641,8 +753,9 @@ export class CodeMirrorSearchHighlighter {
641
753
  private _cm: CodeMirrorEditor | null;
642
754
  private _currentIndex: number | null;
643
755
  private _matches: ISearchMatch[];
644
- private _highlightEffect: StateEffectType<{ matches: ISearchMatch[] }>;
756
+ private _highlightEffect: StateEffectType<IEffectValue>;
645
757
  private _highlightMark: Decoration;
758
+ private _currentMark: Decoration;
646
759
  private _highlightField: StateField<DecorationSet>;
647
760
  }
648
761
 
package/style/base.css CHANGED
@@ -63,10 +63,22 @@
63
63
  color: var(--jp-search-unselected-match-color);
64
64
  }
65
65
 
66
- .cm-searching::selection,
67
- .cm-searching span::selection {
68
- background-color: var(--jp-search-selected-match-background-color) !important;
69
- color: var(--jp-search-selected-match-color) !important;
66
+ .cm-content .cm-searching::selection,
67
+ .cm-content .cm-searching span::selection {
68
+ background-color: var(--jp-search-unselected-match-background-color);
69
+ color: var(--jp-search-unselected-match-color);
70
+ }
71
+
72
+ .jp-current-match .cm-searching,
73
+ .jp-current-match .cm-searching span {
74
+ background-color: var(--jp-search-selected-match-background-color);
75
+ color: var(--jp-search-selected-match-color);
76
+ }
77
+
78
+ .cm-content .jp-current-match .cm-searching::selection,
79
+ .cm-content .jp-current-match .cm-searching span::selection {
80
+ background-color: var(--jp-search-selected-match-background-color);
81
+ color: var(--jp-search-selected-match-color);
70
82
  }
71
83
 
72
84
  .cm-trailingspace {