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