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