1<!--
2 Omnibox Component
3
4 Main container for omnibox/find functionality using Svelte 5 runes
5-->
6<script lang="ts">
7 import { onMount, onDestroy } from 'svelte';
8 import OmniboxInput from './OmniboxInput.svelte';
9 import OmniboxSuggestions from './OmniboxSuggestions.svelte';
10 import OmniboxFind from './OmniboxFind.svelte';
11 import { omniboxStore } from './stores.svelte.ts';
12 import { omniboxBridge } from './messaging';
13
14 // Reactive state from store
15 let visible = $derived(omniboxStore.visible);
16 let mode = $derived(omniboxStore.mode);
17 let faded = $derived(omniboxStore.faded);
18
19 // Component refs
20 let boxElement: HTMLDivElement;
21 let blurLayerElement: HTMLDivElement;
22 let inputElement = $state<HTMLInputElement>();
23
24 // Responsive styling state
25 let responsiveStyles = $state({
26 width: 'min(90vw, 720px)',
27 padding: '8px 12px',
28 fontSize: '16px',
29 inputPadding: '10px 12px'
30 });
31
32 // Track theme mode derived from injected color-scheme module
33 let isDarkMode = $state(false);
34 let themeObserver: MutationObserver | null = null;
35
36 function syncTheme() {
37 if (typeof document === 'undefined') return;
38
39 const globalWindow = typeof window === 'undefined' ? undefined : window;
40
41 if (document.documentElement.classList.contains('dark')) {
42 isDarkMode = true;
43 return;
44 }
45
46 if (globalWindow?.matchMedia) {
47 try {
48 const prefersDark = globalWindow.matchMedia('(prefers-color-scheme: dark)').matches;
49 if (typeof prefersDark === 'boolean') {
50 isDarkMode = prefersDark;
51 return;
52 }
53 } catch {
54 // matchMedia might be unavailable in some environments
55 }
56 }
57
58 // Fallback to injected GTK preference flag if available
59 isDarkMode = Boolean((globalWindow as any)?.__dumber_gtk_prefers_dark);
60 }
61
62 function observeThemeChanges() {
63 if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
64 return;
65 }
66
67 if (themeObserver) {
68 themeObserver.disconnect();
69 }
70
71 themeObserver = new MutationObserver(syncTheme);
72 themeObserver.observe(document.documentElement, {
73 attributes: true,
74 attributeFilter: ['class']
75 });
76 }
77
78 // Handle click outside to close
79 function handleOverlayClick(event: MouseEvent) {
80 const target = event.target as HTMLElement;
81 if (target && boxElement && !boxElement.contains(target)) {
82 event.preventDefault();
83 event.stopPropagation();
84 omniboxStore.close();
85 }
86 }
87
88 // Handle box click to prevent propagation
89 function handleBoxClick(event: MouseEvent) {
90 event.stopPropagation();
91 }
92
93 // Handle mouse enter to focus input and clear fade
94 function handleMouseEnter() {
95 if (visible && inputElement) {
96 try {
97 inputElement.focus({ preventScroll: true });
98 } catch {
99 inputElement.focus();
100 }
101 omniboxStore.setFaded(false);
102 }
103 }
104
105 // Update responsive styles based on viewport width (handles zoom levels)
106 function updateResponsiveStyles() {
107 const vw = window.innerWidth;
108 const isSmall = vw < 640;
109 const isLarge = vw > 1920;
110
111 responsiveStyles = {
112 width: isSmall ? '95vw' : isLarge ? 'min(90vw, 840px)' : 'min(90vw, 720px)',
113 padding: isSmall ? '6px 8px' : '8px 12px',
114 fontSize: isSmall ? '14px' : isLarge ? '17px' : '16px',
115 inputPadding: isSmall ? '8px 10px' : '10px 12px'
116 };
117 }
118
119 // Focus input when component becomes visible and manage page event blocking
120 $effect(() => {
121 if (visible && inputElement) {
122 inputElement.focus();
123 // Enable keyboard event blocking in main world when omnibox opens
124 enablePageEventBlocking();
125 } else if (!visible) {
126 // Disable keyboard event blocking when omnibox closes
127 disablePageEventBlocking();
128 }
129 });
130
131 // Functions to control main-world event blocking
132 function enablePageEventBlocking() {
133 try {
134 // Send message to Go backend to enable keyboard blocking
135 if (window.webkit?.messageHandlers?.dumber) {
136 window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({
137 type: 'keyboard_blocking',
138 action: 'enable'
139 }));
140 }
141 } catch (error) {
142 console.warn('[omnibox] Failed to enable page event blocking:', error);
143 }
144 }
145
146 function disablePageEventBlocking() {
147 try {
148 // Send message to Go backend to disable keyboard blocking
149 if (window.webkit?.messageHandlers?.dumber) {
150 window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({
151 type: 'keyboard_blocking',
152 action: 'disable'
153 }));
154 }
155 } catch (error) {
156 console.warn('[omnibox] Failed to disable page event blocking:', error);
157 }
158 }
159
160
161 // Apply faded styling effect (matching original JS implementation)
162 // NOTE: to avoid text becoming blurry while animating, we animate the
163 // blur via an absolutely-positioned layer's opacity instead of
164 // animating `backdrop-filter` on the container. This keeps the text
165 // on a separate compositing layer and prevents browser antialiasing
166 // artifacts during transitions.
167 $effect(() => {
168 if (!boxElement || !inputElement || !blurLayerElement) return;
169
170 // Ensure subtle transitions are present on the container so
171 // background-color (and other cheap properties) animate smoothly.
172 try {
173 if (!boxElement.style.transition) {
174 boxElement.style.transition = 'background-color 120ms ease, border-color 120ms ease';
175 }
176 } catch (e) {
177 // defensive: ignore if setting styles fails for some reason
178 }
179
180 const surfaceMix = isDarkMode ? '65%' : '92%';
181 const inputMix = isDarkMode ? '60%' : '94%';
182 const borderMix = isDarkMode ? '70%' : '90%';
183 const blurMix = isDarkMode ? '28%' : '16%';
184
185 const containerBackground = faded
186 ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`
187 : 'var(--dynamic-surface)';
188 const inputBackground = faded
189 ? `color-mix(in srgb, var(--dynamic-bg) ${inputMix}, transparent)`
190 : 'var(--dynamic-bg)';
191 const borderColor = faded
192 ? `color-mix(in srgb, var(--dynamic-border) ${borderMix}, transparent)`
193 : 'var(--dynamic-border)';
194 const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;
195
196 boxElement.style.background = containerBackground;
197 boxElement.style.color = 'var(--dynamic-text)';
198 boxElement.style.setProperty('--dumber-border-color', borderColor);
199 boxElement.style.setProperty('--dumber-omnibox-surface', containerBackground);
200
201 inputElement.style.background = inputBackground;
202 inputElement.style.color = 'var(--dynamic-text)';
203 inputElement.style.setProperty('--dumber-input-bg', inputBackground);
204 inputElement.style.setProperty('--dumber-input-border-base', borderColor);
205 inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)');
206
207 blurLayerElement.style.setProperty('--dumber-blur-color', blurTint);
208 blurLayerElement.style.background = 'var(--dumber-blur-color)';
209
210 let accentColor = 'var(--dynamic-accent)';
211 try {
212 const computedAccent = getComputedStyle(boxElement).getPropertyValue('--dynamic-accent');
213 if (computedAccent && computedAccent.trim()) {
214 accentColor = computedAccent.trim();
215 }
216 } catch {
217 // If computed styles fail, keep fallback accent color
218 }
219 inputElement.style.setProperty('--dumber-input-accent', accentColor);
220 boxElement.style.setProperty('--dumber-input-accent', accentColor);
221
222 const isFocused = document.activeElement === inputElement;
223
224 if (!isFocused) {
225 inputElement.style.setProperty('--dumber-input-border-color', borderColor);
226 }
227
228 if (faded) {
229 const textShadow = isDarkMode
230 ? '1px 1px 1px rgba(0,0,0,0.65)'
231 : '1px 1px 1px rgba(255,255,255,0.65)';
232 inputElement.style.textShadow = textShadow;
233 boxElement.style.textShadow = textShadow;
234 blurLayerElement.style.opacity = '1';
235 } else {
236 inputElement.style.textShadow = '';
237 boxElement.style.textShadow = '';
238 blurLayerElement.style.opacity = '0';
239 }
240 });
241
242 // Global click handler to close omnibox when clicking anywhere outside
243 function handleGlobalClick(event: MouseEvent) {
244 if (!visible) return;
245
246 const target = event.target as HTMLElement;
247 const omniboxContainer = document.getElementById('dumber-omnibox-root');
248
249 // Close if clicking outside the omnibox container
250 if (omniboxContainer && !omniboxContainer.contains(target)) {
251 omniboxStore.close();
252 }
253 }
254
255
256
257 onMount(() => {
258 syncTheme();
259 observeThemeChanges();
260
261 console.log('🔧 Omnibox component mounted');
262
263 // Initialize responsive styles
264 updateResponsiveStyles();
265
266 // Add resize listener for responsive updates (handles zoom changes)
267 window.addEventListener('resize', updateResponsiveStyles);
268
269 // Add global click listener
270 document.addEventListener('click', handleGlobalClick, true);
271
272 try {
273 // Set up global API for Go bridge
274 window.__dumber_omnibox = {
275 setSuggestions: (suggestions) => {
276 try {
277 console.log('📋 setSuggestions called with:', suggestions?.length || 0, 'items');
278 omniboxBridge.setSuggestions(suggestions);
279 } catch (error) {
280 console.error('Error in setSuggestions:', error);
281 }
282 },
283 toggle: () => {
284 try {
285 console.log('🔄 Omnibox toggle called, current visible:', omniboxStore.visible);
286 omniboxStore.toggle();
287 console.log('🔄 After toggle, visible:', omniboxStore.visible);
288 } catch (error) {
289 console.error('Error in toggle:', error);
290 }
291 },
292 open: (mode, query) => {
293 try {
294 console.log('🚪 Omnibox open called with mode:', mode, 'query:', query);
295 omniboxStore.open(mode as any, query);
296 console.log('🚪 After open, visible:', omniboxStore.visible, 'mode:', omniboxStore.mode);
297 } catch (error) {
298 console.error('Error in open:', error);
299 }
300 },
301 close: () => {
302 try {
303 console.log('🚪 Omnibox close called');
304 omniboxStore.close();
305 } catch (error) {
306 console.error('Error in close:', error);
307 }
308 },
309 findQuery: (query) => {
310 try {
311 console.log('🔍 Find query called with:', query);
312 if (omniboxStore.mode !== 'find') {
313 omniboxStore.setMode('find');
314 }
315 if (!omniboxStore.visible) {
316 omniboxStore.setVisible(true);
317 }
318 omniboxStore.setInputValue(query || '');
319 // Find will be triggered by input value change
320 } catch (error) {
321 console.error('Error in findQuery:', error);
322 }
323 },
324 setActive: (active) => {
325 try {
326 console.log('🎯 Omnibox setActive called with:', active);
327 // For now, this is a no-op but maintains API compatibility
328 // In the future, this could control focus management or visibility
329 } catch (error) {
330 console.error('Error in setActive:', error);
331 }
332 }
333 };
334
335 console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox));
336
337 // Check for pending suggestions that arrived before API was ready
338 if ((window as any).__dumber_omnibox_pending_suggestions) {
339 console.log('🔄 [OMNIBOX] Processing pending suggestions');
340 const pending = (window as any).__dumber_omnibox_pending_suggestions;
341 omniboxBridge.setSuggestions(pending);
342 delete (window as any).__dumber_omnibox_pending_suggestions;
343 }
344 } catch (error) {
345 console.error('❌ Failed to set up omnibox global API:', error);
346 }
347 });
348
349 onDestroy(() => {
350 // Cleanup resize listener
351 window.removeEventListener('resize', updateResponsiveStyles);
352
353 // Cleanup global click listener
354 document.removeEventListener('click', handleGlobalClick, true);
355
356 if (themeObserver) {
357 themeObserver.disconnect();
358 themeObserver = null;
359 }
360
361 // Reset omnibox state completely
362 omniboxStore.reset();
363
364 // Cleanup global API
365 if (window.__dumber_omnibox) {
366 delete window.__dumber_omnibox;
367 }
368 });
369</script>
370
371<!-- Overlay container -->
372<div
373 class="fixed inset-0 z-[2147483647]"
374 style="display: {visible ? 'block' : 'none'};
375 position: fixed !important;
376 top: 0 !important;
377 left: 0 !important;
378 right: 0 !important;
379 bottom: 0 !important;
380 z-index: 2147483647 !important;
381 pointer-events: none !important;
382 margin: 0 !important;
383 padding: 0 !important;
384 isolation: isolate;"
385 onmousedown={handleOverlayClick}
386 role="presentation"
387>
388 <!-- Main omnibox container -->
389 <div
390 bind:this={boxElement}
391 class="dumber-omnibox-container omnibox-base rounded shadow-[0_10px_30px_rgba(0,0,0,0.6)]"
392 style="position: relative !important;
393 left: 50% !important;
394 transform: translateX(-50%) !important;
395 margin-top: 8vh !important;
396 margin-left: 0 !important;
397 margin-right: 0 !important;
398 width: {responsiveStyles.width};
399 padding: {responsiveStyles.padding};
400 font-family: 'Fira sans', system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif;
401 pointer-events: auto !important;
402 box-sizing: border-box !important;
403 background: var(--dumber-omnibox-surface, var(--dynamic-surface));
404 color: var(--dynamic-text);
405 border: 2px solid var(--dumber-border-color, var(--dynamic-border));
406 --dumber-border-color: var(--dynamic-border);"
407 onmousedown={handleBoxClick}
408 onmouseenter={handleMouseEnter}
409 role="dialog"
410 tabindex="-1"
411 aria-modal="true"
412 aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'}
413 >
414 <!-- Blur layer: absolute full-cover element providing backdrop blur.
415 We animate its opacity rather than the container's backdrop-filter
416 to avoid causing text to blur during transitions. -->
417 <div
418 bind:this={blurLayerElement}
419 class="dumber-omnibox-blur-layer"
420 aria-hidden="true"
421 style="position: absolute; inset: 0; pointer-events: none; border-radius: inherit; opacity: 0;"
422 ></div>
423 <div class="dumber-omnibox-content">
424 <!-- Input component -->
425 <OmniboxInput bind:inputElement {responsiveStyles} />
426
427 <!-- Content based on mode -->
428 {#if mode === 'omnibox'}
429 <OmniboxSuggestions />
430 {:else if mode === 'find'}
431 <OmniboxFind />
432 {/if}
433 </div>
434 </div>
435</div>
436
437<style>
438 .dumber-omnibox-container {
439 background: var(--dumber-omnibox-surface, var(--dynamic-surface));
440 color: var(--dynamic-text);
441 border: 2px solid var(--dumber-border-color, var(--dynamic-border));
442 transition: background-color 160ms ease, border-color 160ms ease;
443 }
444
445 .dumber-omnibox-container:focus-within {
446 border-color: var(--dumber-input-accent, var(--dynamic-accent));
447 }
448
449 :global(.dumber-omnibox-container input) {
450 background: var(--dumber-input-bg, var(--dynamic-bg));
451 color: var(--dynamic-text);
452 border-color: var(--dumber-input-border-color, var(--dynamic-border));
453 transition: border-color 120ms ease, background-color 120ms ease;
454 }
455
456 :global(.dumber-omnibox-container input::placeholder) {
457 color: var(--dynamic-muted);
458 }
459
460 /* Blur layer styles: use `backdrop-filter` but animate opacity only. */
461 .dumber-omnibox-blur-layer {
462 backdrop-filter: blur(2px) saturate(110%);
463 -webkit-backdrop-filter: blur(2px) saturate(110%);
464 background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 16%, transparent));
465 transition: opacity 160ms ease;
466 will-change: opacity;
467 z-index: 0;
468 mix-blend-mode: normal;
469 }
470
471 /* Ensure the actual content (input, suggestions) sits above the blur layer */
472 .dumber-omnibox-content {
473 position: relative;
474 z-index: 1;
475 /* promote to its own layer to avoid being rasterized with the blur */
476 transform: translateZ(0);
477 backface-visibility: hidden;
478 }
479 /* Additional custom styles if needed */
480</style>