find.ts RawHistoryFolder
1/**
2 * Find in Page Functionality
3 *
4 * Text search and highlighting implementation
5 */
6
7import type { FindMatch, HighlightNode } from "./types";
8import { omniboxStore } from "./stores.svelte.ts";
9
10// Track whether we've injected highlight styles into the main document.
11let findStylesInjected = false;
12
13function ensureFindStyles(): void {
14 if (findStylesInjected) {
15 return;
16 }
17
18 try {
19 const style = document.createElement("style");
20 style.id = "dumber-find-styles";
21 style.textContent = `
22 .find-highlight {
23 background-color: rgb(255 235 59);
24 color: rgb(0 0 0);
25 padding: 0 0.125rem;
26 border-radius: 0;
27 box-shadow: 0 0 0 1px rgb(200 185 0) inset;
28 }
29
30 .find-active {
31 background-color: rgb(255 152 0) !important;
32 color: rgb(0 0 0);
33 box-shadow: 0 0 0 1px rgb(179 107 0) inset !important;
34 }
35 `;
36
37 (document.head || document.documentElement).appendChild(style);
38 findStylesInjected = true;
39 } catch (error) {
40 console.warn("Failed to inject find styles:", error);
41 }
42}
43
44/**
45 * Find text in the current page and highlight matches
46 */
47export function findInPage(query: string): void {
48 // Clear previous highlights
49 omniboxStore.clearHighlights();
50
51 const trimmedQuery = (query || "").trim();
52 if (!trimmedQuery) {
53 return;
54 }
55
56 const body = document.body;
57 if (!body) {
58 return;
59 }
60
61 ensureFindStyles();
62
63 // Get omnibox root to exclude from search
64 const omniboxRoot = document.getElementById("dumber-omnibox-root");
65 const matches: FindMatch[] = [];
66 const maxMatches = omniboxStore.config.maxMatches;
67
68 // Create tree walker to find text nodes
69 const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
70 acceptNode(node: Text): number {
71 // Skip empty or whitespace-only nodes
72 if (!node.nodeValue || !node.nodeValue.trim()) {
73 return NodeFilter.FILTER_REJECT;
74 }
75
76 const parentElement = node.parentElement;
77 if (!parentElement) {
78 return NodeFilter.FILTER_REJECT;
79 }
80
81 // Skip omnibox content
82 if (omniboxRoot && omniboxRoot.contains(parentElement)) {
83 return NodeFilter.FILTER_REJECT;
84 }
85
86 // Skip script, style, and noscript tags
87 const tagName = parentElement.tagName;
88 if (
89 tagName === "SCRIPT" ||
90 tagName === "STYLE" ||
91 tagName === "NOSCRIPT"
92 ) {
93 return NodeFilter.FILTER_REJECT;
94 }
95
96 // Skip hidden elements
97 const computedStyle = getComputedStyle(parentElement);
98 if (
99 computedStyle.visibility === "hidden" ||
100 computedStyle.display === "none"
101 ) {
102 return NodeFilter.FILTER_REJECT;
103 }
104
105 return NodeFilter.FILTER_ACCEPT;
106 },
107 });
108
109 const queryLowerCase = trimmedQuery.toLowerCase();
110
111 // Process each text node
112 while (walker.nextNode() && matches.length < maxMatches) {
113 const textNode = walker.currentNode as Text;
114 let nodeValue = textNode.nodeValue || "";
115 let searchIndex = 0;
116
117 // Find all matches in this text node
118 while (nodeValue && matches.length < maxMatches) {
119 const matchIndex = nodeValue
120 .toLowerCase()
121 .indexOf(queryLowerCase, searchIndex);
122 if (matchIndex === -1) {
123 break;
124 }
125
126 // Create text nodes for before, match, and after
127 const beforeText = nodeValue.slice(0, matchIndex);
128 const matchText = nodeValue.slice(
129 matchIndex,
130 matchIndex + trimmedQuery.length,
131 );
132 const afterText = nodeValue.slice(matchIndex + trimmedQuery.length);
133
134 // Create elements
135 const beforeNode = document.createTextNode(beforeText);
136 const matchTextNode = document.createTextNode(matchText);
137 const highlightSpan = document.createElement("span");
138
139 // Style the highlight span
140 highlightSpan.className = "find-highlight";
141 highlightSpan.appendChild(matchTextNode);
142
143 // Insert into DOM
144 const parent = textNode.parentNode;
145 if (!parent) {
146 break;
147 }
148
149 parent.insertBefore(beforeNode, textNode);
150 parent.insertBefore(highlightSpan, textNode);
151 textNode.nodeValue = afterText;
152
153 // Track highlight node for cleanup
154 const highlightNode: HighlightNode = {
155 span: highlightSpan,
156 text: matchTextNode,
157 };
158 omniboxStore.addHighlightNode(highlightNode);
159
160 // Create match context
161 const leftContext = beforeText.slice(-30);
162 let rightContext = afterText.slice(0, 30);
163
164 // Try to break at word boundaries
165 const boundaryMatch = rightContext.match(/[.,;:-]/);
166 if (boundaryMatch && boundaryMatch.index !== undefined) {
167 rightContext = rightContext.slice(0, boundaryMatch.index + 1);
168 }
169
170 const context = (leftContext + matchText + rightContext)
171 .replace(/\s+/g, " ")
172 .trim();
173
174 // Add to matches
175 const findMatch: FindMatch = {
176 element: highlightSpan,
177 context,
178 };
179 matches.push(findMatch);
180
181 // Continue searching in the remaining text
182 nodeValue = afterText;
183 searchIndex = 0;
184 }
185 }
186
187 // Update store with matches
188 omniboxStore.updateMatches(matches);
189
190 // Reveal first match if any
191 if (matches.length > 0) {
192 revealMatch(0);
193 }
194}
195
196/**
197 * Reveal a specific match by index
198 */
199export function revealMatch(index: number): void {
200 const matches = omniboxStore.matches;
201 if (index < 0 || index >= matches.length) {
202 return;
203 }
204
205 // Remove previous active class
206 const prevActiveIndex = omniboxStore.activeIndex;
207 if (prevActiveIndex >= 0 && matches[prevActiveIndex]) {
208 const prevElement = matches[prevActiveIndex].element;
209 if (prevElement.classList) {
210 prevElement.classList.remove("find-active");
211 }
212 }
213
214 // Add active class to current match
215 const currentMatch = matches[index];
216 if (currentMatch && currentMatch.element.classList) {
217 currentMatch.element.classList.add("find-active");
218 omniboxStore.setActiveIndex(index);
219
220 // Scroll into view
221 try {
222 currentMatch.element.scrollIntoView({
223 block: "center",
224 inline: "nearest",
225 });
226 } catch {
227 currentMatch.element.scrollIntoView();
228 }
229 }
230}
231
232/**
233 * Jump to next/previous match
234 */
235export function jumpToMatch(delta: number): void {
236 const matches = omniboxStore.matches;
237 if (matches.length === 0) {
238 return;
239 }
240
241 const currentIndex = omniboxStore.selectedIndex || 0;
242 const newIndex =
243 (((currentIndex + delta) % matches.length) + matches.length) %
244 matches.length;
245
246 omniboxStore.setSelectedIndex(newIndex);
247 revealMatch(newIndex);
248}