Full document, spreadsheet, slideshow, and diagram tooling
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}