├── .npmignore ├── .gitignore ├── .github └── workflows │ └── dispatch.yml ├── src ├── README.md ├── goto-line.ts ├── cursor.ts ├── regexp.ts ├── selection-match.ts └── search.ts ├── README.md ├── package.json ├── LICENSE ├── test ├── test-query.ts ├── test-selection-match.ts └── test-cursor.ts └── CHANGELOG.md /.npmignore: -------------------------------------------------------------------------------- 1 | /src 2 | /test 3 | /node_modules 4 | .tern-* 5 | rollup.config.js 6 | tsconfig.json 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | package-lock.json 3 | /dist 4 | /test/*.js 5 | /test/*.d.ts 6 | /test/*.d.ts.map 7 | .tern-* 8 | -------------------------------------------------------------------------------- /.github/workflows/dispatch.yml: -------------------------------------------------------------------------------- 1 | name: Trigger CI 2 | on: push 3 | 4 | jobs: 5 | build: 6 | name: Dispatch to main repo 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Emit repository_dispatch 10 | uses: mvasigh/dispatch-action@main 11 | with: 12 | # You should create a personal access token and store it in your repository 13 | token: ${{ secrets.DISPATCH_AUTH }} 14 | repo: dev 15 | owner: codemirror 16 | event_type: push 17 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | @searchKeymap 2 | @search 3 | 4 | ### Commands 5 | 6 | @findNext 7 | @findPrevious 8 | @selectMatches 9 | @selectSelectionMatches 10 | @selectNextOccurrence 11 | @replaceNext 12 | @replaceAll 13 | @openSearchPanel 14 | @closeSearchPanel 15 | @gotoLine 16 | 17 | ### Search Query 18 | 19 | @SearchQuery 20 | @getSearchQuery 21 | @setSearchQuery 22 | @searchPanelOpen 23 | 24 | ### Cursor 25 | 26 | @SearchCursor 27 | 28 | @RegExpCursor 29 | 30 | ### Selection matching 31 | 32 | @highlightSelectionMatches 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @codemirror/search [![NPM version](https://img.shields.io/npm/v/@codemirror/search.svg)](https://www.npmjs.org/package/@codemirror/search) 2 | 3 | [ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#search) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/next/) | [**CHANGELOG**](https://github.com/codemirror/search/blob/main/CHANGELOG.md) ] 4 | 5 | This package implements search functionality for the 6 | [CodeMirror](https://codemirror.net/) code editor. 7 | 8 | The [project page](https://codemirror.net/) has more information, a 9 | number of [examples](https://codemirror.net/examples/) and the 10 | [documentation](https://codemirror.net/docs/). 11 | 12 | This code is released under an 13 | [MIT license](https://github.com/codemirror/search/tree/main/LICENSE). 14 | 15 | We aim to be an inclusive, welcoming community. To make that explicit, 16 | we have a [code of 17 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 18 | to communication around the project. 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codemirror/search", 3 | "version": "6.5.11", 4 | "description": "Search functionality for the CodeMirror code editor", 5 | "scripts": { 6 | "test": "cm-runtests", 7 | "prepare": "cm-buildhelper src/search.ts" 8 | }, 9 | "keywords": [ 10 | "editor", 11 | "code" 12 | ], 13 | "author": { 14 | "name": "Marijn Haverbeke", 15 | "email": "marijn@haverbeke.berlin", 16 | "url": "http://marijnhaverbeke.nl" 17 | }, 18 | "type": "module", 19 | "main": "dist/index.cjs", 20 | "exports": { 21 | "import": "./dist/index.js", 22 | "require": "./dist/index.cjs" 23 | }, 24 | "types": "dist/index.d.ts", 25 | "module": "dist/index.js", 26 | "sideEffects": false, 27 | "license": "MIT", 28 | "dependencies": { 29 | "@codemirror/state": "^6.0.0", 30 | "@codemirror/view": "^6.37.0", 31 | "crelt": "^1.0.5" 32 | }, 33 | "devDependencies": { 34 | "@codemirror/buildhelper": "^1.0.0" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/codemirror/search.git" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2018-2021 by Marijn Haverbeke and others 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/goto-line.ts: -------------------------------------------------------------------------------- 1 | import {EditorSelection} from "@codemirror/state" 2 | import {EditorView, Command, showDialog} from "@codemirror/view" 3 | 4 | /// Command that shows a dialog asking the user for a line number, and 5 | /// when a valid position is provided, moves the cursor to that line. 6 | /// 7 | /// Supports line numbers, relative line offsets prefixed with `+` or 8 | /// `-`, document percentages suffixed with `%`, and an optional 9 | /// column position by adding `:` and a second number after the line 10 | /// number. 11 | export const gotoLine: Command = view => { 12 | let {state} = view 13 | let line = String(state.doc.lineAt(view.state.selection.main.head).number) 14 | let {close, result} = showDialog(view, { 15 | label: state.phrase("Go to line"), 16 | input: {type: "text", name: "line", value: line}, 17 | focus: true, 18 | submitLabel: state.phrase("go"), 19 | }) 20 | result.then(form => { 21 | let match = form && /^([+-])?(\d+)?(:\d+)?(%)?$/.exec((form.elements as any)["line"].value) 22 | if (!match) { 23 | view.dispatch({effects: close}) 24 | return 25 | } 26 | let startLine = state.doc.lineAt(state.selection.main.head) 27 | let [, sign, ln, cl, percent] = match 28 | let col = cl ? +cl.slice(1) : 0 29 | let line = ln ? +ln : startLine.number 30 | if (ln && percent) { 31 | let pc = line / 100 32 | if (sign) pc = pc * (sign == "-" ? -1 : 1) + (startLine.number / state.doc.lines) 33 | line = Math.round(state.doc.lines * pc) 34 | } else if (ln && sign) { 35 | line = line * (sign == "-" ? -1 : 1) + startLine.number 36 | } 37 | let docLine = state.doc.line(Math.max(1, Math.min(state.doc.lines, line))) 38 | let selection = EditorSelection.cursor(docLine.from + Math.max(0, Math.min(col, docLine.length))) 39 | view.dispatch({ 40 | effects: [close, EditorView.scrollIntoView(selection.from, {y: 'center'})], 41 | selection, 42 | }) 43 | }) 44 | return true 45 | } 46 | -------------------------------------------------------------------------------- /test/test-query.ts: -------------------------------------------------------------------------------- 1 | import {SearchQuery} from "@codemirror/search" 2 | import {Text} from "@codemirror/state" 3 | import ist from "ist" 4 | 5 | function test(query: SearchQuery, doc: string) { 6 | let matches = [], m 7 | while (m = /\[([^]*?)\]/.exec(doc)) { 8 | matches.push([m.index, m.index + m[1].length]) 9 | doc = doc.slice(0, m.index) + m[1] + doc.slice(m.index + m[0].length) 10 | } 11 | let text = Text.of(doc.split("\n")) 12 | let cursor = query.getCursor(text), found = [] 13 | for (let v; !(v = cursor.next()).done;) found.push([v.value.from, v.value.to]) 14 | ist(JSON.stringify(found), JSON.stringify(matches)) 15 | } 16 | 17 | describe("SearchQuery", () => { 18 | it("can match plain strings", () => { 19 | test(new SearchQuery({search: "abc"}), "[abc] flakdj a[abc] aabbcc") 20 | }) 21 | 22 | it("skips overlapping matches", () => { 23 | test(new SearchQuery({search: "aba"}), "[aba]b[aba].") 24 | }) 25 | 26 | it("can match case-insensitive strings", () => { 27 | test(new SearchQuery({search: "abC", caseSensitive: false}), "[aBc] flakdj a[ABC]") 28 | }) 29 | 30 | it("can match across lines", () => { 31 | test(new SearchQuery({search: "a\\nb"}), "a [a\nb] b") 32 | }) 33 | 34 | it("can match across multiple lines", () => { 35 | test(new SearchQuery({search: "a\\nb\\nc\\nd"}), "a [a\nb\nc\nd] e") 36 | }) 37 | 38 | it("can match literally", () => { 39 | test(new SearchQuery({search: "a\\nb", literal: true}), "a\nb [a\\nb]") 40 | }) 41 | 42 | it("can match by word", () => { 43 | test(new SearchQuery({search: "hello", wholeWord: true}), "[hello] hellothere [hello]\nello ahello ohellop") 44 | }) 45 | 46 | it("doesn't match non-words by word", () => { 47 | test(new SearchQuery({search: "^_^", wholeWord: true}), "x[^_^]y [^_^]") 48 | }) 49 | 50 | it("can match regular expressions", () => { 51 | test(new SearchQuery({search: "a..b", regexp: true}), "[appb] apb") 52 | }) 53 | 54 | it("can match case-insensitive regular expressions", () => { 55 | test(new SearchQuery({search: "a..b", regexp: true, caseSensitive: false}), "[Appb] Apb") 56 | }) 57 | 58 | it("can match regular expressions by word", () => { 59 | test(new SearchQuery({search: "a..", regexp: true, wholeWord: true}), "[aap] baap aapje [a--]w") 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /test/test-selection-match.ts: -------------------------------------------------------------------------------- 1 | import {selectNextOccurrence} from "@codemirror/search" 2 | import {EditorState, EditorSelection, SelectionRange} from "@codemirror/state" 3 | import ist from "ist" 4 | 5 | function mkState(doc: string) { 6 | let ranges: SelectionRange[] = [], off = 0 7 | doc = doc.replace(/\||<([^]*?)>/g, (_m, content, index) => { 8 | ranges.push(EditorSelection.range(index - off, index - off + (content ? content.length : 0))) 9 | off += (content ? 2 : 1) 10 | return content || "" 11 | }) 12 | return EditorState.create({ 13 | doc, 14 | selection: EditorSelection.create(ranges, 0), 15 | extensions: EditorState.allowMultipleSelections.of(true) 16 | }) 17 | } 18 | 19 | function stateStr(state: EditorState) { 20 | let doc = state.doc.toString() 21 | for (let i = state.selection.ranges.length - 1; i >= 0; i--) { 22 | let range = state.selection.ranges[i] 23 | if (range.empty) 24 | doc = doc.slice(0, range.from) + "|" + doc.slice(range.from) 25 | else 26 | doc = doc.slice(0, range.from) + "<" + doc.slice(range.from, range.to) + ">" + doc.slice(range.to) 27 | } 28 | return doc 29 | } 30 | 31 | describe("selectNextOccurrence", () => { 32 | function test(doc: string, expected: string) { 33 | let state = mkState(doc) 34 | selectNextOccurrence({state, dispatch: tr => { state = tr.state }}) 35 | ist(stateStr(state), expected) 36 | } 37 | 38 | it("expands to the surrounding word", () => { 39 | test('one| two', ' two') 40 | test('|one two', ' two') 41 | test('o|ne two', ' two') 42 | test('one |two', 'one ') 43 | }) 44 | 45 | it("selects the next occurrence", () => { 46 | test(" one one", " one") 47 | test(" two one", " two ") 48 | test("one one", "one ") 49 | test("one one", "one ") 50 | test("one ", " ") 51 | test(" ", " ") 52 | }) 53 | 54 | it("matches full words", () => { 55 | test(" two onetwo one", " two onetwo ") 56 | }) 57 | 58 | it("matches subwords if a subword is selected", () => { 59 | test("two onethree", "two three") 60 | test("two onethree", "two three") 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/cursor.ts: -------------------------------------------------------------------------------- 1 | import {Text, TextIterator, codePointAt, codePointSize, fromCodePoint} from "@codemirror/state" 2 | 3 | const basicNormalize: (string: string) => string = typeof String.prototype.normalize == "function" 4 | ? x => x.normalize("NFKD") : x => x 5 | 6 | /// A search cursor provides an iterator over text matches in a 7 | /// document. 8 | export class SearchCursor implements Iterator<{from: number, to: number}>{ 9 | private iter: TextIterator 10 | /// The current match (only holds a meaningful value after 11 | /// [`next`](#search.SearchCursor.next) has been called and when 12 | /// `done` is false). 13 | value = {from: 0, to: 0} 14 | /// Whether the end of the iterated region has been reached. 15 | done = false 16 | private matches: number[] = [] 17 | private buffer = "" 18 | private bufferPos = 0 19 | private bufferStart: number 20 | private normalize: (string: string) => string 21 | private query: string 22 | 23 | /// Create a text cursor. The query is the search string, `from` to 24 | /// `to` provides the region to search. 25 | /// 26 | /// When `normalize` is given, it will be called, on both the query 27 | /// string and the content it is matched against, before comparing. 28 | /// You can, for example, create a case-insensitive search by 29 | /// passing `s => s.toLowerCase()`. 30 | /// 31 | /// Text is always normalized with 32 | /// [`.normalize("NFKD")`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize) 33 | /// (when supported). 34 | constructor(text: Text, query: string, 35 | from: number = 0, to: number = text.length, 36 | normalize?: (string: string) => string, 37 | private test?: (from: number, to: number, buffer: string, bufferPos: number) => boolean) { 38 | this.iter = text.iterRange(from, to) 39 | this.bufferStart = from 40 | this.normalize = normalize ? x => normalize(basicNormalize(x)) : basicNormalize 41 | this.query = this.normalize(query) 42 | } 43 | 44 | private peek() { 45 | if (this.bufferPos == this.buffer.length) { 46 | this.bufferStart += this.buffer.length 47 | this.iter.next() 48 | if (this.iter.done) return -1 49 | this.bufferPos = 0 50 | this.buffer = this.iter.value 51 | } 52 | return codePointAt(this.buffer, this.bufferPos) 53 | } 54 | 55 | /// Look for the next match. Updates the iterator's 56 | /// [`value`](#search.SearchCursor.value) and 57 | /// [`done`](#search.SearchCursor.done) properties. Should be called 58 | /// at least once before using the cursor. 59 | next() { 60 | while (this.matches.length) this.matches.pop() 61 | return this.nextOverlapping() 62 | } 63 | 64 | /// The `next` method will ignore matches that partially overlap a 65 | /// previous match. This method behaves like `next`, but includes 66 | /// such matches. 67 | nextOverlapping() { 68 | for (;;) { 69 | let next = this.peek() 70 | if (next < 0) { 71 | this.done = true 72 | return this 73 | } 74 | let str = fromCodePoint(next), start = this.bufferStart + this.bufferPos 75 | this.bufferPos += codePointSize(next) 76 | let norm = this.normalize(str) 77 | if (norm.length) for (let i = 0, pos = start;; i++) { 78 | let code = norm.charCodeAt(i) 79 | let match = this.match(code, pos, this.bufferPos + this.bufferStart) 80 | if (i == norm.length - 1) { 81 | if (match) { 82 | this.value = match 83 | return this 84 | } 85 | break 86 | } 87 | if (pos == start && i < str.length && str.charCodeAt(i) == code) pos++ 88 | } 89 | } 90 | } 91 | 92 | private match(code: number, pos: number, end: number) { 93 | let match: null | {from: number, to: number} = null 94 | for (let i = 0; i < this.matches.length; i += 2) { 95 | let index = this.matches[i], keep = false 96 | if (this.query.charCodeAt(index) == code) { 97 | if (index == this.query.length - 1) { 98 | match = {from: this.matches[i + 1], to: end} 99 | } else { 100 | this.matches[i]++ 101 | keep = true 102 | } 103 | } 104 | if (!keep) { 105 | this.matches.splice(i, 2) 106 | i -= 2 107 | } 108 | } 109 | if (this.query.charCodeAt(0) == code) { 110 | if (this.query.length == 1) 111 | match = {from: pos, to: end} 112 | else 113 | this.matches.push(1, pos) 114 | } 115 | if (match && this.test && !this.test(match.from, match.to, this.buffer, this.bufferStart)) match = null 116 | return match 117 | } 118 | 119 | declare [Symbol.iterator]: () => Iterator<{from: number, to: number}> 120 | } 121 | 122 | if (typeof Symbol != "undefined") 123 | SearchCursor.prototype[Symbol.iterator] = function(this: SearchCursor) { return this } 124 | -------------------------------------------------------------------------------- /test/test-cursor.ts: -------------------------------------------------------------------------------- 1 | import {SearchCursor, RegExpCursor} from "@codemirror/search" 2 | import {Text} from "@codemirror/state" 3 | import ist from "ist" 4 | 5 | function testMatches(cursor: SearchCursor | RegExpCursor, expected: [number, number][]) { 6 | let matches = [] 7 | while (!cursor.next().done) matches.push([cursor.value.from, cursor.value.to]) 8 | ist(JSON.stringify(matches), JSON.stringify(expected)) 9 | } 10 | 11 | describe("SearchCursor", () => { 12 | it("finds all matches in a simple string", () => { 13 | testMatches(new SearchCursor(Text.of(["one two one two one"]), "one"), 14 | [[0, 3], [8, 11], [16, 19]]) 15 | }) 16 | 17 | it("finds only matches in the given region", () => { 18 | testMatches(new SearchCursor(Text.of(["one two one two one"]), "one", 2, 17), 19 | [[8, 11]]) 20 | }) 21 | 22 | it("can cross lines", () => { 23 | testMatches(new SearchCursor(Text.of(["one two", "one two", "one"]), "one"), 24 | [[0, 3], [8, 11], [16, 19]]) 25 | }) 26 | 27 | it("can normalize case", () => { 28 | testMatches(new SearchCursor(Text.of(["ONE two oNe two one"]), "one", 0, 19, s => s.toLowerCase()), 29 | [[0, 3], [8, 11], [16, 19]]) 30 | }) 31 | 32 | it("doesn't get confused by expanding transforms", () => { 33 | testMatches(new SearchCursor(Text.of(["Auf die Straße"]), "straße", 0, 14, s => s.toUpperCase()), 34 | [[8, 14]]) 35 | }) 36 | 37 | it("normalizes composed chars", () => { 38 | testMatches(new SearchCursor(Text.of(["héé"]), "héé"), // First one is composed, second decomposed 39 | [[0, 3]]) 40 | testMatches(new SearchCursor(Text.of(["héé"]), "héé"), // First one is decomposed, second composed 41 | [[0, 5]]) 42 | }) 43 | 44 | it("can match across lines", () => { 45 | testMatches(new SearchCursor(Text.of(["one two", "three four"]), "two\nthree"), 46 | [[4, 13]]) 47 | }) 48 | 49 | it("can search an empty document", () => { 50 | testMatches(new SearchCursor(Text.empty, "aaaa"), []) 51 | }) 52 | 53 | it("doesn't include overlapping results", () => { 54 | testMatches(new SearchCursor(Text.of(["fofofofo"]), "fofo"), [[0, 4], [4, 8]]) 55 | }) 56 | 57 | it("includes overlapping results with nextOverlapping", () => { 58 | let cursor = new SearchCursor(Text.of(["fofofofo"]), "fofo") 59 | let matches = [] 60 | while (!cursor.nextOverlapping().done) matches.push([cursor.value.from, cursor.value.to]) 61 | ist(JSON.stringify(matches), "[[0,4],[2,6],[4,8]]") 62 | }) 63 | 64 | it("will not match partial normalized content", () => { 65 | testMatches(new SearchCursor(Text.of(["´"]), " "), []) 66 | }) 67 | 68 | it("produces the correct range for astral chars that get normalized to non-astral", () => { 69 | testMatches(new SearchCursor(Text.of(["𝜎"]), "𝜎"), [[0, 2]]) 70 | }) 71 | 72 | it("can handle normalizers that remove text", () => { 73 | testMatches(new SearchCursor(Text.of(["hello"]), "halal", 0, 5, s => s.replace(/[aeuoi]/g, "")), [[0, 4]]) 74 | }) 75 | }) 76 | 77 | describe("RegExpCursor", () => { 78 | it("finds all matches in a simple string", () => { 79 | testMatches(new RegExpCursor(Text.of(["one two one two one"]), "one"), 80 | [[0, 3], [8, 11], [16, 19]]) 81 | }) 82 | 83 | it("matches by-line", () => { 84 | testMatches(new RegExpCursor(Text.of(["one two", "three four five", "six"]), "^\\w+|\\w+$"), 85 | [[0, 3], [4, 7], [8, 13], [19, 23], [24, 27]]) 86 | }) 87 | 88 | it("handles empty lines", () => { 89 | testMatches(new RegExpCursor(Text.of(["one", "", "two"]), ".*"), 90 | [[0, 3], [4, 4], [5, 8]]) 91 | }) 92 | 93 | it("handles empty documents", () => { 94 | testMatches(new RegExpCursor(Text.empty, ".*"), [[0, 0]]) 95 | testMatches(new RegExpCursor(Text.empty, "okay"), []) 96 | }) 97 | 98 | it("properly cuts off long matches", () => { 99 | testMatches(new RegExpCursor(Text.of(["abcdefghi"]), ".*", {}, 3, 6), [[3, 6]]) 100 | }) 101 | 102 | it("can match case-insensitively", () => { 103 | testMatches(new RegExpCursor(Text.of(["abcdefghi"]), "DEF", {ignoreCase: true}), [[3, 6]]) 104 | }) 105 | 106 | it("matches across lines", () => { 107 | testMatches(new RegExpCursor(Text.of(["abc", "def"]), "c\nd"), [[2, 5]]) 108 | }) 109 | 110 | it("detects multi-line regexps", () => { 111 | testMatches(new RegExpCursor(Text.of(["abc", "def"]), "c\\sd"), [[2, 5]]) 112 | testMatches(new RegExpCursor(Text.of(["abc", "def"]), "c\\Wd"), [[2, 5]]) 113 | testMatches(new RegExpCursor(Text.of(["abc", "def"]), "c\\Dd"), [[2, 5]]) 114 | testMatches(new RegExpCursor(Text.of(["abc", "def"]), "c[^x]d"), [[2, 5]]) 115 | }) 116 | 117 | it("can match a large document", () => { 118 | let line = "1234567890".repeat(10) 119 | let doc = Text.of(new Array(100).fill(line)) 120 | let cur = new RegExpCursor(doc, "[^]*").next() 121 | ist(!cur.done) 122 | ist(cur.value.from, 0) 123 | ist(cur.value.to, doc.length) 124 | ist(cur.value.match[0].length, doc.length) 125 | }) 126 | 127 | it("will match line starts properly in multiline mode", () => { 128 | testMatches(new RegExpCursor(Text.of(["x", "a1111", "111a1111"]), "^a(1|\\s)*"), [[2, 11]]) 129 | }) 130 | 131 | it("will match line ends properly in multiline mode", () => { 132 | testMatches(new RegExpCursor(Text.of(["x", "111p111", "1111p"]), "(1|\\s)*p$"), [[6, 15]]) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /src/regexp.ts: -------------------------------------------------------------------------------- 1 | import {Text, TextIterator} from "@codemirror/state" 2 | 3 | const empty = {from: -1, to: -1, match: /.*/.exec("")!} 4 | 5 | const baseFlags = "gm" + (/x/.unicode == null ? "" : "u") 6 | 7 | export interface RegExpCursorOptions { 8 | ignoreCase?: boolean 9 | test?: (from: number, to: number, match: RegExpExecArray) => boolean 10 | } 11 | 12 | /// This class is similar to [`SearchCursor`](#search.SearchCursor) 13 | /// but searches for a regular expression pattern instead of a plain 14 | /// string. 15 | export class RegExpCursor implements Iterator<{from: number, to: number, match: RegExpExecArray}> { 16 | declare private iter: TextIterator 17 | declare private re: RegExp 18 | private test?: (from: number, to: number, match: RegExpExecArray) => boolean 19 | private curLine = "" 20 | declare private curLineStart: number 21 | declare private matchPos: number 22 | 23 | /// Set to `true` when the cursor has reached the end of the search 24 | /// range. 25 | done = false 26 | 27 | /// Will contain an object with the extent of the match and the 28 | /// match object when [`next`](#search.RegExpCursor.next) 29 | /// sucessfully finds a match. 30 | value = empty 31 | 32 | /// Create a cursor that will search the given range in the given 33 | /// document. `query` should be the raw pattern (as you'd pass it to 34 | /// `new RegExp`). 35 | constructor(private text: Text, query: string, options?: RegExpCursorOptions, 36 | from: number = 0, private to: number = text.length) { 37 | if (/\\[sWDnr]|\n|\r|\[\^/.test(query)) return new MultilineRegExpCursor(text, query, options, from, to) as any 38 | this.re = new RegExp(query, baseFlags + (options?.ignoreCase ? "i" : "")) 39 | this.test = options?.test 40 | this.iter = text.iter() 41 | let startLine = text.lineAt(from) 42 | this.curLineStart = startLine.from 43 | this.matchPos = toCharEnd(text, from) 44 | this.getLine(this.curLineStart) 45 | } 46 | 47 | private getLine(skip: number) { 48 | this.iter.next(skip) 49 | if (this.iter.lineBreak) { 50 | this.curLine = "" 51 | } else { 52 | this.curLine = this.iter.value 53 | if (this.curLineStart + this.curLine.length > this.to) 54 | this.curLine = this.curLine.slice(0, this.to - this.curLineStart) 55 | this.iter.next() 56 | } 57 | } 58 | 59 | private nextLine() { 60 | this.curLineStart = this.curLineStart + this.curLine.length + 1 61 | if (this.curLineStart > this.to) this.curLine = "" 62 | else this.getLine(0) 63 | } 64 | 65 | /// Move to the next match, if there is one. 66 | next() { 67 | for (let off = this.matchPos - this.curLineStart;;) { 68 | this.re.lastIndex = off 69 | let match = this.matchPos <= this.to && this.re.exec(this.curLine) 70 | if (match) { 71 | let from = this.curLineStart + match.index, to = from + match[0].length 72 | this.matchPos = toCharEnd(this.text, to + (from == to ? 1 : 0)) 73 | if (from == this.curLineStart + this.curLine.length) this.nextLine() 74 | if ((from < to || from > this.value.to) && (!this.test || this.test(from, to, match))) { 75 | this.value = {from, to, match} 76 | return this 77 | } 78 | off = this.matchPos - this.curLineStart 79 | } else if (this.curLineStart + this.curLine.length < this.to) { 80 | this.nextLine() 81 | off = 0 82 | } else { 83 | this.done = true 84 | return this 85 | } 86 | } 87 | } 88 | 89 | declare [Symbol.iterator]: () => Iterator<{from: number, to: number, match: RegExpExecArray}> 90 | } 91 | 92 | const flattened = new WeakMap() 93 | 94 | // Reusable (partially) flattened document strings 95 | class FlattenedDoc { 96 | constructor(readonly from: number, 97 | readonly text: string) {} 98 | get to() { return this.from + this.text.length } 99 | 100 | static get(doc: Text, from: number, to: number) { 101 | let cached = flattened.get(doc) 102 | if (!cached || cached.from >= to || cached.to <= from) { 103 | let flat = new FlattenedDoc(from, doc.sliceString(from, to)) 104 | flattened.set(doc, flat) 105 | return flat 106 | } 107 | if (cached.from == from && cached.to == to) return cached 108 | let {text, from: cachedFrom} = cached 109 | if (cachedFrom > from) { 110 | text = doc.sliceString(from, cachedFrom) + text 111 | cachedFrom = from 112 | } 113 | if (cached.to < to) 114 | text += doc.sliceString(cached.to, to) 115 | flattened.set(doc, new FlattenedDoc(cachedFrom, text)) 116 | return new FlattenedDoc(from, text.slice(from - cachedFrom, to - cachedFrom)) 117 | } 118 | } 119 | 120 | const enum Chunk { Base = 5000 } 121 | 122 | class MultilineRegExpCursor implements Iterator<{from: number, to: number, match: RegExpExecArray}> { 123 | private flat: FlattenedDoc 124 | private matchPos 125 | private re: RegExp 126 | private test?: (from: number, to: number, match: RegExpExecArray) => boolean 127 | 128 | done = false 129 | value = empty 130 | 131 | constructor(private text: Text, query: string, options: RegExpCursorOptions | undefined, from: number, private to: number) { 132 | this.matchPos = toCharEnd(text, from) 133 | this.re = new RegExp(query, baseFlags + (options?.ignoreCase ? "i" : "")) 134 | this.test = options?.test 135 | this.flat = FlattenedDoc.get(text, from, this.chunkEnd(from + Chunk.Base)) 136 | } 137 | 138 | private chunkEnd(pos: number) { 139 | return pos >= this.to ? this.to : this.text.lineAt(pos).to 140 | } 141 | 142 | next() { 143 | for (;;) { 144 | let off = this.re.lastIndex = this.matchPos - this.flat.from 145 | let match = this.re.exec(this.flat.text) 146 | // Skip empty matches directly after the last match 147 | if (match && !match[0] && match.index == off) { 148 | this.re.lastIndex = off + 1 149 | match = this.re.exec(this.flat.text) 150 | } 151 | if (match) { 152 | let from = this.flat.from + match.index, to = from + match[0].length 153 | // If a match goes almost to the end of a noncomplete chunk, try 154 | // again, since it'll likely be able to match more 155 | if ((this.flat.to >= this.to || match.index + match[0].length <= this.flat.text.length - 10) && 156 | (!this.test || this.test(from, to, match))) { 157 | this.value = {from, to, match} 158 | this.matchPos = toCharEnd(this.text, to + (from == to ? 1 : 0)) 159 | return this 160 | } 161 | } 162 | if (this.flat.to == this.to) { 163 | this.done = true 164 | return this 165 | } 166 | // Grow the flattened doc 167 | this.flat = FlattenedDoc.get(this.text, this.flat.from, this.chunkEnd(this.flat.from + this.flat.text.length * 2)) 168 | } 169 | } 170 | 171 | declare [Symbol.iterator]: () => Iterator<{from: number, to: number, match: RegExpExecArray}> 172 | } 173 | 174 | if (typeof Symbol != "undefined") { 175 | RegExpCursor.prototype[Symbol.iterator] = MultilineRegExpCursor.prototype[Symbol.iterator] = 176 | function(this: RegExpCursor) { return this } 177 | } 178 | 179 | export function validRegExp(source: string) { 180 | try { 181 | new RegExp(source, baseFlags) 182 | return true 183 | } catch { 184 | return false 185 | } 186 | } 187 | 188 | function toCharEnd(text: Text, pos: number) { 189 | if (pos >= text.length) return pos 190 | let line = text.lineAt(pos), next 191 | while (pos < line.to && (next = line.text.charCodeAt(pos - line.from)) >= 0xDC00 && next < 0xE000) pos++ 192 | return pos 193 | } 194 | -------------------------------------------------------------------------------- /src/selection-match.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, ViewPlugin, Decoration, DecorationSet, ViewUpdate} from "@codemirror/view" 2 | import {Facet, combineConfig, Extension, CharCategory, EditorSelection, 3 | EditorState, StateCommand} from "@codemirror/state" 4 | import {SearchCursor} from "./cursor" 5 | 6 | type HighlightOptions = { 7 | /// Determines whether, when nothing is selected, the word around 8 | /// the cursor is matched instead. Defaults to false. 9 | highlightWordAroundCursor?: boolean, 10 | /// The minimum length of the selection before it is highlighted. 11 | /// Defaults to 1 (always highlight non-cursor selections). 12 | minSelectionLength?: number, 13 | /// The amount of matches (in the viewport) at which to disable 14 | /// highlighting. Defaults to 100. 15 | maxMatches?: number 16 | /// Whether to only highlight whole words. 17 | wholeWords?: boolean 18 | } 19 | 20 | const defaultHighlightOptions = { 21 | highlightWordAroundCursor: false, 22 | minSelectionLength: 1, 23 | maxMatches: 100, 24 | wholeWords: false 25 | } 26 | 27 | const highlightConfig = Facet.define>({ 28 | combine(options: readonly HighlightOptions[]) { 29 | return combineConfig(options, defaultHighlightOptions, { 30 | highlightWordAroundCursor: (a, b) => a || b, 31 | minSelectionLength: Math.min, 32 | maxMatches: Math.min 33 | }) 34 | } 35 | }) 36 | 37 | /// This extension highlights text that matches the selection. It uses 38 | /// the `"cm-selectionMatch"` class for the highlighting. When 39 | /// `highlightWordAroundCursor` is enabled, the word at the cursor 40 | /// itself will be highlighted with `"cm-selectionMatch-main"`. 41 | export function highlightSelectionMatches(options?: HighlightOptions): Extension { 42 | let ext = [defaultTheme, matchHighlighter] 43 | if (options) ext.push(highlightConfig.of(options)) 44 | return ext 45 | } 46 | 47 | const matchDeco = Decoration.mark({class: "cm-selectionMatch"}) 48 | const mainMatchDeco = Decoration.mark({class: "cm-selectionMatch cm-selectionMatch-main"}) 49 | 50 | // Whether the characters directly outside the given positions are non-word characters 51 | function insideWordBoundaries (check: (char: string) => CharCategory, state: EditorState, from: number, to: number): boolean { 52 | return (from == 0 || check(state.sliceDoc(from - 1, from)) != CharCategory.Word) && 53 | (to == state.doc.length || check(state.sliceDoc(to, to + 1)) != CharCategory.Word) 54 | } 55 | 56 | // Whether the characters directly at the given positions are word characters 57 | function insideWord (check: (char: string) => CharCategory, state: EditorState, from: number, to: number): boolean { 58 | return check(state.sliceDoc(from, from + 1)) == CharCategory.Word 59 | && check(state.sliceDoc(to - 1, to)) == CharCategory.Word 60 | } 61 | 62 | const matchHighlighter = ViewPlugin.fromClass(class { 63 | decorations: DecorationSet 64 | 65 | constructor(view: EditorView) { 66 | this.decorations = this.getDeco(view) 67 | } 68 | 69 | update(update: ViewUpdate) { 70 | if (update.selectionSet || update.docChanged || update.viewportChanged) this.decorations = this.getDeco(update.view) 71 | } 72 | 73 | getDeco(view: EditorView) { 74 | let conf = view.state.facet(highlightConfig) 75 | let {state} = view, sel = state.selection 76 | if (sel.ranges.length > 1) return Decoration.none 77 | let range = sel.main, query, check = null 78 | if (range.empty) { 79 | if (!conf.highlightWordAroundCursor) return Decoration.none 80 | let word = state.wordAt(range.head) 81 | if (!word) return Decoration.none 82 | check = state.charCategorizer(range.head) 83 | query = state.sliceDoc(word.from, word.to) 84 | } else { 85 | let len = range.to - range.from 86 | if (len < conf.minSelectionLength || len > 200) return Decoration.none 87 | if (conf.wholeWords) { 88 | query = state.sliceDoc(range.from, range.to) // TODO: allow and include leading/trailing space? 89 | check = state.charCategorizer(range.head) 90 | if (!(insideWordBoundaries(check, state, range.from, range.to) && 91 | insideWord(check, state, range.from, range.to))) return Decoration.none 92 | } else { 93 | query = state.sliceDoc(range.from, range.to) 94 | if (!query) return Decoration.none 95 | } 96 | } 97 | let deco = [] 98 | for (let part of view.visibleRanges) { 99 | let cursor = new SearchCursor(state.doc, query, part.from, part.to) 100 | while (!cursor.next().done) { 101 | let {from, to} = cursor.value 102 | if (!check || insideWordBoundaries(check, state, from, to)) { 103 | if (range.empty && from <= range.from && to >= range.to) 104 | deco.push(mainMatchDeco.range(from, to)) 105 | else if (from >= range.to || to <= range.from) 106 | deco.push(matchDeco.range(from, to)) 107 | if (deco.length > conf.maxMatches) return Decoration.none 108 | } 109 | } 110 | } 111 | return Decoration.set(deco) 112 | } 113 | }, { 114 | decorations: v => v.decorations 115 | }) 116 | 117 | const defaultTheme = EditorView.baseTheme({ 118 | ".cm-selectionMatch": { backgroundColor: "#99ff7780" }, 119 | ".cm-searchMatch .cm-selectionMatch": {backgroundColor: "transparent"} 120 | }) 121 | 122 | // Select the words around the cursors. 123 | const selectWord: StateCommand = ({state, dispatch}) => { 124 | let {selection} = state 125 | let newSel = EditorSelection.create(selection.ranges.map( 126 | range => state.wordAt(range.head) || EditorSelection.cursor(range.head) 127 | ), selection.mainIndex) 128 | if (newSel.eq(selection)) return false 129 | dispatch(state.update({selection: newSel})) 130 | return true 131 | } 132 | 133 | // Find next occurrence of query relative to last cursor. Wrap around 134 | // the document if there are no more matches. 135 | function findNextOccurrence(state: EditorState, query: string) { 136 | let {main, ranges} = state.selection 137 | let word = state.wordAt(main.head), fullWord = word && word.from == main.from && word.to == main.to 138 | for (let cycled = false, cursor = new SearchCursor(state.doc, query, ranges[ranges.length - 1].to);;) { 139 | cursor.next() 140 | if (cursor.done) { 141 | if (cycled) return null 142 | cursor = new SearchCursor(state.doc, query, 0, Math.max(0, ranges[ranges.length - 1].from - 1)) 143 | cycled = true 144 | } else { 145 | if (cycled && ranges.some(r => r.from == cursor.value.from)) 146 | continue 147 | if (fullWord) { 148 | let word = state.wordAt(cursor.value.from) 149 | if (!word || word.from != cursor.value.from || word.to != cursor.value.to) continue 150 | } 151 | return cursor.value 152 | } 153 | } 154 | } 155 | 156 | /// Select next occurrence of the current selection. Expand selection 157 | /// to the surrounding word when the selection is empty. 158 | export const selectNextOccurrence: StateCommand = ({state, dispatch}) => { 159 | let {ranges} = state.selection 160 | if (ranges.some(sel => sel.from === sel.to)) return selectWord({state, dispatch}) 161 | 162 | let searchedText = state.sliceDoc(ranges[0].from, ranges[0].to) 163 | if (state.selection.ranges.some(r => state.sliceDoc(r.from, r.to) != searchedText)) 164 | return false 165 | 166 | let range = findNextOccurrence(state, searchedText) 167 | if (!range) return false 168 | 169 | dispatch(state.update({ 170 | selection: state.selection.addRange(EditorSelection.range(range.from, range.to), false), 171 | effects: EditorView.scrollIntoView(range.to) 172 | })) 173 | return true 174 | } 175 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 6.5.11 (2025-05-14) 2 | 3 | ### Bug fixes 4 | 5 | Fix an issue in `replaceNext` that could cause it to create an invalid selection when replacing past the end of the document. 6 | 7 | ## 6.5.10 (2025-02-26) 8 | 9 | ### Bug fixes 10 | 11 | Add a close button to the `gotoLine` panel. 12 | 13 | ## 6.5.9 (2025-02-12) 14 | 15 | ### Bug fixes 16 | 17 | When replacing a regexp match, don't expand multi-digit replacement markers to numbers beyond the captured group count in the query. 18 | 19 | ## 6.5.8 (2024-11-22) 20 | 21 | ### Bug fixes 22 | 23 | Fix a bug that put the selection in the wrong place after running `replaceNext` with a regexp query that could match strings of different length. 24 | 25 | ## 6.5.7 (2024-11-01) 26 | 27 | ### Bug fixes 28 | 29 | Fix an issue where `findNext` and `findPrevious` would do nothing when the only match in the document was partially selected. 30 | 31 | Fix an infinite loop in `SearchCursor` when the normalizer function deletes characters. 32 | 33 | ## 6.5.6 (2024-02-07) 34 | 35 | ### Bug fixes 36 | 37 | Make `highlightSelectionMatches` include whitespace in the selection in its matches. 38 | 39 | Fix a bug that caused `SearchCursor` to return invalid ranges when matching astral chars that the the normalizer normalized to single-code-unit chars. 40 | 41 | ## 6.5.5 (2023-11-27) 42 | 43 | ### Bug fixes 44 | 45 | Fix a bug that caused codes like `\n` to be unescaped in strings inserted via replace placeholders like `$&`. 46 | 47 | Use the keybinding Mod-Alt-g for `gotoLine` to the search keymap, to make it usable for people whose keyboard layout uses Alt/Option-g to type some character. 48 | 49 | ## 6.5.4 (2023-09-20) 50 | 51 | ### Bug fixes 52 | 53 | Fix a bug that caused whole-word search to incorrectly check for word boundaries in some circumstances. 54 | 55 | ## 6.5.3 (2023-09-14) 56 | 57 | ### Bug fixes 58 | 59 | The `gotoLine` dialog is now populated with the current line number when you open it. 60 | 61 | ## 6.5.2 (2023-08-26) 62 | 63 | ### Bug fixes 64 | 65 | Don't use the very lowest precedence for match highlighting decorations. 66 | 67 | ## 6.5.1 (2023-08-04) 68 | 69 | ### Bug fixes 70 | 71 | Make `gotoLine` prefer to scroll the target line to the middle of the view. 72 | 73 | Fix an issue in `SearchCursor` where character normalization could produce nonsensical matches. 74 | 75 | ## 6.5.0 (2023-06-05) 76 | 77 | ### New features 78 | 79 | The new `regexp` option to `search` can be used to control whether queries have the regexp flag on by default. 80 | 81 | ## 6.4.0 (2023-04-25) 82 | 83 | ### Bug fixes 84 | 85 | The `findNext` and `findPrevious` commands now select the search field text if that field is focused. 86 | 87 | ### New features 88 | 89 | The `scrollToMatch` callback option now receives the editor view as a second parameter. 90 | 91 | ## 6.3.0 (2023-03-20) 92 | 93 | ### New features 94 | 95 | The new `scrollToMatch` search option allows you to adjust the way the editor scrolls search matches into view. 96 | 97 | ## 6.2.3 (2022-11-14) 98 | 99 | ### Bug fixes 100 | 101 | Fix a bug that hid the search dialog's close button when the editor was read-only. 102 | 103 | ## 6.2.2 (2022-10-18) 104 | 105 | ### Bug fixes 106 | 107 | When `literal` is off, \n, \r, and \t escapes are now also supported in replacement text. 108 | 109 | Make sure search dialog inputs don't get treated as form fields when the editor is created inside a form. 110 | 111 | Fix a bug in `RegExpCursor` that would cause it to stop matching in the middle of a line when its current match position was equal to the length of the line. 112 | 113 | ## 6.2.1 (2022-09-26) 114 | 115 | ### Bug fixes 116 | 117 | By-word search queries will now skip any result that had word characters both before and after a match boundary. 118 | 119 | ## 6.2.0 (2022-08-25) 120 | 121 | ### New features 122 | 123 | A new `wholeWord` search query flag can be used to limit matches to whole words. 124 | 125 | `SearchCursor` and `RegExpCursor` now support a `test` parameter that can be used to ignore certain matches. 126 | 127 | ## 6.1.0 (2022-08-16) 128 | 129 | ### Bug fixes 130 | 131 | Fix an infinite loop when the match position of a `RegExpCursor` ended up in the middle of an UTF16 surrogate pair. 132 | 133 | ### New features 134 | 135 | The `literal` search option can now be set to make literal queries the default. 136 | 137 | The new `searchPanelOpen` function can be used to find out whether the search panel is open for a given state. 138 | 139 | ## 6.0.1 (2022-07-22) 140 | 141 | ### Bug fixes 142 | 143 | `findNext` and `findPrevious` will now return to the current result (and scroll it into view) if no other matches are found. 144 | 145 | ## 6.0.0 (2022-06-08) 146 | 147 | ### Bug fixes 148 | 149 | Don't crash when a custom search panel doesn't have a field named 'search'. 150 | 151 | Make sure replacements are announced to screen readers. 152 | 153 | ## 0.20.1 (2022-04-22) 154 | 155 | ### New features 156 | 157 | It is now possible to disable backslash escapes in search queries with the `literal` option. 158 | 159 | ## 0.20.0 (2022-04-20) 160 | 161 | ### Bug fixes 162 | 163 | Make the `wholeWords` option to `highlightSelectionMatches` default to false, as intended. 164 | 165 | ## 0.19.10 (2022-04-04) 166 | 167 | ### Bug fixes 168 | 169 | Make sure search matches are highlighted when scrolling new content into view. 170 | 171 | ## 0.19.9 (2022-03-03) 172 | 173 | ### New features 174 | 175 | The selection-matching extension now accepts a `wholeWords` option that makes it only highlight matches that span a whole word. Add SearchQuery.getCursor 176 | 177 | The `SearchQuery` class now has a `getCursor` method that allows external code to create a cursor for the query. 178 | 179 | ## 0.19.8 (2022-02-14) 180 | 181 | ### Bug fixes 182 | 183 | Fix a bug that caused the search panel to start open when configuring a state with the `search()` extension. 184 | 185 | ## 0.19.7 (2022-02-14) 186 | 187 | ### Breaking changes 188 | 189 | `searchConfig` is deprecated in favor of `search` (but will exist until next major release). 190 | 191 | ### New features 192 | 193 | The new `search` function is now used to enable and configure the search extension. 194 | 195 | ## 0.19.6 (2022-01-27) 196 | 197 | ### Bug fixes 198 | 199 | Make `selectNextOccurrence` scroll the newly selected range into view. 200 | 201 | ## 0.19.5 (2021-12-16) 202 | 203 | ### Breaking changes 204 | 205 | The search option `matchCase` was renamed to `caseSensitive` (the old name will continue to work until the next breaking release). 206 | 207 | ### Bug fixes 208 | 209 | `openSearchPanel` will now update the search query to the current selection even if the panel was already open. 210 | 211 | ### New features 212 | 213 | Client code can now pass a custom search panel creation function in the search configuration. 214 | 215 | The `getSearchQuery` function and `setSearchQuery` effect can now be used to inspect or change the current search query. 216 | 217 | ## 0.19.4 (2021-12-02) 218 | 219 | ### Bug fixes 220 | 221 | The search panel will no longer show the replace interface when the editor is read-only. 222 | 223 | ## 0.19.3 (2021-11-22) 224 | 225 | ### Bug fixes 226 | 227 | Add `userEvent` annotations to search and replace transactions. 228 | 229 | Make sure the editor handles keys bound to `findNext`/`findPrevious` even when there are no matches, to avoid the browser's search interrupting users. 230 | 231 | ### New features 232 | 233 | Add a `Symbol.iterator` property to the cursor types, so that they can be used with `for`/`of`. 234 | 235 | ## 0.19.2 (2021-09-16) 236 | 237 | ### Bug fixes 238 | 239 | `selectNextOccurrence` will now only select partial words if the current main selection hold a partial word. 240 | 241 | Explicitly set the button's type to prevent the browser from submitting forms wrapped around the editor. 242 | 243 | ## 0.19.1 (2021-09-06) 244 | 245 | ### Bug fixes 246 | 247 | Make `highlightSelectionMatches` not produce overlapping decorations, since those tend to just get unreadable. 248 | 249 | Make sure any existing search text is selected when opening the search panel. Add search config option to not match case when search panel is opened (#4) 250 | 251 | ### New features 252 | 253 | The `searchConfig` function now takes a `matchCase` option that controls whether the search panel starts in case-sensitive mode. 254 | 255 | ## 0.19.0 (2021-08-11) 256 | 257 | ### Bug fixes 258 | 259 | Make sure to prevent the native Mod-d behavior so that the editor doesn't lose focus after selecting past the last occurrence. 260 | 261 | ## 0.18.4 (2021-05-27) 262 | 263 | ### New features 264 | 265 | Initialize the search query to the current selection, when there is one, when opening the search dialog. 266 | 267 | Add a `searchConfig` function, supporting an option to put the search panel at the top of the editor. 268 | 269 | ## 0.18.3 (2021-05-18) 270 | 271 | ### Bug fixes 272 | 273 | Fix a bug where the first search command in a new editor wouldn't properly open the panel. 274 | 275 | ### New features 276 | 277 | New command `selectNextOccurrence` that selects the next occurrence of the selected word (bound to Mod-d in the search keymap). 278 | 279 | ## 0.18.2 (2021-03-19) 280 | 281 | ### Bug fixes 282 | 283 | The search interface and cursor will no longer include overlapping matches (aligning with what all other editors are doing). 284 | 285 | ### New features 286 | 287 | The package now exports a `RegExpCursor` which is a search cursor that matches regular expression patterns. 288 | 289 | The search/replace interface now allows the user to use regular expressions. 290 | 291 | The `SearchCursor` class now has a `nextOverlapping` method that includes matches that start inside the previous match. 292 | 293 | Basic backslash escapes (\n, \r, \t, and \\) are now accepted in string search patterns in the UI. 294 | 295 | ## 0.18.1 (2021-03-15) 296 | 297 | ### Bug fixes 298 | 299 | Fix an issue where entering an invalid input in the goto-line dialog would submit a form and reload the page. 300 | 301 | ## 0.18.0 (2021-03-03) 302 | 303 | ### Breaking changes 304 | 305 | Update dependencies to 0.18. 306 | 307 | ## 0.17.1 (2021-01-06) 308 | 309 | ### New features 310 | 311 | The package now also exports a CommonJS module. 312 | 313 | ## 0.17.0 (2020-12-29) 314 | 315 | ### Breaking changes 316 | 317 | First numbered release. 318 | 319 | -------------------------------------------------------------------------------- /src/search.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, ViewPlugin, ViewUpdate, Command, Decoration, DecorationSet, 2 | runScopeHandlers, KeyBinding, 3 | PanelConstructor, showPanel, Panel, getPanel} from "@codemirror/view" 4 | import {EditorState, StateField, StateEffect, EditorSelection, SelectionRange, StateCommand, Prec, 5 | Facet, Extension, RangeSetBuilder, Text, CharCategory, findClusterBreak, 6 | combineConfig} from "@codemirror/state" 7 | import elt from "crelt" 8 | import {SearchCursor} from "./cursor" 9 | import {RegExpCursor, validRegExp} from "./regexp" 10 | import {gotoLine} from "./goto-line" 11 | import {selectNextOccurrence} from "./selection-match" 12 | 13 | export {highlightSelectionMatches} from "./selection-match" 14 | export {SearchCursor, RegExpCursor, gotoLine, selectNextOccurrence} 15 | 16 | interface SearchConfig { 17 | /// Whether to position the search panel at the top of the editor 18 | /// (the default is at the bottom). 19 | top?: boolean 20 | 21 | /// Whether to enable case sensitivity by default when the search 22 | /// panel is activated (defaults to false). 23 | caseSensitive?: boolean 24 | 25 | /// Whether to treat string searches literally by default (defaults to false). 26 | literal?: boolean 27 | 28 | /// Controls whether the default query has by-word matching enabled. 29 | /// Defaults to false. 30 | wholeWord?: boolean 31 | 32 | /// Used to turn on regular expression search in the default query. 33 | /// Defaults to false. 34 | regexp?: boolean 35 | 36 | /// Can be used to override the way the search panel is implemented. 37 | /// Should create a [Panel](#view.Panel) that contains a form 38 | /// which lets the user: 39 | /// 40 | /// - See the [current](#search.getSearchQuery) search query. 41 | /// - Manipulate the [query](#search.SearchQuery) and 42 | /// [update](#search.setSearchQuery) the search state with a new 43 | /// query. 44 | /// - Notice external changes to the query by reacting to the 45 | /// appropriate [state effect](#search.setSearchQuery). 46 | /// - Run some of the search commands. 47 | /// 48 | /// The field that should be focused when opening the panel must be 49 | /// tagged with a `main-field=true` DOM attribute. 50 | createPanel?: (view: EditorView) => Panel, 51 | 52 | /// By default, matches are scrolled into view using the default 53 | /// behavior of 54 | /// [`EditorView.scrollIntoView`](#view.EditorView^scrollIntoView). 55 | /// This option allows you to pass a custom function to produce the 56 | /// scroll effect. 57 | scrollToMatch?: (range: SelectionRange, view: EditorView) => StateEffect 58 | } 59 | 60 | const searchConfigFacet: Facet> = Facet.define({ 61 | combine(configs) { 62 | return combineConfig(configs, { 63 | top: false, 64 | caseSensitive: false, 65 | literal: false, 66 | regexp: false, 67 | wholeWord: false, 68 | createPanel: view => new SearchPanel(view), 69 | scrollToMatch: range => EditorView.scrollIntoView(range) 70 | }) 71 | } 72 | }) 73 | 74 | /// Add search state to the editor configuration, and optionally 75 | /// configure the search extension. 76 | /// ([`openSearchPanel`](#search.openSearchPanel) will automatically 77 | /// enable this if it isn't already on). 78 | export function search(config?: SearchConfig): Extension { 79 | return config ? [searchConfigFacet.of(config), searchExtensions] : searchExtensions 80 | } 81 | 82 | /// A search query. Part of the editor's search state. 83 | export class SearchQuery { 84 | /// The search string (or regular expression). 85 | readonly search: string 86 | /// Indicates whether the search is case-sensitive. 87 | readonly caseSensitive: boolean 88 | /// By default, string search will replace `\n`, `\r`, and `\t` in 89 | /// the query with newline, return, and tab characters. When this 90 | /// is set to true, that behavior is disabled. 91 | readonly literal: boolean 92 | /// When true, the search string is interpreted as a regular 93 | /// expression. 94 | readonly regexp: boolean 95 | /// The replace text, or the empty string if no replace text has 96 | /// been given. 97 | readonly replace: string 98 | /// Whether this query is non-empty and, in case of a regular 99 | /// expression search, syntactically valid. 100 | readonly valid: boolean 101 | /// When true, matches that contain words are ignored when there are 102 | /// further word characters around them. 103 | readonly wholeWord: boolean 104 | 105 | /// @internal 106 | readonly unquoted: string 107 | 108 | /// Create a query object. 109 | constructor(config: { 110 | /// The search string. 111 | search: string, 112 | /// Controls whether the search should be case-sensitive. 113 | caseSensitive?: boolean, 114 | /// By default, string search will replace `\n`, `\r`, and `\t` in 115 | /// the query with newline, return, and tab characters. When this 116 | /// is set to true, that behavior is disabled. 117 | literal?: boolean, 118 | /// When true, interpret the search string as a regular expression. 119 | regexp?: boolean, 120 | /// The replace text. 121 | replace?: string, 122 | /// Enable whole-word matching. 123 | wholeWord?: boolean 124 | }) { 125 | this.search = config.search 126 | this.caseSensitive = !!config.caseSensitive 127 | this.literal = !!config.literal 128 | this.regexp = !!config.regexp 129 | this.replace = config.replace || "" 130 | this.valid = !!this.search && (!this.regexp || validRegExp(this.search)) 131 | this.unquoted = this.unquote(this.search) 132 | this.wholeWord = !!config.wholeWord 133 | } 134 | 135 | /// @internal 136 | unquote(text: string) { 137 | return this.literal ? text : 138 | text.replace(/\\([nrt\\])/g, (_, ch) => ch == "n" ? "\n" : ch == "r" ? "\r" : ch == "t" ? "\t" : "\\") 139 | } 140 | 141 | /// Compare this query to another query. 142 | eq(other: SearchQuery) { 143 | return this.search == other.search && this.replace == other.replace && 144 | this.caseSensitive == other.caseSensitive && this.regexp == other.regexp && 145 | this.wholeWord == other.wholeWord 146 | } 147 | 148 | /// @internal 149 | create(): QueryType { 150 | return this.regexp ? new RegExpQuery(this) : new StringQuery(this) 151 | } 152 | 153 | /// Get a search cursor for this query, searching through the given 154 | /// range in the given state. 155 | getCursor(state: EditorState | Text, from: number = 0, to?: number): Iterator<{from: number, to: number}> { 156 | let st = (state as any).doc ? state as EditorState : EditorState.create({doc: state as Text}) 157 | if (to == null) to = st.doc.length 158 | return this.regexp ? regexpCursor(this, st, from, to) : stringCursor(this, st, from, to) 159 | } 160 | } 161 | 162 | type SearchResult = typeof SearchCursor.prototype.value 163 | 164 | abstract class QueryType { 165 | constructor(readonly spec: SearchQuery) {} 166 | 167 | abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null 168 | 169 | abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null 170 | 171 | abstract getReplacement(result: Result): string 172 | 173 | abstract matchAll(state: EditorState, limit: number): readonly Result[] | null 174 | 175 | abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void 176 | } 177 | 178 | const enum FindPrev { ChunkSize = 10000 } 179 | 180 | function stringCursor(spec: SearchQuery, state: EditorState, from: number, to: number) { 181 | return new SearchCursor( 182 | state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), 183 | spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined) 184 | } 185 | 186 | function stringWordTest(doc: Text, categorizer: (ch: string) => CharCategory) { 187 | return (from: number, to: number, buf: string, bufPos: number) => { 188 | if (bufPos > from || bufPos + buf.length < to) { 189 | bufPos = Math.max(0, from - 2) 190 | buf = doc.sliceString(bufPos, Math.min(doc.length, to + 2)) 191 | } 192 | return (categorizer(charBefore(buf, from - bufPos)) != CharCategory.Word || 193 | categorizer(charAfter(buf, from - bufPos)) != CharCategory.Word) && 194 | (categorizer(charAfter(buf, to - bufPos)) != CharCategory.Word || 195 | categorizer(charBefore(buf, to - bufPos)) != CharCategory.Word) 196 | } 197 | } 198 | 199 | class StringQuery extends QueryType { 200 | constructor(spec: SearchQuery) { 201 | super(spec) 202 | } 203 | 204 | nextMatch(state: EditorState, curFrom: number, curTo: number) { 205 | let cursor = stringCursor(this.spec, state, curTo, state.doc.length).nextOverlapping() 206 | if (cursor.done) { 207 | let end = Math.min(state.doc.length, curFrom + this.spec.unquoted.length) 208 | cursor = stringCursor(this.spec, state, 0, end).nextOverlapping() 209 | } 210 | return cursor.done || cursor.value.from == curFrom && cursor.value.to == curTo ? null : cursor.value 211 | } 212 | 213 | // Searching in reverse is, rather than implementing an inverted search 214 | // cursor, done by scanning chunk after chunk forward. 215 | private prevMatchInRange(state: EditorState, from: number, to: number) { 216 | for (let pos = to;;) { 217 | let start = Math.max(from, pos - FindPrev.ChunkSize - this.spec.unquoted.length) 218 | let cursor = stringCursor(this.spec, state, start, pos), range: SearchResult | null = null 219 | while (!cursor.nextOverlapping().done) range = cursor.value 220 | if (range) return range 221 | if (start == from) return null 222 | pos -= FindPrev.ChunkSize 223 | } 224 | } 225 | 226 | prevMatch(state: EditorState, curFrom: number, curTo: number) { 227 | let found = this.prevMatchInRange(state, 0, curFrom) 228 | if (!found) 229 | found = this.prevMatchInRange(state, Math.max(0, curTo - this.spec.unquoted.length), state.doc.length) 230 | return found && (found.from != curFrom || found.to != curTo) ? found : null 231 | } 232 | 233 | getReplacement(_result: SearchResult) { return this.spec.unquote(this.spec.replace) } 234 | 235 | matchAll(state: EditorState, limit: number) { 236 | let cursor = stringCursor(this.spec, state, 0, state.doc.length), ranges = [] 237 | while (!cursor.next().done) { 238 | if (ranges.length >= limit) return null 239 | ranges.push(cursor.value) 240 | } 241 | return ranges 242 | } 243 | 244 | highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void) { 245 | let cursor = stringCursor(this.spec, state, Math.max(0, from - this.spec.unquoted.length), 246 | Math.min(to + this.spec.unquoted.length, state.doc.length)) 247 | while (!cursor.next().done) add(cursor.value.from, cursor.value.to) 248 | } 249 | } 250 | 251 | const enum RegExp { HighlightMargin = 250 } 252 | 253 | type RegExpResult = typeof RegExpCursor.prototype.value 254 | 255 | function regexpCursor(spec: SearchQuery, state: EditorState, from: number, to: number) { 256 | return new RegExpCursor(state.doc, spec.search, { 257 | ignoreCase: !spec.caseSensitive, 258 | test: spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined 259 | }, from, to) 260 | } 261 | 262 | function charBefore(str: string, index: number) { 263 | return str.slice(findClusterBreak(str, index, false), index) 264 | } 265 | function charAfter(str: string, index: number) { 266 | return str.slice(index, findClusterBreak(str, index)) 267 | } 268 | 269 | function regexpWordTest(categorizer: (ch: string) => CharCategory) { 270 | return (_from: number, _to: number, match: RegExpExecArray) => 271 | !match[0].length || 272 | (categorizer(charBefore(match.input, match.index)) != CharCategory.Word || 273 | categorizer(charAfter(match.input, match.index)) != CharCategory.Word) && 274 | (categorizer(charAfter(match.input, match.index + match[0].length)) != CharCategory.Word || 275 | categorizer(charBefore(match.input, match.index + match[0].length)) != CharCategory.Word) 276 | } 277 | 278 | class RegExpQuery extends QueryType { 279 | nextMatch(state: EditorState, curFrom: number, curTo: number) { 280 | let cursor = regexpCursor(this.spec, state, curTo, state.doc.length).next() 281 | if (cursor.done) cursor = regexpCursor(this.spec, state, 0, curFrom).next() 282 | return cursor.done ? null : cursor.value 283 | } 284 | 285 | private prevMatchInRange(state: EditorState, from: number, to: number) { 286 | for (let size = 1;; size++) { 287 | let start = Math.max(from, to - size * FindPrev.ChunkSize) 288 | let cursor = regexpCursor(this.spec, state, start, to), range: RegExpResult | null = null 289 | while (!cursor.next().done) range = cursor.value 290 | if (range && (start == from || range.from > start + 10)) return range 291 | if (start == from) return null 292 | } 293 | } 294 | 295 | prevMatch(state: EditorState, curFrom: number, curTo: number) { 296 | return this.prevMatchInRange(state, 0, curFrom) || 297 | this.prevMatchInRange(state, curTo, state.doc.length) 298 | } 299 | 300 | getReplacement(result: RegExpResult) { 301 | return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g, (m, i) => { 302 | if (i == "&") return result.match[0] 303 | if (i == "$") return "$" 304 | for (let l = i.length; l > 0; l--) { 305 | let n = +i.slice(0, l) 306 | if (n > 0 && n < result.match.length) return result.match[n] + i.slice(l) 307 | } 308 | return m 309 | }) 310 | } 311 | 312 | matchAll(state: EditorState, limit: number) { 313 | let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = [] 314 | while (!cursor.next().done) { 315 | if (ranges.length >= limit) return null 316 | ranges.push(cursor.value) 317 | } 318 | return ranges 319 | } 320 | 321 | highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void) { 322 | let cursor = regexpCursor(this.spec, state, Math.max(0, from - RegExp.HighlightMargin), 323 | Math.min(to + RegExp.HighlightMargin, state.doc.length)) 324 | while (!cursor.next().done) add(cursor.value.from, cursor.value.to) 325 | } 326 | } 327 | 328 | /// A state effect that updates the current search query. Note that 329 | /// this only has an effect if the search state has been initialized 330 | /// (by including [`search`](#search.search) in your configuration or 331 | /// by running [`openSearchPanel`](#search.openSearchPanel) at least 332 | /// once). 333 | export const setSearchQuery = StateEffect.define() 334 | 335 | const togglePanel = StateEffect.define() 336 | 337 | const searchState: StateField = StateField.define({ 338 | create(state) { 339 | return new SearchState(defaultQuery(state).create(), null) 340 | }, 341 | update(value, tr) { 342 | for (let effect of tr.effects) { 343 | if (effect.is(setSearchQuery)) value = new SearchState(effect.value.create(), value.panel) 344 | else if (effect.is(togglePanel)) value = new SearchState(value.query, effect.value ? createSearchPanel : null) 345 | } 346 | return value 347 | }, 348 | provide: f => showPanel.from(f, val => val.panel) 349 | }) 350 | 351 | /// Get the current search query from an editor state. 352 | export function getSearchQuery(state: EditorState) { 353 | let curState = state.field(searchState, false) 354 | return curState ? curState.query.spec : defaultQuery(state) 355 | } 356 | 357 | /// Query whether the search panel is open in the given editor state. 358 | export function searchPanelOpen(state: EditorState) { 359 | return state.field(searchState, false)?.panel != null 360 | } 361 | 362 | class SearchState { 363 | constructor(readonly query: QueryType, readonly panel: PanelConstructor | null) {} 364 | } 365 | 366 | const matchMark = Decoration.mark({class: "cm-searchMatch"}), 367 | selectedMatchMark = Decoration.mark({class: "cm-searchMatch cm-searchMatch-selected"}) 368 | 369 | const searchHighlighter = ViewPlugin.fromClass(class { 370 | decorations: DecorationSet 371 | 372 | constructor(readonly view: EditorView) { 373 | this.decorations = this.highlight(view.state.field(searchState)) 374 | } 375 | 376 | update(update: ViewUpdate) { 377 | let state = update.state.field(searchState) 378 | if (state != update.startState.field(searchState) || update.docChanged || update.selectionSet || update.viewportChanged) 379 | this.decorations = this.highlight(state) 380 | } 381 | 382 | highlight({query, panel}: SearchState) { 383 | if (!panel || !query.spec.valid) return Decoration.none 384 | let {view} = this 385 | let builder = new RangeSetBuilder() 386 | for (let i = 0, ranges = view.visibleRanges, l = ranges.length; i < l; i++) { 387 | let {from, to} = ranges[i] 388 | while (i < l - 1 && to > ranges[i + 1].from - 2 * RegExp.HighlightMargin) to = ranges[++i].to 389 | query.highlight(view.state, from, to, (from, to) => { 390 | let selected = view.state.selection.ranges.some(r => r.from == from && r.to == to) 391 | builder.add(from, to, selected ? selectedMatchMark : matchMark) 392 | }) 393 | } 394 | return builder.finish() 395 | } 396 | }, { 397 | decorations: v => v.decorations 398 | }) 399 | 400 | function searchCommand(f: (view: EditorView, state: SearchState) => boolean): Command { 401 | return view => { 402 | let state = view.state.field(searchState, false) 403 | return state && state.query.spec.valid ? f(view, state) : openSearchPanel(view) 404 | } 405 | } 406 | 407 | /// Open the search panel if it isn't already open, and move the 408 | /// selection to the first match after the current main selection. 409 | /// Will wrap around to the start of the document when it reaches the 410 | /// end. 411 | export const findNext = searchCommand((view, {query}) => { 412 | let {to} = view.state.selection.main 413 | let next = query.nextMatch(view.state, to, to) 414 | if (!next) return false 415 | let selection = EditorSelection.single(next.from, next.to) 416 | let config = view.state.facet(searchConfigFacet) 417 | view.dispatch({ 418 | selection, 419 | effects: [announceMatch(view, next), config.scrollToMatch(selection.main, view)], 420 | userEvent: "select.search" 421 | }) 422 | selectSearchInput(view) 423 | return true 424 | }) 425 | 426 | /// Move the selection to the previous instance of the search query, 427 | /// before the current main selection. Will wrap past the start 428 | /// of the document to start searching at the end again. 429 | export const findPrevious = searchCommand((view, {query}) => { 430 | let {state} = view, {from} = state.selection.main 431 | let prev = query.prevMatch(state, from, from) 432 | if (!prev) return false 433 | let selection = EditorSelection.single(prev.from, prev.to) 434 | let config = view.state.facet(searchConfigFacet) 435 | view.dispatch({ 436 | selection, 437 | effects: [announceMatch(view, prev), config.scrollToMatch(selection.main, view)], 438 | userEvent: "select.search" 439 | }) 440 | selectSearchInput(view) 441 | return true 442 | }) 443 | 444 | /// Select all instances of the search query. 445 | export const selectMatches = searchCommand((view, {query}) => { 446 | let ranges = query.matchAll(view.state, 1000) 447 | if (!ranges || !ranges.length) return false 448 | view.dispatch({ 449 | selection: EditorSelection.create(ranges.map(r => EditorSelection.range(r.from, r.to))), 450 | userEvent: "select.search.matches" 451 | }) 452 | return true 453 | }) 454 | 455 | /// Select all instances of the currently selected text. 456 | export const selectSelectionMatches: StateCommand = ({state, dispatch}) => { 457 | let sel = state.selection 458 | if (sel.ranges.length > 1 || sel.main.empty) return false 459 | let {from, to} = sel.main 460 | let ranges = [], main = 0 461 | for (let cur = new SearchCursor(state.doc, state.sliceDoc(from, to)); !cur.next().done;) { 462 | if (ranges.length > 1000) return false 463 | if (cur.value.from == from) main = ranges.length 464 | ranges.push(EditorSelection.range(cur.value.from, cur.value.to)) 465 | } 466 | dispatch(state.update({ 467 | selection: EditorSelection.create(ranges, main), 468 | userEvent: "select.search.matches" 469 | })) 470 | return true 471 | } 472 | 473 | /// Replace the current match of the search query. 474 | export const replaceNext = searchCommand((view, {query}) => { 475 | let {state} = view, {from, to} = state.selection.main 476 | if (state.readOnly) return false 477 | let match = query.nextMatch(state, from, from) 478 | if (!match) return false 479 | let next: SearchResult | null = match 480 | let changes = [], selection: EditorSelection | undefined, replacement: Text | undefined 481 | let effects: StateEffect[] = [] 482 | if (next.from == from && next.to == to) { 483 | replacement = state.toText(query.getReplacement(next)) 484 | changes.push({from: next.from, to: next.to, insert: replacement}) 485 | next = query.nextMatch(state, next.from, next.to) 486 | effects.push(EditorView.announce.of( 487 | state.phrase("replaced match on line $", state.doc.lineAt(from).number) + ".")) 488 | } 489 | let changeSet = view.state.changes(changes) 490 | if (next) { 491 | selection = EditorSelection.single(next.from, next.to).map(changeSet) 492 | effects.push(announceMatch(view, next)) 493 | effects.push(state.facet(searchConfigFacet).scrollToMatch(selection.main, view)) 494 | } 495 | view.dispatch({ 496 | changes: changeSet, 497 | selection, 498 | effects, 499 | userEvent: "input.replace" 500 | }) 501 | return true 502 | }) 503 | 504 | /// Replace all instances of the search query with the given 505 | /// replacement. 506 | export const replaceAll = searchCommand((view, {query}) => { 507 | if (view.state.readOnly) return false 508 | let changes = query.matchAll(view.state, 1e9)!.map(match => { 509 | let {from, to} = match 510 | return {from, to, insert: query.getReplacement(match)} 511 | }) 512 | if (!changes.length) return false 513 | let announceText = view.state.phrase("replaced $ matches", changes.length) + "." 514 | view.dispatch({ 515 | changes, 516 | effects: EditorView.announce.of(announceText), 517 | userEvent: "input.replace.all" 518 | }) 519 | return true 520 | }) 521 | 522 | function createSearchPanel(view: EditorView) { 523 | return view.state.facet(searchConfigFacet).createPanel(view) 524 | } 525 | 526 | function defaultQuery(state: EditorState, fallback?: SearchQuery) { 527 | let sel = state.selection.main 528 | let selText = sel.empty || sel.to > sel.from + 100 ? "" : state.sliceDoc(sel.from, sel.to) 529 | if (fallback && !selText) return fallback 530 | let config = state.facet(searchConfigFacet) 531 | return new SearchQuery({ 532 | search: (fallback?.literal ?? config.literal) ? selText : selText.replace(/\n/g, "\\n"), 533 | caseSensitive: fallback?.caseSensitive ?? config.caseSensitive, 534 | literal: fallback?.literal ?? config.literal, 535 | regexp: fallback?.regexp ?? config.regexp, 536 | wholeWord: fallback?.wholeWord ?? config.wholeWord 537 | }) 538 | } 539 | 540 | function getSearchInput(view: EditorView) { 541 | let panel = getPanel(view, createSearchPanel) 542 | return panel && panel.dom.querySelector("[main-field]") as HTMLInputElement | null 543 | } 544 | 545 | function selectSearchInput(view: EditorView) { 546 | let input = getSearchInput(view) 547 | if (input && input == view.root.activeElement) 548 | input.select() 549 | } 550 | 551 | /// Make sure the search panel is open and focused. 552 | export const openSearchPanel: Command = view => { 553 | let state = view.state.field(searchState, false) 554 | if (state && state.panel) { 555 | let searchInput = getSearchInput(view) 556 | if (searchInput && searchInput != view.root.activeElement) { 557 | let query = defaultQuery(view.state, state.query.spec) 558 | if (query.valid) view.dispatch({effects: setSearchQuery.of(query)}) 559 | searchInput.focus() 560 | searchInput.select() 561 | } 562 | } else { 563 | view.dispatch({effects: [ 564 | togglePanel.of(true), 565 | state ? setSearchQuery.of(defaultQuery(view.state, state.query.spec)) : StateEffect.appendConfig.of(searchExtensions) 566 | ]}) 567 | } 568 | return true 569 | } 570 | 571 | /// Close the search panel. 572 | export const closeSearchPanel: Command = view => { 573 | let state = view.state.field(searchState, false) 574 | if (!state || !state.panel) return false 575 | let panel = getPanel(view, createSearchPanel) 576 | if (panel && panel.dom.contains(view.root.activeElement)) view.focus() 577 | view.dispatch({effects: togglePanel.of(false)}) 578 | return true 579 | } 580 | 581 | /// Default search-related key bindings. 582 | /// 583 | /// - Mod-f: [`openSearchPanel`](#search.openSearchPanel) 584 | /// - F3, Mod-g: [`findNext`](#search.findNext) 585 | /// - Shift-F3, Shift-Mod-g: [`findPrevious`](#search.findPrevious) 586 | /// - Mod-Alt-g: [`gotoLine`](#search.gotoLine) 587 | /// - Mod-d: [`selectNextOccurrence`](#search.selectNextOccurrence) 588 | export const searchKeymap: readonly KeyBinding[] = [ 589 | {key: "Mod-f", run: openSearchPanel, scope: "editor search-panel"}, 590 | {key: "F3", run: findNext, shift: findPrevious, scope: "editor search-panel", preventDefault: true}, 591 | {key: "Mod-g", run: findNext, shift: findPrevious, scope: "editor search-panel", preventDefault: true}, 592 | {key: "Escape", run: closeSearchPanel, scope: "editor search-panel"}, 593 | {key: "Mod-Shift-l", run: selectSelectionMatches}, 594 | {key: "Mod-Alt-g", run: gotoLine}, 595 | {key: "Mod-d", run: selectNextOccurrence, preventDefault: true}, 596 | ] 597 | 598 | class SearchPanel implements Panel { 599 | searchField: HTMLInputElement 600 | replaceField: HTMLInputElement 601 | caseField: HTMLInputElement 602 | reField: HTMLInputElement 603 | wordField: HTMLInputElement 604 | dom: HTMLElement 605 | query: SearchQuery 606 | 607 | constructor(readonly view: EditorView) { 608 | let query = this.query = view.state.field(searchState).query.spec 609 | this.commit = this.commit.bind(this) 610 | 611 | this.searchField = elt("input", { 612 | value: query.search, 613 | placeholder: phrase(view, "Find"), 614 | "aria-label": phrase(view, "Find"), 615 | class: "cm-textfield", 616 | name: "search", 617 | form: "", 618 | "main-field": "true", 619 | onchange: this.commit, 620 | onkeyup: this.commit 621 | }) as HTMLInputElement 622 | this.replaceField = elt("input", { 623 | value: query.replace, 624 | placeholder: phrase(view, "Replace"), 625 | "aria-label": phrase(view, "Replace"), 626 | class: "cm-textfield", 627 | name: "replace", 628 | form: "", 629 | onchange: this.commit, 630 | onkeyup: this.commit 631 | }) as HTMLInputElement 632 | this.caseField = elt("input", { 633 | type: "checkbox", 634 | name: "case", 635 | form: "", 636 | checked: query.caseSensitive, 637 | onchange: this.commit 638 | }) as HTMLInputElement 639 | this.reField = elt("input", { 640 | type: "checkbox", 641 | name: "re", 642 | form: "", 643 | checked: query.regexp, 644 | onchange: this.commit 645 | }) as HTMLInputElement 646 | this.wordField = elt("input", { 647 | type: "checkbox", 648 | name: "word", 649 | form: "", 650 | checked: query.wholeWord, 651 | onchange: this.commit 652 | }) as HTMLInputElement 653 | 654 | function button(name: string, onclick: () => void, content: (Node | string)[]) { 655 | return elt("button", {class: "cm-button", name, onclick, type: "button"}, content) 656 | } 657 | this.dom = elt("div", {onkeydown: (e: KeyboardEvent) => this.keydown(e), class: "cm-search"}, [ 658 | this.searchField, 659 | button("next", () => findNext(view), [phrase(view, "next")]), 660 | button("prev", () => findPrevious(view), [phrase(view, "previous")]), 661 | button("select", () => selectMatches(view), [phrase(view, "all")]), 662 | elt("label", null, [this.caseField, phrase(view, "match case")]), 663 | elt("label", null, [this.reField, phrase(view, "regexp")]), 664 | elt("label", null, [this.wordField, phrase(view, "by word")]), 665 | ...view.state.readOnly ? [] : [ 666 | elt("br"), 667 | this.replaceField, 668 | button("replace", () => replaceNext(view), [phrase(view, "replace")]), 669 | button("replaceAll", () => replaceAll(view), [phrase(view, "replace all")]) 670 | ], 671 | elt("button", { 672 | name: "close", 673 | onclick: () => closeSearchPanel(view), 674 | "aria-label": phrase(view, "close"), 675 | type: "button" 676 | }, ["×"]) 677 | ]) 678 | } 679 | 680 | commit() { 681 | let query = new SearchQuery({ 682 | search: this.searchField.value, 683 | caseSensitive: this.caseField.checked, 684 | regexp: this.reField.checked, 685 | wholeWord: this.wordField.checked, 686 | replace: this.replaceField.value, 687 | }) 688 | if (!query.eq(this.query)) { 689 | this.query = query 690 | this.view.dispatch({effects: setSearchQuery.of(query)}) 691 | } 692 | } 693 | 694 | keydown(e: KeyboardEvent) { 695 | if (runScopeHandlers(this.view, e, "search-panel")) { 696 | e.preventDefault() 697 | } else if (e.keyCode == 13 && e.target == this.searchField) { 698 | e.preventDefault() 699 | ;(e.shiftKey ? findPrevious : findNext)(this.view) 700 | } else if (e.keyCode == 13 && e.target == this.replaceField) { 701 | e.preventDefault() 702 | replaceNext(this.view) 703 | } 704 | } 705 | 706 | update(update: ViewUpdate) { 707 | for (let tr of update.transactions) for (let effect of tr.effects) { 708 | if (effect.is(setSearchQuery) && !effect.value.eq(this.query)) this.setQuery(effect.value) 709 | } 710 | } 711 | 712 | setQuery(query: SearchQuery) { 713 | this.query = query 714 | this.searchField.value = query.search 715 | this.replaceField.value = query.replace 716 | this.caseField.checked = query.caseSensitive 717 | this.reField.checked = query.regexp 718 | this.wordField.checked = query.wholeWord 719 | } 720 | 721 | mount() { 722 | this.searchField.select() 723 | } 724 | 725 | get pos() { return 80 } 726 | 727 | get top() { return this.view.state.facet(searchConfigFacet).top } 728 | } 729 | 730 | function phrase(view: EditorView, phrase: string) { return view.state.phrase(phrase) } 731 | 732 | const AnnounceMargin = 30 733 | 734 | const Break = /[\s\.,:;?!]/ 735 | 736 | function announceMatch(view: EditorView, {from, to}: {from: number, to: number}) { 737 | let line = view.state.doc.lineAt(from), lineEnd = view.state.doc.lineAt(to).to 738 | let start = Math.max(line.from, from - AnnounceMargin), end = Math.min(lineEnd, to + AnnounceMargin) 739 | let text = view.state.sliceDoc(start, end) 740 | if (start != line.from) { 741 | for (let i = 0; i < AnnounceMargin; i++) if (!Break.test(text[i + 1]) && Break.test(text[i])) { 742 | text = text.slice(i) 743 | break 744 | } 745 | } 746 | if (end != lineEnd) { 747 | for (let i = text.length - 1; i > text.length - AnnounceMargin; i--) if (!Break.test(text[i - 1]) && Break.test(text[i])) { 748 | text = text.slice(0, i) 749 | break 750 | } 751 | } 752 | 753 | return EditorView.announce.of( 754 | `${view.state.phrase("current match")}. ${text} ${view.state.phrase("on line")} ${line.number}.`) 755 | } 756 | 757 | const baseTheme = EditorView.baseTheme({ 758 | ".cm-panel.cm-search": { 759 | padding: "2px 6px 4px", 760 | position: "relative", 761 | "& [name=close]": { 762 | position: "absolute", 763 | top: "0", 764 | right: "4px", 765 | backgroundColor: "inherit", 766 | border: "none", 767 | font: "inherit", 768 | padding: 0, 769 | margin: 0 770 | }, 771 | "& input, & button, & label": { 772 | margin: ".2em .6em .2em 0" 773 | }, 774 | "& input[type=checkbox]": { 775 | marginRight: ".2em" 776 | }, 777 | "& label": { 778 | fontSize: "80%", 779 | whiteSpace: "pre" 780 | } 781 | }, 782 | 783 | "&light .cm-searchMatch": { backgroundColor: "#ffff0054" }, 784 | "&dark .cm-searchMatch": { backgroundColor: "#00ffff8a" }, 785 | 786 | "&light .cm-searchMatch-selected": { backgroundColor: "#ff6a0054" }, 787 | "&dark .cm-searchMatch-selected": { backgroundColor: "#ff00ff8a" } 788 | }) 789 | 790 | const searchExtensions = [ 791 | searchState, 792 | Prec.low(searchHighlighter), 793 | baseTheme 794 | ] 795 | --------------------------------------------------------------------------------