Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * Find & Replace for TipTap / ProseMirror.
3 *
4 * Uses ProseMirror decorations to highlight matches in the document.
5 * Provides commands for next/prev/replace/replaceAll and a case-sensitive toggle.
6 */
7
8import { Extension } from '@tiptap/core';
9import type { Editor } from '@tiptap/core';
10import { Plugin, PluginKey } from '@tiptap/pm/state';
11import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
12import { Decoration, DecorationSet } from '@tiptap/pm/view';
13import type { SearchResult } from './types.js';
14
15const searchPluginKey = new PluginKey('searchReplace');
16
17/**
18 * Find all occurrences of `search` inside the ProseMirror doc.
19 * Returns an array of { from, to } positions.
20 */
21function findMatches(doc: ProseMirrorNode, search: string, caseSensitive: boolean): SearchResult[] {
22 if (!search) return [];
23 const results: SearchResult[] = [];
24 const flags = caseSensitive ? 'g' : 'gi';
25 const regex = new RegExp(escapeRegex(search), flags);
26
27 doc.descendants((node, pos) => {
28 if (!node.isText) return;
29 const text = node.text;
30 if (!text) return;
31 let match: RegExpExecArray | null;
32 while ((match = regex.exec(text)) !== null) {
33 results.push({
34 from: pos + match.index,
35 to: pos + match.index + match[0].length,
36 });
37 }
38 });
39
40 return results;
41}
42
43function escapeRegex(s: string): string {
44 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
45}
46
47function buildDecorations(doc: ProseMirrorNode, matches: SearchResult[], activeIndex: number): DecorationSet {
48 const decorations: Decoration[] = [];
49 for (let i = 0; i < matches.length; i++) {
50 const { from, to } = matches[i];
51 const className = i === activeIndex
52 ? 'search-match search-match-active'
53 : 'search-match';
54 decorations.push(Decoration.inline(from, to, { class: className }));
55 }
56 return DecorationSet.create(doc, decorations);
57}
58
59interface SearchReplaceOptions {
60 onStateChange?: () => void;
61}
62
63export const SearchReplace = Extension.create<SearchReplaceOptions>({
64 name: 'searchReplace',
65
66 addStorage() {
67 return {
68 searchTerm: '',
69 replaceTerm: '',
70 caseSensitive: false,
71 matches: [] as SearchResult[],
72 activeIndex: -1,
73 isOpen: false,
74 showReplace: false,
75 };
76 },
77
78 addCommands() {
79 return {
80 openSearch: () => ({ editor }) => {
81 editor.storage.searchReplace.isOpen = true;
82 editor.storage.searchReplace.showReplace = false;
83 this.options.onStateChange?.();
84 return true;
85 },
86 openSearchReplace: () => ({ editor }) => {
87 editor.storage.searchReplace.isOpen = true;
88 editor.storage.searchReplace.showReplace = true;
89 this.options.onStateChange?.();
90 return true;
91 },
92 closeSearch: () => ({ editor }) => {
93 editor.storage.searchReplace.isOpen = false;
94 editor.storage.searchReplace.searchTerm = '';
95 editor.storage.searchReplace.replaceTerm = '';
96 editor.storage.searchReplace.matches = [];
97 editor.storage.searchReplace.activeIndex = -1;
98 // Force decoration update
99 const { tr } = editor.state;
100 tr.setMeta(searchPluginKey, { search: '', caseSensitive: false });
101 editor.view.dispatch(tr);
102 this.options.onStateChange?.();
103 return true;
104 },
105 setSearchTerm: (term: string) => ({ editor }) => {
106 const storage = editor.storage.searchReplace;
107 storage.searchTerm = term;
108 const matches = findMatches(editor.state.doc, term, storage.caseSensitive);
109 storage.matches = matches;
110 // Keep activeIndex in bounds, or reset
111 if (matches.length === 0) {
112 storage.activeIndex = -1;
113 } else if (storage.activeIndex >= matches.length) {
114 storage.activeIndex = 0;
115 } else if (storage.activeIndex < 0) {
116 storage.activeIndex = 0;
117 }
118 // Force decoration update
119 const { tr } = editor.state;
120 tr.setMeta(searchPluginKey, {
121 search: term,
122 caseSensitive: storage.caseSensitive,
123 });
124 editor.view.dispatch(tr);
125 this.options.onStateChange?.();
126 return true;
127 },
128 setReplaceTerm: (term: string) => ({ editor }) => {
129 editor.storage.searchReplace.replaceTerm = term;
130 return true;
131 },
132 toggleCaseSensitive: () => ({ editor }) => {
133 const storage = editor.storage.searchReplace;
134 storage.caseSensitive = !storage.caseSensitive;
135 // Re-run search
136 editor.commands.setSearchTerm(storage.searchTerm);
137 return true;
138 },
139 nextMatch: () => ({ editor }) => {
140 const storage = editor.storage.searchReplace;
141 if (storage.matches.length === 0) return false;
142 storage.activeIndex = (storage.activeIndex + 1) % storage.matches.length;
143 // Force decoration update
144 const { tr } = editor.state;
145 tr.setMeta(searchPluginKey, {
146 search: storage.searchTerm,
147 caseSensitive: storage.caseSensitive,
148 });
149 editor.view.dispatch(tr);
150 // Scroll active match into view
151 scrollToMatch(editor, storage.matches[storage.activeIndex]);
152 this.options.onStateChange?.();
153 return true;
154 },
155 prevMatch: () => ({ editor }) => {
156 const storage = editor.storage.searchReplace;
157 if (storage.matches.length === 0) return false;
158 storage.activeIndex =
159 (storage.activeIndex - 1 + storage.matches.length) % storage.matches.length;
160 // Force decoration update
161 const { tr } = editor.state;
162 tr.setMeta(searchPluginKey, {
163 search: storage.searchTerm,
164 caseSensitive: storage.caseSensitive,
165 });
166 editor.view.dispatch(tr);
167 scrollToMatch(editor, storage.matches[storage.activeIndex]);
168 this.options.onStateChange?.();
169 return true;
170 },
171 replaceCurrent: () => ({ editor }) => {
172 const storage = editor.storage.searchReplace;
173 if (storage.matches.length === 0 || storage.activeIndex < 0) return false;
174 const match = storage.matches[storage.activeIndex];
175 // Replace the current match
176 editor.chain()
177 .command(({ tr }) => {
178 tr.insertText(storage.replaceTerm, match.from, match.to);
179 return true;
180 })
181 .run();
182 // Re-search after replacement
183 setTimeout(() => {
184 editor.commands.setSearchTerm(storage.searchTerm);
185 }, 0);
186 return true;
187 },
188 replaceAll: () => ({ editor }) => {
189 const storage = editor.storage.searchReplace;
190 if (storage.matches.length === 0) return false;
191 // Replace from end to start so positions stay valid
192 const sorted = [...storage.matches].sort((a, b) => b.from - a.from);
193 editor.chain()
194 .command(({ tr }) => {
195 for (const match of sorted) {
196 tr.insertText(storage.replaceTerm, match.from, match.to);
197 }
198 return true;
199 })
200 .run();
201 // Re-search after replacement
202 setTimeout(() => {
203 editor.commands.setSearchTerm(storage.searchTerm);
204 }, 0);
205 return true;
206 },
207 };
208 },
209
210 addKeyboardShortcuts() {
211 return {
212 'Mod-f': () => {
213 this.editor.commands.openSearch();
214 return true;
215 },
216 'Mod-h': () => {
217 this.editor.commands.openSearchReplace();
218 return true;
219 },
220 Escape: () => {
221 if (this.editor.storage.searchReplace.isOpen) {
222 this.editor.commands.closeSearch();
223 return true;
224 }
225 return false;
226 },
227 'Mod-g': () => {
228 if (this.editor.storage.searchReplace.isOpen) {
229 this.editor.commands.nextMatch();
230 return true;
231 }
232 return false;
233 },
234 'Mod-Shift-g': () => {
235 if (this.editor.storage.searchReplace.isOpen) {
236 this.editor.commands.prevMatch();
237 return true;
238 }
239 return false;
240 },
241 };
242 },
243
244 addProseMirrorPlugins() {
245 const extensionThis = this;
246 return [
247 new Plugin({
248 key: searchPluginKey,
249 state: {
250 init() {
251 return DecorationSet.empty;
252 },
253 apply(tr, oldDecorations, _oldState, newState) {
254 const meta = tr.getMeta(searchPluginKey);
255 if (meta !== undefined) {
256 const storage = extensionThis.editor.storage.searchReplace;
257 return buildDecorations(newState.doc, storage.matches, storage.activeIndex);
258 }
259 // If doc changed, re-map decorations (they may be stale)
260 if (tr.docChanged) {
261 const storage = extensionThis.editor.storage.searchReplace;
262 if (storage.searchTerm) {
263 const matches = findMatches(newState.doc, storage.searchTerm, storage.caseSensitive);
264 storage.matches = matches;
265 if (matches.length === 0) {
266 storage.activeIndex = -1;
267 } else if (storage.activeIndex >= matches.length) {
268 storage.activeIndex = Math.max(0, matches.length - 1);
269 }
270 return buildDecorations(newState.doc, matches, storage.activeIndex);
271 }
272 }
273 return oldDecorations;
274 },
275 },
276 props: {
277 decorations(state) {
278 return this.getState(state) as DecorationSet;
279 },
280 },
281 }),
282 ];
283 },
284});
285
286function scrollToMatch(editor: Editor, match: SearchResult): void {
287 if (!match) return;
288 try {
289 const dom = editor.view.domAtPos(match.from);
290 if (dom?.node) {
291 const el = dom.node.nodeType === Node.TEXT_NODE ? dom.node.parentElement : dom.node;
292 (el as Element)?.scrollIntoView({ block: 'center', behavior: 'smooth' });
293 }
294 } catch {
295 // Position might be invalid after edits
296 }
297}