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 111 lines 3.2 kB view raw
1/** 2 * SearchState - Pure logic for find-and-replace in documents. 3 * 4 * Handles match finding, navigation, and replacement. 5 * Independent of TipTap/ProseMirror so it can be tested in isolation. 6 */ 7import type { SearchResult } from './types.js'; 8 9interface SearchStateOptions { 10 caseSensitive?: boolean; 11} 12 13export class SearchState { 14 caseSensitive: boolean; 15 matches: SearchResult[]; 16 currentIndex: number; 17 18 constructor(opts: SearchStateOptions = {}) { 19 this.caseSensitive = opts.caseSensitive ?? false; 20 this.matches = []; 21 this.currentIndex = -1; 22 } 23 24 /** 25 * Find all occurrences of `term` in `text`. 26 * Returns array of { from, to } positions. 27 */ 28 findMatches(text: string, term: string): SearchResult[] { 29 this.matches = []; 30 this.currentIndex = -1; 31 32 if (!term || term.length === 0) { 33 return this.matches; 34 } 35 36 const searchText = this.caseSensitive ? text : text.toLowerCase(); 37 const searchTerm = this.caseSensitive ? term : term.toLowerCase(); 38 39 let startPos = 0; 40 while (startPos < searchText.length) { 41 const idx = searchText.indexOf(searchTerm, startPos); 42 if (idx === -1) break; 43 this.matches.push({ from: idx, to: idx + term.length }); 44 startPos = idx + 1; 45 } 46 47 if (this.matches.length > 0) { 48 this.currentIndex = 0; 49 } 50 51 return this.matches; 52 } 53 54 /** Advance to the next match (wraps around). */ 55 next(): void { 56 if (this.matches.length === 0) return; 57 this.currentIndex = (this.currentIndex + 1) % this.matches.length; 58 } 59 60 /** Go to the previous match (wraps around). */ 61 prev(): void { 62 if (this.matches.length === 0) return; 63 this.currentIndex = (this.currentIndex - 1 + this.matches.length) % this.matches.length; 64 } 65 66 /** Replace the current match and return the new text. */ 67 replaceOne(text: string, replacement: string): { text: string; replaced: boolean } { 68 if (this.matches.length === 0 || this.currentIndex < 0) { 69 return { text, replaced: false }; 70 } 71 72 const match = this.matches[this.currentIndex]; 73 const newText = text.slice(0, match.from) + replacement + text.slice(match.to); 74 75 return { text: newText, replaced: true }; 76 } 77 78 /** Replace all matches and return the new text and count. */ 79 replaceAll(text: string, replacement: string): { text: string; count: number } { 80 if (this.matches.length === 0) { 81 return { text, count: 0 }; 82 } 83 84 // Replace from end to start to preserve positions 85 let newText = text; 86 const reversedMatches = [...this.matches].reverse(); 87 for (const match of reversedMatches) { 88 newText = newText.slice(0, match.from) + replacement + newText.slice(match.to); 89 } 90 91 return { text: newText, count: this.matches.length }; 92 } 93 94 /** Toggle case sensitivity. */ 95 setCaseSensitive(value: boolean): void { 96 this.caseSensitive = value; 97 } 98 99 /** Number of matches found. */ 100 get matchCount(): number { 101 return this.matches.length; 102 } 103 104 /** The current match object, or null. */ 105 get currentMatch(): SearchResult | null { 106 if (this.currentIndex < 0 || this.currentIndex >= this.matches.length) { 107 return null; 108 } 109 return this.matches[this.currentIndex]; 110 } 111}