1/**
2 * Omnibox State Management
3 *
4 * Svelte 5 rune-based state management for omnibox functionality
5 */
6
7import type {
8 OmniboxMode,
9 Suggestion,
10 FindMatch,
11 HighlightNode,
12 OmniboxConfig,
13 SearchShortcut,
14} from "./types";
15
16// Default configuration
17const DEFAULT_CONFIG: Required<OmniboxConfig> = {
18 maxMatches: 2000,
19 debounceDelay: 120,
20 defaultLimit: 10,
21};
22
23// Global state using Svelte 5 runes
24let visible = $state(false);
25let mode = $state<OmniboxMode>("omnibox");
26let suggestions = $state<Suggestion[]>([]);
27let matches = $state<FindMatch[]>([]);
28let selectedIndex = $state(-1);
29let activeIndex = $state(-1);
30let faded = $state(false);
31let inputValue = $state("");
32let highlightNodes = $state<HighlightNode[]>([]);
33let prevOverflow = $state("");
34let debounceTimer = $state(0);
35let searchShortcuts = $state<Record<string, SearchShortcut>>({});
36
37// Configuration
38let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);
39
40export const omniboxStore = {
41 // State getters
42 get visible() {
43 return visible;
44 },
45 get mode() {
46 return mode;
47 },
48 get suggestions() {
49 return suggestions;
50 },
51 get matches() {
52 return matches;
53 },
54 get selectedIndex() {
55 return selectedIndex;
56 },
57 get activeIndex() {
58 return activeIndex;
59 },
60 get faded() {
61 return faded;
62 },
63 get inputValue() {
64 return inputValue;
65 },
66 get highlightNodes() {
67 return highlightNodes;
68 },
69 get prevOverflow() {
70 return prevOverflow;
71 },
72 get config() {
73 return config;
74 },
75 get searchShortcuts() {
76 return searchShortcuts;
77 },
78
79 // Computed getters
80 get hasContent() {
81 return (
82 (mode === "omnibox" && suggestions.length > 0) ||
83 (mode === "find" && matches.length > 0)
84 );
85 },
86 get totalItems() {
87 return mode === "omnibox" ? suggestions.length : matches.length;
88 },
89 get selectedItem() {
90 if (selectedIndex < 0) return null;
91 return mode === "omnibox"
92 ? suggestions[selectedIndex]
93 : matches[selectedIndex];
94 },
95
96 // Actions
97 setVisible(value: boolean) {
98 if (value && !visible) {
99 // Store scroll state when opening
100 prevOverflow = document.documentElement.style.overflow;
101 document.documentElement.style.overflow = "hidden";
102 } else if (!value && visible) {
103 // Restore scroll state when closing
104 document.documentElement.style.overflow = prevOverflow || "";
105 prevOverflow = "";
106
107 // Clear find highlights when closing
108 if (mode === "find") {
109 this.clearHighlights();
110 }
111 }
112 visible = value;
113 },
114
115 setMode(newMode: OmniboxMode) {
116 // Clear previous mode's state
117 if (newMode === "omnibox") {
118 // Switching to omnibox mode - clear find state
119 this.clearHighlights();
120 } else if (newMode === "find") {
121 // Switching to find mode - clear suggestions
122 this.updateSuggestions([]);
123 }
124
125 mode = newMode;
126 selectedIndex = -1;
127 faded = false;
128 inputValue = ""; // Clear input for fresh start
129 },
130
131 setInputValue(value: string) {
132 inputValue = value;
133 },
134
135 setFaded(value: boolean) {
136 faded = value;
137 },
138
139 setSelectedIndex(index: number) {
140 const maxIndex = this.totalItems - 1;
141 if (maxIndex < 0) {
142 selectedIndex = -1;
143 } else {
144 selectedIndex = Math.max(0, Math.min(index, maxIndex));
145 }
146 },
147
148 selectNext() {
149 if (this.totalItems === 0) return;
150 const newIndex =
151 selectedIndex < 0 ? 0 : (selectedIndex + 1) % this.totalItems;
152 this.setSelectedIndex(newIndex);
153 faded = true;
154 },
155
156 selectPrevious() {
157 if (this.totalItems === 0) return;
158 const newIndex =
159 selectedIndex <= 0 ? this.totalItems - 1 : selectedIndex - 1;
160 this.setSelectedIndex(newIndex);
161 faded = true;
162 },
163
164 open(newMode: OmniboxMode = "omnibox", initialQuery?: string) {
165 this.setMode(newMode);
166 this.setVisible(true);
167 if (typeof initialQuery === "string") {
168 inputValue = initialQuery;
169 }
170 },
171
172 close() {
173 this.setVisible(false);
174 faded = false;
175 },
176
177 toggle() {
178 if (!visible) {
179 // When opening via toggle (Ctrl+L), always open in omnibox mode
180 this.setMode("omnibox");
181 this.setVisible(true);
182 } else {
183 this.setVisible(false);
184 }
185 },
186
187 updateSuggestions(newSuggestions: Suggestion[]) {
188 suggestions = Array.isArray(newSuggestions) ? newSuggestions : [];
189 selectedIndex = -1;
190 },
191
192 updateMatches(newMatches: FindMatch[]) {
193 matches = Array.isArray(newMatches) ? newMatches : [];
194 selectedIndex = newMatches.length > 0 ? 0 : -1;
195 },
196
197 clearHighlights() {
198 try {
199 highlightNodes.forEach(({ span, text }) => {
200 const parent = span.parentNode;
201 if (parent) {
202 parent.replaceChild(text, span);
203 parent.normalize();
204 }
205 });
206 } catch (error) {
207 console.warn("Error clearing highlights:", error);
208 }
209 highlightNodes = [];
210 matches = [];
211 selectedIndex = -1;
212 activeIndex = -1;
213 },
214
215 addHighlightNode(node: HighlightNode) {
216 highlightNodes = [...highlightNodes, node];
217 },
218
219 setActiveIndex(index: number) {
220 activeIndex = index;
221 },
222
223 setDebounceTimer(timerId: number) {
224 if (debounceTimer) {
225 clearTimeout(debounceTimer);
226 }
227 debounceTimer = timerId;
228 },
229
230 clearDebounceTimer() {
231 if (debounceTimer) {
232 clearTimeout(debounceTimer);
233 debounceTimer = 0;
234 }
235 },
236
237 updateConfig(newConfig: Partial<OmniboxConfig>) {
238 config = { ...config, ...newConfig };
239 },
240
241 updateSearchShortcuts(shortcuts: Record<string, SearchShortcut>) {
242 searchShortcuts = shortcuts;
243 },
244
245 reset() {
246 visible = false;
247 mode = "omnibox";
248 suggestions = [];
249 matches = [];
250 selectedIndex = -1;
251 activeIndex = -1;
252 faded = false;
253 inputValue = "";
254 prevOverflow = "";
255 this.clearHighlights();
256 this.clearDebounceTimer();
257 },
258};