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