Skip to Content
PackagesCoreSearch & Replace

Search & Replace

Search is searchPlugin plus a set of commands. The plugin ships in customPlugins and is on by default; opt out with searchPlugin: false:

import { customPlugins } from "@stesura/core/plugins"; const plugins = [...customPlugins({ field: editorId })]; // with search const withoutSearch = [...customPlugins({ field: editorId, searchPlugin: false })];

The plugin holds the query, an optional range restriction and the match decorations. While search is open, edits map the existing decorations instead of rescanning. A full rescan runs 500ms after the last doc change, and immediately when track-changes visibility toggles: while suggestions are hidden, deleted text doesn’t match (skipDeletions follows the toggle).

import { enableSearchState, disableSearchState, toggleSearchState, } from "@stesura/core/commands"; enableSearchState(state, dispatch); // open, empty query; false if already open disableSearchState(state, dispatch); // close and reset; false if already closed toggleSearchState(state, dispatch); // flip, resetting the query either way

The core keymap binds Mod-f to enableSearchState, and Escape closes search (unless the format painter is armed, in which case it disarms that).

Setting a query

setSearchState is a transaction helper, not a command. It stamps meta on a Transaction and enables search once dispatched:

import { setSearchState } from "@stesura/core/helpers"; import { SearchQuery } from "@stesura/core/plugins"; const query = new SearchQuery({ search: "hello", replace: "world" }); view.dispatch(setSearchState(view.state.tr, query));

SearchQuery is adapted from prosemirror-search.

SearchQuery optionTypeDescription
searchstringThe search string, or regex source. Required.
replacestringReplacement text. $& inserts the match, and with regexp, $1… insert groups. Default "".
caseSensitivebooleanCase-sensitive matching. Default false.
regexpbooleanTreat search as a regular expression. Default false.
wholeWordbooleanOnly match whole words. Default false.
literalbooleanDon’t expand \n/\r/\t in the query. Default false.
skipDeletionsbooleanIgnore text with deletion marks and nodes with deletion track-changes attrs. Default false.
preserveCasebooleanRecase the replacement after the match: word by word when both have as many words, otherwise by the match’s overall pattern (ALL CAPS, lower, Title Case, Sentence case). preserveCase(matched, replacement) is exported. Default false.
replaceMarksreadonly Mark[]Marks added to the replacement, on top of those the match carries throughout. A mark whose type the text already has merges into it (non-null attrs win), so a textStyle carrying only color keeps the text’s font and size. Default none.
formatOnlybooleanReplacing keeps the matched text and only adds replaceMarks, as mark steps (tracked as format changes in review mode). Default false.
approximatebooleanAlso find text a few edits away from the query. See Approximate matches. Ignored with regexp. Default false.
maxEditsnumberThe edit budget of approximate matches, 0–3. Defaults to defaultMaxEdits of the query’s length.
filter(state, result) => booleanResults it rejects are ignored.

query.valid is false for an empty string or an invalid regex. Every find and replace command returns false on an invalid query.

query.findAll(state, from?, to?) returns every match in a range, in document order, in one walk over the doc: the plugin’s highlights and replaceAll use it.

Reading search state

import { getSearchState, getMatchHighlights } from "@stesura/core/helpers"; const searchState = getSearchState(view.state); // undefined without the plugin // searchState.enabled — boolean // searchState.query — SearchQuery // searchState.range — { from, to } | null // searchState.deco — DecorationSet of matches const decorations = getMatchHighlights(view.state); // DecorationSet.empty without the plugin

Matches carry the class ProseMirror-search-match, and the match under the selection carries ProseMirror-active-search-match.

import { findNext, findPrev, findNextNoWrap, findPrevNoWrap, } from "@stesura/core/commands"; findNext(state, dispatch); // select the next match, wrapping at the end findPrev(state, dispatch); // select the previous match, wrapping at the start findNextNoWrap(state, dispatch); // false once past the last match findPrevNoWrap(state, dispatch); // false once before the first match

Replacing matches

import { replaceNext, replaceNextNoWrap, replaceCurrent, replaceAll, } from "@stesura/core/commands"; replaceNext(state, dispatch); // replace the selected match, then select the next one replaceCurrent(state, dispatch); // replace the selected match and stay on the result replaceAll(state, dispatch); // replace every match, in one transaction (exact ones only with `approximate`)

