Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 297 lines 10 kB view raw
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}