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).
Opening and closing search
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 wayThe 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 option | Type | Description |
|---|---|---|
search | string | The search string, or regex source. Required. |
replace | string | Replacement text. $& inserts the match, and with regexp, $1… insert groups. Default "". |
caseSensitive | boolean | Case-sensitive matching. Default false. |
regexp | boolean | Treat search as a regular expression. Default false. |
wholeWord | boolean | Only match whole words. Default false. |
literal | boolean | Don’t expand \n/\r/\t in the query. Default false. |
skipDeletions | boolean | Ignore text with deletion marks and nodes with deletion track-changes attrs. Default false. |
preserveCase | boolean | Recase 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. |
replaceMarks | readonly 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. |
formatOnly | boolean | Replacing keeps the matched text and only adds replaceMarks, as mark steps (tracked as format changes in review mode). Default false. |
approximate | boolean | Also find text a few edits away from the query. See Approximate matches. Ignored with regexp. Default false. |
maxEdits | number | The edit budget of approximate matches, 0–3. Defaults to defaultMaxEdits of the query’s length. |
filter | (state, result) => boolean | Results 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 pluginMatches carry the class ProseMirror-search-match, and the match under the
selection carries ProseMirror-active-search-match.
Navigating matches
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 matchReplacing 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.maxEditsoverrides it (0–3). PastMAX_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;
wholeWordgoverns them only. - Results. Each
SearchResultcarriesdistance: 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.
replaceNextandreplaceCurrentreplace approximate matches one at a time.replaceAllreplaces exact matches only, so a bulk replace never rewrites near misses, and returnsfalsewhen 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
- Plugins —
searchPluginandsearchPluginKey. - Customizing the Toolbar — hiding or replacing the search panel.
- UI Extensions — adding your own search-adjacent toolbar controls.