├── .npmrc ├── .npmignore ├── .gitignore ├── style └── search.css ├── CHANGELOG.md ├── LICENSE ├── package.json ├── src ├── README.md ├── search.ts └── query.ts ├── test ├── test-query.ts └── test-search.ts ├── CONTRIBUTING.md └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .tern-port 3 | /test 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .tern-port 3 | /dist 4 | /test/*.js 5 | -------------------------------------------------------------------------------- /style/search.css: -------------------------------------------------------------------------------- 1 | .ProseMirror-search-match { 2 | background-color: #ffff0054; 3 | } 4 | .ProseMirror-active-search-match { 5 | background-color: #ff6a0054; 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.0 (2025-04-22) 2 | 3 | ### Bug fixes 4 | 5 | Fix another issue in regular expression replacement content reuse. 6 | 7 | ### New features 8 | 9 | `SearchResult` objects now have a `matchStart` property indicating where the node that it matches against starts. 10 | 11 | ## 1.0.0 (2024-07-14) 12 | 13 | ### New Features 14 | 15 | The new `filter` query option makes the query skip matches for which a predicate returns false. 16 | 17 | ### Bug Fixes 18 | 19 | Fix an issue where replacement that reused parts of the match inside an inline node with content preserved the wrong pieces of content. 20 | 21 | ## 0.1.0 (2024-06-21) 22 | 23 | ### Breaking Changes 24 | 25 | First numbered release. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2024 by Marijn Haverbeke and others 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-search", 3 | "version": "1.1.0", 4 | "description": "Search API for ProseMirror", 5 | "type": "module", 6 | "main": "dist/index.cjs", 7 | "module": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/index.js", 12 | "require": "./dist/index.cjs" 13 | }, 14 | "./style/search.css": "./style/search.css" 15 | }, 16 | "style": "style/search.css", 17 | "license": "MIT", 18 | "maintainers": [ 19 | { 20 | "name": "Marijn Haverbeke", 21 | "email": "marijn@haverbeke.berlin", 22 | "web": "http://marijnhaverbeke.nl" 23 | } 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "git://github.com/prosemirror/prosemirror-search.git" 28 | }, 29 | "dependencies": { 30 | "prosemirror-model": "^1.21.0", 31 | "prosemirror-state": "^1.4.3", 32 | "prosemirror-view": "^1.33.6" 33 | }, 34 | "devDependencies": { 35 | "@prosemirror/buildhelper": "^0.1.5", 36 | "prosemirror-test-builder": "^1.0.0", 37 | "builddocs": "^1.0.6", 38 | "getdocs-ts": "^1.0.0" 39 | }, 40 | "scripts": { 41 | "test": "pm-runtests", 42 | "prepare": "pm-buildhelper src/search.ts", 43 | "build-readme": "builddocs --name search --main src/README.md --format markdown src/search.ts > README.md" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # prosemirror-search 2 | 3 | [ [**WEBSITE**](https://prosemirror.net) | [**ISSUES**](https://github.com/prosemirror/prosemirror/issues) | [**FORUM**](https://discuss.prosemirror.net) | [**GITTER**](https://gitter.im/ProseMirror/prosemirror) ] 4 | 5 | This [module](https://prosemirror.net/docs/ref/#search) defines an API 6 | for searching through ProseMirror documents, search/replace commands, 7 | and a plugin that highlights the matches of a given search query. 8 | 9 | When using this module, you should either load 10 | [`style/search.css`](https://github.com/ProseMirror/prosemirror-search/blob/master/style/search.css) 11 | into your page, or define your own styles for the 12 | `ProseMirror-search-match` (search match) and 13 | `ProseMirror-active-search-match` (the active match) classes. 14 | 15 | The [project page](https://prosemirror.net) has more information, a 16 | number of [examples](https://prosemirror.net/examples/) and the 17 | [documentation](https://prosemirror.net/docs/). 18 | 19 | This code is released under an 20 | [MIT license](https://github.com/prosemirror/prosemirror/tree/master/LICENSE). 21 | There's a [forum](http://discuss.prosemirror.net) for general 22 | discussion and support requests, and the 23 | [Github bug tracker](https://github.com/prosemirror/prosemirror/issues) 24 | is the place to report issues. 25 | 26 | We aim to be an inclusive, welcoming community. To make that explicit, 27 | we have a [code of 28 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 29 | to communication around the project. 30 | 31 | ## API 32 | 33 | @search 34 | 35 | @SearchQuery 36 | 37 | @SearchResult 38 | 39 | These functions can be used to manipulate the active search state: 40 | 41 | @getSearchState 42 | 43 | @setSearchState 44 | 45 | @getMatchHighlights 46 | 47 | ### Commands 48 | 49 | @findNext 50 | 51 | @findPrev 52 | 53 | @findNextNoWrap 54 | 55 | @findPrevNoWrap 56 | 57 | @replaceNext 58 | 59 | @replaceNextNoWrap 60 | 61 | @replaceCurrent 62 | 63 | @replaceAll 64 | -------------------------------------------------------------------------------- /test/test-query.ts: -------------------------------------------------------------------------------- 1 | import {SearchQuery} from "prosemirror-search" 2 | import {Node} from "prosemirror-model" 3 | import {EditorState} from "prosemirror-state" 4 | import {doc, p, em} from "prosemirror-test-builder" 5 | import ist from "ist" 6 | 7 | function test(conf: ConstructorParameters[0], doc: Node) { 8 | let matches = [] 9 | for (let i = 1;; i++) { 10 | let s = (doc as any).tag["s" + i], e = (doc as any).tag["e" + i] 11 | if (s == null || e == null) break 12 | matches.push({from: s, to: e}) 13 | } 14 | let state = EditorState.create({doc}) 15 | let query = new SearchQuery(conf) 16 | 17 | let forward = [] 18 | for (let pos = 0;;) { 19 | let next = query.findNext(state, pos) 20 | if (!next) break 21 | forward.push({from: next.from, to: next.to}) 22 | pos = next.to 23 | } 24 | ist(JSON.stringify(forward), JSON.stringify(matches)) 25 | 26 | let backward = [] 27 | for (let pos = doc.content.size;;) { 28 | let next = query.findPrev(state, pos) 29 | if (!next) break 30 | backward.push({from: next.from, to: next.to}) 31 | pos = next.from 32 | } 33 | ist(JSON.stringify(backward), JSON.stringify(matches.slice().reverse())) 34 | } 35 | 36 | describe("SearchQuery", () => { 37 | it("can match plain strings", () => { 38 | test({search: "abc"}, p("abc flakdj aabc aabbcc")) 39 | }) 40 | 41 | it("skips overlapping matches", () => { 42 | test({search: "aba"}, p("abababa.")) 43 | }) 44 | 45 | it("goes through multiple textblocks", () => { 46 | test({search: "12"}, doc(p("a12b"), p("..."), p("and 12"))) 47 | }) 48 | 49 | it("matches across mark boundaries", () => { 50 | test({search: "two"}, p("abt", em("w"), "ooo")) 51 | }) 52 | 53 | it("can match case-insensitive strings", () => { 54 | test({search: "abC", caseSensitive: false}, p("aBc flakdj aABC")) 55 | }) 56 | 57 | it("can match literally", () => { 58 | test({search: "a\\nb", literal: true}, p("a\nb a\\nb")) 59 | }) 60 | 61 | it("can match by word", () => { 62 | test({search: "hello", wholeWord: true}, p("hello hellothere hello\nello ahello ohellop")) 63 | }) 64 | 65 | it("doesn't match non-words by word", () => { 66 | test({search: "^_^", wholeWord: true}, p("x^_^y ^_^")) 67 | }) 68 | 69 | it("can match regular expressions", () => { 70 | test({search: "a..b", regexp: true}, p("appb apb")) 71 | }) 72 | 73 | it("can match case-insensitive regular expressions", () => { 74 | test({search: "a..b", regexp: true, caseSensitive: false}, p("Appb Apb")) 75 | }) 76 | 77 | it("can match regular expressions through multiple textblocks", () => { 78 | test({search: "12", regexp: true}, doc(p("a12b"), p("..."), p("and 12"))) 79 | }) 80 | 81 | it("can match regular expressions by word", () => { 82 | test({search: "a..", regexp: true, wholeWord: true}, p("aap baap aapje a--w")) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | - [Getting help](#getting-help) 4 | - [Submitting bug reports](#submitting-bug-reports) 5 | - [Contributing code](#contributing-code) 6 | 7 | ## Getting help 8 | 9 | Community discussion, questions, and informal bug reporting is done on the 10 | [discuss.ProseMirror forum](http://discuss.prosemirror.net). 11 | 12 | ## Submitting bug reports 13 | 14 | Report bugs on the 15 | [GitHub issue tracker](http://github.com/prosemirror/prosemirror/issues). 16 | Before reporting a bug, please read these pointers. 17 | 18 | - The issue tracker is for *bugs*, not requests for help. Questions 19 | should be asked on the [forum](http://discuss.prosemirror.net). 20 | 21 | - Include information about the version of the code that exhibits the 22 | problem. For browser-related issues, include the browser and browser 23 | version on which the problem occurred. 24 | 25 | - Mention very precisely what went wrong. "X is broken" is not a good 26 | bug report. What did you expect to happen? What happened instead? 27 | Describe the exact steps a maintainer has to take to make the 28 | problem occur. A screencast can be useful, but is no substitute for 29 | a textual description. 30 | 31 | - A great way to make it easy to reproduce your problem, if it can not 32 | be trivially reproduced on the website demos, is to submit a script 33 | that triggers the issue. 34 | 35 | ## Contributing code 36 | 37 | - Make sure you have a [GitHub Account](https://github.com/signup/free) 38 | 39 | - Fork the relevant repository 40 | ([how to fork a repo](https://help.github.com/articles/fork-a-repo)) 41 | 42 | - Create a local checkout of the code. You can use the 43 | [main repository](https://github.com/prosemirror/prosemirror) to 44 | easily check out all core modules. 45 | 46 | - Make your changes, and commit them 47 | 48 | - Follow the code style of the rest of the project (see below). Run 49 | `npm run lint` (in the main repository checkout) to make sure that 50 | the linter is happy. 51 | 52 | - If your changes are easy to test or likely to regress, add tests in 53 | the relevant `test/` directory. Either put them in an existing 54 | `test-*.js` file, if they fit there, or add a new file. 55 | 56 | - Make sure all tests pass. Run `npm run test` to verify tests pass 57 | (you will need Node.js v6+). 58 | 59 | - Submit a pull request ([how to create a pull request](https://help.github.com/articles/fork-a-repo)). 60 | Don't put more than one feature/fix in a single pull request. 61 | 62 | By contributing code to ProseMirror you 63 | 64 | - Agree to license the contributed code under the project's [MIT 65 | license](https://github.com/ProseMirror/prosemirror/blob/master/LICENSE). 66 | 67 | - Confirm that you have the right to contribute and license the code 68 | in question. (Either you hold all rights on the code, or the rights 69 | holder has explicitly granted the right to use it like this, 70 | through a compatible open source license or through a direct 71 | agreement with you.) 72 | 73 | ### Coding standards 74 | 75 | - ES6 syntax, targeting an ES5 runtime (i.e. don't use library 76 | elements added by ES6, don't use ES7/ES.next syntax). 77 | 78 | - 2 spaces per indentation level, no tabs. 79 | 80 | - No semicolons except when necessary. 81 | 82 | - Follow the surrounding code when it comes to spacing, brace 83 | placement, etc. 84 | 85 | - Brace-less single-statement bodies are encouraged (whenever they 86 | don't impact readability). 87 | 88 | - [getdocs](https://github.com/marijnh/getdocs)-style doc comments 89 | above items that are part of the public API. 90 | 91 | - When documenting non-public items, you can put the type after a 92 | single colon, so that getdocs doesn't pick it up and add it to the 93 | API reference. 94 | 95 | - The linter (`npm run lint`) complains about unused variables and 96 | functions. Prefix their names with an underscore to muffle it. 97 | 98 | - ProseMirror does *not* follow JSHint or JSLint prescribed style. 99 | Patches that try to 'fix' code to pass one of these linters will not 100 | be accepted. 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prosemirror-search 2 | 3 | [ [**WEBSITE**](https://prosemirror.net) | [**ISSUES**](https://github.com/prosemirror/prosemirror/issues) | [**FORUM**](https://discuss.prosemirror.net) | [**GITTER**](https://gitter.im/ProseMirror/prosemirror) ] 4 | 5 | This [module](https://prosemirror.net/docs/ref/#search) defines an API 6 | for searching through ProseMirror documents, search/replace commands, 7 | and a plugin that highlights the matches of a given search query. 8 | 9 | When using this module, you should either load 10 | [`style/search.css`](https://github.com/ProseMirror/prosemirror-search/blob/master/style/search.css) 11 | into your page, or define your own styles for the 12 | `ProseMirror-search-match` (search match) and 13 | `ProseMirror-active-search-match` (the active match) classes. 14 | 15 | The [project page](https://prosemirror.net) has more information, a 16 | number of [examples](https://prosemirror.net/examples/) and the 17 | [documentation](https://prosemirror.net/docs/). 18 | 19 | This code is released under an 20 | [MIT license](https://github.com/prosemirror/prosemirror/tree/master/LICENSE). 21 | There's a [forum](http://discuss.prosemirror.net) for general 22 | discussion and support requests, and the 23 | [Github bug tracker](https://github.com/prosemirror/prosemirror/issues) 24 | is the place to report issues. 25 | 26 | We aim to be an inclusive, welcoming community. To make that explicit, 27 | we have a [code of 28 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 29 | to communication around the project. 30 | 31 | ## API 32 | 33 | * **`search`**`(options?: {initialQuery?: SearchQuery, initialRange?: {from: number, to: number}} = {}) → Plugin`\ 34 | Returns a plugin that stores a current search query and searched 35 | range, and highlights matches of the query. 36 | 37 | 38 | ### class SearchQuery 39 | 40 | * `new `**`SearchQuery`**`(config: Object)`\ 41 | Create a query object. 42 | 43 | * **`config`**`: Object` 44 | 45 | * **`search`**`: string`\ 46 | The search string. 47 | 48 | * **`caseSensitive`**`?: boolean`\ 49 | Controls whether the search should be case-sensitive. 50 | 51 | * **`literal`**`?: boolean`\ 52 | By default, string search will replace `\n`, `\r`, and `\t` in 53 | the query with newline, return, and tab characters. When this 54 | is set to true, that behavior is disabled. 55 | 56 | * **`regexp`**`?: boolean`\ 57 | When true, interpret the search string as a regular expression. 58 | 59 | * **`replace`**`?: string`\ 60 | The replace text. 61 | 62 | * **`wholeWord`**`?: boolean`\ 63 | Enable whole-word matching. 64 | 65 | * **`search`**`: string`\ 66 | The search string (or regular expression). 67 | 68 | * **`caseSensitive`**`: boolean`\ 69 | Indicates whether the search is case-sensitive. 70 | 71 | * **`literal`**`: boolean`\ 72 | By default, string search will replace `\n`, `\r`, and `\t` in 73 | the query with newline, return, and tab characters. When this 74 | is set to true, that behavior is disabled. 75 | 76 | * **`regexp`**`: boolean`\ 77 | When true, the search string is interpreted as a regular 78 | expression. 79 | 80 | * **`replace`**`: string`\ 81 | The replace text, or the empty string if no replace text has 82 | been given. 83 | 84 | * **`valid`**`: boolean`\ 85 | Whether this query is non-empty and, in case of a regular 86 | expression search, syntactically valid. 87 | 88 | * **`wholeWord`**`: boolean`\ 89 | When true, matches that contain words are ignored when there are 90 | further word characters around them. 91 | 92 | * **`eq`**`(other: SearchQuery) → boolean`\ 93 | Compare this query to another query. 94 | 95 | * **`findNext`**`(state: EditorState, from?: number = 0, to?: number = state.doc.content.size) → SearchResult`\ 96 | Find the next occurrence of this query in the given range. 97 | 98 | * **`findPrev`**`(state: EditorState, from?: number = state.doc.content.size, to?: number = 0) → SearchResult`\ 99 | Find the previous occurrence of this query in the given range. 100 | Note that, if `to` is given, it should be _less_ than `from`. 101 | 102 | * **`getReplacements`**`(state: EditorState, result: SearchResult) → {from: number, to: number, insert: Slice}[]`\ 103 | Get the ranges that should be replaced for this result. This can 104 | return multiple ranges when `this.replace` contains 105 | `$1`/`$&`-style placeholders, in which case the preserved 106 | content is skipped by the replacements. 107 | 108 | Ranges are sorted by position, and `from`/`to` positions all 109 | refer to positions in `state.doc`. When applying these, you'll 110 | want to either apply them from back to front, or map these 111 | positions through your transaction's current mapping. 112 | 113 | 114 | ### interface SearchResult 115 | 116 | A matched instance of a search query. `match` will be non-null 117 | only for regular expression queries. 118 | 119 | * **`from`**`: number` 120 | 121 | * **`to`**`: number` 122 | 123 | * **`match`**`: RegExpMatchArray` 124 | 125 | 126 | These functions can be used to manipulate the active search state: 127 | 128 | * **`getSearchState`**`(state: EditorState) → {query: SearchQuery, range: {from: number, to: number}}`\ 129 | Get the current active search query and searched range. Will 130 | return `undefined` is the search plugin isn't active. 131 | 132 | 133 | * **`setSearchState`**`(tr: Transaction, query: SearchQuery, range?: {from: number, to: number} = null) → Transaction`\ 134 | Add metadata to a transaction that updates the active search query 135 | and searched range, when dispatched. 136 | 137 | 138 | * **`getMatchHighlights`**`(state: EditorState) → DecorationSet`\ 139 | Access the decoration set holding the currently highlighted search 140 | matches in the document. 141 | 142 | 143 | ### Commands 144 | 145 | * **`findNext`**`: Command`\ 146 | Find the next instance of the search query after the current 147 | selection and move the selection to it. 148 | 149 | 150 | * **`findPrev`**`: Command`\ 151 | Find the previous instance of the search query and move the 152 | selection to it. 153 | 154 | 155 | * **`findNextNoWrap`**`: Command`\ 156 | Find the next instance of the search query and move the selection 157 | to it. Don't wrap around at the end of document or search range. 158 | 159 | 160 | * **`findPrevNoWrap`**`: Command`\ 161 | Find the previous instance of the search query and move the 162 | selection to it. Don't wrap at the start of the document or search 163 | range. 164 | 165 | 166 | * **`replaceNext`**`: Command`\ 167 | Replace the currently selected instance of the search query, and 168 | move to the next one. Or select the next match, if none is already 169 | selected. 170 | 171 | 172 | * **`replaceNextNoWrap`**`: Command`\ 173 | Replace the next instance of the search query. Don't wrap around 174 | at the end of the document. 175 | 176 | 177 | * **`replaceCurrent`**`: Command`\ 178 | Replace the currently selected instance of the search query, if 179 | any, and keep it selected. 180 | 181 | 182 | * **`replaceAll`**`: Command`\ 183 | Replace all instances of the search query. 184 | 185 | 186 | -------------------------------------------------------------------------------- /src/search.ts: -------------------------------------------------------------------------------- 1 | import {Command, Plugin, PluginKey, TextSelection, EditorState, Transaction} from "prosemirror-state" 2 | import {DecorationSet, Decoration} from "prosemirror-view" 3 | 4 | import {SearchQuery, SearchResult} from "./query" 5 | export {SearchQuery, SearchResult} 6 | 7 | class SearchState { 8 | constructor( 9 | readonly query: SearchQuery, 10 | readonly range: {from: number, to: number} | null, 11 | readonly deco: DecorationSet 12 | ) {} 13 | } 14 | 15 | function buildMatchDeco(state: EditorState, query: SearchQuery, range: {from: number, to: number} | null) { 16 | if (!query.valid) return DecorationSet.empty 17 | let deco: Decoration[] = [] 18 | let sel = state.selection 19 | for (let pos = range ? range.from : 0, end = range ? range.to : state.doc.content.size;;) { 20 | let next = query.findNext(state, pos, end) 21 | if (!next) break 22 | let cls = next.from == sel.from && next.to == sel.to ? "ProseMirror-active-search-match" : "ProseMirror-search-match" 23 | deco.push(Decoration.inline(next.from, next.to, {class: cls})) 24 | pos = next.to 25 | } 26 | return DecorationSet.create(state.doc, deco) 27 | } 28 | 29 | const searchKey: PluginKey = new PluginKey("search") 30 | 31 | /// Returns a plugin that stores a current search query and searched 32 | /// range, and highlights matches of the query. 33 | export function search(options: {initialQuery?: SearchQuery, initialRange?: {from: number, to: number}} = {}): Plugin { 34 | return new Plugin({ 35 | key: searchKey, 36 | state: { 37 | init(_config, state) { 38 | let query = options.initialQuery || new SearchQuery({search: ""}) 39 | let range = options.initialRange || null 40 | return new SearchState(query, range, buildMatchDeco(state, query, range)) 41 | }, 42 | apply(tr, search, _oldState, state) { 43 | let set = tr.getMeta(searchKey) as {query: SearchQuery, range: {from: number, to: number} | null} | undefined 44 | if (set) return new SearchState(set.query, set.range, buildMatchDeco(state, set.query, set.range)) 45 | 46 | if (tr.docChanged || tr.selectionSet) { 47 | let range = search.range 48 | if (range) { 49 | let from = tr.mapping.map(range.from, 1) 50 | let to = tr.mapping.map(range.to, -1) 51 | range = from < to ? {from, to} : null 52 | } 53 | search = new SearchState(search.query, range, buildMatchDeco(state, search.query, range)) 54 | } 55 | return search 56 | } 57 | }, 58 | props: { 59 | decorations: state => searchKey.getState(state)!.deco 60 | } 61 | }) 62 | } 63 | 64 | /// Get the current active search query and searched range. Will 65 | /// return `undefined` is the search plugin isn't active. 66 | export function getSearchState(state: EditorState): { 67 | query: SearchQuery, 68 | range: {from: number, to: number} | null 69 | } | undefined { 70 | return searchKey.getState(state) 71 | } 72 | 73 | /// Access the decoration set holding the currently highlighted search 74 | /// matches in the document. 75 | export function getMatchHighlights(state: EditorState) { 76 | let search = searchKey.getState(state) 77 | return search ? search.deco : DecorationSet.empty 78 | } 79 | 80 | /// Add metadata to a transaction that updates the active search query 81 | /// and searched range, when dispatched. 82 | export function setSearchState(tr: Transaction, query: SearchQuery, range: {from: number, to: number} | null = null) { 83 | return tr.setMeta(searchKey, {query, range}) 84 | } 85 | 86 | function nextMatch(search: SearchState, state: EditorState, wrap: boolean, curFrom: number, curTo: number) { 87 | let range = search.range || {from: 0, to: state.doc.content.size} 88 | let next = search.query.findNext(state, Math.max(curTo, range.from), range.to) 89 | if (!next && wrap) 90 | next = search.query.findNext(state, range.from, Math.min(curFrom, range.to)) 91 | return next 92 | } 93 | 94 | function prevMatch(search: SearchState, state: EditorState, wrap: boolean, curFrom: number, curTo: number) { 95 | let range = search.range || {from: 0, to: state.doc.content.size} 96 | let prev = search.query.findPrev(state, Math.min(curFrom, range.to), range.from) 97 | if (!prev && wrap) 98 | prev = search.query.findPrev(state, range.to, Math.max(curTo, range.from)) 99 | return prev 100 | } 101 | 102 | function findCommand(wrap: boolean, dir: -1 | 1): Command { 103 | return (state, dispatch) => { 104 | let search = searchKey.getState(state) 105 | if (!search || !search.query.valid) return false 106 | let {from, to} = state.selection 107 | let next = dir > 0 ? nextMatch(search, state, wrap, from, to) : prevMatch(search, state, wrap, from, to) 108 | if (!next) return false 109 | let selection = TextSelection.create(state.doc, next.from, next.to) 110 | if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView()) 111 | return true 112 | } 113 | } 114 | 115 | /// Find the next instance of the search query after the current 116 | /// selection and move the selection to it. 117 | export const findNext = findCommand(true, 1) 118 | 119 | /// Find the next instance of the search query and move the selection 120 | /// to it. Don't wrap around at the end of document or search range. 121 | export const findNextNoWrap = findCommand(false, 1) 122 | 123 | /// Find the previous instance of the search query and move the 124 | /// selection to it. 125 | export const findPrev = findCommand(true, -1) 126 | 127 | /// Find the previous instance of the search query and move the 128 | /// selection to it. Don't wrap at the start of the document or search 129 | /// range. 130 | export const findPrevNoWrap = findCommand(false, -1) 131 | 132 | function replaceCommand(wrap: boolean, moveForward: boolean): Command { 133 | return (state, dispatch) => { 134 | let search = searchKey.getState(state) 135 | if (!search || !search.query.valid) return false 136 | let {from} = state.selection 137 | let next = nextMatch(search, state, wrap, from, from) 138 | if (!next) return false 139 | 140 | if (!dispatch) return true 141 | if (state.selection.from == next.from && state.selection.to == next.to) { 142 | let tr = state.tr, replacements = search.query.getReplacements(state, next) 143 | for (let i = replacements.length - 1; i >= 0; i--) { 144 | let {from, to, insert} = replacements[i] 145 | tr.replace(from, to, insert) 146 | } 147 | let after = moveForward && nextMatch(search, state, wrap, next.from, next.to) 148 | if (after) 149 | tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(after.from, 1), tr.mapping.map(after.to, -1))) 150 | else 151 | tr.setSelection(TextSelection.create(tr.doc, next.from, tr.mapping.map(next.to, 1))) 152 | dispatch(tr.scrollIntoView()) 153 | } else if (!moveForward){ 154 | return false 155 | } else { 156 | dispatch(state.tr.setSelection(TextSelection.create(state.doc, next.from, next.to)).scrollIntoView()) 157 | } 158 | return true 159 | } 160 | } 161 | 162 | /// Replace the currently selected instance of the search query, and 163 | /// move to the next one. Or select the next match, if none is already 164 | /// selected. 165 | export const replaceNext = replaceCommand(true, true) 166 | 167 | /// Replace the next instance of the search query. Don't wrap around 168 | /// at the end of the document. 169 | export const replaceNextNoWrap = replaceCommand(false, true) 170 | 171 | /// Replace the currently selected instance of the search query, if 172 | /// any, and keep it selected. 173 | export const replaceCurrent = replaceCommand(false, false) 174 | 175 | /// Replace all instances of the search query. 176 | export const replaceAll: Command = (state, dispatch) => { 177 | let search = searchKey.getState(state) 178 | if (!search) return false 179 | let matches: SearchResult[] = [], range = search.range || {from: 0, to: state.doc.content.size} 180 | for (let pos = range.from;;) { 181 | let next = search.query.findNext(state, pos, range.to) 182 | if (!next) break 183 | matches.push(next) 184 | pos = next.to 185 | } 186 | if (dispatch) { 187 | let tr = state.tr 188 | for (let i = matches.length - 1; i >= 0; i--) { 189 | let match = matches[i] 190 | let replacements = search.query.getReplacements(state, match) 191 | for (let j = replacements.length - 1; j >= 0; j--) { 192 | let {from, to, insert} = replacements[j] 193 | tr.replace(from, to, insert) 194 | } 195 | } 196 | dispatch(tr) 197 | } 198 | return true 199 | } 200 | -------------------------------------------------------------------------------- /test/test-search.ts: -------------------------------------------------------------------------------- 1 | import {EditorState, TextSelection, Command, Transaction} from "prosemirror-state" 2 | import {Node, Schema} from "prosemirror-model" 3 | 4 | import {SearchQuery, search, 5 | findNext, findNextNoWrap, findPrev, findPrevNoWrap, 6 | replaceNext, replaceNextNoWrap, replaceCurrent, replaceAll, 7 | SearchResult} from "prosemirror-search" 8 | 9 | import {doc, blockquote, p, img, em, eq, schema, builders} from "prosemirror-test-builder" 10 | import ist from "ist" 11 | 12 | type Query = ConstructorParameters[0] & {range?: {from: number, to: number}} 13 | 14 | function tag(node: Node, tag: string): number | undefined { 15 | return (node as any).tag[tag] 16 | } 17 | 18 | function mkState(query: Query, doc: Node) { 19 | let a = tag(doc, "a"), b = tag(doc, "b") 20 | return EditorState.create({ 21 | doc, 22 | selection: a == null ? undefined : TextSelection.create(doc, a, b), 23 | plugins: [search({initialQuery: new SearchQuery(query), initialRange: query.range})] 24 | }) 25 | } 26 | 27 | function testSelCommand(query: Query, doc: Node, command: Command) { 28 | let state = mkState(query, doc) 29 | let result = command(state, tr => state = state.apply(tr)) 30 | let c = tag(doc, "c"), d = tag(doc, "d") 31 | ist(result, c != null) 32 | if (c != null) ist(JSON.stringify(state.selection), JSON.stringify(TextSelection.create(doc, c, d))) 33 | } 34 | 35 | function testCommand(query: Query, start: Node, next: Node | null, command: Command) { 36 | let state = mkState(query, start) 37 | let result = command(state, tr => state = state.apply(tr)) 38 | ist(result, !!next) 39 | if (next) { 40 | let expect = mkState(query, next) 41 | ist(state.doc, expect.doc, eq) 42 | ist(JSON.stringify(state.selection), JSON.stringify(expect.selection)) 43 | } 44 | } 45 | 46 | describe("search", () => { 47 | describe("findNext", () => { 48 | it("can find the next match", () => { 49 | testSelCommand({search: "two"}, p("one two two"), findNext) 50 | }) 51 | it("can find the next match from selection", () => { 52 | testSelCommand({search: "two"}, p("one two two"), findNext) 53 | }) 54 | it("wraps around at end of document", () => { 55 | testSelCommand({search: "two"}, p("one two two"), findNext) 56 | }) 57 | it("doesn't wrap around in no-wrap mode", () => { 58 | testSelCommand({search: "two"}, p("one two two"), findNextNoWrap) 59 | }) 60 | it("can search a limited range", () => { 61 | testSelCommand({search: "two", range: {from: 7, to: 11}}, p("one two two"), findNext) 62 | }) 63 | it("wraps within the given range", () => { 64 | testSelCommand({search: "two", range: {from: 3, to: 11}}, p("two two two"), findNext) 65 | }) 66 | it("can match in nested structure", () => { 67 | testSelCommand({search: "one"}, doc(blockquote(p("para one"), p("para two")), p("and one")), findNext) 68 | }) 69 | }) 70 | 71 | describe("findPrev", () => { 72 | it("can find the previous match", () => { 73 | testSelCommand({search: "two"}, p("one two two"), findPrev) 74 | }) 75 | it("wraps around at start of document", () => { 76 | testSelCommand({search: "two"}, p("one two two"), findPrev) 77 | }) 78 | it("doesn't wrap around in no-wrap mode", () => { 79 | testSelCommand({search: "two"}, p("one two two"), findPrevNoWrap) 80 | }) 81 | it("can search a limited range", () => { 82 | testSelCommand({search: "two", range: {from: 7, to: 11}}, p("one two two"), findPrev) 83 | }) 84 | it("wraps within the given range", () => { 85 | testSelCommand({search: "two", range: {from: 3, to: 11}}, p("two two two"), findPrev) 86 | }) 87 | it("can match in nested structure", () => { 88 | testSelCommand({search: "one"}, doc(blockquote(p("para one"), p("para two")), p("and one")), findPrev) 89 | }) 90 | }) 91 | 92 | describe("replaceNext", () => { 93 | it("moves to a match when not already on one", () => { 94 | testCommand({search: "one", replace: "two"}, p("one one"), p("one one"), replaceNext) 95 | }) 96 | it("can replace the current match", () => { 97 | testCommand({search: "one", replace: "two"}, p("one two"), p("two two"), replaceNext) 98 | }) 99 | it("moves selection to the next match", () => { 100 | testCommand({search: "one", replace: "two"}, p("one one"), p("two one"), replaceNext) 101 | }) 102 | it("wraps around the end of the document", () => { 103 | testCommand({search: "one", replace: "two"}, p("one one"), p("one two"), replaceNext) 104 | }) 105 | it("doesn't wrap with wrapping disabled", () => { 106 | testCommand({search: "one", replace: "two"}, p("one one"), p("one two"), replaceNextNoWrap) 107 | }) 108 | it("can replace within a limited range", () => { 109 | testCommand({search: "one", replace: "two", range: {from: 0, to: 7}}, 110 | p("one one one"), p("one two one"), replaceNext) 111 | }) 112 | it("can reuse parts of the match", () => { 113 | testCommand({search: "\\((.*?)\\)", regexp: true, replace: "[$1]"}, 114 | p("(hi) (x)"), p("[hi] (x)"), replaceNext) 115 | }) 116 | it("can reuse matched leaf nodes", () => { 117 | testCommand({search: "\\((.*?)\\)", regexp: true, replace: "[$1]"}, 118 | p("(", img(), ") (x)"), p("[", img(), "] (x)"), replaceNext) 119 | }) 120 | it("can replace in nested structure", () => { 121 | testCommand({search: "one", replace: "two"}, 122 | doc(blockquote(p("para one"), p("para two")), p("and one")), 123 | doc(blockquote(p("para two"), p("para two")), p("and one")), 124 | replaceNext) 125 | }) 126 | it("doesn't replace reused content", () => { 127 | let state = mkState({search: ".(eu).", regexp: true, replace: "p$1t"}, p("deux trois")) 128 | let tr: Transaction | undefined 129 | replaceNext(state, t => tr = t) 130 | ist(tr) 131 | ist(tr!.doc, p("peut trois"), eq) 132 | ist(tr!.mapping.map(2), 2) 133 | }) 134 | it("can handle multiple references to groups", () => { 135 | testCommand({search: "(ab)-(cd)", regexp: true, replace: "$2$1$2"}, 136 | p("ab-cd"), p("cdabcd"), replaceNext) 137 | }) 138 | it("replaces non-matched groups with nothing", () => { 139 | testCommand({search: "(ab)|(cd)", regexp: true, replace: "x$2"}, 140 | p("ab"), p("x"), replaceNext) 141 | }) 142 | it("supports matches in string replacements", () => { 143 | testCommand({search: "one", replace: "$&$&"}, p("one"), p("oneone"), replaceNext) 144 | }) 145 | }) 146 | 147 | function footnoteSchema() { 148 | let footnoteSchema = new Schema({ 149 | nodes: schema.spec.nodes.addBefore("image", "footnote", { 150 | group: "inline", 151 | content: "text*", 152 | inline: true, 153 | // This makes the view treat the node as a leaf, even though it 154 | // technically has content 155 | atom: true, 156 | toDOM: () => ["footnote", 0], 157 | parseDOM: [{tag: "footnote"}] 158 | }), 159 | marks: schema.spec.marks 160 | }) 161 | return builders(footnoteSchema, { 162 | p: {nodeType: "paragraph"}, 163 | pre: {nodeType: "code_block"}, 164 | h1: {nodeType: "heading", level: 1}, 165 | h2: {nodeType: "heading", level: 2}, 166 | h3: {nodeType: "heading", level: 3}, 167 | li: {nodeType: "list_item"}, 168 | ul: {nodeType: "bullet_list"}, 169 | ol: {nodeType: "ordered_list"}, 170 | br: {nodeType: "hard_break"}, 171 | footnote: {nodeType: "footnote"}, 172 | img: {nodeType: "image", src: "img.png"}, 173 | hr: {nodeType: "horizontal_rule"}, 174 | a: {markType: "link", href: "foo"}, 175 | }) 176 | } 177 | 178 | describe("replaceCurrent", () => { 179 | it("does nothing when not at a match", () => { 180 | testCommand({search: "one", replace: "two"}, p("one"), null, replaceCurrent) 181 | }) 182 | it("selects the replacement", () => { 183 | testCommand({search: "one", replace: "two"}, p("one"), p("two"), replaceCurrent) 184 | }) 185 | it("replaces delimiters with regexp", () => { 186 | testCommand({search: "“([^”]+)”", replace: "$1", regexp: true}, 187 | p("This is the “footnote” text"), 188 | p("This is the footnote text"), 189 | replaceCurrent) 190 | }) 191 | it("replaces inside non-leaf atoms", () => { 192 | let b = footnoteSchema() 193 | testCommand({search: "footnote", replace: "NOTE"}, 194 | b.p("text", b.footnote("This is the footnote text")), 195 | b.p("text", b.footnote("This is the NOTE text")), 196 | replaceCurrent) 197 | }) 198 | it("replaces delimiters with regexp inside non-leaf atoms", () => { 199 | let b = footnoteSchema() 200 | testCommand({search: "“([^”]+)”", replace: "$1", regexp: true}, 201 | b.p("text", b.footnote("This is the “footnote” text")), 202 | b.p("text", b.footnote("This is the footnote text")), 203 | replaceCurrent) 204 | }) 205 | }) 206 | 207 | describe("replaceAll", () => { 208 | it("replaces all instances", () => { 209 | testCommand({search: "one", replace: "two"}, 210 | doc(p("this one"), p("that one"), blockquote(p("another one"))), 211 | doc(p("this two"), p("that two"), blockquote(p("another two"))), 212 | replaceAll) 213 | }) 214 | it("support using parts of the match", () => { 215 | testCommand({search: "(\\d+)-(\\d+)", replace: "$1:$2", regexp: true}, 216 | p("50-20 vs 40-15"), 217 | p("50:20 vs 40:15"), 218 | replaceAll) 219 | }) 220 | it("works within a limited range", () => { 221 | testCommand({search: "one", replace: "two", range: {from: 2, to: 17}}, 222 | p("one one one one one"), 223 | p("one two two two one"), 224 | replaceAll) 225 | }) 226 | }) 227 | 228 | describe("filter", () => { 229 | it("lets you replace only emphasized texts", () => { 230 | const filter = (state: EditorState, result: SearchResult) => 231 | state.doc.rangeHasMark(result.from, result.to, state.schema.marks.em.create()) 232 | testCommand({search: "one", replace: "two", filter}, 233 | doc(p("this one"), p("that ", em("one")), blockquote(p("another ", em("one")))), 234 | doc(p("this one"), p("that ", em("two")), blockquote(p("another ", em("two")))), 235 | replaceAll) 236 | }) 237 | }) 238 | }) 239 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import {EditorState} from "prosemirror-state" 2 | import {Node, Slice, Fragment} from "prosemirror-model" 3 | 4 | export class SearchQuery { 5 | /// The search string (or regular expression). 6 | readonly search: string 7 | /// Indicates whether the search is case-sensitive. 8 | readonly caseSensitive: boolean 9 | /// By default, string search will replace `\n`, `\r`, and `\t` in 10 | /// the query with newline, return, and tab characters. When this 11 | /// is set to true, that behavior is disabled. 12 | readonly literal: boolean 13 | /// When true, the search string is interpreted as a regular 14 | /// expression. 15 | readonly regexp: boolean 16 | /// The replace text, or the empty string if no replace text has 17 | /// been given. 18 | readonly replace: string 19 | /// Whether this query is non-empty and, in case of a regular 20 | /// expression search, syntactically valid. 21 | readonly valid: boolean 22 | /// When true, matches that contain words are ignored when there are 23 | /// further word characters around them. 24 | readonly wholeWord: boolean 25 | /// An optional filter that causes some results to be ignored. 26 | readonly filter: ((state: EditorState, result: SearchResult) => boolean) | null 27 | 28 | /// @internal 29 | impl: QueryImpl 30 | 31 | /// Create a query object. 32 | constructor(config: { 33 | /// The search string. 34 | search: string, 35 | /// Controls whether the search should be case-sensitive. 36 | caseSensitive?: boolean, 37 | /// By default, string search will replace `\n`, `\r`, and `\t` in 38 | /// the query with newline, return, and tab characters. When this 39 | /// is set to true, that behavior is disabled. 40 | literal?: boolean, 41 | /// When true, interpret the search string as a regular expression. 42 | regexp?: boolean, 43 | /// The replace text. 44 | replace?: string, 45 | /// Enable whole-word matching. 46 | wholeWord?: boolean, 47 | /// Providing a filter causes results for which the filter returns 48 | /// false to be ignored. 49 | filter?: (state: EditorState, result: SearchResult) => boolean 50 | }) { 51 | this.search = config.search 52 | this.caseSensitive = !!config.caseSensitive 53 | this.literal = !!config.literal 54 | this.regexp = !!config.regexp 55 | this.replace = config.replace || "" 56 | this.valid = !!this.search && !(this.regexp && !validRegExp(this.search)) 57 | this.wholeWord = !!config.wholeWord 58 | this.filter = config.filter || null 59 | this.impl = !this.valid ? nullQuery : this.regexp ? new RegExpQuery(this) : new StringQuery(this) 60 | } 61 | 62 | /// Compare this query to another query. 63 | eq(other: SearchQuery) { 64 | return this.search == other.search && this.replace == other.replace && 65 | this.caseSensitive == other.caseSensitive && this.regexp == other.regexp && 66 | this.wholeWord == other.wholeWord 67 | } 68 | 69 | /// Find the next occurrence of this query in the given range. 70 | findNext(state: EditorState, from: number = 0, to: number = state.doc.content.size) { 71 | for (;;) { 72 | if (from >= to) return null 73 | let result = this.impl.findNext(state, from, to) 74 | if (!result || this.checkResult(state, result)) return result 75 | from = result.from + 1 76 | } 77 | } 78 | 79 | /// Find the previous occurrence of this query in the given range. 80 | /// Note that, if `to` is given, it should be _less_ than `from`. 81 | findPrev(state: EditorState, from: number = state.doc.content.size, to: number = 0) { 82 | for (;;) { 83 | if (from <= to) return null 84 | let result = this.impl.findPrev(state, from, to) 85 | if (!result || this.checkResult(state, result)) return result 86 | from = result.to - 1 87 | } 88 | } 89 | 90 | /// @internal 91 | checkResult(state: EditorState, result: SearchResult) { 92 | return (!this.wholeWord || checkWordBoundary(state, result.from) && checkWordBoundary(state, result.to)) && 93 | (!this.filter || this.filter(state, result)) 94 | } 95 | 96 | /// @internal 97 | unquote(string: string) { 98 | return this.literal ? string 99 | : string.replace(/\\([nrt\\])/g, (_, ch) => ch == "n" ? "\n" : ch == "r" ? "\r" : ch == "t" ? "\t" : "\\") 100 | } 101 | 102 | /// Get the ranges that should be replaced for this result. This can 103 | /// return multiple ranges when `this.replace` contains 104 | /// `$1`/`$&`-style placeholders, in which case the preserved 105 | /// content is skipped by the replacements. 106 | /// 107 | /// Ranges are sorted by position, and `from`/`to` positions all 108 | /// refer to positions in `state.doc`. When applying these, you'll 109 | /// want to either apply them from back to front, or map these 110 | /// positions through your transaction's current mapping. 111 | getReplacements(state: EditorState, result: SearchResult): {from: number, to: number, insert: Slice}[] { 112 | let $from = state.doc.resolve(result.from) 113 | let marks = $from.marksAcross(state.doc.resolve(result.to)) 114 | let ranges: {from: number, to: number, insert: Slice}[] = [] 115 | 116 | let frag = Fragment.empty, pos = result.from, {match} = result 117 | let groups = match ? getGroupIndices(match) : [[0, result.to - result.from]] 118 | let replParts = parseReplacement(this.unquote(this.replace)), groupSpan 119 | for (let part of replParts) { 120 | if (typeof part == "string") { // Replacement text 121 | frag = frag.addToEnd(state.schema.text(part, marks)) 122 | } else if (groupSpan = groups[part.group]) { 123 | let from = result.matchStart + groupSpan[0], to = result.matchStart + groupSpan[1] 124 | if (part.copy) { // Copied content 125 | frag = frag.append(state.doc.slice(from, to).content) 126 | } else { // Skipped content 127 | if (frag != Fragment.empty || from > pos) { 128 | ranges.push({from: pos, to: from, insert: new Slice(frag, 0, 0)}) 129 | frag = Fragment.empty 130 | } 131 | pos = to 132 | } 133 | } 134 | } 135 | if (frag != Fragment.empty || pos < result.to) 136 | ranges.push({from: pos, to: result.to, insert: new Slice(frag, 0, 0)}) 137 | return ranges 138 | } 139 | } 140 | 141 | /// A matched instance of a search query. `match` will be non-null 142 | /// only for regular expression queries. 143 | export interface SearchResult { 144 | from: number 145 | to: number 146 | match: RegExpMatchArray | null 147 | matchStart: number 148 | } 149 | 150 | interface QueryImpl { 151 | findNext(state: EditorState, from: number, to: number): SearchResult | null 152 | findPrev(state: EditorState, from: number, to: number): SearchResult | null 153 | } 154 | 155 | const nullQuery = new class implements QueryImpl { 156 | findNext() { return null } 157 | findPrev() { return null } 158 | } 159 | 160 | class StringQuery implements QueryImpl { 161 | string: string 162 | 163 | constructor(readonly query: SearchQuery) { 164 | let string = query.unquote(query.search) 165 | if (!query.caseSensitive) string = string.toLowerCase() 166 | this.string = string 167 | } 168 | 169 | findNext(state: EditorState, from: number, to: number) { 170 | return scanTextblocks(state.doc, from, to, (node, start) => { 171 | let off = Math.max(from, start) 172 | let content = textContent(node).slice(off - start, Math.min(node.content.size, to - start)) 173 | let index = (this.query.caseSensitive ? content : content.toLowerCase()).indexOf(this.string) 174 | return index < 0 ? null : {from: off + index, to: off + index + this.string.length, match: null, matchStart: start} 175 | }) 176 | } 177 | 178 | findPrev(state: EditorState, from: number, to: number) { 179 | return scanTextblocks(state.doc, from, to, (node, start) => { 180 | let off = Math.max(start, to) 181 | let content = textContent(node).slice(off - start, Math.min(node.content.size, from - start)) 182 | if (!this.query.caseSensitive) content = content.toLowerCase() 183 | let index = content.lastIndexOf(this.string) 184 | return index < 0 ? null : {from: off + index, to: off + index + this.string.length, match: null, matchStart: start} 185 | }) 186 | } 187 | } 188 | 189 | const baseFlags = "g" + (/x/.unicode == null ? "" : "u") + ((/x/ as any).hasIndices == null ? "" : "d") 190 | 191 | class RegExpQuery implements QueryImpl { 192 | regexp: RegExp 193 | 194 | constructor(readonly query: SearchQuery) { 195 | this.regexp = new RegExp(query.search, baseFlags + (query.caseSensitive ? "" : "i")) 196 | } 197 | 198 | findNext(state: EditorState, from: number, to: number) { 199 | return scanTextblocks(state.doc, from, to, (node, start) => { 200 | let content = textContent(node).slice(0, Math.min(node.content.size, to - start)) 201 | this.regexp.lastIndex = from - start 202 | let match = this.regexp.exec(content) 203 | return match ? {from: start + match.index, to: start + match.index + match[0].length, match, matchStart: start} : null 204 | }) 205 | } 206 | 207 | findPrev(state: EditorState, from: number, to: number) { 208 | return scanTextblocks(state.doc, from, to, (node, start) => { 209 | let content = textContent(node).slice(0, Math.min(node.content.size, from - start)) 210 | let match 211 | for (let off = 0;;) { 212 | this.regexp.lastIndex = off 213 | let next = this.regexp.exec(content) 214 | if (!next) break 215 | match = next 216 | off = next.index + 1 217 | } 218 | return match ? {from: start + match.index, to: start + match.index + match[0].length, match, matchStart: start} : null 219 | }) 220 | } 221 | } 222 | 223 | function getGroupIndices(match: RegExpMatchArray): ([number, number] | undefined)[] { 224 | if ((match as any).indices) return (match as any).indices 225 | let result: ([number, number] | undefined)[] = [[0, match[0].length]] 226 | for (let i = 1, pos = 0; i < match.length; i++) { 227 | let found = match[i] ? match[0].indexOf(match[i], pos) : -1 228 | result.push(found < 0 ? undefined : [found, pos = found + match[i].length]) 229 | } 230 | return result 231 | } 232 | 233 | function parseReplacement(text: string): (string | {group: number, copy: boolean})[] { 234 | let result: (string | {group: number, copy: boolean})[] = [], highestSeen = -1 235 | function add(text: string) { 236 | let last = result.length - 1 237 | if (last > -1 && typeof result[last] == "string") result[last] += text 238 | else result.push(text) 239 | } 240 | while (text.length) { 241 | let m = /\$([$&\d+])/.exec(text) 242 | if (!m) { 243 | add(text) 244 | return result 245 | } 246 | if (m.index > 0) add(text.slice(0, m.index + (m[1] == "$" ? 1 : 0))) 247 | if (m[1] != "$") { 248 | let n = m[1] == "&" ? 0 : +m[1] 249 | if (highestSeen >= n) { 250 | result.push({group: n, copy: true}) 251 | } else { 252 | highestSeen = n || 1000 253 | result.push({group: n, copy: false}) 254 | } 255 | } 256 | text = text.slice(m.index + m[0].length) 257 | } 258 | return result 259 | } 260 | 261 | export function validRegExp(source: string) { 262 | try { new RegExp(source, baseFlags); return true } 263 | catch { return false } 264 | } 265 | 266 | const TextContentCache = new WeakMap() 267 | 268 | function textContent(node: Node) { 269 | let cached = TextContentCache.get(node) 270 | if (cached) return cached 271 | 272 | let content = "" 273 | for (let i = 0; i < node.childCount; i++) { 274 | let child = node.child(i) 275 | if (child.isText) content += child.text! 276 | else if (child.isLeaf) content += "\ufffc" 277 | else content += " " + textContent(child) + " " 278 | } 279 | TextContentCache.set(node, content) 280 | return content 281 | } 282 | 283 | function scanTextblocks(node: Node, from: number, to: number, 284 | f: (node: Node, startPos: number) => T | null, 285 | nodeStart: number = 0): T | null { 286 | if (node.inlineContent) { 287 | return f(node, nodeStart) 288 | } else if (!node.isLeaf) { 289 | if (from > to) { 290 | for (let i = node.childCount - 1, pos = nodeStart + node.content.size; i >= 0 && pos > to; i--) { 291 | let child = node.child(i) 292 | pos -= child.nodeSize 293 | if (pos < from) { 294 | let result = scanTextblocks(child, from, to, f, pos + 1) 295 | if (result != null) return result 296 | } 297 | } 298 | } else { 299 | for (let i = 0, pos = nodeStart; i < node.childCount && pos < to; i++) { 300 | let child = node.child(i), start = pos 301 | pos += child.nodeSize 302 | if (pos > from) { 303 | let result = scanTextblocks(child, from, to, f, start + 1) 304 | if (result != null) return result 305 | } 306 | } 307 | } 308 | } 309 | return null 310 | } 311 | 312 | function checkWordBoundary(state: EditorState, pos: number) { 313 | let $pos = state.doc.resolve(pos) 314 | let before = $pos.nodeBefore, after = $pos.nodeAfter 315 | if (!before || !after || !before.isText || !after.isText) return true 316 | return !/\p{L}$/u.test(before.text!) || !/^\p{L}/u.test(after.text!) 317 | } 318 | --------------------------------------------------------------------------------