replaceNext only replaces when the selection is a match. Otherwise it selects the next match, so the first call finds and the second replaces. replaceCurrent returns false unless the selection is a match. replaceNextNoWrap is replaceNext without wrapping.

Restricting the search range

Pass a range as the third argument to setSearchState:

view.dispatch(setSearchState(view.state.tr, query, { from: 100, to: 500 }));

Finding, replacing and the highlights all stay inside the range. It maps through later edits, and if an edit collapses it the range resets to the whole document.

Clearing search state

import { clearSearchState } from "@stesura/core/commands"; clearSearchState()(state, dispatch);

Unlike the other commands, clearSearchState is a factory. It empties the query and range and closes search.

Matches outside the body

Header, footer and note content lives in the main doc but is edited through sub-editors. classifySearchMatch(state, pos) from @stesura/core/helpers tells a navigation UI which sub-editor to open. It returns body, footnote, endnote or headerFooter along with the details it needs.

Comment bodies are not part of the doc. findCommentMatches(threads, config) searches them with the same string rules (search, caseSensitive, wholeWord, literal, approximate) and returns the first match per comment, with its distance when approximate.

Approximate matches

With approximate: true, the search also finds text within a few edits of the query: “recieve” finds “receive”, “Smyth” finds “Smith”, “teh” finds “the”. An edit is inserting, deleting or replacing one character, or swapping two adjacent ones. A character and its combining marks count as one, so composed and decomposed “é” are the same.

  • Budget. defaultMaxEdits(length): none up to 3 code points, so short queries stay exact; one up to 7; two from 8. maxEdits overrides it (0–3). Past MAX_APPROXIMATE_LENGTH (256) code points only exact matches are found.
  • Words. An approximate match never cuts a word at either end, and never starts or ends on whitespace or punctuation the query doesn’t have. “form” finds “for” and “from”, but nothing inside “information”. Exact matches keep substring semantics; wholeWord governs them only.
  • Results. Each SearchResult carries distance: 0 or missing for an exact match. Exact matches come first: they are what exact search finds, except that accents compare composed or decomposed alike (“é” as e + U+0301 is exact for “é”, and one edit from “e”). An approximate match never overlaps an exact one.
  • Highlights. Approximate matches also carry the class ProseMirror-search-match-approximate (a dotted underline and a lighter background) and the decoration spec { approximate: true }.
  • Replacing. replaceNext and replaceCurrent replace approximate matches one at a time. replaceAll replaces exact matches only, so a bulk replace never rewrites near misses, and returns false when there is no exact match.

The matcher behind it, createApproximateMatcher, is a pure helper in @stesura/core/utils for matching plain strings outside the editor:

import { createApproximateMatcher } from "@stesura/core/utils"; const matcher = createApproximateMatcher("Smyth", { wholeWord: true }); matcher.findAll("Mr Smith, Ms Smyth"); // [{ from: 3, to: 8, distance: 1 }, { from: 13, to: 18, distance: 0 }]

ApproximateMatchOptions takes maxEdits, caseSensitive, ignoreAccents, wholeWord, transpositions (default true) and isBarrier, the code points no match may contain (U+FFFC by default). findAll returns ApproximateMatch objects in UTF-16 offsets, in document order. matcher.maxEdits is the budget in effect: 0 means exact only. The ApproximateMatcher compiles the pattern once, so build it once and run it over many texts.

Driving the built-in search UI

editor-react’s ToolBar renders a find-and-replace bar below the ribbon, opened from the Home tab’s search button or with Mod-F (also in view mode, where replace is hidden). It drives the commands above and also lists matches in comment bodies. Its ≈ toggle, next to match case and whole word, turns on approximate matches: the status names an approximate active match, and Replace All says it changes exact matches only. You only need the raw commands if you are building a custom UI or driving search programmatically.

The Home tab’s search button also offers Open search panel, as does the bar’s open-in-panel button: the "search" left panel is a find-and-replace panel listing every match — body, headers, footers, notes and comments — with its surrounding text. Clicking a result selects the match and opens the header, footer, note or comment thread that shows it; approximate results are badged ”≈”. While the panel is open it is the search UI: the bar is hidden, Mod-F focuses the panel’s search box, and closing the panel closes search. Its replace options add preserve case and bold, italic, underline, strikethrough, code, all/small caps, text colour, background colour and highlight on the replacement; with formatting set and an empty replace field, the matches keep their text and only get the formatting. Remove it with defaultLeftPanels={{ search: false }}.

Next steps

Last updated on