UNPKG

41.8 kBJavaScriptView Raw
1import { EditorView, Decoration, ViewPlugin, runScopeHandlers } from '@codemirror/view';
2import { StateEffect, StateField, EditorSelection, Facet, combineConfig, CharCategory, Prec } from '@codemirror/state';
3import { showPanel, getPanel } from '@codemirror/panel';
4import { RangeSetBuilder } from '@codemirror/rangeset';
5import elt from 'crelt';
6import { codePointAt, fromCodePoint, codePointSize } from '@codemirror/text';
7
8const basicNormalize = typeof String.prototype.normalize == "function"
9 ? x => x.normalize("NFKD") : x => x;
10/**
11A search cursor provides an iterator over text matches in a
12document.
13*/
14class SearchCursor {
15 /**
16 Create a text cursor. The query is the search string, `from` to
17 `to` provides the region to search.
18
19 When `normalize` is given, it will be called, on both the query
20 string and the content it is matched against, before comparing.
21 You can, for example, create a case-insensitive search by
22 passing `s => s.toLowerCase()`.
23
24 Text is always normalized with
25 [`.normalize("NFKD")`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize)
26 (when supported).
27 */
28 constructor(text, query, from = 0, to = text.length, normalize) {
29 /**
30 The current match (only holds a meaningful value after
31 [`next`](https://codemirror.net/6/docs/ref/#search.SearchCursor.next) has been called and when
32 `done` is false).
33 */
34 this.value = { from: 0, to: 0 };
35 /**
36 Whether the end of the iterated region has been reached.
37 */
38 this.done = false;
39 this.matches = [];
40 this.buffer = "";
41 this.bufferPos = 0;
42 this.iter = text.iterRange(from, to);
43 this.bufferStart = from;
44 this.normalize = normalize ? x => normalize(basicNormalize(x)) : basicNormalize;
45 this.query = this.normalize(query);
46 }
47 peek() {
48 if (this.bufferPos == this.buffer.length) {
49 this.bufferStart += this.buffer.length;
50 this.iter.next();
51 if (this.iter.done)
52 return -1;
53 this.bufferPos = 0;
54 this.buffer = this.iter.value;
55 }
56 return codePointAt(this.buffer, this.bufferPos);
57 }
58 /**
59 Look for the next match. Updates the iterator's
60 [`value`](https://codemirror.net/6/docs/ref/#search.SearchCursor.value) and
61 [`done`](https://codemirror.net/6/docs/ref/#search.SearchCursor.done) properties. Should be called
62 at least once before using the cursor.
63 */
64 next() {
65 while (this.matches.length)
66 this.matches.pop();
67 return this.nextOverlapping();
68 }
69 /**
70 The `next` method will ignore matches that partially overlap a
71 previous match. This method behaves like `next`, but includes
72 such matches.
73 */
74 nextOverlapping() {
75 for (;;) {
76 let next = this.peek();
77 if (next < 0) {
78 this.done = true;
79 return this;
80 }
81 let str = fromCodePoint(next), start = this.bufferStart + this.bufferPos;
82 this.bufferPos += codePointSize(next);
83 let norm = this.normalize(str);
84 for (let i = 0, pos = start;; i++) {
85 let code = norm.charCodeAt(i);
86 let match = this.match(code, pos);
87 if (match) {
88 this.value = match;
89 return this;
90 }
91 if (i == norm.length - 1)
92 break;
93 if (pos == start && i < str.length && str.charCodeAt(i) == code)
94 pos++;
95 }
96 }
97 }
98 match(code, pos) {
99 let match = null;
100 for (let i = 0; i < this.matches.length; i += 2) {
101 let index = this.matches[i], keep = false;
102 if (this.query.charCodeAt(index) == code) {
103 if (index == this.query.length - 1) {
104 match = { from: this.matches[i + 1], to: pos + 1 };
105 }
106 else {
107 this.matches[i]++;
108 keep = true;
109 }
110 }
111 if (!keep) {
112 this.matches.splice(i, 2);
113 i -= 2;
114 }
115 }
116 if (this.query.charCodeAt(0) == code) {
117 if (this.query.length == 1)
118 match = { from: pos, to: pos + 1 };
119 else
120 this.matches.push(1, pos);
121 }
122 return match;
123 }
124}
125if (typeof Symbol != "undefined")
126 SearchCursor.prototype[Symbol.iterator] = function () { return this; };
127
128const empty = { from: -1, to: -1, match: /*@__PURE__*//.*/.exec("") };
129const baseFlags = "gm" + (/x/.unicode == null ? "" : "u");
130/**
131This class is similar to [`SearchCursor`](https://codemirror.net/6/docs/ref/#search.SearchCursor)
132but searches for a regular expression pattern instead of a plain
133string.
134*/
135class RegExpCursor {
136 /**
137 Create a cursor that will search the given range in the given
138 document. `query` should be the raw pattern (as you'd pass it to
139 `new RegExp`).
140 */
141 constructor(text, query, options, from = 0, to = text.length) {
142 this.to = to;
143 this.curLine = "";
144 /**
145 Set to `true` when the cursor has reached the end of the search
146 range.
147 */
148 this.done = false;
149 /**
150 Will contain an object with the extent of the match and the
151 match object when [`next`](https://codemirror.net/6/docs/ref/#search.RegExpCursor.next)
152 sucessfully finds a match.
153 */
154 this.value = empty;
155 if (/\\[sWDnr]|\n|\r|\[\^/.test(query))
156 return new MultilineRegExpCursor(text, query, options, from, to);
157 this.re = new RegExp(query, baseFlags + ((options === null || options === void 0 ? void 0 : options.ignoreCase) ? "i" : ""));
158 this.iter = text.iter();
159 let startLine = text.lineAt(from);
160 this.curLineStart = startLine.from;
161 this.matchPos = from;
162 this.getLine(this.curLineStart);
163 }
164 getLine(skip) {
165 this.iter.next(skip);
166 if (this.iter.lineBreak) {
167 this.curLine = "";
168 }
169 else {
170 this.curLine = this.iter.value;
171 if (this.curLineStart + this.curLine.length > this.to)
172 this.curLine = this.curLine.slice(0, this.to - this.curLineStart);
173 this.iter.next();
174 }
175 }
176 nextLine() {
177 this.curLineStart = this.curLineStart + this.curLine.length + 1;
178 if (this.curLineStart > this.to)
179 this.curLine = "";
180 else
181 this.getLine(0);
182 }
183 /**
184 Move to the next match, if there is one.
185 */
186 next() {
187 for (let off = this.matchPos - this.curLineStart;;) {
188 this.re.lastIndex = off;
189 let match = this.matchPos <= this.to && this.re.exec(this.curLine);
190 if (match) {
191 let from = this.curLineStart + match.index, to = from + match[0].length;
192 this.matchPos = to + (from == to ? 1 : 0);
193 if (from == this.curLine.length)
194 this.nextLine();
195 if (from < to || from > this.value.to) {
196 this.value = { from, to, match };
197 return this;
198 }
199 off = this.matchPos - this.curLineStart;
200 }
201 else if (this.curLineStart + this.curLine.length < this.to) {
202 this.nextLine();
203 off = 0;
204 }
205 else {
206 this.done = true;
207 return this;
208 }
209 }
210 }
211}
212const flattened = /*@__PURE__*/new WeakMap();
213// Reusable (partially) flattened document strings
214class FlattenedDoc {
215 constructor(from, text) {
216 this.from = from;
217 this.text = text;
218 }
219 get to() { return this.from + this.text.length; }
220 static get(doc, from, to) {
221 let cached = flattened.get(doc);
222 if (!cached || cached.from >= to || cached.to <= from) {
223 let flat = new FlattenedDoc(from, doc.sliceString(from, to));
224 flattened.set(doc, flat);
225 return flat;
226 }
227 if (cached.from == from && cached.to == to)
228 return cached;
229 let { text, from: cachedFrom } = cached;
230 if (cachedFrom > from) {
231 text = doc.sliceString(from, cachedFrom) + text;
232 cachedFrom = from;
233 }
234 if (cached.to < to)
235 text += doc.sliceString(cached.to, to);
236 flattened.set(doc, new FlattenedDoc(cachedFrom, text));
237 return new FlattenedDoc(from, text.slice(from - cachedFrom, to - cachedFrom));
238 }
239}
240class MultilineRegExpCursor {
241 constructor(text, query, options, from, to) {
242 this.text = text;
243 this.to = to;
244 this.done = false;
245 this.value = empty;
246 this.matchPos = from;
247 this.re = new RegExp(query, baseFlags + ((options === null || options === void 0 ? void 0 : options.ignoreCase) ? "i" : ""));
248 this.flat = FlattenedDoc.get(text, from, this.chunkEnd(from + 5000 /* Base */));
249 }
250 chunkEnd(pos) {
251 return pos >= this.to ? this.to : this.text.lineAt(pos).to;
252 }
253 next() {
254 for (;;) {
255 let off = this.re.lastIndex = this.matchPos - this.flat.from;
256 let match = this.re.exec(this.flat.text);
257 // Skip empty matches directly after the last match
258 if (match && !match[0] && match.index == off) {
259 this.re.lastIndex = off + 1;
260 match = this.re.exec(this.flat.text);
261 }
262 // If a match goes almost to the end of a noncomplete chunk, try
263 // again, since it'll likely be able to match more
264 if (match && this.flat.to < this.to && match.index + match[0].length > this.flat.text.length - 10)
265 match = null;
266 if (match) {
267 let from = this.flat.from + match.index, to = from + match[0].length;
268 this.value = { from, to, match };
269 this.matchPos = to + (from == to ? 1 : 0);
270 return this;
271 }
272 else {
273 if (this.flat.to == this.to) {
274 this.done = true;
275 return this;
276 }
277 // Grow the flattened doc
278 this.flat = FlattenedDoc.get(this.text, this.flat.from, this.chunkEnd(this.flat.from + this.flat.text.length * 2));
279 }
280 }
281 }
282}
283if (typeof Symbol != "undefined") {
284 RegExpCursor.prototype[Symbol.iterator] = MultilineRegExpCursor.prototype[Symbol.iterator] =
285 function () { return this; };
286}
287function validRegExp(source) {
288 try {
289 new RegExp(source, baseFlags);
290 return true;
291 }
292 catch (_a) {
293 return false;
294 }
295}
296
297function createLineDialog(view) {
298 let input = elt("input", { class: "cm-textfield", name: "line" });
299 let dom = elt("form", {
300 class: "cm-gotoLine",
301 onkeydown: (event) => {
302 if (event.keyCode == 27) { // Escape
303 event.preventDefault();
304 view.dispatch({ effects: dialogEffect.of(false) });
305 view.focus();
306 }
307 else if (event.keyCode == 13) { // Enter
308 event.preventDefault();
309 go();
310 }
311 },
312 onsubmit: (event) => {
313 event.preventDefault();
314 go();
315 }
316 }, elt("label", view.state.phrase("Go to line"), ": ", input), " ", elt("button", { class: "cm-button", type: "submit" }, view.state.phrase("go")));
317 function go() {
318 let match = /^([+-])?(\d+)?(:\d+)?(%)?$/.exec(input.value);
319 if (!match)
320 return;
321 let { state } = view, startLine = state.doc.lineAt(state.selection.main.head);
322 let [, sign, ln, cl, percent] = match;
323 let col = cl ? +cl.slice(1) : 0;
324 let line = ln ? +ln : startLine.number;
325 if (ln && percent) {
326 let pc = line / 100;
327 if (sign)
328 pc = pc * (sign == "-" ? -1 : 1) + (startLine.number / state.doc.lines);
329 line = Math.round(state.doc.lines * pc);
330 }
331 else if (ln && sign) {
332 line = line * (sign == "-" ? -1 : 1) + startLine.number;
333 }
334 let docLine = state.doc.line(Math.max(1, Math.min(state.doc.lines, line)));
335 view.dispatch({
336 effects: dialogEffect.of(false),
337 selection: EditorSelection.cursor(docLine.from + Math.max(0, Math.min(col, docLine.length))),
338 scrollIntoView: true
339 });
340 view.focus();
341 }
342 return { dom, pos: -10 };
343}
344const dialogEffect = /*@__PURE__*/StateEffect.define();
345const dialogField = /*@__PURE__*/StateField.define({
346 create() { return true; },
347 update(value, tr) {
348 for (let e of tr.effects)
349 if (e.is(dialogEffect))
350 value = e.value;
351 return value;
352 },
353 provide: f => showPanel.from(f, val => val ? createLineDialog : null)
354});
355/**
356Command that shows a dialog asking the user for a line number, and
357when a valid position is provided, moves the cursor to that line.
358
359Supports line numbers, relative line offsets prefixed with `+` or
360`-`, document percentages suffixed with `%`, and an optional
361column position by adding `:` and a second number after the line
362number.
363
364The dialog can be styled with the `panel.gotoLine` theme
365selector.
366*/
367const gotoLine = view => {
368 let panel = getPanel(view, createLineDialog);
369 if (!panel) {
370 let effects = [dialogEffect.of(true)];
371 if (view.state.field(dialogField, false) == null)
372 effects.push(StateEffect.appendConfig.of([dialogField, baseTheme$1]));
373 view.dispatch({ effects });
374 panel = getPanel(view, createLineDialog);
375 }
376 if (panel)
377 panel.dom.querySelector("input").focus();
378 return true;
379};
380const baseTheme$1 = /*@__PURE__*/EditorView.baseTheme({
381 ".cm-panel.cm-gotoLine": {
382 padding: "2px 6px 4px",
383 "& label": { fontSize: "80%" }
384 }
385});
386
387const defaultHighlightOptions = {
388 highlightWordAroundCursor: false,
389 minSelectionLength: 1,
390 maxMatches: 100
391};
392const highlightConfig = /*@__PURE__*/Facet.define({
393 combine(options) {
394 return combineConfig(options, defaultHighlightOptions, {
395 highlightWordAroundCursor: (a, b) => a || b,
396 minSelectionLength: Math.min,
397 maxMatches: Math.min
398 });
399 }
400});
401/**
402This extension highlights text that matches the selection. It uses
403the `"cm-selectionMatch"` class for the highlighting. When
404`highlightWordAroundCursor` is enabled, the word at the cursor
405itself will be highlighted with `"cm-selectionMatch-main"`.
406*/
407function highlightSelectionMatches(options) {
408 let ext = [defaultTheme, matchHighlighter];
409 if (options)
410 ext.push(highlightConfig.of(options));
411 return ext;
412}
413const matchDeco = /*@__PURE__*/Decoration.mark({ class: "cm-selectionMatch" });
414const mainMatchDeco = /*@__PURE__*/Decoration.mark({ class: "cm-selectionMatch cm-selectionMatch-main" });
415const matchHighlighter = /*@__PURE__*/ViewPlugin.fromClass(class {
416 constructor(view) {
417 this.decorations = this.getDeco(view);
418 }
419 update(update) {
420 if (update.selectionSet || update.docChanged || update.viewportChanged)
421 this.decorations = this.getDeco(update.view);
422 }
423 getDeco(view) {
424 let conf = view.state.facet(highlightConfig);
425 let { state } = view, sel = state.selection;
426 if (sel.ranges.length > 1)
427 return Decoration.none;
428 let range = sel.main, query, check = null;
429 if (range.empty) {
430 if (!conf.highlightWordAroundCursor)
431 return Decoration.none;
432 let word = state.wordAt(range.head);
433 if (!word)
434 return Decoration.none;
435 check = state.charCategorizer(range.head);
436 query = state.sliceDoc(word.from, word.to);
437 }
438 else {
439 let len = range.to - range.from;
440 if (len < conf.minSelectionLength || len > 200)
441 return Decoration.none;
442 query = state.sliceDoc(range.from, range.to).trim();
443 if (!query)
444 return Decoration.none;
445 }
446 let deco = [];
447 for (let part of view.visibleRanges) {
448 let cursor = new SearchCursor(state.doc, query, part.from, part.to);
449 while (!cursor.next().done) {
450 let { from, to } = cursor.value;
451 if (!check || ((from == 0 || check(state.sliceDoc(from - 1, from)) != CharCategory.Word) &&
452 (to == state.doc.length || check(state.sliceDoc(to, to + 1)) != CharCategory.Word))) {
453 if (check && from <= range.from && to >= range.to)
454 deco.push(mainMatchDeco.range(from, to));
455 else if (from >= range.to || to <= range.from)
456 deco.push(matchDeco.range(from, to));
457 if (deco.length > conf.maxMatches)
458 return Decoration.none;
459 }
460 }
461 }
462 return Decoration.set(deco);
463 }
464}, {
465 decorations: v => v.decorations
466});
467const defaultTheme = /*@__PURE__*/EditorView.baseTheme({
468 ".cm-selectionMatch": { backgroundColor: "#99ff7780" },
469 ".cm-searchMatch .cm-selectionMatch": { backgroundColor: "transparent" }
470});
471// Select the words around the cursors.
472const selectWord = ({ state, dispatch }) => {
473 let { selection } = state;
474 let newSel = EditorSelection.create(selection.ranges.map(range => state.wordAt(range.head) || EditorSelection.cursor(range.head)), selection.mainIndex);
475 if (newSel.eq(selection))
476 return false;
477 dispatch(state.update({ selection: newSel }));
478 return true;
479};
480// Find next occurrence of query relative to last cursor. Wrap around
481// the document if there are no more matches.
482function findNextOccurrence(state, query) {
483 let { main, ranges } = state.selection;
484 let word = state.wordAt(main.head), fullWord = word && word.from == main.from && word.to == main.to;
485 for (let cycled = false, cursor = new SearchCursor(state.doc, query, ranges[ranges.length - 1].to);;) {
486 cursor.next();
487 if (cursor.done) {
488 if (cycled)
489 return null;
490 cursor = new SearchCursor(state.doc, query, 0, Math.max(0, ranges[ranges.length - 1].from - 1));
491 cycled = true;
492 }
493 else {
494 if (cycled && ranges.some(r => r.from == cursor.value.from))
495 continue;
496 if (fullWord) {
497 let word = state.wordAt(cursor.value.from);
498 if (!word || word.from != cursor.value.from || word.to != cursor.value.to)
499 continue;
500 }
501 return cursor.value;
502 }
503 }
504}
505/**
506Select next occurrence of the current selection.
507Expand selection to the word when selection range is empty.
508*/
509const selectNextOccurrence = ({ state, dispatch }) => {
510 let { ranges } = state.selection;
511 if (ranges.some(sel => sel.from === sel.to))
512 return selectWord({ state, dispatch });
513 let searchedText = state.sliceDoc(ranges[0].from, ranges[0].to);
514 if (state.selection.ranges.some(r => state.sliceDoc(r.from, r.to) != searchedText))
515 return false;
516 let range = findNextOccurrence(state, searchedText);
517 if (!range)
518 return false;
519 dispatch(state.update({
520 selection: state.selection.addRange(EditorSelection.range(range.from, range.to), false),
521 effects: EditorView.scrollIntoView(range.to)
522 }));
523 return true;
524};
525
526const searchConfigFacet = /*@__PURE__*/Facet.define({
527 combine(configs) {
528 var _a;
529 return {
530 top: configs.reduce((val, conf) => val !== null && val !== void 0 ? val : conf.top, undefined) || false,
531 caseSensitive: configs.reduce((val, conf) => val !== null && val !== void 0 ? val : (conf.caseSensitive || conf.matchCase), undefined) || false,
532 createPanel: ((_a = configs.find(c => c.createPanel)) === null || _a === void 0 ? void 0 : _a.createPanel) || (view => new SearchPanel(view))
533 };
534 }
535});
536/**
537Add search state to the editor configuration, and optionally
538configure the search extension.
539([`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSearchPanel) when automatically
540enable this if it isn't already on.)
541*/
542function search(config) {
543 return config ? [searchConfigFacet.of(config), searchExtensions] : searchExtensions;
544}
545/**
546@internal
547*/
548const searchConfig = search; // FIXME drop on next release
549/**
550A search query. Part of the editor's search state.
551*/
552class SearchQuery {
553 /**
554 Create a query object.
555 */
556 constructor(config) {
557 this.search = config.search;
558 this.caseSensitive = !!config.caseSensitive;
559 this.regexp = !!config.regexp;
560 this.replace = config.replace || "";
561 this.valid = !!this.search && (!this.regexp || validRegExp(this.search));
562 }
563 /**
564 Compare this query to another query.
565 */
566 eq(other) {
567 return this.search == other.search && this.replace == other.replace &&
568 this.caseSensitive == other.caseSensitive && this.regexp == other.regexp;
569 }
570 /**
571 @internal
572 */
573 create() {
574 return this.regexp ? new RegExpQuery(this) : new StringQuery(this);
575 }
576}
577class QueryType {
578 constructor(spec) {
579 this.spec = spec;
580 }
581}
582class StringQuery extends QueryType {
583 constructor(spec) {
584 super(spec);
585 this.unquoted = spec.search.replace(/\\([nrt\\])/g, (_, ch) => ch == "n" ? "\n" : ch == "r" ? "\r" : ch == "t" ? "\t" : "\\");
586 }
587 cursor(doc, from = 0, to = doc.length) {
588 return new SearchCursor(doc, this.unquoted, from, to, this.spec.caseSensitive ? undefined : x => x.toLowerCase());
589 }
590 nextMatch(doc, curFrom, curTo) {
591 let cursor = this.cursor(doc, curTo).nextOverlapping();
592 if (cursor.done)
593 cursor = this.cursor(doc, 0, curFrom).nextOverlapping();
594 return cursor.done ? null : cursor.value;
595 }
596 // Searching in reverse is, rather than implementing inverted search
597 // cursor, done by scanning chunk after chunk forward.
598 prevMatchInRange(doc, from, to) {
599 for (let pos = to;;) {
600 let start = Math.max(from, pos - 10000 /* ChunkSize */ - this.unquoted.length);
601 let cursor = this.cursor(doc, start, pos), range = null;
602 while (!cursor.nextOverlapping().done)
603 range = cursor.value;
604 if (range)
605 return range;
606 if (start == from)
607 return null;
608 pos -= 10000 /* ChunkSize */;
609 }
610 }
611 prevMatch(doc, curFrom, curTo) {
612 return this.prevMatchInRange(doc, 0, curFrom) ||
613 this.prevMatchInRange(doc, curTo, doc.length);
614 }
615 getReplacement(_result) { return this.spec.replace; }
616 matchAll(doc, limit) {
617 let cursor = this.cursor(doc), ranges = [];
618 while (!cursor.next().done) {
619 if (ranges.length >= limit)
620 return null;
621 ranges.push(cursor.value);
622 }
623 return ranges;
624 }
625 highlight(doc, from, to, add) {
626 let cursor = this.cursor(doc, Math.max(0, from - this.unquoted.length), Math.min(to + this.unquoted.length, doc.length));
627 while (!cursor.next().done)
628 add(cursor.value.from, cursor.value.to);
629 }
630}
631class RegExpQuery extends QueryType {
632 cursor(doc, from = 0, to = doc.length) {
633 return new RegExpCursor(doc, this.spec.search, this.spec.caseSensitive ? undefined : { ignoreCase: true }, from, to);
634 }
635 nextMatch(doc, curFrom, curTo) {
636 let cursor = this.cursor(doc, curTo).next();
637 if (cursor.done)
638 cursor = this.cursor(doc, 0, curFrom).next();
639 return cursor.done ? null : cursor.value;
640 }
641 prevMatchInRange(doc, from, to) {
642 for (let size = 1;; size++) {
643 let start = Math.max(from, to - size * 10000 /* ChunkSize */);
644 let cursor = this.cursor(doc, start, to), range = null;
645 while (!cursor.next().done)
646 range = cursor.value;
647 if (range && (start == from || range.from > start + 10))
648 return range;
649 if (start == from)
650 return null;
651 }
652 }
653 prevMatch(doc, curFrom, curTo) {
654 return this.prevMatchInRange(doc, 0, curFrom) ||
655 this.prevMatchInRange(doc, curTo, doc.length);
656 }
657 getReplacement(result) {
658 return this.spec.replace.replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$"
659 : i == "&" ? result.match[0]
660 : i != "0" && +i < result.match.length ? result.match[i]
661 : m);
662 }
663 matchAll(doc, limit) {
664 let cursor = this.cursor(doc), ranges = [];
665 while (!cursor.next().done) {
666 if (ranges.length >= limit)
667 return null;
668 ranges.push(cursor.value);
669 }
670 return ranges;
671 }
672 highlight(doc, from, to, add) {
673 let cursor = this.cursor(doc, Math.max(0, from - 250 /* HighlightMargin */), Math.min(to + 250 /* HighlightMargin */, doc.length));
674 while (!cursor.next().done)
675 add(cursor.value.from, cursor.value.to);
676 }
677}
678/**
679A state effect that updates the current search query. Note that
680this only has an effect if the search state has been initialized
681(by including [`search`](https://codemirror.net/6/docs/ref/#search.search) in your configuration or
682by running [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSearchPanel) at least
683once).
684*/
685const setSearchQuery = /*@__PURE__*/StateEffect.define();
686const togglePanel = /*@__PURE__*/StateEffect.define();
687const searchState = /*@__PURE__*/StateField.define({
688 create(state) {
689 return new SearchState(defaultQuery(state).create(), null);
690 },
691 update(value, tr) {
692 for (let effect of tr.effects) {
693 if (effect.is(setSearchQuery))
694 value = new SearchState(effect.value.create(), value.panel);
695 else if (effect.is(togglePanel))
696 value = new SearchState(value.query, effect.value ? createSearchPanel : null);
697 }
698 return value;
699 },
700 provide: f => showPanel.from(f, val => val.panel)
701});
702/**
703Get the current search query from an editor state.
704*/
705function getSearchQuery(state) {
706 let curState = state.field(searchState, false);
707 return curState ? curState.query.spec : defaultQuery(state);
708}
709class SearchState {
710 constructor(query, panel) {
711 this.query = query;
712 this.panel = panel;
713 }
714}
715const matchMark = /*@__PURE__*/Decoration.mark({ class: "cm-searchMatch" }), selectedMatchMark = /*@__PURE__*/Decoration.mark({ class: "cm-searchMatch cm-searchMatch-selected" });
716const searchHighlighter = /*@__PURE__*/ViewPlugin.fromClass(class {
717 constructor(view) {
718 this.view = view;
719 this.decorations = this.highlight(view.state.field(searchState));
720 }
721 update(update) {
722 let state = update.state.field(searchState);
723 if (state != update.startState.field(searchState) || update.docChanged || update.selectionSet)
724 this.decorations = this.highlight(state);
725 }
726 highlight({ query, panel }) {
727 if (!panel || !query.spec.valid)
728 return Decoration.none;
729 let { view } = this;
730 let builder = new RangeSetBuilder();
731 for (let i = 0, ranges = view.visibleRanges, l = ranges.length; i < l; i++) {
732 let { from, to } = ranges[i];
733 while (i < l - 1 && to > ranges[i + 1].from - 2 * 250 /* HighlightMargin */)
734 to = ranges[++i].to;
735 query.highlight(view.state.doc, from, to, (from, to) => {
736 let selected = view.state.selection.ranges.some(r => r.from == from && r.to == to);
737 builder.add(from, to, selected ? selectedMatchMark : matchMark);
738 });
739 }
740 return builder.finish();
741 }
742}, {
743 decorations: v => v.decorations
744});
745function searchCommand(f) {
746 return view => {
747 let state = view.state.field(searchState, false);
748 return state && state.query.spec.valid ? f(view, state) : openSearchPanel(view);
749 };
750}
751/**
752Open the search panel if it isn't already open, and move the
753selection to the first match after the current main selection.
754Will wrap around to the start of the document when it reaches the
755end.
756*/
757const findNext = /*@__PURE__*/searchCommand((view, { query }) => {
758 let { from, to } = view.state.selection.main;
759 let next = query.nextMatch(view.state.doc, from, to);
760 if (!next || next.from == from && next.to == to)
761 return false;
762 view.dispatch({
763 selection: { anchor: next.from, head: next.to },
764 scrollIntoView: true,
765 effects: announceMatch(view, next),
766 userEvent: "select.search"
767 });
768 return true;
769});
770/**
771Move the selection to the previous instance of the search query,
772before the current main selection. Will wrap past the start
773of the document to start searching at the end again.
774*/
775const findPrevious = /*@__PURE__*/searchCommand((view, { query }) => {
776 let { state } = view, { from, to } = state.selection.main;
777 let range = query.prevMatch(state.doc, from, to);
778 if (!range)
779 return false;
780 view.dispatch({
781 selection: { anchor: range.from, head: range.to },
782 scrollIntoView: true,
783 effects: announceMatch(view, range),
784 userEvent: "select.search"
785 });
786 return true;
787});
788/**
789Select all instances of the search query.
790*/
791const selectMatches = /*@__PURE__*/searchCommand((view, { query }) => {
792 let ranges = query.matchAll(view.state.doc, 1000);
793 if (!ranges || !ranges.length)
794 return false;
795 view.dispatch({
796 selection: EditorSelection.create(ranges.map(r => EditorSelection.range(r.from, r.to))),
797 userEvent: "select.search.matches"
798 });
799 return true;
800});
801/**
802Select all instances of the currently selected text.
803*/
804const selectSelectionMatches = ({ state, dispatch }) => {
805 let sel = state.selection;
806 if (sel.ranges.length > 1 || sel.main.empty)
807 return false;
808 let { from, to } = sel.main;
809 let ranges = [], main = 0;
810 for (let cur = new SearchCursor(state.doc, state.sliceDoc(from, to)); !cur.next().done;) {
811 if (ranges.length > 1000)
812 return false;
813 if (cur.value.from == from)
814 main = ranges.length;
815 ranges.push(EditorSelection.range(cur.value.from, cur.value.to));
816 }
817 dispatch(state.update({
818 selection: EditorSelection.create(ranges, main),
819 userEvent: "select.search.matches"
820 }));
821 return true;
822};
823/**
824Replace the current match of the search query.
825*/
826const replaceNext = /*@__PURE__*/searchCommand((view, { query }) => {
827 let { state } = view, { from, to } = state.selection.main;
828 if (state.readOnly)
829 return false;
830 let next = query.nextMatch(state.doc, from, from);
831 if (!next)
832 return false;
833 let changes = [], selection, replacement;
834 if (next.from == from && next.to == to) {
835 replacement = state.toText(query.getReplacement(next));
836 changes.push({ from: next.from, to: next.to, insert: replacement });
837 next = query.nextMatch(state.doc, next.from, next.to);
838 }
839 if (next) {
840 let off = changes.length == 0 || changes[0].from >= next.to ? 0 : next.to - next.from - replacement.length;
841 selection = { anchor: next.from - off, head: next.to - off };
842 }
843 view.dispatch({
844 changes, selection,
845 scrollIntoView: !!selection,
846 effects: next ? announceMatch(view, next) : undefined,
847 userEvent: "input.replace"
848 });
849 return true;
850});
851/**
852Replace all instances of the search query with the given
853replacement.
854*/
855const replaceAll = /*@__PURE__*/searchCommand((view, { query }) => {
856 if (view.state.readOnly)
857 return false;
858 let changes = query.matchAll(view.state.doc, 1e9).map(match => {
859 let { from, to } = match;
860 return { from, to, insert: query.getReplacement(match) };
861 });
862 if (!changes.length)
863 return false;
864 view.dispatch({
865 changes,
866 userEvent: "input.replace.all"
867 });
868 return true;
869});
870function createSearchPanel(view) {
871 return view.state.facet(searchConfigFacet).createPanel(view);
872}
873function defaultQuery(state, fallback) {
874 var _a;
875 let sel = state.selection.main;
876 let selText = sel.empty || sel.to > sel.from + 100 ? "" : state.sliceDoc(sel.from, sel.to);
877 let caseSensitive = (_a = fallback === null || fallback === void 0 ? void 0 : fallback.caseSensitive) !== null && _a !== void 0 ? _a : state.facet(searchConfigFacet).caseSensitive;
878 return fallback && !selText ? fallback : new SearchQuery({ search: selText.replace(/\n/g, "\\n"), caseSensitive });
879}
880/**
881Make sure the search panel is open and focused.
882*/
883const openSearchPanel = view => {
884 let state = view.state.field(searchState, false);
885 if (state && state.panel) {
886 let panel = getPanel(view, createSearchPanel);
887 if (!panel)
888 return false;
889 let searchInput = panel.dom.querySelector("[name=search]");
890 if (searchInput != view.root.activeElement) {
891 let query = defaultQuery(view.state, state.query.spec);
892 if (query.valid)
893 view.dispatch({ effects: setSearchQuery.of(query) });
894 searchInput.focus();
895 searchInput.select();
896 }
897 }
898 else {
899 view.dispatch({ effects: [
900 togglePanel.of(true),
901 state ? setSearchQuery.of(defaultQuery(view.state, state.query.spec)) : StateEffect.appendConfig.of(searchExtensions)
902 ] });
903 }
904 return true;
905};
906/**
907Close the search panel.
908*/
909const closeSearchPanel = view => {
910 let state = view.state.field(searchState, false);
911 if (!state || !state.panel)
912 return false;
913 let panel = getPanel(view, createSearchPanel);
914 if (panel && panel.dom.contains(view.root.activeElement))
915 view.focus();
916 view.dispatch({ effects: togglePanel.of(false) });
917 return true;
918};
919/**
920Default search-related key bindings.
921
922 - Mod-f: [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSearchPanel)
923 - F3, Mod-g: [`findNext`](https://codemirror.net/6/docs/ref/#search.findNext)
924 - Shift-F3, Shift-Mod-g: [`findPrevious`](https://codemirror.net/6/docs/ref/#search.findPrevious)
925 - Alt-g: [`gotoLine`](https://codemirror.net/6/docs/ref/#search.gotoLine)
926 - Mod-d: [`selectNextOccurrence`](https://codemirror.net/6/docs/ref/#search.selectNextOccurrence)
927*/
928const searchKeymap = [
929 { key: "Mod-f", run: openSearchPanel, scope: "editor search-panel" },
930 { key: "F3", run: findNext, shift: findPrevious, scope: "editor search-panel", preventDefault: true },
931 { key: "Mod-g", run: findNext, shift: findPrevious, scope: "editor search-panel", preventDefault: true },
932 { key: "Escape", run: closeSearchPanel, scope: "editor search-panel" },
933 { key: "Mod-Shift-l", run: selectSelectionMatches },
934 { key: "Alt-g", run: gotoLine },
935 { key: "Mod-d", run: selectNextOccurrence, preventDefault: true },
936];
937class SearchPanel {
938 constructor(view) {
939 this.view = view;
940 let query = this.query = view.state.field(searchState).query.spec;
941 this.commit = this.commit.bind(this);
942 this.searchField = elt("input", {
943 value: query.search,
944 placeholder: phrase(view, "Find"),
945 "aria-label": phrase(view, "Find"),
946 class: "cm-textfield",
947 name: "search",
948 onchange: this.commit,
949 onkeyup: this.commit
950 });
951 this.replaceField = elt("input", {
952 value: query.replace,
953 placeholder: phrase(view, "Replace"),
954 "aria-label": phrase(view, "Replace"),
955 class: "cm-textfield",
956 name: "replace",
957 onchange: this.commit,
958 onkeyup: this.commit
959 });
960 this.caseField = elt("input", {
961 type: "checkbox",
962 name: "case",
963 checked: query.caseSensitive,
964 onchange: this.commit
965 });
966 this.reField = elt("input", {
967 type: "checkbox",
968 name: "re",
969 checked: query.regexp,
970 onchange: this.commit
971 });
972 function button(name, onclick, content) {
973 return elt("button", { class: "cm-button", name, onclick, type: "button" }, content);
974 }
975 this.dom = elt("div", { onkeydown: (e) => this.keydown(e), class: "cm-search" }, [
976 this.searchField,
977 button("next", () => findNext(view), [phrase(view, "next")]),
978 button("prev", () => findPrevious(view), [phrase(view, "previous")]),
979 button("select", () => selectMatches(view), [phrase(view, "all")]),
980 elt("label", null, [this.caseField, phrase(view, "match case")]),
981 elt("label", null, [this.reField, phrase(view, "regexp")]),
982 ...view.state.readOnly ? [] : [
983 elt("br"),
984 this.replaceField,
985 button("replace", () => replaceNext(view), [phrase(view, "replace")]),
986 button("replaceAll", () => replaceAll(view), [phrase(view, "replace all")]),
987 elt("button", {
988 name: "close",
989 onclick: () => closeSearchPanel(view),
990 "aria-label": phrase(view, "close"),
991 type: "button"
992 }, ["×"])
993 ]
994 ]);
995 }
996 commit() {
997 let query = new SearchQuery({
998 search: this.searchField.value,
999 caseSensitive: this.caseField.checked,
1000 regexp: this.reField.checked,
1001 replace: this.replaceField.value
1002 });
1003 if (!query.eq(this.query)) {
1004 this.query = query;
1005 this.view.dispatch({ effects: setSearchQuery.of(query) });
1006 }
1007 }
1008 keydown(e) {
1009 if (runScopeHandlers(this.view, e, "search-panel")) {
1010 e.preventDefault();
1011 }
1012 else if (e.keyCode == 13 && e.target == this.searchField) {
1013 e.preventDefault();
1014 (e.shiftKey ? findPrevious : findNext)(this.view);
1015 }
1016 else if (e.keyCode == 13 && e.target == this.replaceField) {
1017 e.preventDefault();
1018 replaceNext(this.view);
1019 }
1020 }
1021 update(update) {
1022 for (let tr of update.transactions)
1023 for (let effect of tr.effects) {
1024 if (effect.is(setSearchQuery) && !effect.value.eq(this.query))
1025 this.setQuery(effect.value);
1026 }
1027 }
1028 setQuery(query) {
1029 this.query = query;
1030 this.searchField.value = query.search;
1031 this.replaceField.value = query.replace;
1032 this.caseField.checked = query.caseSensitive;
1033 this.reField.checked = query.regexp;
1034 }
1035 mount() {
1036 this.searchField.select();
1037 }
1038 get pos() { return 80; }
1039 get top() { return this.view.state.facet(searchConfigFacet).top; }
1040}
1041function phrase(view, phrase) { return view.state.phrase(phrase); }
1042const AnnounceMargin = 30;
1043const Break = /[\s\.,:;?!]/;
1044function announceMatch(view, { from, to }) {
1045 let lineStart = view.state.doc.lineAt(from).from, lineEnd = view.state.doc.lineAt(to).to;
1046 let start = Math.max(lineStart, from - AnnounceMargin), end = Math.min(lineEnd, to + AnnounceMargin);
1047 let text = view.state.sliceDoc(start, end);
1048 if (start != lineStart) {
1049 for (let i = 0; i < AnnounceMargin; i++)
1050 if (!Break.test(text[i + 1]) && Break.test(text[i])) {
1051 text = text.slice(i);
1052 break;
1053 }
1054 }
1055 if (end != lineEnd) {
1056 for (let i = text.length - 1; i > text.length - AnnounceMargin; i--)
1057 if (!Break.test(text[i - 1]) && Break.test(text[i])) {
1058 text = text.slice(0, i);
1059 break;
1060 }
1061 }
1062 return EditorView.announce.of(`${view.state.phrase("current match")}. ${text} ${view.state.phrase("on line")} ${view.state.doc.lineAt(from).number}`);
1063}
1064const baseTheme = /*@__PURE__*/EditorView.baseTheme({
1065 ".cm-panel.cm-search": {
1066 padding: "2px 6px 4px",
1067 position: "relative",
1068 "& [name=close]": {
1069 position: "absolute",
1070 top: "0",
1071 right: "4px",
1072 backgroundColor: "inherit",
1073 border: "none",
1074 font: "inherit",
1075 padding: 0,
1076 margin: 0
1077 },
1078 "& input, & button, & label": {
1079 margin: ".2em .6em .2em 0"
1080 },
1081 "& input[type=checkbox]": {
1082 marginRight: ".2em"
1083 },
1084 "& label": {
1085 fontSize: "80%",
1086 whiteSpace: "pre"
1087 }
1088 },
1089 "&light .cm-searchMatch": { backgroundColor: "#ffff0054" },
1090 "&dark .cm-searchMatch": { backgroundColor: "#00ffff8a" },
1091 "&light .cm-searchMatch-selected": { backgroundColor: "#ff6a0054" },
1092 "&dark .cm-searchMatch-selected": { backgroundColor: "#ff00ff8a" }
1093});
1094const searchExtensions = [
1095 searchState,
1096 /*@__PURE__*/Prec.lowest(searchHighlighter),
1097 baseTheme
1098];
1099
1100export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchConfig, searchKeymap, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };