refactor(webui): consolidate vite configs and remove bootstrapGUI dependency
- commit
- bce8d5b6433dec111dc63a8d006013899a5452ff
- author
- brice · · 2025-12-16 18:31 UTC
- committer
- brice · · 2025-12-16 17:02 UTC
- parent
- 38d2b5d
- signature
- unsigned
42 files · +87 -7201
M Makefile +4 -4
| @@ -36,10 +36,10 @@ build: build-frontend ## Build the application (pure Go, no CGO) | |||
| 36 | 36 | go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 37 | 37 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 38 | 38 | ||
| 39 | - | build-frontend: ## Build Svelte GUI with Tailwind CSS and main-world script | |
| 40 | - | @echo "Building Svelte GUI with Tailwind CSS..." | |
| 39 | + | build-frontend: ## Build homepage and blocked pages | |
| 40 | + | @echo "Building webui pages (homepage + blocked)..." | |
| 41 | 41 | @cd webui && npm install --silent && npm run build | |
| 42 | - | @echo "GUI build complete" | |
| 42 | + | @echo "Frontend build complete" | |
| 43 | 43 | ||
| 44 | 44 | build-quick: ## Build without frontend (faster for backend development) | |
| 45 | 45 | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | |
| @@ -108,7 +108,7 @@ clean: ## Clean build artifacts | |||
| 108 | 108 | rm -rf $(DIST_DIR) | |
| 109 | 109 | rm -f $(BINARY_NAME) | |
| 110 | 110 | rm -rf webui/dist webui/node_modules | |
| 111 | - | rm -f assets/webui/gui.min.js assets/webui/homepage.min.js assets/webui/homepage.css assets/webui/main-world.min.js assets/webui/color-scheme.js | |
| 111 | + | rm -f assets/webui/homepage.min.js assets/webui/blocked.min.js assets/webui/index.html assets/webui/blocked.html assets/webui/style.css | |
| 112 | 112 | rm -f coverage.out coverage.html | |
| 113 | 113 | go clean -cache | |
| 114 | 114 | go clean -testcache | |
M webui/package.json +7 -13
| @@ -4,21 +4,15 @@ | |||
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "scripts": { | |
| 7 | - | "build": "npm run build:color-scheme && npm run build:gui && npm run build:homepage && npm run build:blocked && npm run build:main-world", | |
| 8 | - | "build:gui": "vite build --config vite.config.gui.ts", | |
| 9 | - | "build:homepage": "vite build --config vite.config.homepage.ts", | |
| 10 | - | "build:blocked": "vite build --config vite.config.blocked.ts", | |
| 11 | - | "build:color-scheme": "vite build --config vite.config.color-scheme.ts", | |
| 12 | - | "build:main-world": "vite build --config vite.config.main-world.ts", | |
| 13 | - | "dev": "npm run build:gui -- --watch", | |
| 14 | - | "dev:homepage": "npm run build:homepage -- --watch", | |
| 15 | - | "dev:blocked": "npm run build:blocked -- --watch", | |
| 16 | - | "dev:color-scheme": "npm run build:color-scheme -- --watch", | |
| 17 | - | "dev:main-world": "npm run build:main-world -- --watch", | |
| 7 | + | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=blocked vite build", | |
| 8 | + | "build:homepage": "VITE_PAGE=homepage vite build", | |
| 9 | + | "build:blocked": "VITE_PAGE=blocked vite build", | |
| 10 | + | "dev": "VITE_PAGE=homepage vite build --watch", | |
| 11 | + | "dev:homepage": "VITE_PAGE=homepage vite build --watch", | |
| 12 | + | "dev:blocked": "VITE_PAGE=blocked vite build --watch", | |
| 18 | 13 | "check": "svelte-check", | |
| 19 | 14 | "lint": "eslint src --fix", | |
| 20 | - | "fmt": "prettier --write .", | |
| 21 | - | "generate:types": "node ../scripts/generate-types.js" | |
| 15 | + | "fmt": "prettier --write ." | |
| 22 | 16 | }, | |
| 23 | 17 | "devDependencies": { | |
| 24 | 18 | "@sveltejs/vite-plugin-svelte": "^4.0.0", | |
D webui/src/components/Footer.svelte +0 -80
| @@ -1,80 +0,0 @@ | |||
| 1 | - | <script lang="ts"> | |
| 2 | - | const openGithub = () => { | |
| 3 | - | window.open("https://github.com/bnema/dumber", "_blank"); | |
| 4 | - | }; | |
| 5 | - | </script> | |
| 6 | - | ||
| 7 | - | <footer class="footer"> | |
| 8 | - | <div class="footer-content"> | |
| 9 | - | <div class="footer-text"> | |
| 10 | - | <span class="footer-name">Dumber Browser</span> | |
| 11 | - | <span class="footer-dot">•</span> | |
| 12 | - | <button class="footer-link" onclick={openGithub}> | |
| 13 | - | View on GitHub | |
| 14 | - | </button> | |
| 15 | - | </div> | |
| 16 | - | </div> | |
| 17 | - | </footer> | |
| 18 | - | ||
| 19 | - | <style> | |
| 20 | - | .footer { | |
| 21 | - | margin-top: 1.5rem; | |
| 22 | - | width: 100%; | |
| 23 | - | border-top: 1px solid var(--dynamic-border); | |
| 24 | - | background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%); | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | .footer-content { | |
| 28 | - | display: flex; | |
| 29 | - | justify-content: center; | |
| 30 | - | align-items: center; | |
| 31 | - | padding: 0.75rem 0; | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | .footer-text { | |
| 35 | - | display: flex; | |
| 36 | - | align-items: center; | |
| 37 | - | gap: 0.75rem; | |
| 38 | - | font-size: 0.75rem; | |
| 39 | - | text-transform: uppercase; | |
| 40 | - | letter-spacing: 0.12em; | |
| 41 | - | color: var(--dynamic-muted); | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | .footer-name { | |
| 45 | - | color: var(--dynamic-text); | |
| 46 | - | font-weight: 600; | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | .footer-dot { | |
| 50 | - | color: var(--dynamic-border); | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | .footer-link { | |
| 54 | - | background: none; | |
| 55 | - | border: none; | |
| 56 | - | color: var(--dynamic-muted); | |
| 57 | - | text-decoration: none; | |
| 58 | - | cursor: pointer; | |
| 59 | - | font-size: 0.85rem; | |
| 60 | - | padding: 0; | |
| 61 | - | transition: color 0.2s ease, letter-spacing 0.2s ease; | |
| 62 | - | font-family: | |
| 63 | - | "JetBrains Mono", | |
| 64 | - | "Fira Code", | |
| 65 | - | "SFMono-Regular", | |
| 66 | - | Menlo, | |
| 67 | - | monospace; | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | .footer-link:hover { | |
| 71 | - | color: var(--dynamic-text); | |
| 72 | - | letter-spacing: 0.16em; | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | .footer-link:focus { | |
| 76 | - | outline: 1px solid var(--dynamic-border); | |
| 77 | - | outline-offset: 2px; | |
| 78 | - | border-radius: 0; | |
| 79 | - | } | |
| 80 | - | </style> | |
D webui/src/components/omnibox/Omnibox.svelte +0 -560
| @@ -1,560 +0,0 @@ | |||
| 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 | - | // Ready state - prevents flash before initialization | |
| 20 | - | let isReady = $state(false); | |
| 21 | - | ||
| 22 | - | // Component refs | |
| 23 | - | let boxElement: HTMLDivElement; | |
| 24 | - | let blurLayerElement: HTMLDivElement; | |
| 25 | - | let inputElement = $state<HTMLInputElement>(); | |
| 26 | - | ||
| 27 | - | // Responsive styling state | |
| 28 | - | let responsiveStyles = $state({ | |
| 29 | - | width: 'min(90vw, 720px)', | |
| 30 | - | padding: '8px 12px', | |
| 31 | - | fontSize: '16px', | |
| 32 | - | inputPadding: '10px 12px' | |
| 33 | - | }); | |
| 34 | - | ||
| 35 | - | // Track theme mode derived from injected color-scheme module | |
| 36 | - | let isDarkMode = $state(false); | |
| 37 | - | let themeObserver: MutationObserver | null = null; | |
| 38 | - | ||
| 39 | - | function syncTheme() { | |
| 40 | - | if (typeof document === 'undefined') return; | |
| 41 | - | ||
| 42 | - | const globalWindow = typeof window === 'undefined' ? undefined : window; | |
| 43 | - | ||
| 44 | - | if (document.documentElement.classList.contains('dark')) { | |
| 45 | - | isDarkMode = true; | |
| 46 | - | return; | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | if (globalWindow?.matchMedia) { | |
| 50 | - | try { | |
| 51 | - | const prefersDark = globalWindow.matchMedia('(prefers-color-scheme: dark)').matches; | |
| 52 | - | if (typeof prefersDark === 'boolean') { | |
| 53 | - | isDarkMode = prefersDark; | |
| 54 | - | return; | |
| 55 | - | } | |
| 56 | - | } catch { | |
| 57 | - | // matchMedia might be unavailable in some environments | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // Fallback to injected GTK preference flag if available | |
| 62 | - | isDarkMode = Boolean((globalWindow as any)?.__dumber_gtk_prefers_dark); | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | function observeThemeChanges() { | |
| 66 | - | if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') { | |
| 67 | - | return; | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | if (themeObserver) { | |
| 71 | - | themeObserver.disconnect(); | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | themeObserver = new MutationObserver(syncTheme); | |
| 75 | - | themeObserver.observe(document.documentElement, { | |
| 76 | - | attributes: true, | |
| 77 | - | attributeFilter: ['class'] | |
| 78 | - | }); | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Handle click outside to close | |
| 82 | - | function handleOverlayClick(event: MouseEvent) { | |
| 83 | - | const target = event.target as HTMLElement; | |
| 84 | - | if (target && boxElement && !boxElement.contains(target)) { | |
| 85 | - | event.preventDefault(); | |
| 86 | - | event.stopPropagation(); | |
| 87 | - | omniboxStore.close(); | |
| 88 | - | } | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // Handle box click to prevent propagation | |
| 92 | - | function handleBoxClick(event: MouseEvent) { | |
| 93 | - | event.stopPropagation(); | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // Handle mouse enter to focus input and clear fade | |
| 97 | - | function handleMouseEnter() { | |
| 98 | - | if (visible && inputElement) { | |
| 99 | - | try { | |
| 100 | - | inputElement.focus({ preventScroll: true }); | |
| 101 | - | } catch { | |
| 102 | - | inputElement.focus(); | |
| 103 | - | } | |
| 104 | - | omniboxStore.setFaded(false); | |
| 105 | - | } | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // Update responsive styles based on viewport width (handles zoom levels) | |
| 109 | - | function updateResponsiveStyles() { | |
| 110 | - | const vw = window.innerWidth; | |
| 111 | - | const isSmall = vw < 640; | |
| 112 | - | const isLarge = vw > 1920; | |
| 113 | - | ||
| 114 | - | responsiveStyles = { | |
| 115 | - | width: isSmall ? '95vw' : isLarge ? 'min(90vw, 840px)' : 'min(90vw, 720px)', | |
| 116 | - | padding: isSmall ? '6px 8px' : '8px 12px', | |
| 117 | - | fontSize: isSmall ? '14px' : isLarge ? '17px' : '16px', | |
| 118 | - | inputPadding: isSmall ? '8px 10px' : '10px 12px' | |
| 119 | - | }; | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | // Focus input when component becomes visible and manage page event blocking | |
| 123 | - | $effect(() => { | |
| 124 | - | if (visible && inputElement) { | |
| 125 | - | inputElement.focus(); | |
| 126 | - | // Enable keyboard event blocking in main world when omnibox opens | |
| 127 | - | enablePageEventBlocking(); | |
| 128 | - | ||
| 129 | - | // Fetch initial history if opening in omnibox mode with empty input | |
| 130 | - | if (mode === 'omnibox' && omniboxStore.inputValue === '') { | |
| 131 | - | omniboxBridge.fetchInitialHistory(); | |
| 132 | - | } | |
| 133 | - | } else if (!visible) { | |
| 134 | - | // Disable keyboard event blocking when omnibox closes | |
| 135 | - | disablePageEventBlocking(); | |
| 136 | - | } | |
| 137 | - | }); | |
| 138 | - | ||
| 139 | - | // Functions to control main-world event blocking | |
| 140 | - | function enablePageEventBlocking() { | |
| 141 | - | try { | |
| 142 | - | // Send message to Go backend via bridge (isolated world → main world → Go) | |
| 143 | - | omniboxBridge.postMessage({ | |
| 144 | - | type: 'keyboard_blocking', | |
| 145 | - | action: 'enable' | |
| 146 | - | }); | |
| 147 | - | console.log('[omnibox] Sent enable keyboard blocking message'); | |
| 148 | - | } catch (error) { | |
| 149 | - | console.error('[omnibox] Failed to enable page event blocking:', error); | |
| 150 | - | } | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | function disablePageEventBlocking() { | |
| 154 | - | try { | |
| 155 | - | // Send message to Go backend via bridge (isolated world → main world → Go) | |
| 156 | - | omniboxBridge.postMessage({ | |
| 157 | - | type: 'keyboard_blocking', | |
| 158 | - | action: 'disable' | |
| 159 | - | }); | |
| 160 | - | console.log('[omnibox] Sent disable keyboard blocking message'); | |
| 161 | - | } catch (error) { | |
| 162 | - | console.error('[omnibox] Failed to disable page event blocking:', error); | |
| 163 | - | } | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | ||
| 167 | - | // Apply faded styling effect (matching original JS implementation) | |
| 168 | - | // NOTE: to avoid text becoming blurry while animating, we animate the | |
| 169 | - | // blur via an absolutely-positioned layer's opacity instead of | |
| 170 | - | // animating `backdrop-filter` on the container. This keeps the text | |
| 171 | - | // on a separate compositing layer and prevents browser antialiasing | |
| 172 | - | // artifacts during transitions. | |
| 173 | - | $effect(() => { | |
| 174 | - | if (!boxElement || !inputElement || !blurLayerElement) return; | |
| 175 | - | ||
| 176 | - | // Ensure subtle transitions are present on the container so | |
| 177 | - | // background-color (and other cheap properties) animate smoothly. | |
| 178 | - | try { | |
| 179 | - | if (!boxElement.style.transition) { | |
| 180 | - | boxElement.style.transition = 'background-color 120ms ease, border-color 120ms ease'; | |
| 181 | - | } | |
| 182 | - | } catch (e) { | |
| 183 | - | // defensive: ignore if setting styles fails for some reason | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | const surfaceMix = isDarkMode ? '55%' : '88%'; | |
| 187 | - | const blurMix = isDarkMode ? '22%' : '12%'; | |
| 188 | - | ||
| 189 | - | const applyFade = faded && mode === 'find'; | |
| 190 | - | ||
| 191 | - | // GTK4-style: Container is slightly raised (use surface) | |
| 192 | - | const containerBackground = applyFade | |
| 193 | - | ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)` | |
| 194 | - | : 'var(--dynamic-surface)'; | |
| 195 | - | ||
| 196 | - | // GTK4-style: Input is recessed (darker background) | |
| 197 | - | const inputBackground = applyFade | |
| 198 | - | ? isDarkMode | |
| 199 | - | ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 85%, black 15%) ${surfaceMix}, transparent)` | |
| 200 | - | : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 95%, black 5%) ${surfaceMix}, transparent)` | |
| 201 | - | : isDarkMode | |
| 202 | - | ? 'color-mix(in srgb, var(--dynamic-bg) 85%, black 15%)' | |
| 203 | - | : 'color-mix(in srgb, var(--dynamic-bg) 95%, black 5%)'; | |
| 204 | - | ||
| 205 | - | // GTK4-style: Use palette border directly, slightly darker for input | |
| 206 | - | const containerBorderColor = applyFade | |
| 207 | - | ? `color-mix(in srgb, var(--dynamic-border) ${surfaceMix}, transparent)` | |
| 208 | - | : 'var(--dynamic-border)'; | |
| 209 | - | ||
| 210 | - | const inputBorderColor = applyFade | |
| 211 | - | ? isDarkMode | |
| 212 | - | ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-border) 80%, black 20%) ${surfaceMix}, transparent)` | |
| 213 | - | : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-border) 85%, black 15%) ${surfaceMix}, transparent)` | |
| 214 | - | : isDarkMode | |
| 215 | - | ? 'color-mix(in srgb, var(--dynamic-border) 80%, black 20%)' | |
| 216 | - | : 'color-mix(in srgb, var(--dynamic-border) 85%, black 15%)'; | |
| 217 | - | ||
| 218 | - | const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`; | |
| 219 | - | ||
| 220 | - | boxElement.style.background = containerBackground; | |
| 221 | - | boxElement.style.color = 'var(--dynamic-text)'; | |
| 222 | - | boxElement.style.setProperty('--dumber-border-color', containerBorderColor); | |
| 223 | - | // Force the omnibox to use our neutral surfaces so it stays in sync with the shell palette | |
| 224 | - | boxElement.style.setProperty('--dumber-omnibox-surface', containerBackground); | |
| 225 | - | ||
| 226 | - | inputElement.style.background = inputBackground; | |
| 227 | - | inputElement.style.color = 'var(--dynamic-text)'; | |
| 228 | - | inputElement.style.setProperty('--dumber-input-bg', inputBackground); | |
| 229 | - | inputElement.style.setProperty('--dumber-input-border-base', inputBorderColor); | |
| 230 | - | inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)'); | |
| 231 | - | ||
| 232 | - | blurLayerElement.style.setProperty('--dumber-blur-color', blurTint); | |
| 233 | - | blurLayerElement.style.background = 'var(--dumber-blur-color)'; | |
| 234 | - | ||
| 235 | - | let accentColor = 'var(--dynamic-accent)'; | |
| 236 | - | try { | |
| 237 | - | const computedAccent = getComputedStyle(boxElement).getPropertyValue('--dynamic-accent'); | |
| 238 | - | if (computedAccent && computedAccent.trim()) { | |
| 239 | - | accentColor = computedAccent.trim(); | |
| 240 | - | } | |
| 241 | - | } catch { | |
| 242 | - | // If computed styles fail, keep fallback accent color | |
| 243 | - | } | |
| 244 | - | inputElement.style.setProperty('--dumber-input-accent', accentColor); | |
| 245 | - | boxElement.style.setProperty('--dumber-input-accent', 'var(--dynamic-muted)'); | |
| 246 | - | ||
| 247 | - | const isFocused = document.activeElement === inputElement; | |
| 248 | - | ||
| 249 | - | if (!isFocused) { | |
| 250 | - | inputElement.style.setProperty('--dumber-input-border-color', 'var(--dynamic-border)'); | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | if (applyFade) { | |
| 254 | - | blurLayerElement.style.opacity = '1'; | |
| 255 | - | } else { | |
| 256 | - | blurLayerElement.style.opacity = '0'; | |
| 257 | - | } | |
| 258 | - | }); | |
| 259 | - | ||
| 260 | - | // Global click handler to close omnibox when clicking anywhere outside | |
| 261 | - | function handleGlobalClick(event: MouseEvent) { | |
| 262 | - | if (!visible) return; | |
| 263 | - | ||
| 264 | - | const target = event.target as HTMLElement; | |
| 265 | - | const omniboxContainer = document.getElementById('dumber-omnibox-root'); | |
| 266 | - | ||
| 267 | - | // Close if clicking outside the omnibox container | |
| 268 | - | if (omniboxContainer && !omniboxContainer.contains(target)) { | |
| 269 | - | omniboxStore.close(); | |
| 270 | - | } | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | ||
| 274 | - | ||
| 275 | - | onMount(async () => { | |
| 276 | - | syncTheme(); | |
| 277 | - | observeThemeChanges(); | |
| 278 | - | ||
| 279 | - | console.log('🔧 Omnibox component mounted'); | |
| 280 | - | ||
| 281 | - | // Initialize responsive styles | |
| 282 | - | updateResponsiveStyles(); | |
| 283 | - | ||
| 284 | - | // Add resize listener for responsive updates (handles zoom changes) | |
| 285 | - | window.addEventListener('resize', updateResponsiveStyles); | |
| 286 | - | ||
| 287 | - | // Add global click listener | |
| 288 | - | document.addEventListener('click', handleGlobalClick, true); | |
| 289 | - | ||
| 290 | - | // Load search shortcuts and favorites on mount | |
| 291 | - | try { | |
| 292 | - | await Promise.all([ | |
| 293 | - | omniboxBridge.fetchSearchShortcuts(), | |
| 294 | - | omniboxBridge.fetchFavorites() | |
| 295 | - | ]); | |
| 296 | - | console.log('✅ Search shortcuts and favorites loaded on mount'); | |
| 297 | - | } catch (error) { | |
| 298 | - | console.warn('Failed to load initial data:', error); | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | try { | |
| 302 | - | // Set up global API for Go bridge | |
| 303 | - | window.__dumber_omnibox = { | |
| 304 | - | setSuggestions: (suggestions) => { | |
| 305 | - | try { | |
| 306 | - | console.log('📋 setSuggestions called with:', suggestions?.length || 0, 'items'); | |
| 307 | - | omniboxBridge.setSuggestions(suggestions); | |
| 308 | - | } catch (error) { | |
| 309 | - | console.error('Error in setSuggestions:', error); | |
| 310 | - | } | |
| 311 | - | }, | |
| 312 | - | toggle: () => { | |
| 313 | - | try { | |
| 314 | - | console.log('🔄 Omnibox toggle called, current visible:', omniboxStore.visible); | |
| 315 | - | omniboxStore.toggle(); | |
| 316 | - | console.log('🔄 After toggle, visible:', omniboxStore.visible); | |
| 317 | - | } catch (error) { | |
| 318 | - | console.error('Error in toggle:', error); | |
| 319 | - | } | |
| 320 | - | }, | |
| 321 | - | open: (mode, query) => { | |
| 322 | - | try { | |
| 323 | - | console.log('🚪 Omnibox open called with mode:', mode, 'query:', query); | |
| 324 | - | omniboxStore.open(mode as any, query); | |
| 325 | - | console.log('🚪 After open, visible:', omniboxStore.visible, 'mode:', omniboxStore.mode); | |
| 326 | - | } catch (error) { | |
| 327 | - | console.error('Error in open:', error); | |
| 328 | - | } | |
| 329 | - | }, | |
| 330 | - | close: () => { | |
| 331 | - | try { | |
| 332 | - | console.log('🚪 Omnibox close called'); | |
| 333 | - | omniboxStore.close(); | |
| 334 | - | } catch (error) { | |
| 335 | - | console.error('Error in close:', error); | |
| 336 | - | } | |
| 337 | - | }, | |
| 338 | - | findQuery: (query) => { | |
| 339 | - | try { | |
| 340 | - | console.log('🔍 Find query called with:', query); | |
| 341 | - | if (omniboxStore.mode !== 'find') { | |
| 342 | - | omniboxStore.setMode('find'); | |
| 343 | - | } | |
| 344 | - | if (!omniboxStore.visible) { | |
| 345 | - | omniboxStore.setVisible(true); | |
| 346 | - | } | |
| 347 | - | omniboxStore.setInputValue(query || ''); | |
| 348 | - | // Find will be triggered by input value change | |
| 349 | - | } catch (error) { | |
| 350 | - | console.error('Error in findQuery:', error); | |
| 351 | - | } | |
| 352 | - | }, | |
| 353 | - | setActive: (active) => { | |
| 354 | - | try { | |
| 355 | - | console.log('🎯 Omnibox setActive called with:', active); | |
| 356 | - | // For now, this is a no-op but maintains API compatibility | |
| 357 | - | // In the future, this could control focus management or visibility | |
| 358 | - | } catch (error) { | |
| 359 | - | console.error('Error in setActive:', error); | |
| 360 | - | } | |
| 361 | - | }, | |
| 362 | - | setInlineSuggestion: (url) => { | |
| 363 | - | try { | |
| 364 | - | console.log('[INLINE] window.__dumber_omnibox.setInlineSuggestion called:', url); | |
| 365 | - | omniboxBridge.setInlineSuggestion(url); | |
| 366 | - | } catch (error) { | |
| 367 | - | console.error('Error in setInlineSuggestion:', error); | |
| 368 | - | } | |
| 369 | - | } | |
| 370 | - | }; | |
| 371 | - | ||
| 372 | - | console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox)); | |
| 373 | - | ||
| 374 | - | // Check for pending suggestions that arrived before API was ready | |
| 375 | - | if ((window as any).__dumber_omnibox_pending_suggestions) { | |
| 376 | - | console.log('🔄 [OMNIBOX] Processing pending suggestions'); | |
| 377 | - | const pending = (window as any).__dumber_omnibox_pending_suggestions; | |
| 378 | - | omniboxBridge.setSuggestions(pending); | |
| 379 | - | delete (window as any).__dumber_omnibox_pending_suggestions; | |
| 380 | - | } | |
| 381 | - | } catch (error) { | |
| 382 | - | console.error('❌ Failed to set up omnibox global API:', error); | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | // Mark as ready - removes initial hiding styles | |
| 386 | - | isReady = true; | |
| 387 | - | console.log('✅ Omnibox fully initialized and ready'); | |
| 388 | - | }); | |
| 389 | - | ||
| 390 | - | onDestroy(() => { | |
| 391 | - | // Cleanup resize listener | |
| 392 | - | window.removeEventListener('resize', updateResponsiveStyles); | |
| 393 | - | ||
| 394 | - | // Cleanup global click listener | |
| 395 | - | document.removeEventListener('click', handleGlobalClick, true); | |
| 396 | - | ||
| 397 | - | if (themeObserver) { | |
| 398 | - | themeObserver.disconnect(); | |
| 399 | - | themeObserver = null; | |
| 400 | - | } | |
| 401 | - | ||
| 402 | - | // Reset omnibox state completely | |
| 403 | - | omniboxStore.reset(); | |
| 404 | - | ||
| 405 | - | // Cleanup global API | |
| 406 | - | if (window.__dumber_omnibox) { | |
| 407 | - | delete window.__dumber_omnibox; | |
| 408 | - | } | |
| 409 | - | }); | |
| 410 | - | </script> | |
| 411 | - | ||
| 412 | - | <!-- Overlay container --> | |
| 413 | - | <div | |
| 414 | - | class="fixed inset-0 z-[2147483647]" | |
| 415 | - | style="display: {visible ? 'block' : 'none'}; | |
| 416 | - | position: fixed !important; | |
| 417 | - | top: 0 !important; | |
| 418 | - | left: 0 !important; | |
| 419 | - | right: 0 !important; | |
| 420 | - | bottom: 0 !important; | |
| 421 | - | z-index: {isReady ? '2147483647' : '-9999'} !important; | |
| 422 | - | visibility: {isReady ? 'visible' : 'hidden'} !important; | |
| 423 | - | opacity: {isReady ? '1' : '0'} !important; | |
| 424 | - | pointer-events: {isReady ? 'none' : 'none'} !important; | |
| 425 | - | margin: 0 !important; | |
| 426 | - | padding: 0 !important; | |
| 427 | - | isolation: isolate;" | |
| 428 | - | onmousedown={handleOverlayClick} | |
| 429 | - | role="presentation" | |
| 430 | - | > | |
| 431 | - | <!-- Main omnibox container --> | |
| 432 | - | <div | |
| 433 | - | bind:this={boxElement} | |
| 434 | - | class="dumber-omnibox-container omnibox-base terminal-omnibox" | |
| 435 | - | style="position: relative !important; | |
| 436 | - | left: 50% !important; | |
| 437 | - | transform: translateX(-50%) !important; | |
| 438 | - | margin-top: 20vh !important; | |
| 439 | - | margin-left: 0 !important; | |
| 440 | - | margin-right: 0 !important; | |
| 441 | - | width: {responsiveStyles.width}; | |
| 442 | - | padding: {responsiveStyles.padding}; | |
| 443 | - | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |
| 444 | - | pointer-events: auto !important; | |
| 445 | - | box-sizing: border-box !important; | |
| 446 | - | background: var(--dumber-omnibox-surface, var(--dynamic-surface)); | |
| 447 | - | color: var(--dynamic-text); | |
| 448 | - | border: 1px solid var(--dumber-border-color, var(--dynamic-border)); | |
| 449 | - | border-radius: 3px; | |
| 450 | - | box-shadow: | |
| 451 | - | 0 4px 12px rgba(0, 0, 0, 0.2), | |
| 452 | - | 0 2px 6px rgba(0, 0, 0, 0.15), | |
| 453 | - | 0 1px 3px rgba(0, 0, 0, 0.1), | |
| 454 | - | inset 0 1px 0 rgba(255, 255, 255, 0.04); | |
| 455 | - | --dumber-border-color: var(--dynamic-border);" | |
| 456 | - | onmousedown={handleBoxClick} | |
| 457 | - | onmouseenter={handleMouseEnter} | |
| 458 | - | role="dialog" | |
| 459 | - | tabindex="-1" | |
| 460 | - | aria-modal="true" | |
| 461 | - | aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'} | |
| 462 | - | > | |
| 463 | - | <!-- Blur layer: absolute full-cover element providing backdrop blur. | |
| 464 | - | We animate its opacity rather than the container's backdrop-filter | |
| 465 | - | to avoid causing text to blur during transitions. --> | |
| 466 | - | <div | |
| 467 | - | bind:this={blurLayerElement} | |
| 468 | - | class="dumber-omnibox-blur-layer" | |
| 469 | - | aria-hidden="true" | |
| 470 | - | style="position: absolute; inset: 0; pointer-events: none; border-radius: 0; opacity: 0;" | |
| 471 | - | ></div> | |
| 472 | - | <div class="dumber-omnibox-content"> | |
| 473 | - | <!-- Input component --> | |
| 474 | - | <OmniboxInput bind:inputElement {responsiveStyles} /> | |
| 475 | - | ||
| 476 | - | <!-- Content based on mode --> | |
| 477 | - | {#if mode === 'omnibox'} | |
| 478 | - | <OmniboxSuggestions /> | |
| 479 | - | {:else if mode === 'find'} | |
| 480 | - | <OmniboxFind /> | |
| 481 | - | {/if} | |
| 482 | - | </div> | |
| 483 | - | </div> | |
| 484 | - | </div> | |
| 485 | - | ||
| 486 | - | <style> | |
| 487 | - | .dumber-omnibox-container { | |
| 488 | - | background: var(--dumber-omnibox-surface, var(--dynamic-surface)); | |
| 489 | - | color: var(--dynamic-text); | |
| 490 | - | border: 1px solid var(--dumber-border-color, var(--dynamic-border)); | |
| 491 | - | transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease; | |
| 492 | - | border-radius: 3px; | |
| 493 | - | box-shadow: | |
| 494 | - | 0 4px 12px rgba(0, 0, 0, 0.2), | |
| 495 | - | 0 2px 6px rgba(0, 0, 0, 0.15), | |
| 496 | - | 0 1px 3px rgba(0, 0, 0, 0.1), | |
| 497 | - | inset 0 1px 0 rgba(255, 255, 255, 0.04); | |
| 498 | - | } | |
| 499 | - | ||
| 500 | - | .dumber-omnibox-container:focus-within { | |
| 501 | - | border-color: var(--dynamic-accent); | |
| 502 | - | box-shadow: | |
| 503 | - | 0 6px 16px rgba(0, 0, 0, 0.24), | |
| 504 | - | 0 3px 8px rgba(0, 0, 0, 0.18), | |
| 505 | - | 0 2px 4px rgba(0, 0, 0, 0.12), | |
| 506 | - | inset 0 1px 0 rgba(255, 255, 255, 0.06), | |
| 507 | - | 0 0 0 1px var(--dynamic-accent); | |
| 508 | - | } | |
| 509 | - | ||
| 510 | - | :global(.dumber-omnibox-container input) { | |
| 511 | - | background: var(--dumber-input-bg, var(--dynamic-bg)); | |
| 512 | - | color: var(--dynamic-text); | |
| 513 | - | border: 1px solid var(--dumber-input-border-color, var(--dynamic-border)); | |
| 514 | - | border-radius: 2px; | |
| 515 | - | box-shadow: | |
| 516 | - | inset 0 1px 2px rgba(0, 0, 0, 0.15), | |
| 517 | - | inset 0 0 0 1px rgba(0, 0, 0, 0.03); | |
| 518 | - | transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease; | |
| 519 | - | font-family: | |
| 520 | - | "Fira Sans", | |
| 521 | - | system-ui, | |
| 522 | - | -apple-system, | |
| 523 | - | "Segoe UI", | |
| 524 | - | "Ubuntu", | |
| 525 | - | "Cantarell", | |
| 526 | - | sans-serif; | |
| 527 | - | font-size: inherit; | |
| 528 | - | letter-spacing: normal; | |
| 529 | - | text-transform: none; | |
| 530 | - | } | |
| 531 | - | ||
| 532 | - | :global(.dumber-omnibox-container input::placeholder) { | |
| 533 | - | color: var(--dynamic-muted); | |
| 534 | - | letter-spacing: normal; | |
| 535 | - | text-transform: none; | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | /* Blur layer styles: use `backdrop-filter` but animate opacity only. */ | |
| 539 | - | /* GTK4-style: Minimal blur for faded state */ | |
| 540 | - | .dumber-omnibox-blur-layer { | |
| 541 | - | backdrop-filter: blur(0.5px) saturate(102%); | |
| 542 | - | -webkit-backdrop-filter: blur(0.5px) saturate(102%); | |
| 543 | - | background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 8%, transparent)); | |
| 544 | - | transition: opacity 140ms ease; | |
| 545 | - | will-change: opacity; | |
| 546 | - | z-index: 0; | |
| 547 | - | mix-blend-mode: normal; | |
| 548 | - | border-radius: 0; | |
| 549 | - | } | |
| 550 | - | ||
| 551 | - | /* Ensure the actual content (input, suggestions) sits above the blur layer */ | |
| 552 | - | .dumber-omnibox-content { | |
| 553 | - | position: relative; | |
| 554 | - | z-index: 1; | |
| 555 | - | /* promote to its own layer to avoid being rasterized with the blur */ | |
| 556 | - | transform: translateZ(0); | |
| 557 | - | backface-visibility: hidden; | |
| 558 | - | } | |
| 559 | - | /* Additional custom styles if needed */ | |
| 560 | - | </style> | |
D webui/src/components/omnibox/OmniboxFind.svelte +0 -135
| @@ -1,135 +0,0 @@ | |||
| 1 | - | <!-- | |
| 2 | - | Omnibox Find Component | |
| 3 | - | ||
| 4 | - | Displays find results and match navigation | |
| 5 | - | --> | |
| 6 | - | <script lang="ts"> | |
| 7 | - | import { omniboxStore } from './stores.svelte.ts'; | |
| 8 | - | import { revealMatch } from './find'; | |
| 9 | - | ||
| 10 | - | // Reactive state | |
| 11 | - | let matches = $derived(omniboxStore.matches); | |
| 12 | - | let selectedIndex = $derived(omniboxStore.selectedIndex); | |
| 13 | - | let hasContent = $state(false); | |
| 14 | - | ||
| 15 | - | // Update hasContent when matches change | |
| 16 | - | $effect(() => { | |
| 17 | - | hasContent = omniboxStore.mode === 'find' && matches.length > 0; | |
| 18 | - | }); | |
| 19 | - | ||
| 20 | - | // Computed total count | |
| 21 | - | let totalMatches = $derived(matches.length); | |
| 22 | - | ||
| 23 | - | // Handle match item mouse enter | |
| 24 | - | function handleItemMouseEnter(index: number) { | |
| 25 | - | omniboxStore.setSelectedIndex(index); | |
| 26 | - | omniboxStore.setFaded(true); | |
| 27 | - | scrollToSelection(); | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // Handle match item click | |
| 31 | - | function handleItemClick(index: number) { | |
| 32 | - | omniboxStore.setSelectedIndex(index); | |
| 33 | - | revealMatch(index); | |
| 34 | - | omniboxStore.close(); | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | // Scroll list to show selected item | |
| 38 | - | function scrollToSelection() { | |
| 39 | - | const selectedItem = document.getElementById(`find-item-${selectedIndex}`); | |
| 40 | - | if (selectedItem && selectedItem.scrollIntoView) { | |
| 41 | - | try { | |
| 42 | - | selectedItem.scrollIntoView({ block: 'nearest' }); | |
| 43 | - | } catch { | |
| 44 | - | selectedItem.scrollIntoView(); | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Watch for selection changes to scroll | |
| 50 | - | $effect(() => { | |
| 51 | - | if (selectedIndex >= 0) { | |
| 52 | - | scrollToSelection(); | |
| 53 | - | } | |
| 54 | - | }); | |
| 55 | - | </script> | |
| 56 | - | ||
| 57 | - | <div id="find-list" class="find-list" role="listbox" aria-label="Find results"> | |
| 58 | - | <!-- Header with match count --> | |
| 59 | - | <div class="find-header"> | |
| 60 | - | {#if totalMatches > 0} | |
| 61 | - | {totalMatches} match{totalMatches === 1 ? '' : 'es'} | |
| 62 | - | {:else} | |
| 63 | - | No matches | |
| 64 | - | {/if} | |
| 65 | - | </div> | |
| 66 | - | ||
| 67 | - | <!-- Match results --> | |
| 68 | - | {#if hasContent} | |
| 69 | - | {#each matches as match, index (index)} | |
| 70 | - | {@const isSelected = index === selectedIndex} | |
| 71 | - | ||
| 72 | - | <div | |
| 73 | - | id="find-item-{index}" | |
| 74 | - | class={isSelected ? 'find-item selected' : 'find-item'} | |
| 75 | - | role="option" | |
| 76 | - | tabindex="-1" | |
| 77 | - | aria-selected={isSelected} | |
| 78 | - | onmouseenter={() => handleItemMouseEnter(index)} | |
| 79 | - | onclick={() => handleItemClick(index)} | |
| 80 | - | onkeydown={(e) => e.key === 'Enter' && handleItemClick(index)} | |
| 81 | - | > | |
| 82 | - | <!-- Match context --> | |
| 83 | - | <div class="find-context"> | |
| 84 | - | {match.context || ''} | |
| 85 | - | </div> | |
| 86 | - | </div> | |
| 87 | - | {/each} | |
| 88 | - | {/if} | |
| 89 | - | </div> | |
| 90 | - | ||
| 91 | - | <style> | |
| 92 | - | .find-list { | |
| 93 | - | margin-top: 0.75rem; | |
| 94 | - | max-height: 50vh; | |
| 95 | - | overflow-y: auto; | |
| 96 | - | border-top: 1px solid var(--dynamic-border); | |
| 97 | - | background: color-mix(in srgb, var(--dynamic-bg) 94%, var(--dynamic-surface) 6%); | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | .find-header { | |
| 101 | - | padding: 0.65rem 0.85rem; | |
| 102 | - | font-size: 0.7rem; | |
| 103 | - | text-transform: uppercase; | |
| 104 | - | letter-spacing: 0.08em; | |
| 105 | - | color: var(--dynamic-muted); | |
| 106 | - | border-bottom: 1px dashed var(--dynamic-border); | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | .find-item { | |
| 110 | - | padding: 0.65rem 0.85rem; | |
| 111 | - | border-bottom: 1px dashed var(--dynamic-border); | |
| 112 | - | cursor: pointer; | |
| 113 | - | transition: background-color 120ms ease, color 120ms ease; | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | .find-item:last-child { | |
| 117 | - | border-bottom: none; | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | .find-item.selected, | |
| 121 | - | .find-item:hover, | |
| 122 | - | .find-item:focus-visible { | |
| 123 | - | background: color-mix(in srgb, var(--dynamic-bg) 75%, var(--dynamic-surface) 25%); | |
| 124 | - | color: var(--dynamic-text); | |
| 125 | - | outline: none; | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | .find-context { | |
| 129 | - | color: var(--dynamic-text); | |
| 130 | - | font-size: 0.75rem; | |
| 131 | - | white-space: nowrap; | |
| 132 | - | overflow: hidden; | |
| 133 | - | text-overflow: ellipsis; | |
| 134 | - | } | |
| 135 | - | </style> | |
D webui/src/components/omnibox/OmniboxInput.svelte +0 -654
| @@ -1,654 +0,0 @@ | |||
| 1 | - | <!-- | |
| 2 | - | Omnibox Input Component | |
| 3 | - | ||
| 4 | - | Input field with keyboard handling and debounced search | |
| 5 | - | --> | |
| 6 | - | <script lang="ts"> | |
| 7 | - | import { omniboxStore } from './stores.svelte.ts'; | |
| 8 | - | import { debouncedQuery, debouncedFind, debouncedPrefixQuery, omniboxBridge } from './messaging'; | |
| 9 | - | import { findInPage } from './find'; | |
| 10 | - | ||
| 11 | - | // Reactive state | |
| 12 | - | let mode = $derived(omniboxStore.mode); | |
| 13 | - | let viewMode = $derived(omniboxStore.viewMode); | |
| 14 | - | let inputValue = $derived(omniboxStore.inputValue); | |
| 15 | - | let selectedIndex = $derived(omniboxStore.selectedIndex); | |
| 16 | - | let suggestions = $derived(omniboxStore.suggestions); | |
| 17 | - | let favorites = $derived(omniboxStore.favorites); | |
| 18 | - | let matches = $derived(omniboxStore.matches); | |
| 19 | - | let searchShortcuts = $derived(omniboxStore.searchShortcuts); | |
| 20 | - | let inlineCompletion = $derived(omniboxStore.inlineCompletion); | |
| 21 | - | let inlineSuggestion = $derived(omniboxStore.inlineSuggestion); | |
| 22 | - | ||
| 23 | - | // Cursor tracking for ghost text visibility | |
| 24 | - | let cursorAtEnd = $state(true); | |
| 25 | - | ||
| 26 | - | // Input element ref and responsive styles (props for parent to bind) | |
| 27 | - | interface Props { | |
| 28 | - | inputElement?: HTMLInputElement; | |
| 29 | - | responsiveStyles?: { | |
| 30 | - | fontSize: string; | |
| 31 | - | inputPadding: string; | |
| 32 | - | }; | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | let { inputElement = $bindable(), responsiveStyles }: Props = $props(); | |
| 36 | - | ||
| 37 | - | const ACCENT_FALLBACK = 'var(--dynamic-accent)'; | |
| 38 | - | ||
| 39 | - | interface CommandBadge { | |
| 40 | - | prefix: string; | |
| 41 | - | label: string; | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Build command badges dynamically from search shortcuts | |
| 45 | - | let COMMAND_BADGES = $derived( | |
| 46 | - | Object.entries(searchShortcuts).map(([key, shortcut]) => ({ | |
| 47 | - | prefix: `${key}:`, | |
| 48 | - | label: shortcut.description || key.toUpperCase() | |
| 49 | - | })) | |
| 50 | - | ); | |
| 51 | - | ||
| 52 | - | const BADGE_LEFT_OFFSET = '64px'; | |
| 53 | - | ||
| 54 | - | function resolveAccentColor(): string { | |
| 55 | - | if (!inputElement) return ACCENT_FALLBACK; | |
| 56 | - | const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent'); | |
| 57 | - | if (inlineAccent && inlineAccent.trim()) { | |
| 58 | - | return inlineAccent.trim(); | |
| 59 | - | } | |
| 60 | - | try { | |
| 61 | - | const computedAccent = getComputedStyle(inputElement).getPropertyValue('--dynamic-accent'); | |
| 62 | - | if (computedAccent && computedAccent.trim()) { | |
| 63 | - | return computedAccent.trim(); | |
| 64 | - | } | |
| 65 | - | } catch { /* no-op */ } | |
| 66 | - | return ACCENT_FALLBACK; | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // Computed placeholder | |
| 70 | - | let placeholder = $derived( | |
| 71 | - | mode === 'find' ? 'Find in page…' : 'Type URL or search…' | |
| 72 | - | ); | |
| 73 | - | ||
| 74 | - | const DEFAULT_PADDING: [string, string, string, string] = ['10px', '12px', '10px', '12px']; | |
| 75 | - | ||
| 76 | - | function normalizePadding(padding?: string): [string, string, string, string] { | |
| 77 | - | if (!padding) return DEFAULT_PADDING; | |
| 78 | - | const parts = padding.trim().split(/\s+/).filter(Boolean); | |
| 79 | - | if (parts.length === 0) return DEFAULT_PADDING; | |
| 80 | - | const p0 = parts[0] ?? DEFAULT_PADDING[0]; | |
| 81 | - | const p1 = parts[1] ?? p0; | |
| 82 | - | const p2 = parts[2] ?? p0; | |
| 83 | - | const p3 = parts[3] ?? p1; | |
| 84 | - | ||
| 85 | - | if (parts.length === 1) return [p0, p0, p0, p0]; | |
| 86 | - | if (parts.length === 2) return [p0, p1, p0, p1]; | |
| 87 | - | if (parts.length === 3) return [p0, p1, p2, p1]; | |
| 88 | - | return [p0, p1, p2, p3]; | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | let activeBadge = $state<CommandBadge | null>(null); | |
| 92 | - | // Ghost text visibility - only show when cursor is at end and no command badge active | |
| 93 | - | let showGhostText = $derived( | |
| 94 | - | mode === 'omnibox' && | |
| 95 | - | inlineCompletion && | |
| 96 | - | cursorAtEnd && | |
| 97 | - | inputValue.length > 0 && | |
| 98 | - | !activeBadge | |
| 99 | - | ); | |
| 100 | - | let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING); | |
| 101 | - | let inputPadding = $state(DEFAULT_PADDING.join(' ')); | |
| 102 | - | let baseLeftPadding = $state(DEFAULT_PADDING[3]); | |
| 103 | - | let inputFontSize = $derived(responsiveStyles?.fontSize || '16px'); | |
| 104 | - | ||
| 105 | - | $effect(() => { | |
| 106 | - | basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding); | |
| 107 | - | }); | |
| 108 | - | ||
| 109 | - | // Debug: log when inline suggestion state changes | |
| 110 | - | $effect(() => { | |
| 111 | - | console.log('[INLINE] State changed:', { | |
| 112 | - | inlineCompletion, | |
| 113 | - | inlineSuggestion, | |
| 114 | - | showGhostText, | |
| 115 | - | cursorAtEnd, | |
| 116 | - | mode, | |
| 117 | - | inputValueLength: inputValue?.length, | |
| 118 | - | activeBadge: !!activeBadge | |
| 119 | - | }); | |
| 120 | - | }); | |
| 121 | - | ||
| 122 | - | $effect(() => { | |
| 123 | - | const value = inputValue || ''; | |
| 124 | - | const trimmedValue = value.trimStart(); | |
| 125 | - | activeBadge = COMMAND_BADGES.find(({ prefix }) => trimmedValue.startsWith(prefix)) || null; | |
| 126 | - | }); | |
| 127 | - | ||
| 128 | - | $effect(() => { | |
| 129 | - | const [top, right, bottom, left] = basePaddingSegments; | |
| 130 | - | baseLeftPadding = left; | |
| 131 | - | const leftPadding = activeBadge ? `calc(${left} + ${BADGE_LEFT_OFFSET})` : left; | |
| 132 | - | inputPadding = `${top} ${right} ${bottom} ${leftPadding}`; | |
| 133 | - | }); | |
| 134 | - | ||
| 135 | - | // Track cursor position for ghost text visibility | |
| 136 | - | function updateCursorPosition() { | |
| 137 | - | if (inputElement) { | |
| 138 | - | cursorAtEnd = inputElement.selectionStart === inputValue.length && | |
| 139 | - | inputElement.selectionEnd === inputValue.length; | |
| 140 | - | } | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | // Accept inline suggestion (full or word-by-word) | |
| 144 | - | function acceptInlineSuggestion(acceptMode: 'full' | 'word') { | |
| 145 | - | if (!inlineSuggestion || !inlineCompletion) return; | |
| 146 | - | ||
| 147 | - | switch (acceptMode) { | |
| 148 | - | case 'full': | |
| 149 | - | // Accept entire suggestion | |
| 150 | - | omniboxStore.setInputValue(inlineSuggestion); | |
| 151 | - | omniboxStore.clearInlineSuggestion(); | |
| 152 | - | // Query for next prefix match | |
| 153 | - | debouncedPrefixQuery(inlineSuggestion); | |
| 154 | - | // Also update the search results | |
| 155 | - | debouncedQuery(inlineSuggestion); | |
| 156 | - | break; | |
| 157 | - | ||
| 158 | - | case 'word': | |
| 159 | - | // Accept next word at cursor position | |
| 160 | - | const cursorPos = inputElement?.selectionStart ?? inputValue.length; | |
| 161 | - | const remainingCompletion = inlineSuggestion.slice(cursorPos); | |
| 162 | - | ||
| 163 | - | // Find next word boundary (handles /, ., -, _ as boundaries) | |
| 164 | - | const wordMatch = remainingCompletion.match(/^([^\s\/\.\-\_]+[\s\/\.\-\_]?)/); | |
| 165 | - | if (wordMatch && wordMatch[1]) { | |
| 166 | - | const wordToInsert = wordMatch[1]; | |
| 167 | - | const newValue = inputValue.slice(0, cursorPos) + wordToInsert + inputValue.slice(cursorPos); | |
| 168 | - | const newCursorPos = cursorPos + wordToInsert.length; | |
| 169 | - | ||
| 170 | - | omniboxStore.setInputValue(newValue); | |
| 171 | - | ||
| 172 | - | requestAnimationFrame(() => { | |
| 173 | - | if (inputElement) { | |
| 174 | - | inputElement.setSelectionRange(newCursorPos, newCursorPos); | |
| 175 | - | updateCursorPosition(); | |
| 176 | - | } | |
| 177 | - | }); | |
| 178 | - | ||
| 179 | - | debouncedPrefixQuery(newValue); | |
| 180 | - | debouncedQuery(newValue); | |
| 181 | - | } | |
| 182 | - | break; | |
| 183 | - | } | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | // Handle input changes | |
| 187 | - | function handleInput(event: Event) { | |
| 188 | - | const target = event.target as HTMLInputElement; | |
| 189 | - | const value = target.value || ''; | |
| 190 | - | ||
| 191 | - | omniboxStore.setInputValue(value); | |
| 192 | - | omniboxStore.setFaded(false); | |
| 193 | - | updateCursorPosition(); | |
| 194 | - | ||
| 195 | - | if (mode === 'omnibox') { | |
| 196 | - | // Only query backend if we're in history view | |
| 197 | - | // In favorites view, filtering happens locally via computed state | |
| 198 | - | if (viewMode === 'history') { | |
| 199 | - | if (value === '') { | |
| 200 | - | // Input is empty - fetch initial history based on config | |
| 201 | - | omniboxBridge.fetchInitialHistory(); | |
| 202 | - | omniboxStore.clearInlineSuggestion(); | |
| 203 | - | } else { | |
| 204 | - | // Input has content - perform search | |
| 205 | - | debouncedQuery(value); | |
| 206 | - | // Also query for inline suggestion (fish-style ghost text) | |
| 207 | - | debouncedPrefixQuery(value); | |
| 208 | - | } | |
| 209 | - | } | |
| 210 | - | } else if (mode === 'find') { | |
| 211 | - | // Debounced find with minimum length to avoid freeze on first letter | |
| 212 | - | debouncedFind(value, findInPage); | |
| 213 | - | } | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | // Handle key events | |
| 217 | - | function handleKeyDown(event: KeyboardEvent) { | |
| 218 | - | switch (event.key) { | |
| 219 | - | case 'Escape': | |
| 220 | - | event.preventDefault(); | |
| 221 | - | event.stopPropagation(); | |
| 222 | - | ||
| 223 | - | if (inputValue && inputValue.trim() !== '') { | |
| 224 | - | // Input has text - clear it but keep omnibox open | |
| 225 | - | omniboxStore.setInputValue(''); | |
| 226 | - | ||
| 227 | - | // In find mode, clear the search highlights | |
| 228 | - | if (mode === 'find') { | |
| 229 | - | findInPage(''); | |
| 230 | - | } | |
| 231 | - | } else { | |
| 232 | - | // Input is empty - close omnibox immediately | |
| 233 | - | omniboxStore.close(); | |
| 234 | - | } | |
| 235 | - | break; | |
| 236 | - | ||
| 237 | - | case 'Enter': | |
| 238 | - | event.preventDefault(); | |
| 239 | - | event.stopPropagation(); | |
| 240 | - | handleEnterKey(event); | |
| 241 | - | break; | |
| 242 | - | ||
| 243 | - | case 'Tab': | |
| 244 | - | // Only handle Tab in omnibox mode (not in find mode) | |
| 245 | - | if (mode === 'omnibox') { | |
| 246 | - | event.preventDefault(); | |
| 247 | - | event.stopPropagation(); | |
| 248 | - | // Toggle between history and favorites views | |
| 249 | - | const newViewMode = viewMode === 'history' ? 'favorites' : 'history'; | |
| 250 | - | omniboxStore.setViewMode(newViewMode); | |
| 251 | - | } | |
| 252 | - | break; | |
| 253 | - | ||
| 254 | - | case ' ': | |
| 255 | - | // Only handle Space in omnibox mode when an item is selected | |
| 256 | - | if (mode === 'omnibox' && selectedIndex >= 0) { | |
| 257 | - | event.preventDefault(); | |
| 258 | - | event.stopPropagation(); | |
| 259 | - | handleSpaceKey(); | |
| 260 | - | } | |
| 261 | - | break; | |
| 262 | - | ||
| 263 | - | case 'ArrowDown': | |
| 264 | - | case 'ArrowUp': | |
| 265 | - | event.preventDefault(); | |
| 266 | - | event.stopPropagation(); | |
| 267 | - | handleArrowKeys(event.key); | |
| 268 | - | break; | |
| 269 | - | ||
| 270 | - | case 'ArrowRight': | |
| 271 | - | // Handle inline suggestion acceptance (fish-style) | |
| 272 | - | if (mode === 'omnibox' && inlineCompletion) { | |
| 273 | - | if (event.ctrlKey || event.metaKey) { | |
| 274 | - | // Ctrl+Right: accept next word | |
| 275 | - | event.preventDefault(); | |
| 276 | - | event.stopPropagation(); | |
| 277 | - | acceptInlineSuggestion('word'); | |
| 278 | - | } else if (cursorAtEnd) { | |
| 279 | - | // Right at end: accept full suggestion | |
| 280 | - | event.preventDefault(); | |
| 281 | - | event.stopPropagation(); | |
| 282 | - | acceptInlineSuggestion('full'); | |
| 283 | - | } | |
| 284 | - | // If cursor not at end and no modifier, let default behavior happen | |
| 285 | - | } | |
| 286 | - | break; | |
| 287 | - | ||
| 288 | - | case 'y': | |
| 289 | - | // Ctrl+Y: accept full inline suggestion (alternative to Right Arrow) | |
| 290 | - | if (event.ctrlKey && mode === 'omnibox' && inlineCompletion) { | |
| 291 | - | event.preventDefault(); | |
| 292 | - | event.stopPropagation(); | |
| 293 | - | acceptInlineSuggestion('full'); | |
| 294 | - | } | |
| 295 | - | break; | |
| 296 | - | ||
| 297 | - | default: | |
| 298 | - | // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results | |
| 299 | - | // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.) | |
| 300 | - | if (event.ctrlKey && mode === 'omnibox') { | |
| 301 | - | const code = event.code; | |
| 302 | - | let targetIndex = -1; | |
| 303 | - | ||
| 304 | - | // Map physical keys: Digit1->0, Digit2->1, ..., Digit9->8, Digit0->9 | |
| 305 | - | if (code >= 'Digit1' && code <= 'Digit9') { | |
| 306 | - | targetIndex = parseInt(code.substring(5), 10) - 1; | |
| 307 | - | } else if (code === 'Digit0') { | |
| 308 | - | targetIndex = 9; | |
| 309 | - | } | |
| 310 | - | ||
| 311 | - | if (targetIndex >= 0) { | |
| 312 | - | event.preventDefault(); | |
| 313 | - | event.stopPropagation(); | |
| 314 | - | handleNumberKey(targetIndex); | |
| 315 | - | return; | |
| 316 | - | } | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | // For normal typing keys, only stop propagation to prevent page handlers | |
| 320 | - | // but don't prevent default so typing still works in the input | |
| 321 | - | event.stopPropagation(); | |
| 322 | - | // Any other key should restore full opacity | |
| 323 | - | omniboxStore.setFaded(false); | |
| 324 | - | break; | |
| 325 | - | } | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | // Handle Enter key based on mode | |
| 329 | - | function handleEnterKey(event: KeyboardEvent) { | |
| 330 | - | if (mode === 'omnibox') { | |
| 331 | - | // Get selected item from current view (history or favorites) | |
| 332 | - | const currentList = viewMode === 'history' ? suggestions : favorites; | |
| 333 | - | const selectedItem = selectedIndex >= 0 ? currentList[selectedIndex] : null; | |
| 334 | - | const url = selectedItem?.url || inputValue || ''; | |
| 335 | - | ||
| 336 | - | if (url) { | |
| 337 | - | omniboxBridge.navigate(url); | |
| 338 | - | omniboxStore.close(); | |
| 339 | - | } | |
| 340 | - | } else if (mode === 'find') { | |
| 341 | - | if (event.shiftKey) { | |
| 342 | - | // Shift+Enter: jump to previous match | |
| 343 | - | jumpToMatch(-1); | |
| 344 | - | } else if (event.altKey) { | |
| 345 | - | // Alt+Enter: center on current match but keep overlay open | |
| 346 | - | revealCurrentMatch(); | |
| 347 | - | } else { | |
| 348 | - | // Enter: center and close | |
| 349 | - | revealCurrentMatch(); | |
| 350 | - | omniboxStore.close(); | |
| 351 | - | } | |
| 352 | - | } | |
| 353 | - | } | |
| 354 | - | ||
| 355 | - | // Handle Space key to toggle favorite | |
| 356 | - | function handleSpaceKey() { | |
| 357 | - | if (mode !== 'omnibox' || selectedIndex < 0) return; | |
| 358 | - | ||
| 359 | - | // Get the selected item based on current view mode | |
| 360 | - | const item = viewMode === 'history' | |
| 361 | - | ? suggestions[selectedIndex] | |
| 362 | - | : favorites[selectedIndex]; | |
| 363 | - | ||
| 364 | - | if (!item || !item.url) return; | |
| 365 | - | ||
| 366 | - | // Extract title and favicon with proper type narrowing | |
| 367 | - | const title = 'title' in item ? item.title : ''; | |
| 368 | - | const faviconURL = 'favicon_url' in item ? item.favicon_url : ('favicon' in item ? item.favicon : '') ?? ''; | |
| 369 | - | ||
| 370 | - | // Toggle the favorite status | |
| 371 | - | omniboxBridge.toggleFavorite(item.url, title, faviconURL); | |
| 372 | - | ||
| 373 | - | // Refresh the favorites list after toggling | |
| 374 | - | setTimeout(() => { | |
| 375 | - | omniboxBridge.fetchFavorites(); | |
| 376 | - | }, 100); | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | // Handle Ctrl+Number key for quick navigation | |
| 380 | - | function handleNumberKey(targetIndex: number) { | |
| 381 | - | if (mode !== 'omnibox') return; | |
| 382 | - | ||
| 383 | - | // Get the current list based on view mode | |
| 384 | - | const currentList = viewMode === 'history' ? suggestions : favorites; | |
| 385 | - | ||
| 386 | - | // Check if the target index is valid | |
| 387 | - | if (targetIndex >= currentList.length) return; | |
| 388 | - | ||
| 389 | - | const item = currentList[targetIndex]; | |
| 390 | - | if (!item || !item.url) return; | |
| 391 | - | ||
| 392 | - | // Navigate to the item and close omnibox | |
| 393 | - | omniboxBridge.navigate(item.url); | |
| 394 | - | omniboxStore.close(); | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | // Handle arrow key navigation | |
| 398 | - | function handleArrowKeys(key: string) { | |
| 399 | - | let totalItems: number; | |
| 400 | - | if (mode === 'omnibox') { | |
| 401 | - | // Get count from current view (history or favorites) | |
| 402 | - | totalItems = viewMode === 'history' ? suggestions.length : favorites.length; | |
| 403 | - | } else { | |
| 404 | - | totalItems = matches.length; | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | if (totalItems > 0) { | |
| 408 | - | if (key === 'ArrowDown') { | |
| 409 | - | omniboxStore.selectNext(); | |
| 410 | - | } else { | |
| 411 | - | omniboxStore.selectPrevious(); | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | // Fade overlay while navigating results only for find mode | |
| 415 | - | if (mode === 'find') { | |
| 416 | - | omniboxStore.setFaded(true); | |
| 417 | - | } else { | |
| 418 | - | omniboxStore.setFaded(false); | |
| 419 | - | } | |
| 420 | - | ||
| 421 | - | // For find mode, reveal the current match | |
| 422 | - | if (mode === 'find') { | |
| 423 | - | revealCurrentMatch(); | |
| 424 | - | } | |
| 425 | - | } | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | // Jump to match (for find mode) | |
| 429 | - | function jumpToMatch(delta: number) { | |
| 430 | - | if (mode !== 'find' || matches.length === 0) return; | |
| 431 | - | ||
| 432 | - | const currentIndex = selectedIndex || 0; | |
| 433 | - | const newIndex = ((currentIndex + delta) % matches.length + matches.length) % matches.length; | |
| 434 | - | ||
| 435 | - | omniboxStore.setSelectedIndex(newIndex); | |
| 436 | - | revealCurrentMatch(); | |
| 437 | - | } | |
| 438 | - | ||
| 439 | - | // Reveal current match (for find mode) | |
| 440 | - | function revealCurrentMatch() { | |
| 441 | - | if (mode !== 'find' || selectedIndex < 0 || !matches[selectedIndex]) return; | |
| 442 | - | ||
| 443 | - | // Remove previous active class | |
| 444 | - | const prevMatch = matches[omniboxStore.activeIndex]; | |
| 445 | - | if (prevMatch?.element?.classList) { | |
| 446 | - | prevMatch.element.classList.remove('find-active'); | |
| 447 | - | } | |
| 448 | - | ||
| 449 | - | // Add active class to current match | |
| 450 | - | const currentMatch = matches[selectedIndex]; | |
| 451 | - | if (currentMatch?.element?.classList) { | |
| 452 | - | currentMatch.element.classList.add('find-active'); | |
| 453 | - | omniboxStore.setActiveIndex(selectedIndex); | |
| 454 | - | ||
| 455 | - | // Scroll into view | |
| 456 | - | try { | |
| 457 | - | currentMatch.element.scrollIntoView({ | |
| 458 | - | block: 'center', | |
| 459 | - | inline: 'nearest' | |
| 460 | - | }); | |
| 461 | - | } catch { | |
| 462 | - | currentMatch.element.scrollIntoView(); | |
| 463 | - | } | |
| 464 | - | } | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | // Handle mouse events | |
| 468 | - | function handleMouseDown() { | |
| 469 | - | omniboxStore.setFaded(false); | |
| 470 | - | } | |
| 471 | - | ||
| 472 | - | function handleFocus() { | |
| 473 | - | omniboxStore.setFaded(false); | |
| 474 | - | if (inputElement) { | |
| 475 | - | const accent = resolveAccentColor(); | |
| 476 | - | inputElement.style.setProperty('--dumber-input-border-color', accent); | |
| 477 | - | } | |
| 478 | - | } | |
| 479 | - | ||
| 480 | - | function handleBlur() { | |
| 481 | - | if (!inputElement) return; | |
| 482 | - | const base = inputElement.style.getPropertyValue('--dumber-input-border-base'); | |
| 483 | - | const fallback = (base && base.trim()) || 'var(--dynamic-border)'; | |
| 484 | - | inputElement.style.setProperty('--dumber-input-border-color', fallback); | |
| 485 | - | } | |
| 486 | - | ||
| 487 | - | function handleMouseEnter() { | |
| 488 | - | if (omniboxStore.visible && inputElement) { | |
| 489 | - | try { | |
| 490 | - | inputElement.focus({ preventScroll: true }); | |
| 491 | - | inputElement.select(); | |
| 492 | - | } catch { | |
| 493 | - | inputElement.focus(); | |
| 494 | - | } | |
| 495 | - | } | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | // Handle selection changes (for cursor tracking) | |
| 499 | - | function handleSelect() { | |
| 500 | - | updateCursorPosition(); | |
| 501 | - | } | |
| 502 | - | ||
| 503 | - | // Handle click with cursor tracking | |
| 504 | - | function handleClickWithCursor() { | |
| 505 | - | omniboxStore.setFaded(false); | |
| 506 | - | updateCursorPosition(); | |
| 507 | - | } | |
| 508 | - | </script> | |
| 509 | - | ||
| 510 | - | <div class="omnibox-input-wrapper"> | |
| 511 | - | {#if activeBadge} | |
| 512 | - | <span | |
| 513 | - | class="omnibox-prefix-badge" | |
| 514 | - | aria-hidden="true" | |
| 515 | - | style={`left: ${baseLeftPadding};`} | |
| 516 | - | >{activeBadge.label}</span> | |
| 517 | - | {/if} | |
| 518 | - | ||
| 519 | - | <!-- Ghost text overlay (fish-style inline suggestion) --> | |
| 520 | - | {#if showGhostText} | |
| 521 | - | <div | |
| 522 | - | class="omnibox-ghost-text" | |
| 523 | - | aria-hidden="true" | |
| 524 | - | style={`padding: ${inputPadding}; font-size: ${inputFontSize};`} | |
| 525 | - | > | |
| 526 | - | <span class="ghost-spacer">{inputValue}</span><span class="ghost-completion">{inlineCompletion}</span> | |
| 527 | - | </div> | |
| 528 | - | {/if} | |
| 529 | - | ||
| 530 | - | <input | |
| 531 | - | bind:this={inputElement} | |
| 532 | - | type="text" | |
| 533 | - | {placeholder} | |
| 534 | - | value={inputValue} | |
| 535 | - | class="w-full box-border omnibox-input omnibox-input-field focus:outline-none" | |
| 536 | - | class:has-ghost-text={showGhostText} | |
| 537 | - | style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`} | |
| 538 | - | oninput={handleInput} | |
| 539 | - | onkeydown={handleKeyDown} | |
| 540 | - | onmousedown={handleMouseDown} | |
| 541 | - | onclick={handleClickWithCursor} | |
| 542 | - | onselect={handleSelect} | |
| 543 | - | onfocus={handleFocus} | |
| 544 | - | onblur={handleBlur} | |
| 545 | - | onmouseenter={handleMouseEnter} | |
| 546 | - | autocomplete="off" | |
| 547 | - | spellcheck="false" | |
| 548 | - | role="combobox" | |
| 549 | - | aria-expanded={omniboxStore.hasContent} | |
| 550 | - | aria-controls="omnibox-list" | |
| 551 | - | aria-haspopup="listbox" | |
| 552 | - | aria-owns="omnibox-list" | |
| 553 | - | aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined} | |
| 554 | - | /> | |
| 555 | - | </div> | |
| 556 | - | ||
| 557 | - | <style> | |
| 558 | - | .omnibox-input-wrapper { | |
| 559 | - | position: relative; | |
| 560 | - | } | |
| 561 | - | ||
| 562 | - | .omnibox-input-field { | |
| 563 | - | background: var(--dumber-input-bg, var(--dynamic-bg)); | |
| 564 | - | color: var(--dynamic-text); | |
| 565 | - | border: 1px solid var(--dumber-input-border-color, var(--dynamic-border)); | |
| 566 | - | border-radius: 2px; | |
| 567 | - | box-shadow: | |
| 568 | - | inset 0 1px 2px rgba(0, 0, 0, 0.15), | |
| 569 | - | inset 0 0 0 1px rgba(0, 0, 0, 0.03); | |
| 570 | - | transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease; | |
| 571 | - | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |
| 572 | - | letter-spacing: normal; | |
| 573 | - | } | |
| 574 | - | ||
| 575 | - | .omnibox-input-field::placeholder { | |
| 576 | - | color: var(--dynamic-muted); | |
| 577 | - | letter-spacing: normal; | |
| 578 | - | } | |
| 579 | - | ||
| 580 | - | .omnibox-input-field:focus { | |
| 581 | - | color: var(--dynamic-text); | |
| 582 | - | border-color: var(--dynamic-accent); | |
| 583 | - | box-shadow: | |
| 584 | - | inset 0 1px 2px rgba(0, 0, 0, 0.15), | |
| 585 | - | 0 0 0 1px var(--dynamic-accent); | |
| 586 | - | } | |
| 587 | - | ||
| 588 | - | .omnibox-prefix-badge { | |
| 589 | - | position: absolute; | |
| 590 | - | top: 50%; | |
| 591 | - | transform: translateY(-50%); | |
| 592 | - | padding: 0.2rem 0.6rem; | |
| 593 | - | border-radius: 0; | |
| 594 | - | border: 1px solid var(--dynamic-border, transparent); | |
| 595 | - | background: var(--dumber-input-badge-bg, var(--dynamic-accent)); | |
| 596 | - | color: var(--dumber-input-badge-text, var(--dynamic-bg)); | |
| 597 | - | font-size: 0.75rem; | |
| 598 | - | font-weight: 600; | |
| 599 | - | display: inline-flex; | |
| 600 | - | align-items: center; | |
| 601 | - | gap: 4px; | |
| 602 | - | line-height: 1; | |
| 603 | - | pointer-events: none; | |
| 604 | - | white-space: nowrap; | |
| 605 | - | box-shadow: 0 0 0 1px var(--dynamic-border, transparent); | |
| 606 | - | z-index: 1; | |
| 607 | - | } | |
| 608 | - | ||
| 609 | - | /* Ghost text overlay for fish-style inline suggestions */ | |
| 610 | - | .omnibox-ghost-text { | |
| 611 | - | position: absolute; | |
| 612 | - | top: 0; | |
| 613 | - | left: 0; | |
| 614 | - | right: 0; | |
| 615 | - | bottom: 0; | |
| 616 | - | pointer-events: none; | |
| 617 | - | display: flex; | |
| 618 | - | align-items: center; | |
| 619 | - | white-space: nowrap; | |
| 620 | - | overflow: hidden; | |
| 621 | - | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |
| 622 | - | letter-spacing: normal; | |
| 623 | - | z-index: 0; | |
| 624 | - | box-sizing: border-box; | |
| 625 | - | border: 1px solid transparent; | |
| 626 | - | } | |
| 627 | - | ||
| 628 | - | .ghost-spacer { | |
| 629 | - | visibility: hidden; | |
| 630 | - | white-space: pre; | |
| 631 | - | } | |
| 632 | - | ||
| 633 | - | .ghost-completion { | |
| 634 | - | color: var(--dynamic-muted); | |
| 635 | - | opacity: 0.5; | |
| 636 | - | white-space: pre; | |
| 637 | - | transition: opacity 100ms ease-out; | |
| 638 | - | } | |
| 639 | - | ||
| 640 | - | .omnibox-ghost-text { | |
| 641 | - | transition: opacity 80ms ease-out; | |
| 642 | - | } | |
| 643 | - | ||
| 644 | - | /* Make input transparent when showing ghost text */ | |
| 645 | - | .omnibox-input-field.has-ghost-text { | |
| 646 | - | background: transparent !important; | |
| 647 | - | } | |
| 648 | - | ||
| 649 | - | /* Ensure wrapper provides background when ghost text is active */ | |
| 650 | - | .omnibox-input-wrapper:has(.has-ghost-text) { | |
| 651 | - | background: var(--dumber-input-bg, var(--dynamic-bg)); | |
| 652 | - | border-radius: 2px; | |
| 653 | - | } | |
| 654 | - | </style> | |
D webui/src/components/omnibox/OmniboxSuggestions.svelte +0 -396
| @@ -1,396 +0,0 @@ | |||
| 1 | - | <!-- | |
| 2 | - | Omnibox Suggestions Component | |
| 3 | - | ||
| 4 | - | Displays search suggestions with favicons and URL highlighting | |
| 5 | - | --> | |
| 6 | - | <script lang="ts"> | |
| 7 | - | import { omniboxStore } from './stores.svelte.ts'; | |
| 8 | - | import { omniboxBridge } from './messaging'; | |
| 9 | - | ||
| 10 | - | // Reactive state | |
| 11 | - | let suggestions = $derived(omniboxStore.suggestions); | |
| 12 | - | let favorites = $derived(omniboxStore.favorites); | |
| 13 | - | let viewMode = $derived(omniboxStore.viewMode); | |
| 14 | - | let selectedIndex = $derived(omniboxStore.selectedIndex); | |
| 15 | - | let inputValue = $derived(omniboxStore.inputValue); | |
| 16 | - | let hasContent = $state(false); | |
| 17 | - | ||
| 18 | - | // Debug effect to log favorites changes | |
| 19 | - | $effect(() => { | |
| 20 | - | console.log('[OmniboxSuggestions] Favorites updated, count:', favorites.length); | |
| 21 | - | }); | |
| 22 | - | ||
| 23 | - | // Computed: Get current list based on view mode with local filtering for favorites | |
| 24 | - | let currentList = $derived.by(() => { | |
| 25 | - | const list = viewMode === 'history' ? suggestions : favorites; | |
| 26 | - | ||
| 27 | - | // In favorites view with input, filter locally | |
| 28 | - | if (viewMode === 'favorites' && inputValue && inputValue.trim()) { | |
| 29 | - | const query = inputValue.toLowerCase().trim(); | |
| 30 | - | return list.filter(item => | |
| 31 | - | item.url.toLowerCase().includes(query) || | |
| 32 | - | ('title' in item && item.title && item.title.toLowerCase().includes(query)) | |
| 33 | - | ); | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | return list; | |
| 37 | - | }); | |
| 38 | - | ||
| 39 | - | // Update hasContent - always show in omnibox mode to display header | |
| 40 | - | $effect(() => { | |
| 41 | - | hasContent = omniboxStore.mode === 'omnibox'; | |
| 42 | - | }); | |
| 43 | - | ||
| 44 | - | // Check if a URL is in favorites | |
| 45 | - | function isFavorited(url: string): boolean { | |
| 46 | - | return favorites.some(fav => fav.url === url); | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Handle suggestion item mouse enter | |
| 50 | - | function handleItemMouseEnter(index: number) { | |
| 51 | - | omniboxStore.setSelectedIndex(index); | |
| 52 | - | omniboxStore.setFaded(false); | |
| 53 | - | scrollToSelection(); | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // Handle suggestion item click | |
| 57 | - | function handleItemClick(suggestion: any) { | |
| 58 | - | omniboxBridge.navigate(suggestion.url); | |
| 59 | - | omniboxStore.close(); | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // Handle keyboard activation on suggestion item | |
| 63 | - | function handleItemKeyDown(event: KeyboardEvent, suggestion: any) { | |
| 64 | - | if (event.key === 'Enter' || event.key === ' ') { | |
| 65 | - | event.preventDefault(); | |
| 66 | - | handleItemClick(suggestion); | |
| 67 | - | } | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // Scroll list to show selected item | |
| 71 | - | function scrollToSelection() { | |
| 72 | - | const selectedItem = document.getElementById(`omnibox-item-${selectedIndex}`); | |
| 73 | - | if (selectedItem && selectedItem.scrollIntoView) { | |
| 74 | - | try { | |
| 75 | - | selectedItem.scrollIntoView({ block: 'nearest' }); | |
| 76 | - | } catch { | |
| 77 | - | selectedItem.scrollIntoView(); | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | // Parse URL for display | |
| 83 | - | function parseUrl(url: string) { | |
| 84 | - | try { | |
| 85 | - | const urlObj = new URL(url, window.location.href); | |
| 86 | - | return { | |
| 87 | - | domain: urlObj.hostname, | |
| 88 | - | path: (urlObj.pathname || '') + (urlObj.search || '') + (urlObj.hash || '') | |
| 89 | - | }; | |
| 90 | - | } catch { | |
| 91 | - | return { | |
| 92 | - | domain: url || '', | |
| 93 | - | path: '' | |
| 94 | - | }; | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | // Handle favicon error | |
| 99 | - | function handleFaviconError(event: Event) { | |
| 100 | - | const target = event.target as HTMLImageElement; | |
| 101 | - | const chip = target.parentElement; | |
| 102 | - | if (chip) { | |
| 103 | - | chip.style.display = 'none'; | |
| 104 | - | } | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | // Watch for selection changes to scroll | |
| 108 | - | $effect(() => { | |
| 109 | - | if (selectedIndex >= 0) { | |
| 110 | - | scrollToSelection(); | |
| 111 | - | } | |
| 112 | - | }); | |
| 113 | - | </script> | |
| 114 | - | ||
| 115 | - | {#if hasContent} | |
| 116 | - | <div | |
| 117 | - | id="omnibox-list" | |
| 118 | - | class="suggestion-list" | |
| 119 | - | role="listbox" | |
| 120 | - | aria-label="Search suggestions" | |
| 121 | - | > | |
| 122 | - | <!-- View mode indicator --> | |
| 123 | - | <div class="view-mode-header"> | |
| 124 | - | <span class={viewMode === 'history' ? 'view-tab active' : 'view-tab'}> | |
| 125 | - | History ({suggestions.length}) | |
| 126 | - | </span> | |
| 127 | - | <span class={viewMode === 'favorites' ? 'view-tab active' : 'view-tab'}> | |
| 128 | - | Favorites ({favorites.length}) | |
| 129 | - | </span> | |
| 130 | - | <span class="view-hint">Tab to switch</span> | |
| 131 | - | </div> | |
| 132 | - | ||
| 133 | - | {#if currentList.length === 0} | |
| 134 | - | <!-- Empty state --> | |
| 135 | - | {#if viewMode === 'favorites'} | |
| 136 | - | <!-- Always show empty state for favorites --> | |
| 137 | - | <div class="empty-state"> | |
| 138 | - | <span class="empty-icon">⭐</span> | |
| 139 | - | <span class="empty-text">No favorites yet</span> | |
| 140 | - | <span class="empty-hint">Press Space on any item in History to add it here</span> | |
| 141 | - | </div> | |
| 142 | - | {:else if inputValue && inputValue.trim()} | |
| 143 | - | <!-- Only show "No results found" for history when user has typed something --> | |
| 144 | - | <div class="empty-state"> | |
| 145 | - | <span class="empty-icon">🔍</span> | |
| 146 | - | <span class="empty-text">No results found</span> | |
| 147 | - | </div> | |
| 148 | - | {/if} | |
| 149 | - | {:else} | |
| 150 | - | {#each currentList as item, index (item.url)} | |
| 151 | - | {@const { domain, path } = parseUrl(item.url)} | |
| 152 | - | {@const isSelected = index === selectedIndex} | |
| 153 | - | {@const favicon = ('favicon' in item ? item.favicon : '') || ('favicon_url' in item ? item.favicon_url : '') || ''} | |
| 154 | - | {@const isFav = viewMode === 'history' && isFavorited(item.url)} | |
| 155 | - | {@const shortcutNumber = index < 9 ? index + 1 : index === 9 ? 0 : null} | |
| 156 | - | ||
| 157 | - | <div | |
| 158 | - | id="omnibox-item-{index}" | |
| 159 | - | class:suggestion-item={true} | |
| 160 | - | class:selected={isSelected} | |
| 161 | - | class:favorited={isFav} | |
| 162 | - | role="option" | |
| 163 | - | tabindex={isSelected ? 0 : -1} | |
| 164 | - | aria-selected={isSelected} | |
| 165 | - | onmouseenter={() => handleItemMouseEnter(index)} | |
| 166 | - | onclick={() => handleItemClick(item)} | |
| 167 | - | onkeydown={(e) => handleItemKeyDown(e, item)} | |
| 168 | - | > | |
| 169 | - | <!-- Favicon chip --> | |
| 170 | - | {#if favicon} | |
| 171 | - | <div class="suggestion-favicon"> | |
| 172 | - | <img | |
| 173 | - | src={favicon} | |
| 174 | - | alt="" | |
| 175 | - | class="suggestion-favicon-img" | |
| 176 | - | loading="lazy" | |
| 177 | - | onerror={handleFaviconError} | |
| 178 | - | /> | |
| 179 | - | </div> | |
| 180 | - | {/if} | |
| 181 | - | ||
| 182 | - | <!-- URL text with gradient fade --> | |
| 183 | - | <div class="suggestion-text"> | |
| 184 | - | <!-- Domain --> | |
| 185 | - | <span class="suggestion-domain"> | |
| 186 | - | {domain} | |
| 187 | - | </span> | |
| 188 | - | ||
| 189 | - | <!-- Separator --> | |
| 190 | - | <span class="suggestion-separator"> | |
| 191 | - | {' | '} | |
| 192 | - | </span> | |
| 193 | - | ||
| 194 | - | <!-- Path --> | |
| 195 | - | <span class="suggestion-path"> | |
| 196 | - | {path || '/'} | |
| 197 | - | </span> | |
| 198 | - | </div> | |
| 199 | - | ||
| 200 | - | <!-- Keyboard shortcut hint (Ctrl+1-9, Ctrl+0 for 10th) --> | |
| 201 | - | {#if shortcutNumber !== null} | |
| 202 | - | <div class="suggestion-shortcut-hint" aria-hidden="true"> | |
| 203 | - | {shortcutNumber} | |
| 204 | - | </div> | |
| 205 | - | {/if} | |
| 206 | - | </div> | |
| 207 | - | {/each} | |
| 208 | - | {/if} | |
| 209 | - | </div> | |
| 210 | - | {/if} | |
| 211 | - | ||
| 212 | - | ||
| 213 | - | ||
| 214 | - | <style> | |
| 215 | - | .suggestion-list { | |
| 216 | - | margin-top: 0.5rem; | |
| 217 | - | max-height: 50vh; | |
| 218 | - | overflow-y: auto; | |
| 219 | - | border-top: 1px solid var(--dynamic-border); | |
| 220 | - | background: var(--dynamic-bg); | |
| 221 | - | border-radius: 0 0 2px 2px; | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | .view-mode-header { | |
| 225 | - | display: flex; | |
| 226 | - | gap: 0.5rem; | |
| 227 | - | align-items: center; | |
| 228 | - | padding: 0.5rem 0.85rem; | |
| 229 | - | border-bottom: 1px solid var(--dynamic-border); | |
| 230 | - | background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-text)); | |
| 231 | - | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | .view-tab { | |
| 235 | - | padding: 0.25rem 0.75rem; | |
| 236 | - | border-radius: 2px; | |
| 237 | - | font-size: 0.85rem; | |
| 238 | - | font-weight: 500; | |
| 239 | - | color: var(--dynamic-muted); | |
| 240 | - | transition: all 100ms ease; | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | .view-tab.active { | |
| 244 | - | color: var(--dynamic-accent); | |
| 245 | - | background: color-mix(in srgb, var(--dynamic-accent) 15%, transparent); | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | .view-hint { | |
| 249 | - | margin-left: auto; | |
| 250 | - | font-size: 0.75rem; | |
| 251 | - | color: var(--dynamic-muted); | |
| 252 | - | opacity: 0.7; | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | .empty-state { | |
| 256 | - | display: flex; | |
| 257 | - | flex-direction: column; | |
| 258 | - | align-items: center; | |
| 259 | - | justify-content: center; | |
| 260 | - | padding: 2rem 1rem; | |
| 261 | - | gap: 0.5rem; | |
| 262 | - | color: var(--dynamic-muted); | |
| 263 | - | text-align: center; | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | .empty-icon { | |
| 267 | - | font-size: 2rem; | |
| 268 | - | opacity: 0.5; | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | .empty-text { | |
| 272 | - | font-size: 0.95rem; | |
| 273 | - | font-weight: 500; | |
| 274 | - | color: var(--dynamic-text); | |
| 275 | - | opacity: 0.7; | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | .empty-hint { | |
| 279 | - | font-size: 0.8rem; | |
| 280 | - | opacity: 0.6; | |
| 281 | - | max-width: 300px; | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | .suggestion-item { | |
| 285 | - | display: flex; | |
| 286 | - | gap: 0.5rem; | |
| 287 | - | align-items: center; | |
| 288 | - | padding: 0.5rem 0.75rem; | |
| 289 | - | border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent); | |
| 290 | - | cursor: pointer; | |
| 291 | - | transition: background-color 100ms ease, border-left-color 100ms ease; | |
| 292 | - | letter-spacing: normal; | |
| 293 | - | border-left: 3px solid transparent; | |
| 294 | - | position: relative; | |
| 295 | - | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | .suggestion-item:last-child { | |
| 299 | - | border-bottom: none; | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | /* Favorited items get a permanent yellow left border */ | |
| 303 | - | .suggestion-item.favorited { | |
| 304 | - | border-left-color: #f59e0b; | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | .suggestion-item.selected, | |
| 308 | - | .suggestion-item:hover, | |
| 309 | - | .suggestion-item:focus-visible { | |
| 310 | - | background: color-mix(in srgb, var(--dynamic-accent) 15%, var(--dynamic-bg)); | |
| 311 | - | border-left-color: var(--dynamic-accent); | |
| 312 | - | color: var(--dynamic-text); | |
| 313 | - | outline: none; | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | /* Keep yellow border even when selected/hover for favorited items */ | |
| 317 | - | .suggestion-item.favorited.selected, | |
| 318 | - | .suggestion-item.favorited:hover, | |
| 319 | - | .suggestion-item.favorited:focus-visible { | |
| 320 | - | border-left-color: #f59e0b; | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | .suggestion-item.selected { | |
| 324 | - | font-weight: 500; | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | .suggestion-item .suggestion-domain { | |
| 328 | - | color: var(--dynamic-text); | |
| 329 | - | opacity: 0.95; | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | .suggestion-item .suggestion-separator { | |
| 333 | - | color: var(--dynamic-muted); | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | .suggestion-item .suggestion-path { | |
| 337 | - | color: var(--dynamic-muted); | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | .suggestion-favicon { | |
| 341 | - | width: 16px; | |
| 342 | - | height: 16px; | |
| 343 | - | border: none; | |
| 344 | - | border-radius: 2px; | |
| 345 | - | display: flex; | |
| 346 | - | align-items: center; | |
| 347 | - | justify-content: center; | |
| 348 | - | background: transparent; | |
| 349 | - | flex-shrink: 0; | |
| 350 | - | overflow: hidden; | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | .suggestion-favicon-img { | |
| 354 | - | width: 16px; | |
| 355 | - | height: 16px; | |
| 356 | - | object-fit: contain; | |
| 357 | - | image-rendering: auto; | |
| 358 | - | } | |
| 359 | - | ||
| 360 | - | .suggestion-text { | |
| 361 | - | flex: 1; | |
| 362 | - | min-width: 0; | |
| 363 | - | display: flex; | |
| 364 | - | gap: 0.75rem; | |
| 365 | - | align-items: center; | |
| 366 | - | white-space: nowrap; | |
| 367 | - | overflow: hidden; | |
| 368 | - | text-overflow: ellipsis; | |
| 369 | - | mask-image: linear-gradient(90deg, black 85%, transparent 100%); | |
| 370 | - | -webkit-mask-image: linear-gradient(90deg, black 85%, transparent 100%); | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | .suggestion-shortcut-hint { | |
| 374 | - | position: absolute; | |
| 375 | - | right: 0.85rem; | |
| 376 | - | top: 50%; | |
| 377 | - | transform: translateY(-50%); | |
| 378 | - | font-size: 0.75rem; | |
| 379 | - | font-weight: 600; | |
| 380 | - | color: var(--dynamic-muted); | |
| 381 | - | opacity: 0.6; | |
| 382 | - | background: var(--dynamic-bg); | |
| 383 | - | padding: 0.25rem 0.5rem; | |
| 384 | - | border-radius: 3px; | |
| 385 | - | border: 1px solid var(--dynamic-border); | |
| 386 | - | z-index: 10; | |
| 387 | - | pointer-events: none; | |
| 388 | - | transition: opacity 100ms ease, color 100ms ease; | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | .suggestion-item.selected .suggestion-shortcut-hint, | |
| 392 | - | .suggestion-item:hover .suggestion-shortcut-hint { | |
| 393 | - | opacity: 0.9; | |
| 394 | - | color: var(--dynamic-accent); | |
| 395 | - | } | |
| 396 | - | </style> | |
D webui/src/components/omnibox/find.ts +0 -248
| @@ -1,248 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Find in Page Functionality | |
| 3 | - | * | |
| 4 | - | * Text search and highlighting implementation | |
| 5 | - | */ | |
| 6 | - | ||
| 7 | - | import type { FindMatch, HighlightNode } from "./types"; | |
| 8 | - | import { omniboxStore } from "./stores.svelte.ts"; | |
| 9 | - | ||
| 10 | - | // Track whether we've injected highlight styles into the main document. | |
| 11 | - | let findStylesInjected = false; | |
| 12 | - | ||
| 13 | - | function 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 | - | */ | |
| 47 | - | export 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 | - | */ | |
| 199 | - | export 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 | - | */ | |
| 235 | - | export 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 | - | } | |
D webui/src/components/omnibox/index.ts +0 -16
| @@ -1,16 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Omnibox Component Exports | |
| 3 | - | * | |
| 4 | - | * Main entry point for omnibox functionality | |
| 5 | - | */ | |
| 6 | - | ||
| 7 | - | export { default as Omnibox } from "./Omnibox.svelte"; | |
| 8 | - | export { default as OmniboxInput } from "./OmniboxInput.svelte"; | |
| 9 | - | export { default as OmniboxSuggestions } from "./OmniboxSuggestions.svelte"; | |
| 10 | - | export { default as OmniboxFind } from "./OmniboxFind.svelte"; | |
| 11 | - | ||
| 12 | - | export { omniboxStore } from "./stores.svelte.ts"; | |
| 13 | - | export { omniboxBridge } from "./messaging"; | |
| 14 | - | export { findInPage, revealMatch, jumpToMatch } from "./find"; | |
| 15 | - | ||
| 16 | - | export type * from "./types"; | |
D webui/src/components/omnibox/messaging.ts +0 -323
| @@ -1,323 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Omnibox Message Bridge | |
| 3 | - | * | |
| 4 | - | * TypeScript interface for Go-JavaScript communication | |
| 5 | - | */ | |
| 6 | - | ||
| 7 | - | import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, Favorite, SearchShortcut } from "./types"; | |
| 8 | - | import { omniboxStore } from "./stores.svelte.ts"; | |
| 9 | - | ||
| 10 | - | export class OmniboxBridge implements OmniboxMessageBridge { | |
| 11 | - | // Forward messages via CustomEvent to main world bridge | |
| 12 | - | // The main world will forward to webkit.messageHandlers.dumber | |
| 13 | - | postMessage(msg: OmniboxMessage): void { | |
| 14 | - | console.log("📤 [DEBUG] Posting message to backend via bridge:", msg); | |
| 15 | - | try { | |
| 16 | - | // Structure message for Go backend: type + payload + webview_id | |
| 17 | - | // Go expects: { type: string, payload: {...}, webview_id: number } | |
| 18 | - | const { type, ...rest } = msg; | |
| 19 | - | const structuredMessage = { | |
| 20 | - | type, | |
| 21 | - | payload: rest, | |
| 22 | - | webview_id: (window as any).__dumber_webview_id ?? 0, | |
| 23 | - | }; | |
| 24 | - | // Dispatch CustomEvent that main-world bridge will listen to | |
| 25 | - | document.dispatchEvent(new CustomEvent("dumber:isolated-message", { | |
| 26 | - | detail: { payload: structuredMessage } | |
| 27 | - | })); | |
| 28 | - | console.log("✅ [DEBUG] Dispatched isolated message event"); | |
| 29 | - | } catch (e) { | |
| 30 | - | console.error("❌ [DEBUG] Failed to dispatch message:", e); | |
| 31 | - | // Fallback navigation if dispatch fails | |
| 32 | - | if (msg.type === "navigate" && typeof msg.url === "string" && msg.url) { | |
| 33 | - | try { | |
| 34 | - | window.location.href = msg.url; | |
| 35 | - | } catch (navError) { | |
| 36 | - | console.error("Fallback navigation failed:", navError); | |
| 37 | - | } | |
| 38 | - | } | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | /** | |
| 43 | - | * Update suggestions from Go backend | |
| 44 | - | */ | |
| 45 | - | setSuggestions(suggestions: Suggestion[]): void { | |
| 46 | - | console.log("📝 [DEBUG] Received suggestions from backend:", suggestions); | |
| 47 | - | omniboxStore.updateSuggestions(suggestions); | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | /** | |
| 51 | - | * Update search shortcuts from Go backend | |
| 52 | - | */ | |
| 53 | - | setSearchShortcuts(shortcuts: Record<string, SearchShortcut>): void { | |
| 54 | - | console.log("📝 [DEBUG] Received search shortcuts from backend:", shortcuts); | |
| 55 | - | omniboxStore.updateSearchShortcuts(shortcuts); | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | /** | |
| 59 | - | * Handle navigation request | |
| 60 | - | */ | |
| 61 | - | navigate(url: string): void { | |
| 62 | - | console.log("🚀 Omnibox navigate called with:", url); | |
| 63 | - | this.postMessage({ type: "navigate", url }); | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | /** | |
| 67 | - | * Handle search query | |
| 68 | - | */ | |
| 69 | - | query(searchTerm: string, limit?: number): void { | |
| 70 | - | const q = searchTerm ?? ""; | |
| 71 | - | const lim = limit || omniboxStore.config.defaultLimit; | |
| 72 | - | console.log("🔍 [DEBUG] Sending query to backend:", { q, limit: lim }); | |
| 73 | - | // Send to native handler; Go will compute suggestions and call setSuggestions | |
| 74 | - | this.postMessage({ type: "query", q, limit: lim }); | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | /** | |
| 78 | - | * Fetch initial history for empty omnibox | |
| 79 | - | */ | |
| 80 | - | fetchInitialHistory(limit?: number): void { | |
| 81 | - | const lim = limit || omniboxStore.config.defaultLimit; | |
| 82 | - | console.log("[DEBUG] Fetching initial history:", { limit: lim }); | |
| 83 | - | // Send to native handler; Go will compute suggestions based on config and call setSuggestions | |
| 84 | - | this.postMessage({ type: "omnibox_initial_history", limit: lim }); | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | /** | |
| 88 | - | * Fetch search shortcuts from backend via messaging bridge | |
| 89 | - | */ | |
| 90 | - | async fetchSearchShortcuts(): Promise<void> { | |
| 91 | - | return new Promise((resolve, reject) => { | |
| 92 | - | // Set up one-time response handler via CustomEvent (isolated world pattern) | |
| 93 | - | const handleShortcuts = ((event: CustomEvent) => { | |
| 94 | - | try { | |
| 95 | - | const data = event.detail?.shortcuts ?? event.detail; | |
| 96 | - | // Type guard | |
| 97 | - | if (typeof data !== "object" || data === null) { | |
| 98 | - | reject(new Error("Invalid shortcuts data")); | |
| 99 | - | return; | |
| 100 | - | } | |
| 101 | - | const dataObj = data as Record<string, unknown>; | |
| 102 | - | // Normalize to match our SearchShortcut type | |
| 103 | - | const normalized: Record<string, SearchShortcut> = {}; | |
| 104 | - | for (const [key, value] of Object.entries(dataObj)) { | |
| 105 | - | const v = value as Record<string, unknown>; | |
| 106 | - | normalized[key] = { | |
| 107 | - | url: (v.url ?? v.URL ?? "") as string, | |
| 108 | - | description: (v.description ?? v.Description ?? "") as string, | |
| 109 | - | }; | |
| 110 | - | } | |
| 111 | - | this.setSearchShortcuts(normalized); | |
| 112 | - | document.removeEventListener("dumber:search-shortcuts", handleShortcuts as EventListener); | |
| 113 | - | resolve(); | |
| 114 | - | } catch (error) { | |
| 115 | - | console.error("Failed to process search shortcuts:", error); | |
| 116 | - | document.removeEventListener("dumber:search-shortcuts", handleShortcuts as EventListener); | |
| 117 | - | reject(error); | |
| 118 | - | } | |
| 119 | - | }) as EventListener; | |
| 120 | - | ||
| 121 | - | // Listen for response CustomEvent from main world bridge | |
| 122 | - | document.addEventListener("dumber:search-shortcuts", handleShortcuts as EventListener); | |
| 123 | - | ||
| 124 | - | // Send message via CustomEvent bridge (isolated world cannot access webkit.messageHandlers) | |
| 125 | - | this.postMessage({ type: "get_search_shortcuts" }); | |
| 126 | - | }); | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | /** | |
| 130 | - | * Update favorites from Go backend | |
| 131 | - | */ | |
| 132 | - | setFavorites(favorites: Favorite[]): void { | |
| 133 | - | console.log("📝 [DEBUG] Received favorites from backend:", favorites); | |
| 134 | - | omniboxStore.updateFavorites(favorites); | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | /** | |
| 138 | - | * Fetch favorites from backend via messaging bridge | |
| 139 | - | */ | |
| 140 | - | async fetchFavorites(): Promise<void> { | |
| 141 | - | return new Promise((resolve, reject) => { | |
| 142 | - | // Set up one-time event listener for favorites response | |
| 143 | - | const handleFavorites = ((event: CustomEvent) => { | |
| 144 | - | try { | |
| 145 | - | const { favorites } = event.detail; | |
| 146 | - | if (!Array.isArray(favorites)) { | |
| 147 | - | reject(new Error("Invalid favorites data")); | |
| 148 | - | return; | |
| 149 | - | } | |
| 150 | - | console.log("📝 [DEBUG] Received favorites via CustomEvent:", favorites); | |
| 151 | - | this.setFavorites(favorites as Favorite[]); | |
| 152 | - | ||
| 153 | - | // Clean up event listener | |
| 154 | - | document.removeEventListener("dumber:favorites", handleFavorites as EventListener); | |
| 155 | - | ||
| 156 | - | resolve(); | |
| 157 | - | } catch (error) { | |
| 158 | - | console.error("Failed to process favorites:", error); | |
| 159 | - | document.removeEventListener("dumber:favorites", handleFavorites as EventListener); | |
| 160 | - | reject(error); | |
| 161 | - | } | |
| 162 | - | }) as EventListener; | |
| 163 | - | ||
| 164 | - | // Listen for favorites event from main world bridge | |
| 165 | - | document.addEventListener("dumber:favorites", handleFavorites as EventListener); | |
| 166 | - | ||
| 167 | - | // Send message to Go backend via CustomEvent bridge (like other omnibox messages) | |
| 168 | - | this.postMessage({ type: "get_favorites" }); | |
| 169 | - | ||
| 170 | - | // Timeout after 5 seconds | |
| 171 | - | setTimeout(() => { | |
| 172 | - | document.removeEventListener("dumber:favorites", handleFavorites as EventListener); | |
| 173 | - | reject(new Error("Favorites fetch timeout")); | |
| 174 | - | }, 5000); | |
| 175 | - | }); | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | /** | |
| 179 | - | * Toggle favorite status for a URL | |
| 180 | - | */ | |
| 181 | - | toggleFavorite(url: string, title: string, faviconURL: string): void { | |
| 182 | - | console.log("⭐ [DEBUG] Toggling favorite:", { url, title, faviconURL }); | |
| 183 | - | this.postMessage({ type: "toggle_favorite", url, title, faviconURL }); | |
| 184 | - | ||
| 185 | - | // Notify other parts of the app that favorites changed | |
| 186 | - | // Homepage will listen for this and refresh its favorites list | |
| 187 | - | setTimeout(() => { | |
| 188 | - | document.dispatchEvent(new CustomEvent("dumber:favorites-changed")); | |
| 189 | - | }, 100); // Small delay to let backend complete | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | /** | |
| 193 | - | * Handle inline suggestion from backend (fish-style ghost text) | |
| 194 | - | */ | |
| 195 | - | setInlineSuggestion(url: string | null): void { | |
| 196 | - | console.log('[INLINE] bridge.setInlineSuggestion called:', url); | |
| 197 | - | const inputValue = omniboxStore.inputValue; | |
| 198 | - | omniboxStore.setInlineSuggestion(url, inputValue); | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | /** | |
| 202 | - | * Query for prefix-matching URL (for inline suggestions) | |
| 203 | - | */ | |
| 204 | - | prefixQuery(prefix: string): void { | |
| 205 | - | console.log('[INLINE] prefixQuery called:', prefix); | |
| 206 | - | if (!prefix || prefix.trim().length < MIN_SEARCH_LENGTH) { | |
| 207 | - | omniboxStore.clearInlineSuggestion(); | |
| 208 | - | return; | |
| 209 | - | } | |
| 210 | - | this.postMessage({ type: "prefix_query", q: prefix }); | |
| 211 | - | console.log('[INLINE] prefix_query message sent'); | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | // Suggestions are returned via setSuggestions() when native handler responds | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | // Singleton instance | |
| 218 | - | export const omniboxBridge = new OmniboxBridge(); | |
| 219 | - | ||
| 220 | - | // Minimum characters required before triggering search operations | |
| 221 | - | const MIN_SEARCH_LENGTH = 2; | |
| 222 | - | ||
| 223 | - | // Debounce timers for different operations | |
| 224 | - | const debounceTimers: Record<string, number> = {}; | |
| 225 | - | ||
| 226 | - | /** | |
| 227 | - | * Creates a debounced search function with minimum length threshold | |
| 228 | - | * Prevents freezing on first letter and rapid input | |
| 229 | - | */ | |
| 230 | - | function createDebouncedSearch( | |
| 231 | - | key: string, | |
| 232 | - | action: (query: string) => void, | |
| 233 | - | onClear?: () => void | |
| 234 | - | ): (query: string) => void { | |
| 235 | - | return (query: string) => { | |
| 236 | - | // Clear previous timer | |
| 237 | - | if (debounceTimers[key]) { | |
| 238 | - | clearTimeout(debounceTimers[key]); | |
| 239 | - | debounceTimers[key] = 0; | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | const trimmed = (query || "").trim(); | |
| 243 | - | ||
| 244 | - | // Clear/reset immediately if query is empty or too short | |
| 245 | - | if (trimmed.length < MIN_SEARCH_LENGTH) { | |
| 246 | - | if (onClear) { | |
| 247 | - | onClear(); | |
| 248 | - | } | |
| 249 | - | return; | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | // Debounce the actual search operation | |
| 253 | - | debounceTimers[key] = window.setTimeout(() => { | |
| 254 | - | action(query); | |
| 255 | - | }, omniboxStore.config.debounceDelay); | |
| 256 | - | }; | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | /** | |
| 260 | - | * Debounced query function for omnibox search input | |
| 261 | - | */ | |
| 262 | - | export const debouncedQuery = createDebouncedSearch( | |
| 263 | - | "query", | |
| 264 | - | (searchTerm) => omniboxBridge.query(searchTerm), | |
| 265 | - | () => omniboxStore.updateSuggestions([]) | |
| 266 | - | ); | |
| 267 | - | ||
| 268 | - | /** | |
| 269 | - | * Debounced find function for find-in-page | |
| 270 | - | */ | |
| 271 | - | export function debouncedFind(query: string, findFn: (q: string) => void): void { | |
| 272 | - | createDebouncedSearch("find", findFn, () => findFn(""))(query); | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | // Debounce for inline suggestions - balanced between responsiveness and smoothness | |
| 276 | - | const PREFIX_DEBOUNCE_MS = 80; | |
| 277 | - | let prefixDebounceTimer = 0; | |
| 278 | - | ||
| 279 | - | /** | |
| 280 | - | * Debounced prefix query for inline suggestions (fish-style ghost text) | |
| 281 | - | */ | |
| 282 | - | export function debouncedPrefixQuery(query: string): void { | |
| 283 | - | if (prefixDebounceTimer) { | |
| 284 | - | clearTimeout(prefixDebounceTimer); | |
| 285 | - | prefixDebounceTimer = 0; | |
| 286 | - | } | |
| 287 | - | ||
| 288 | - | const trimmed = (query || "").trim(); | |
| 289 | - | if (trimmed.length < MIN_SEARCH_LENGTH) { | |
| 290 | - | // Clear after a short delay to avoid flicker | |
| 291 | - | prefixDebounceTimer = window.setTimeout(() => { | |
| 292 | - | omniboxStore.clearInlineSuggestion(); | |
| 293 | - | }, 50); | |
| 294 | - | return; | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | prefixDebounceTimer = window.setTimeout(() => { | |
| 298 | - | omniboxBridge.prefixQuery(trimmed); | |
| 299 | - | }, PREFIX_DEBOUNCE_MS); | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | // Extend global window interface for Go bridge compatibility | |
| 303 | - | declare global { | |
| 304 | - | interface Window { | |
| 305 | - | webkit?: { | |
| 306 | - | messageHandlers?: { | |
| 307 | - | dumber?: { | |
| 308 | - | postMessage: (message: unknown) => void; | |
| 309 | - | }; | |
| 310 | - | }; | |
| 311 | - | }; | |
| 312 | - | // Omnibox API for Go bridge | |
| 313 | - | __dumber_omnibox?: { | |
| 314 | - | setSuggestions: (suggestions: Suggestion[]) => void; | |
| 315 | - | setInlineSuggestion: (url: string | null) => void; | |
| 316 | - | toggle: () => void; | |
| 317 | - | open: (mode?: string, query?: string) => void; | |
| 318 | - | close: () => void; | |
| 319 | - | findQuery: (query: string) => void; | |
| 320 | - | setActive: (active: boolean) => void; | |
| 321 | - | }; | |
| 322 | - | } | |
| 323 | - | } | |
D webui/src/components/omnibox/stores.svelte.ts +0 -355
| @@ -1,355 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Omnibox State Management | |
| 3 | - | * | |
| 4 | - | * Svelte 5 rune-based state management for omnibox functionality | |
| 5 | - | */ | |
| 6 | - | ||
| 7 | - | import type { | |
| 8 | - | OmniboxMode, | |
| 9 | - | ViewMode, | |
| 10 | - | Suggestion, | |
| 11 | - | Favorite, | |
| 12 | - | FindMatch, | |
| 13 | - | HighlightNode, | |
| 14 | - | OmniboxConfig, | |
| 15 | - | SearchShortcut, | |
| 16 | - | } from "./types"; | |
| 17 | - | ||
| 18 | - | // Default configuration | |
| 19 | - | const DEFAULT_CONFIG: Required<OmniboxConfig> = { | |
| 20 | - | maxMatches: 2000, | |
| 21 | - | debounceDelay: 120, | |
| 22 | - | defaultLimit: 10, | |
| 23 | - | }; | |
| 24 | - | ||
| 25 | - | // Load viewMode from localStorage or default to "history" | |
| 26 | - | function loadViewMode(): ViewMode { | |
| 27 | - | try { | |
| 28 | - | const stored = localStorage.getItem("dumber_omnibox_viewMode"); | |
| 29 | - | if (stored === "favorites" || stored === "history") { | |
| 30 | - | return stored; | |
| 31 | - | } | |
| 32 | - | } catch (e) { | |
| 33 | - | console.warn("Failed to load viewMode from localStorage:", e); | |
| 34 | - | } | |
| 35 | - | return "history"; | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // Global state using Svelte 5 runes | |
| 39 | - | let visible = $state(false); | |
| 40 | - | let mode = $state<OmniboxMode>("omnibox"); | |
| 41 | - | let viewMode = $state<ViewMode>(loadViewMode()); | |
| 42 | - | let suggestions = $state<Suggestion[]>([]); | |
| 43 | - | let favorites = $state<Favorite[]>([]); | |
| 44 | - | let matches = $state<FindMatch[]>([]); | |
| 45 | - | let selectedIndex = $state(-1); | |
| 46 | - | let activeIndex = $state(-1); | |
| 47 | - | let faded = $state(false); | |
| 48 | - | let inputValue = $state(""); | |
| 49 | - | let highlightNodes = $state<HighlightNode[]>([]); | |
| 50 | - | let prevOverflow = $state(""); | |
| 51 | - | let debounceTimer = $state(0); | |
| 52 | - | let searchShortcuts = $state<Record<string, SearchShortcut>>({}); | |
| 53 | - | ||
| 54 | - | // Inline suggestion state (fish-style ghost text) | |
| 55 | - | let inlineSuggestion = $state<string | null>(null); | |
| 56 | - | let inlineCompletion = $state<string | null>(null); | |
| 57 | - | ||
| 58 | - | // Configuration | |
| 59 | - | let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG); | |
| 60 | - | ||
| 61 | - | export const omniboxStore = { | |
| 62 | - | // State getters | |
| 63 | - | get visible() { | |
| 64 | - | return visible; | |
| 65 | - | }, | |
| 66 | - | get mode() { | |
| 67 | - | return mode; | |
| 68 | - | }, | |
| 69 | - | get viewMode() { | |
| 70 | - | return viewMode; | |
| 71 | - | }, | |
| 72 | - | get suggestions() { | |
| 73 | - | return suggestions; | |
| 74 | - | }, | |
| 75 | - | get favorites() { | |
| 76 | - | return favorites; | |
| 77 | - | }, | |
| 78 | - | get matches() { | |
| 79 | - | return matches; | |
| 80 | - | }, | |
| 81 | - | get selectedIndex() { | |
| 82 | - | return selectedIndex; | |
| 83 | - | }, | |
| 84 | - | get activeIndex() { | |
| 85 | - | return activeIndex; | |
| 86 | - | }, | |
| 87 | - | get faded() { | |
| 88 | - | return faded; | |
| 89 | - | }, | |
| 90 | - | get inputValue() { | |
| 91 | - | return inputValue; | |
| 92 | - | }, | |
| 93 | - | get highlightNodes() { | |
| 94 | - | return highlightNodes; | |
| 95 | - | }, | |
| 96 | - | get prevOverflow() { | |
| 97 | - | return prevOverflow; | |
| 98 | - | }, | |
| 99 | - | get config() { | |
| 100 | - | return config; | |
| 101 | - | }, | |
| 102 | - | get searchShortcuts() { | |
| 103 | - | return searchShortcuts; | |
| 104 | - | }, | |
| 105 | - | get inlineSuggestion() { | |
| 106 | - | return inlineSuggestion; | |
| 107 | - | }, | |
| 108 | - | get inlineCompletion() { | |
| 109 | - | return inlineCompletion; | |
| 110 | - | }, | |
| 111 | - | ||
| 112 | - | // Computed getters | |
| 113 | - | get hasContent() { | |
| 114 | - | if (mode === "find") { | |
| 115 | - | return matches.length > 0; | |
| 116 | - | } | |
| 117 | - | // In omnibox mode, check the current view | |
| 118 | - | return viewMode === "history" ? suggestions.length > 0 : favorites.length > 0; | |
| 119 | - | }, | |
| 120 | - | get totalItems() { | |
| 121 | - | if (mode === "find") { | |
| 122 | - | return matches.length; | |
| 123 | - | } | |
| 124 | - | // In omnibox mode, return count based on current view | |
| 125 | - | return viewMode === "history" ? suggestions.length : favorites.length; | |
| 126 | - | }, | |
| 127 | - | get selectedItem() { | |
| 128 | - | if (selectedIndex < 0) return null; | |
| 129 | - | if (mode === "find") { | |
| 130 | - | return matches[selectedIndex]; | |
| 131 | - | } | |
| 132 | - | // In omnibox mode, return item from current view | |
| 133 | - | return viewMode === "history" | |
| 134 | - | ? suggestions[selectedIndex] | |
| 135 | - | : favorites[selectedIndex]; | |
| 136 | - | }, | |
| 137 | - | ||
| 138 | - | // Actions | |
| 139 | - | setVisible(value: boolean) { | |
| 140 | - | if (value && !visible) { | |
| 141 | - | // Store scroll state when opening | |
| 142 | - | prevOverflow = document.documentElement.style.overflow; | |
| 143 | - | document.documentElement.style.overflow = "hidden"; | |
| 144 | - | } else if (!value && visible) { | |
| 145 | - | // Restore scroll state when closing | |
| 146 | - | document.documentElement.style.overflow = prevOverflow || ""; | |
| 147 | - | prevOverflow = ""; | |
| 148 | - | ||
| 149 | - | // Clear find highlights when closing | |
| 150 | - | if (mode === "find") { | |
| 151 | - | this.clearHighlights(); | |
| 152 | - | } | |
| 153 | - | } | |
| 154 | - | visible = value; | |
| 155 | - | }, | |
| 156 | - | ||
| 157 | - | setMode(newMode: OmniboxMode) { | |
| 158 | - | // Clear previous mode's state | |
| 159 | - | if (newMode === "omnibox") { | |
| 160 | - | // Switching to omnibox mode - clear find state | |
| 161 | - | this.clearHighlights(); | |
| 162 | - | } else if (newMode === "find") { | |
| 163 | - | // Switching to find mode - clear suggestions | |
| 164 | - | this.updateSuggestions([]); | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | mode = newMode; | |
| 168 | - | selectedIndex = -1; | |
| 169 | - | faded = false; | |
| 170 | - | inputValue = ""; // Clear input for fresh start | |
| 171 | - | }, | |
| 172 | - | ||
| 173 | - | setInputValue(value: string) { | |
| 174 | - | inputValue = value; | |
| 175 | - | }, | |
| 176 | - | ||
| 177 | - | setFaded(value: boolean) { | |
| 178 | - | faded = value; | |
| 179 | - | }, | |
| 180 | - | ||
| 181 | - | setSelectedIndex(index: number) { | |
| 182 | - | const maxIndex = this.totalItems - 1; | |
| 183 | - | if (maxIndex < 0) { | |
| 184 | - | selectedIndex = -1; | |
| 185 | - | } else { | |
| 186 | - | selectedIndex = Math.max(0, Math.min(index, maxIndex)); | |
| 187 | - | } | |
| 188 | - | }, | |
| 189 | - | ||
| 190 | - | selectNext() { | |
| 191 | - | if (this.totalItems === 0) return; | |
| 192 | - | const newIndex = | |
| 193 | - | selectedIndex < 0 ? 0 : (selectedIndex + 1) % this.totalItems; | |
| 194 | - | this.setSelectedIndex(newIndex); | |
| 195 | - | faded = true; | |
| 196 | - | }, | |
| 197 | - | ||
| 198 | - | selectPrevious() { | |
| 199 | - | if (this.totalItems === 0) return; | |
| 200 | - | const newIndex = | |
| 201 | - | selectedIndex <= 0 ? this.totalItems - 1 : selectedIndex - 1; | |
| 202 | - | this.setSelectedIndex(newIndex); | |
| 203 | - | faded = true; | |
| 204 | - | }, | |
| 205 | - | ||
| 206 | - | open(newMode: OmniboxMode = "omnibox", initialQuery?: string) { | |
| 207 | - | this.setMode(newMode); | |
| 208 | - | this.setVisible(true); | |
| 209 | - | if (typeof initialQuery === "string") { | |
| 210 | - | inputValue = initialQuery; | |
| 211 | - | } | |
| 212 | - | }, | |
| 213 | - | ||
| 214 | - | close() { | |
| 215 | - | this.setVisible(false); | |
| 216 | - | faded = false; | |
| 217 | - | }, | |
| 218 | - | ||
| 219 | - | toggle() { | |
| 220 | - | if (!visible) { | |
| 221 | - | // When opening via toggle (Ctrl+L), always open in omnibox mode | |
| 222 | - | this.setMode("omnibox"); | |
| 223 | - | this.setVisible(true); | |
| 224 | - | } else { | |
| 225 | - | this.setVisible(false); | |
| 226 | - | } | |
| 227 | - | }, | |
| 228 | - | ||
| 229 | - | updateSuggestions(newSuggestions: Suggestion[]) { | |
| 230 | - | suggestions = Array.isArray(newSuggestions) ? newSuggestions : []; | |
| 231 | - | selectedIndex = -1; | |
| 232 | - | }, | |
| 233 | - | ||
| 234 | - | updateMatches(newMatches: FindMatch[]) { | |
| 235 | - | matches = Array.isArray(newMatches) ? newMatches : []; | |
| 236 | - | selectedIndex = newMatches.length > 0 ? 0 : -1; | |
| 237 | - | }, | |
| 238 | - | ||
| 239 | - | clearHighlights() { | |
| 240 | - | try { | |
| 241 | - | highlightNodes.forEach(({ span, text }) => { | |
| 242 | - | const parent = span.parentNode; | |
| 243 | - | if (parent) { | |
| 244 | - | parent.replaceChild(text, span); | |
| 245 | - | parent.normalize(); | |
| 246 | - | } | |
| 247 | - | }); | |
| 248 | - | } catch (error) { | |
| 249 | - | console.warn("Error clearing highlights:", error); | |
| 250 | - | } | |
| 251 | - | highlightNodes = []; | |
| 252 | - | matches = []; | |
| 253 | - | selectedIndex = -1; | |
| 254 | - | activeIndex = -1; | |
| 255 | - | }, | |
| 256 | - | ||
| 257 | - | addHighlightNode(node: HighlightNode) { | |
| 258 | - | highlightNodes = [...highlightNodes, node]; | |
| 259 | - | }, | |
| 260 | - | ||
| 261 | - | setActiveIndex(index: number) { | |
| 262 | - | activeIndex = index; | |
| 263 | - | }, | |
| 264 | - | ||
| 265 | - | setDebounceTimer(timerId: number) { | |
| 266 | - | if (debounceTimer) { | |
| 267 | - | clearTimeout(debounceTimer); | |
| 268 | - | } | |
| 269 | - | debounceTimer = timerId; | |
| 270 | - | }, | |
| 271 | - | ||
| 272 | - | clearDebounceTimer() { | |
| 273 | - | if (debounceTimer) { | |
| 274 | - | clearTimeout(debounceTimer); | |
| 275 | - | debounceTimer = 0; | |
| 276 | - | } | |
| 277 | - | }, | |
| 278 | - | ||
| 279 | - | updateConfig(newConfig: Partial<OmniboxConfig>) { | |
| 280 | - | config = { ...config, ...newConfig }; | |
| 281 | - | }, | |
| 282 | - | ||
| 283 | - | updateSearchShortcuts(shortcuts: Record<string, SearchShortcut>) { | |
| 284 | - | searchShortcuts = shortcuts; | |
| 285 | - | }, | |
| 286 | - | ||
| 287 | - | setViewMode(newViewMode: ViewMode) { | |
| 288 | - | viewMode = newViewMode; | |
| 289 | - | selectedIndex = -1; // Reset selection when switching views | |
| 290 | - | ||
| 291 | - | // Persist to localStorage | |
| 292 | - | try { | |
| 293 | - | localStorage.setItem("dumber_omnibox_viewMode", newViewMode); | |
| 294 | - | } catch (e) { | |
| 295 | - | console.warn("Failed to save viewMode to localStorage:", e); | |
| 296 | - | } | |
| 297 | - | }, | |
| 298 | - | ||
| 299 | - | updateFavorites(newFavorites: Favorite[]) { | |
| 300 | - | favorites = Array.isArray(newFavorites) ? newFavorites : []; | |
| 301 | - | // Reset selection if we're in favorites view | |
| 302 | - | if (viewMode === "favorites") { | |
| 303 | - | selectedIndex = -1; | |
| 304 | - | } | |
| 305 | - | }, | |
| 306 | - | ||
| 307 | - | setInlineSuggestion(url: string | null, currentInput: string) { | |
| 308 | - | console.log('[INLINE] setInlineSuggestion called:', { url, currentInput }); | |
| 309 | - | inlineSuggestion = url; | |
| 310 | - | if (!url || !currentInput) { | |
| 311 | - | inlineCompletion = null; | |
| 312 | - | console.log('[INLINE] Cleared - missing url or input'); | |
| 313 | - | return; | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | // Normalize URL by stripping protocol and www for matching | |
| 317 | - | let normalizedUrl = url.toLowerCase(); | |
| 318 | - | normalizedUrl = normalizedUrl.replace(/^https?:\/\//, ''); | |
| 319 | - | normalizedUrl = normalizedUrl.replace(/^www\./, ''); | |
| 320 | - | ||
| 321 | - | const normalizedInput = currentInput.toLowerCase(); | |
| 322 | - | ||
| 323 | - | if (normalizedUrl.startsWith(normalizedInput)) { | |
| 324 | - | // Show the completion (rest of normalized URL after what user typed) | |
| 325 | - | inlineCompletion = normalizedUrl.slice(normalizedInput.length); | |
| 326 | - | console.log('[INLINE] Set completion:', inlineCompletion); | |
| 327 | - | } else { | |
| 328 | - | inlineCompletion = null; | |
| 329 | - | console.log('[INLINE] Cleared - no prefix match after normalization'); | |
| 330 | - | } | |
| 331 | - | }, | |
| 332 | - | ||
| 333 | - | clearInlineSuggestion() { | |
| 334 | - | inlineSuggestion = null; | |
| 335 | - | inlineCompletion = null; | |
| 336 | - | }, | |
| 337 | - | ||
| 338 | - | reset() { | |
| 339 | - | visible = false; | |
| 340 | - | mode = "omnibox"; | |
| 341 | - | // Keep viewMode persistent - it's user preference | |
| 342 | - | suggestions = []; | |
| 343 | - | favorites = []; | |
| 344 | - | matches = []; | |
| 345 | - | selectedIndex = -1; | |
| 346 | - | activeIndex = -1; | |
| 347 | - | faded = false; | |
| 348 | - | inputValue = ""; | |
| 349 | - | prevOverflow = ""; | |
| 350 | - | inlineSuggestion = null; | |
| 351 | - | inlineCompletion = null; | |
| 352 | - | this.clearHighlights(); | |
| 353 | - | this.clearDebounceTimer(); | |
| 354 | - | }, | |
| 355 | - | }; | |
D webui/src/components/omnibox/types.ts +0 -83
| @@ -1,83 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Omnibox Component Types | |
| 3 | - | * | |
| 4 | - | * TypeScript interfaces for omnibox/find functionality that match Go structs | |
| 5 | - | */ | |
| 6 | - | ||
| 7 | - | export interface Suggestion { | |
| 8 | - | url: string; | |
| 9 | - | favicon?: string; | |
| 10 | - | } | |
| 11 | - | ||
| 12 | - | export interface Favorite { | |
| 13 | - | id: number; | |
| 14 | - | url: string; | |
| 15 | - | title: string; | |
| 16 | - | favicon_url: string; | |
| 17 | - | position: number; | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | export interface SearchShortcut { | |
| 21 | - | url: string; | |
| 22 | - | description: string; | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | export interface OmniboxMessage { | |
| 26 | - | type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite" | "prefix_query" | "keyboard_blocking"; | |
| 27 | - | url?: string; | |
| 28 | - | q?: string; | |
| 29 | - | limit?: number; | |
| 30 | - | title?: string; | |
| 31 | - | faviconURL?: string; | |
| 32 | - | action?: "enable" | "disable"; | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | export interface FindMatch { | |
| 36 | - | element: HTMLElement; | |
| 37 | - | context: string; | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | export interface HighlightNode { | |
| 41 | - | span: HTMLElement; | |
| 42 | - | text: Text; | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | export type OmniboxMode = "omnibox" | "find"; | |
| 46 | - | export type ViewMode = "history" | "favorites"; | |
| 47 | - | ||
| 48 | - | export interface OmniboxConfig { | |
| 49 | - | maxMatches?: number; | |
| 50 | - | debounceDelay?: number; | |
| 51 | - | defaultLimit?: number; | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | export interface OmniboxState { | |
| 55 | - | visible: boolean; | |
| 56 | - | mode: OmniboxMode; | |
| 57 | - | viewMode: ViewMode; | |
| 58 | - | suggestions: Suggestion[]; | |
| 59 | - | favorites: Favorite[]; | |
| 60 | - | matches: FindMatch[]; | |
| 61 | - | selectedIndex: number; | |
| 62 | - | activeIndex: number; | |
| 63 | - | faded: boolean; | |
| 64 | - | inputValue: string; | |
| 65 | - | highlightNodes: HighlightNode[]; | |
| 66 | - | prevOverflow: string; | |
| 67 | - | searchShortcuts: Record<string, SearchShortcut>; | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // Message bridge interface for Go communication | |
| 71 | - | export interface OmniboxMessageBridge { | |
| 72 | - | postMessage(msg: OmniboxMessage): void; | |
| 73 | - | setSuggestions(suggestions: Suggestion[]): void; | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | // Global API for Go bridge | |
| 77 | - | export interface OmniboxAPI { | |
| 78 | - | setSuggestions: (suggestions: Suggestion[]) => void; | |
| 79 | - | toggle: () => void; | |
| 80 | - | open: (mode?: OmniboxMode, query?: string) => void; | |
| 81 | - | close: () => void; | |
| 82 | - | findQuery: (query: string) => void; | |
| 83 | - | } | |
D webui/src/components/toast/Toast.svelte +0 -147
| @@ -1,147 +0,0 @@ | |||
| 1 | - | <script lang="ts"> | |
| 2 | - | import { onMount, onDestroy } from 'svelte'; | |
| 3 | - | ||
| 4 | - | interface ToastProps { | |
| 5 | - | id: number; | |
| 6 | - | message: string; | |
| 7 | - | duration?: number; | |
| 8 | - | type?: 'info' | 'success' | 'error'; | |
| 9 | - | onDismiss: (id: number) => void; | |
| 10 | - | } | |
| 11 | - | ||
| 12 | - | let { id, message, duration = 2500, type = 'info', onDismiss }: ToastProps = $props(); | |
| 13 | - | ||
| 14 | - | let isExiting = $state(false); | |
| 15 | - | let isDarkMode = $state(false); | |
| 16 | - | let themeObserver: MutationObserver | undefined; | |
| 17 | - | let timeoutId: number | undefined; | |
| 18 | - | ||
| 19 | - | onMount(() => { | |
| 20 | - | timeoutId = window.setTimeout(() => { | |
| 21 | - | dismiss(); | |
| 22 | - | }, duration); | |
| 23 | - | ||
| 24 | - | syncTheme(); | |
| 25 | - | ||
| 26 | - | if (typeof window !== 'undefined' && 'MutationObserver' in window) { | |
| 27 | - | themeObserver = new MutationObserver(syncTheme); | |
| 28 | - | themeObserver.observe(document.documentElement, { | |
| 29 | - | attributes: true, | |
| 30 | - | attributeFilter: ['class'] | |
| 31 | - | }); | |
| 32 | - | } | |
| 33 | - | }); | |
| 34 | - | ||
| 35 | - | onDestroy(() => { | |
| 36 | - | if (timeoutId) { | |
| 37 | - | window.clearTimeout(timeoutId); | |
| 38 | - | timeoutId = undefined; | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | if (themeObserver) { | |
| 42 | - | themeObserver.disconnect(); | |
| 43 | - | themeObserver = undefined; | |
| 44 | - | } | |
| 45 | - | }); | |
| 46 | - | ||
| 47 | - | function dismiss() { | |
| 48 | - | if (timeoutId) { | |
| 49 | - | window.clearTimeout(timeoutId); | |
| 50 | - | timeoutId = undefined; | |
| 51 | - | } | |
| 52 | - | isExiting = true; | |
| 53 | - | // Allow time for exit animation before removing from parent | |
| 54 | - | setTimeout(() => { | |
| 55 | - | onDismiss(id); | |
| 56 | - | }, 250); | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | function handleClick() { | |
| 60 | - | dismiss(); | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | function syncTheme() { | |
| 64 | - | if (typeof document === 'undefined') { | |
| 65 | - | return; | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | const prefersDark = document.documentElement.classList.contains('dark'); | |
| 69 | - | ||
| 70 | - | if (prefersDark) { | |
| 71 | - | isDarkMode = true; | |
| 72 | - | return; | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | try { | |
| 76 | - | isDarkMode = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; | |
| 77 | - | } catch { | |
| 78 | - | isDarkMode = false; | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | </script> | |
| 82 | - | ||
| 83 | - | <button | |
| 84 | - | class={`toast ${isDarkMode ? 'toast-dark' : 'toast-light'} toast-${type} ${isExiting ? 'toast-exit' : 'toast-enter'}`} | |
| 85 | - | onclick={handleClick} | |
| 86 | - | data-toast-id={id} | |
| 87 | - | type="button" | |
| 88 | - | aria-label="Dismiss notification" | |
| 89 | - | title="Click to dismiss" | |
| 90 | - | > | |
| 91 | - | <div class="toast-message"> | |
| 92 | - | {message || 'Notification'} | |
| 93 | - | </div> | |
| 94 | - | </button> | |
| 95 | - | ||
| 96 | - | <style> | |
| 97 | - | .toast { | |
| 98 | - | --toast-bg: color-mix(in srgb, var(--dynamic-surface) 88%, var(--dynamic-text) 12%); | |
| 99 | - | --toast-border: color-mix(in srgb, var(--dynamic-border) 70%, var(--dynamic-text) 30%); | |
| 100 | - | --toast-text: var(--dynamic-text); | |
| 101 | - | background-color: var(--toast-bg); | |
| 102 | - | border: 1px solid var(--toast-border); | |
| 103 | - | border-radius: 0; | |
| 104 | - | box-shadow: 0 8px 14px rgba(0, 0, 0, 0.08); | |
| 105 | - | cursor: pointer; | |
| 106 | - | display: inline-flex; | |
| 107 | - | margin: 0; | |
| 108 | - | pointer-events: auto; | |
| 109 | - | padding: 0; | |
| 110 | - | transition: transform 0.2s ease, opacity 0.2s ease; | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | .toast-enter { | |
| 114 | - | opacity: 1; | |
| 115 | - | transform: translateY(0); | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | .toast-exit { | |
| 119 | - | opacity: 0; | |
| 120 | - | transform: translateY(-16px); | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | .toast-message { | |
| 124 | - | color: var(--toast-text); | |
| 125 | - | font-size: 0.875rem; | |
| 126 | - | font-weight: 600; | |
| 127 | - | min-width: 9rem; | |
| 128 | - | padding: 0.75rem 1rem; | |
| 129 | - | text-align: center; | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | .toast.toast-info { | |
| 133 | - | --toast-bg: color-mix(in srgb, var(--dynamic-surface) 82%, var(--dynamic-text) 18%); | |
| 134 | - | --toast-border: color-mix(in srgb, var(--dynamic-border) 55%, var(--dynamic-text) 45%); | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | .toast.toast-success { | |
| 138 | - | --toast-bg: color-mix(in srgb, var(--dynamic-surface) 78%, var(--dynamic-text) 22%); | |
| 139 | - | --toast-border: color-mix(in srgb, var(--dynamic-border) 50%, var(--dynamic-text) 50%); | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | .toast.toast-error { | |
| 143 | - | --toast-bg: color-mix(in srgb, var(--dynamic-surface) 74%, var(--dynamic-text) 26%); | |
| 144 | - | --toast-border: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%); | |
| 145 | - | --toast-text: color-mix(in srgb, var(--dynamic-text) 80%, var(--dynamic-muted) 20%); | |
| 146 | - | } | |
| 147 | - | </style> | |
D webui/src/components/toast/ToastContainer.svelte +0 -157
| @@ -1,157 +0,0 @@ | |||
| 1 | - | <script lang="ts"> | |
| 2 | - | import Toast from './Toast.svelte'; | |
| 3 | - | ||
| 4 | - | interface ToastData { | |
| 5 | - | id: number; | |
| 6 | - | message: string; | |
| 7 | - | duration?: number; | |
| 8 | - | type?: 'info' | 'success' | 'error'; | |
| 9 | - | } | |
| 10 | - | ||
| 11 | - | // Svelte 5 rune for reactive state | |
| 12 | - | let toasts = $state<ToastData[]>([]); | |
| 13 | - | let counter = $state(0); | |
| 14 | - | let zoomToastId = $state<number | null>(null); | |
| 15 | - | let zoomDebounceTimer: number | undefined = undefined; | |
| 16 | - | ||
| 17 | - | function addToast(message: string, duration = 2500, type: 'info' | 'success' | 'error' = 'info'): number { | |
| 18 | - | const id = ++counter; | |
| 19 | - | const toast: ToastData = { id, message, duration, type }; | |
| 20 | - | ||
| 21 | - | toasts.push(toast); | |
| 22 | - | console.log('📋 Toast:', message); | |
| 23 | - | return id; | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | function dismissToast(id: number) { | |
| 27 | - | console.log(`[ToastContainer] Dismissing toast with id: ${id}`); | |
| 28 | - | const index = toasts.findIndex(t => t.id === id); | |
| 29 | - | if (index === -1) { | |
| 30 | - | console.log(`[ToastContainer] Toast with id ${id} not found in array`); | |
| 31 | - | return; | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // Clear zoom toast tracking if this is the zoom toast | |
| 35 | - | if (zoomToastId === id) { | |
| 36 | - | console.log(`[ToastContainer] Clearing zoom toast tracking for id: ${id}`); | |
| 37 | - | zoomToastId = null; | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | console.log(`[ToastContainer] Removing toast at index ${index}, remaining: ${toasts.length - 1}`); | |
| 41 | - | toasts.splice(index, 1); | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | function clearAllToasts() { | |
| 45 | - | toasts = []; | |
| 46 | - | zoomToastId = null; | |
| 47 | - | if (zoomDebounceTimer) { | |
| 48 | - | window.clearTimeout(zoomDebounceTimer); | |
| 49 | - | zoomDebounceTimer = undefined; | |
| 50 | - | } | |
| 51 | - | } | |
| 52 | - | // Export for external use | |
| 53 | - | window.__dumber_clearToasts = clearAllToasts; | |
| 54 | - | ||
| 55 | - | function showZoomToast(zoomLevel: number) { | |
| 56 | - | console.log('[dumber] showZoomToast called with level:', zoomLevel); | |
| 57 | - | ||
| 58 | - | // Debug: Check if toast container exists in DOM | |
| 59 | - | const container = document.querySelector('.toast-container'); | |
| 60 | - | const browserRoot = document.querySelector('.browser-component-root'); | |
| 61 | - | console.log('[dumber] DOM check - container exists:', !!container, 'browser-root exists:', !!browserRoot, 'toasts array length:', toasts.length); | |
| 62 | - | ||
| 63 | - | // Clear existing debounce timer | |
| 64 | - | if (zoomDebounceTimer) { | |
| 65 | - | window.clearTimeout(zoomDebounceTimer); | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // Remove existing zoom toasts immediately (no animation needed for replacement) | |
| 69 | - | toasts = toasts.filter(toast => { | |
| 70 | - | const isZoomToast = toast.message.includes('Zoom level:') || toast.id === zoomToastId; | |
| 71 | - | return !isZoomToast; | |
| 72 | - | }); | |
| 73 | - | ||
| 74 | - | // Show new zoom toast | |
| 75 | - | const percentage = Math.round(zoomLevel * 100); | |
| 76 | - | const message = `Zoom level: ${percentage}%`; | |
| 77 | - | const id = addToast(message, 1500, 'info'); | |
| 78 | - | zoomToastId = id; | |
| 79 | - | ||
| 80 | - | console.log('[dumber] Added zoom toast - id:', id, 'current toasts count:', toasts.length); | |
| 81 | - | ||
| 82 | - | // Debounced cleanup for rapid zoom changes | |
| 83 | - | zoomDebounceTimer = window.setTimeout(() => { | |
| 84 | - | zoomToastId = null; | |
| 85 | - | zoomDebounceTimer = undefined; | |
| 86 | - | }, 1500); | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | // Listen for toast events from main world bridge | |
| 90 | - | if (typeof document !== 'undefined') { | |
| 91 | - | // Listen for general toast requests | |
| 92 | - | document.addEventListener('dumber:toast', ((event: CustomEvent) => { | |
| 93 | - | const { message, duration, type } = event.detail; | |
| 94 | - | console.log('[ToastContainer] Received toast event:', { message, duration, type }); | |
| 95 | - | addToast(message, duration, type); | |
| 96 | - | }) as EventListener); | |
| 97 | - | ||
| 98 | - | // Listen for zoom toast requests | |
| 99 | - | document.addEventListener('dumber:toast:zoom', ((event: CustomEvent) => { | |
| 100 | - | const { level } = event.detail; | |
| 101 | - | console.log('[ToastContainer] Received zoom toast event:', level); | |
| 102 | - | showZoomToast(level); | |
| 103 | - | }) as EventListener); | |
| 104 | - | ||
| 105 | - | console.log('✅ Toast event listeners set up for CustomEvents'); | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // Effect that runs when toasts array changes | |
| 109 | - | $effect(() => { | |
| 110 | - | const container = document.querySelector('.toast-container'); | |
| 111 | - | const browserRoot = document.querySelector('.browser-component-root'); | |
| 112 | - | console.log('[dumber] Toast state changed - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length, 'toasts:', toasts.map(t => t.message)); | |
| 113 | - | }); | |
| 114 | - | ||
| 115 | - | // One-time setup effect | |
| 116 | - | $effect(() => { | |
| 117 | - | console.log('🔄 ToastContainer initialized, functions exposed'); | |
| 118 | - | ||
| 119 | - | // Monitor container periodically for debugging (disabled) | |
| 120 | - | // const monitorInterval = setInterval(() => { | |
| 121 | - | // const container = document.querySelector('.toast-container'); | |
| 122 | - | // const browserRoot = document.querySelector('.browser-component-root'); | |
| 123 | - | // console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length); | |
| 124 | - | // }, 5000); | |
| 125 | - | ||
| 126 | - | return () => { | |
| 127 | - | // clearInterval(monitorInterval); | |
| 128 | - | console.log('🗑️ ToastContainer cleanup'); | |
| 129 | - | }; | |
| 130 | - | }); | |
| 131 | - | </script> | |
| 132 | - | ||
| 133 | - | <div class="browser-component-root toast-container"> | |
| 134 | - | {#each toasts as toast (toast.id)} | |
| 135 | - | <Toast | |
| 136 | - | id={toast.id} | |
| 137 | - | message={toast.message} | |
| 138 | - | duration={toast.duration} | |
| 139 | - | type={toast.type} | |
| 140 | - | onDismiss={dismissToast} | |
| 141 | - | /> | |
| 142 | - | {/each} | |
| 143 | - | </div> | |
| 144 | - | ||
| 145 | - | <style> | |
| 146 | - | .toast-container { | |
| 147 | - | display: flex; | |
| 148 | - | flex-direction: column; | |
| 149 | - | gap: 0.5rem; | |
| 150 | - | left: 0.5rem; | |
| 151 | - | max-width: 20rem; | |
| 152 | - | pointer-events: none; | |
| 153 | - | position: fixed; | |
| 154 | - | top: 0.5rem; | |
| 155 | - | z-index: 2147483647; | |
| 156 | - | } | |
| 157 | - | </style> | |
D webui/src/injected/bootstrap.ts +0 -422
| @@ -1,422 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Dumber GUI bootstrap routine shared between injected pages and special schemes. | |
| 3 | - | * | |
| 4 | - | * Sets up toast, omnibox, keyboard service, and workspace event listeners. | |
| 5 | - | */ | |
| 6 | - | ||
| 7 | - | import { initializeToast, type ToastConfig } from "./modules/toast"; | |
| 8 | - | import { | |
| 9 | - | initializeOmnibox, | |
| 10 | - | type OmniboxInitConfig, | |
| 11 | - | } from "./modules/omnibox"; | |
| 12 | - | import "./modules/workspace"; // Auto-initializes pane mode UI notifications | |
| 13 | - | import { keyboardService, type KeyboardService } from "$lib/keyboard"; | |
| 14 | - | import type { Suggestion } from "../components/omnibox/types"; | |
| 15 | - | ||
| 16 | - | declare global { | |
| 17 | - | interface Window { | |
| 18 | - | __dumber_gui?: DumberGUI; | |
| 19 | - | __dumber_gui_ready?: boolean; | |
| 20 | - | __dumber_gui_ready_for?: Document | null; | |
| 21 | - | __dumber_showToast?: ( | |
| 22 | - | message: string, | |
| 23 | - | duration?: number, | |
| 24 | - | type?: "info" | "success" | "error", | |
| 25 | - | ) => number | void; | |
| 26 | - | __dumber_showZoomToast?: (level: number) => void; | |
| 27 | - | __dumber_keyboard?: KeyboardService; | |
| 28 | - | __dumber_omnibox?: { | |
| 29 | - | setSuggestions: (suggestions: Suggestion[]) => void; | |
| 30 | - | setInlineSuggestion: (url: string | null) => void; | |
| 31 | - | toggle: () => void; | |
| 32 | - | open: (mode?: string, query?: string) => void; | |
| 33 | - | close: () => void; | |
| 34 | - | findQuery: (query: string) => void; | |
| 35 | - | setActive: (active: boolean) => void; | |
| 36 | - | }; | |
| 37 | - | __dumber_gui_bootstrap?: () => void; | |
| 38 | - | __dumber_toggle?: () => void; | |
| 39 | - | __dumber_find_open?: (query?: string) => void; | |
| 40 | - | __dumber_find_close?: () => void; | |
| 41 | - | __dumber_find_query?: (query: string) => void; | |
| 42 | - | __dumber_dismissToast?: (id: number) => void; | |
| 43 | - | __dumber_clearToasts?: () => void; | |
| 44 | - | webkit?: { | |
| 45 | - | messageHandlers?: { | |
| 46 | - | dumber?: { | |
| 47 | - | postMessage: (message: unknown) => void; | |
| 48 | - | }; | |
| 49 | - | }; | |
| 50 | - | }; | |
| 51 | - | __dumber_webview_id?: number | string; | |
| 52 | - | } | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | interface DumberGUI { | |
| 56 | - | initializeToast: (config?: ToastConfig) => Promise<void>; | |
| 57 | - | initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>; | |
| 58 | - | keyboard: KeyboardService; | |
| 59 | - | isReady: boolean; | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | function whenDOMReady(callback: () => void) { | |
| 63 | - | if (document.readyState === "loading") { | |
| 64 | - | document.addEventListener("DOMContentLoaded", callback, { once: true }); | |
| 65 | - | } else if (document.readyState === "interactive" && !document.body) { | |
| 66 | - | setTimeout(() => whenDOMReady(callback), 10); | |
| 67 | - | } else { | |
| 68 | - | callback(); | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | function _whenPageLoaded(callback: () => void) { | |
| 73 | - | if (document.readyState === "complete") { | |
| 74 | - | callback(); | |
| 75 | - | } else { | |
| 76 | - | window.addEventListener("load", callback, { once: true }); | |
| 77 | - | } | |
| 78 | - | } | |
| 79 | - | void _whenPageLoaded; // Reserved for future use | |
| 80 | - | ||
| 81 | - | function normalizeWebviewId(value: unknown): number | null { | |
| 82 | - | if (typeof value === "number" && Number.isFinite(value)) { | |
| 83 | - | return value; | |
| 84 | - | } | |
| 85 | - | if (typeof value === "string") { | |
| 86 | - | const trimmed = value.trim(); | |
| 87 | - | if (!trimmed || trimmed === "__WEBVIEW_ID__") { | |
| 88 | - | return null; | |
| 89 | - | } | |
| 90 | - | const parsed = Number.parseInt(trimmed, 10); | |
| 91 | - | return Number.isNaN(parsed) ? null : parsed; | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | return null; | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | export function bootstrapGUI(): void { | |
| 98 | - | if (window.__dumber_gui_ready_for === document) { | |
| 99 | - | return; | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | if (window.self !== window.top) { | |
| 103 | - | console.log("🚫 Dumber GUI skipped in iframe"); | |
| 104 | - | return; | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | window.__dumber_gui_ready_for = document; | |
| 108 | - | window.__dumber_gui_ready = true; | |
| 109 | - | ||
| 110 | - | try { | |
| 111 | - | window.addEventListener("pagehide", () => { | |
| 112 | - | window.__dumber_gui_ready = false; | |
| 113 | - | window.__dumber_gui_ready_for = null; | |
| 114 | - | }, { once: true }); | |
| 115 | - | } catch { | |
| 116 | - | // Ignore environments without pagehide support | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // Request color palettes from Go backend | |
| 120 | - | // The isolated world has access to webkit.messageHandlers, main world does not. | |
| 121 | - | // Main world has set up the __dumber_color_palettes callback handler. | |
| 122 | - | const requestColorPalettes = () => { | |
| 123 | - | try { | |
| 124 | - | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 125 | - | if (bridge && typeof bridge.postMessage === "function") { | |
| 126 | - | const webviewId = window.__dumber_webview_id; | |
| 127 | - | console.log("[dumber-palette] Requesting color palettes from backend, webview_id:", webviewId); | |
| 128 | - | bridge.postMessage({ | |
| 129 | - | type: "get_color_palettes", | |
| 130 | - | webview_id: typeof webviewId === "number" ? webviewId : 0, | |
| 131 | - | }); | |
| 132 | - | } else { | |
| 133 | - | console.warn("[dumber-palette] WebKit message handler not available"); | |
| 134 | - | } | |
| 135 | - | } catch (err) { | |
| 136 | - | console.error("[dumber-palette] Failed to request color palettes:", err); | |
| 137 | - | } | |
| 138 | - | }; | |
| 139 | - | requestColorPalettes(); | |
| 140 | - | ||
| 141 | - | let workspaceHasFocus = false; | |
| 142 | - | const initialRawWebViewId = window.__dumber_webview_id; | |
| 143 | - | let currentWebViewId = normalizeWebviewId(initialRawWebViewId); | |
| 144 | - | if (currentWebViewId !== null) { | |
| 145 | - | window.__dumber_webview_id = currentWebViewId; | |
| 146 | - | } | |
| 147 | - | let hasReceivedFocusEvent = false; | |
| 148 | - | ||
| 149 | - | if (currentWebViewId === null) { | |
| 150 | - | console.log("[workspace] WebView ID unknown, requesting from Go backend"); | |
| 151 | - | if (window.webkit?.messageHandlers?.dumber) { | |
| 152 | - | window.webkit.messageHandlers.dumber.postMessage({ | |
| 153 | - | type: "request-webview-id", | |
| 154 | - | webview_id: 0, | |
| 155 | - | payload: { timestamp: Date.now() }, | |
| 156 | - | }); | |
| 157 | - | } | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | document.addEventListener("dumber:webview-id", (event: Event) => { | |
| 161 | - | const detail = (event as CustomEvent).detail || {}; | |
| 162 | - | const incomingId = normalizeWebviewId(detail.webviewId); | |
| 163 | - | if (incomingId !== null && incomingId !== currentWebViewId) { | |
| 164 | - | currentWebViewId = incomingId; | |
| 165 | - | window.__dumber_webview_id = incomingId; | |
| 166 | - | console.log("[workspace] Received webview ID from Go:", incomingId); | |
| 167 | - | } | |
| 168 | - | }); | |
| 169 | - | ||
| 170 | - | document.addEventListener("dumber:workspace-focus", (event: Event) => { | |
| 171 | - | const detail = (event as CustomEvent).detail || {}; | |
| 172 | - | const eventWebviewId = normalizeWebviewId(detail?.webviewId); | |
| 173 | - | ||
| 174 | - | if (eventWebviewId !== null) { | |
| 175 | - | if (currentWebViewId === null) { | |
| 176 | - | currentWebViewId = eventWebviewId; | |
| 177 | - | window.__dumber_webview_id = eventWebviewId; | |
| 178 | - | console.log( | |
| 179 | - | "[workspace] Learned webview ID from focus event:", | |
| 180 | - | eventWebviewId, | |
| 181 | - | ); | |
| 182 | - | } else if (eventWebviewId !== currentWebViewId) { | |
| 183 | - | console.log("[workspace] Ignoring focus event for other webview", { | |
| 184 | - | eventWebviewId, | |
| 185 | - | self: currentWebViewId, | |
| 186 | - | detail, | |
| 187 | - | }); | |
| 188 | - | return; | |
| 189 | - | } | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | console.log("[workspace focus event]", detail, "prev focus=", workspaceHasFocus); | |
| 193 | - | ||
| 194 | - | hasReceivedFocusEvent = true; | |
| 195 | - | ||
| 196 | - | if (typeof detail?.active === "boolean") { | |
| 197 | - | workspaceHasFocus = detail.active; | |
| 198 | - | } else { | |
| 199 | - | workspaceHasFocus = detail !== false; | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | console.log( | |
| 203 | - | "[workspace focus updated] focus=", | |
| 204 | - | workspaceHasFocus, | |
| 205 | - | "webviewId=", | |
| 206 | - | currentWebViewId, | |
| 207 | - | ); | |
| 208 | - | ||
| 209 | - | if (window.__dumber_omnibox) { | |
| 210 | - | if (workspaceHasFocus) { | |
| 211 | - | window.__dumber_omnibox.setActive(true); | |
| 212 | - | } else { | |
| 213 | - | window.__dumber_omnibox.setActive(false); | |
| 214 | - | window.__dumber_omnibox.close(); | |
| 215 | - | } | |
| 216 | - | } | |
| 217 | - | }); | |
| 218 | - | ||
| 219 | - | // Initialize toast early (DOM ready is enough) | |
| 220 | - | whenDOMReady(async () => { | |
| 221 | - | console.log("[GUI] DOM ready, initializing toast"); | |
| 222 | - | try { | |
| 223 | - | await initializeToast(); | |
| 224 | - | console.log("[GUI] Toast initialized successfully"); | |
| 225 | - | } catch (e) { | |
| 226 | - | console.error("❌ Failed to initialize toast system:", e); | |
| 227 | - | } | |
| 228 | - | }); | |
| 229 | - | ||
| 230 | - | // Initialize omnibox as soon as the DOM is ready (no need to wait for full load) | |
| 231 | - | whenDOMReady(async () => { | |
| 232 | - | console.log("[GUI] DOM ready, initializing omnibox"); | |
| 233 | - | try { | |
| 234 | - | await initializeOmnibox(); | |
| 235 | - | console.log("✅ Omnibox system initialized successfully"); | |
| 236 | - | } catch (omniboxError) { | |
| 237 | - | console.error("❌ Failed to initialize omnibox system:", omniboxError); | |
| 238 | - | } | |
| 239 | - | }); | |
| 240 | - | ||
| 241 | - | window.__dumber_keyboard = keyboardService; | |
| 242 | - | ||
| 243 | - | whenDOMReady(() => { | |
| 244 | - | document.addEventListener( | |
| 245 | - | "keydown", | |
| 246 | - | (event) => { | |
| 247 | - | keyboardService.handleKeyboardEvent(event); | |
| 248 | - | }, | |
| 249 | - | true, | |
| 250 | - | ); | |
| 251 | - | ||
| 252 | - | document.addEventListener( | |
| 253 | - | "mousedown", | |
| 254 | - | (event) => { | |
| 255 | - | keyboardService.handleMouseEvent(event); | |
| 256 | - | }, | |
| 257 | - | true, | |
| 258 | - | ); | |
| 259 | - | ||
| 260 | - | console.log("✅ KeyboardService initialized with global listeners"); | |
| 261 | - | ||
| 262 | - | document.addEventListener("dumber:key", (e: Event) => { | |
| 263 | - | const detail = (e as CustomEvent).detail || {}; | |
| 264 | - | if (detail && typeof detail.shortcut === "string") { | |
| 265 | - | keyboardService.handleNativeShortcut(detail.shortcut); | |
| 266 | - | } | |
| 267 | - | }); | |
| 268 | - | ||
| 269 | - | document.addEventListener("dumber:ui:shortcut", (e: Event) => { | |
| 270 | - | const detail = (e as CustomEvent).detail || {}; | |
| 271 | - | const action = detail?.action; | |
| 272 | - | const eventWebViewId = normalizeWebviewId(detail?.webviewId); | |
| 273 | - | const source = detail?.source || "unknown"; | |
| 274 | - | ||
| 275 | - | if (eventWebViewId !== null && currentWebViewId === null) { | |
| 276 | - | currentWebViewId = eventWebViewId; | |
| 277 | - | window.__dumber_webview_id = eventWebViewId; | |
| 278 | - | console.log( | |
| 279 | - | "[dumber shortcuts] Learned webview ID from shortcut event:", | |
| 280 | - | eventWebViewId, | |
| 281 | - | ); | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | console.log("[dumber shortcuts] Event received", { | |
| 285 | - | action, | |
| 286 | - | eventWebViewId, | |
| 287 | - | currentWebViewId, | |
| 288 | - | detail, | |
| 289 | - | }); | |
| 290 | - | ||
| 291 | - | if (typeof action !== "string") { | |
| 292 | - | console.log("[dumber shortcuts] No action in event, ignoring"); | |
| 293 | - | return; | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | const isForThisWebView = | |
| 297 | - | eventWebViewId === null || eventWebViewId === currentWebViewId; | |
| 298 | - | const isOmniboxAction = action.startsWith("omnibox-"); | |
| 299 | - | const shouldHandle = | |
| 300 | - | isForThisWebView && | |
| 301 | - | (isOmniboxAction | |
| 302 | - | ? hasReceivedFocusEvent | |
| 303 | - | ? workspaceHasFocus | |
| 304 | - | : true | |
| 305 | - | : true); | |
| 306 | - | ||
| 307 | - | if (!shouldHandle) { | |
| 308 | - | console.log("[dumber shortcuts] ignored action", { | |
| 309 | - | action, | |
| 310 | - | workspaceHasFocus, | |
| 311 | - | isForThisWebView, | |
| 312 | - | eventWebViewId, | |
| 313 | - | currentWebViewId, | |
| 314 | - | source, | |
| 315 | - | detail, | |
| 316 | - | hasReceivedFocusEvent, | |
| 317 | - | }); | |
| 318 | - | return; | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | console.log("[dumber shortcuts] handling action", { | |
| 322 | - | action, | |
| 323 | - | source, | |
| 324 | - | webviewId: currentWebViewId, | |
| 325 | - | }); | |
| 326 | - | ||
| 327 | - | const omnibox = window.__dumber_omnibox; | |
| 328 | - | ||
| 329 | - | switch (action) { | |
| 330 | - | case "omnibox-nav-toggle": | |
| 331 | - | omnibox?.open?.("omnibox", detail?.query); | |
| 332 | - | break; | |
| 333 | - | case "omnibox-open": | |
| 334 | - | omnibox?.open?.("omnibox", detail?.query); | |
| 335 | - | break; | |
| 336 | - | case "omnibox-find-toggle": | |
| 337 | - | omnibox?.open?.("find", detail?.query); | |
| 338 | - | break; | |
| 339 | - | case "omnibox-close": | |
| 340 | - | omnibox?.close?.(); | |
| 341 | - | break; | |
| 342 | - | default: | |
| 343 | - | console.warn("[dumber] Unknown UI shortcut action:", action); | |
| 344 | - | } | |
| 345 | - | }); | |
| 346 | - | }); | |
| 347 | - | ||
| 348 | - | window.__dumber_toggle = () => { | |
| 349 | - | try { | |
| 350 | - | console.log("🎯 __dumber_toggle called"); | |
| 351 | - | console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox); | |
| 352 | - | if (window.__dumber_omnibox?.toggle) { | |
| 353 | - | console.log("✅ Using Svelte omnibox toggle"); | |
| 354 | - | window.__dumber_omnibox.toggle(); | |
| 355 | - | return; | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | throw new Error("Omnibox toggle requested but API is unavailable"); | |
| 359 | - | } catch (error) { | |
| 360 | - | console.error("❌ Error in __dumber_toggle:", error); | |
| 361 | - | } | |
| 362 | - | }; | |
| 363 | - | ||
| 364 | - | window.__dumber_find_open = (query?: string) => { | |
| 365 | - | try { | |
| 366 | - | if (window.__dumber_omnibox?.open) { | |
| 367 | - | window.__dumber_omnibox.open("find", query); | |
| 368 | - | return; | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | throw new Error("Omnibox find requested but API is unavailable"); | |
| 372 | - | } catch (error) { | |
| 373 | - | console.error("❌ Error in __dumber_find_open:", error); | |
| 374 | - | } | |
| 375 | - | }; | |
| 376 | - | ||
| 377 | - | window.__dumber_find_close = () => { | |
| 378 | - | if (window.__dumber_omnibox?.close) { | |
| 379 | - | window.__dumber_omnibox.close(); | |
| 380 | - | return; | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | const error = new Error("Omnibox close requested but API is unavailable"); | |
| 384 | - | console.error("❌ Error in __dumber_find_close:", error); | |
| 385 | - | }; | |
| 386 | - | ||
| 387 | - | window.__dumber_find_query = (query: string) => { | |
| 388 | - | if (window.__dumber_omnibox?.findQuery) { | |
| 389 | - | window.__dumber_omnibox.findQuery(query); | |
| 390 | - | } | |
| 391 | - | }; | |
| 392 | - | ||
| 393 | - | window.__dumber_gui = { | |
| 394 | - | initializeToast: async (config?: ToastConfig) => { | |
| 395 | - | return await initializeToast(config); | |
| 396 | - | }, | |
| 397 | - | initializeOmnibox: async (config?: OmniboxInitConfig) => { | |
| 398 | - | return await initializeOmnibox(config); | |
| 399 | - | }, | |
| 400 | - | keyboard: keyboardService, | |
| 401 | - | isReady: true, | |
| 402 | - | }; | |
| 403 | - | ||
| 404 | - | window.__dumber_gui_bootstrap = function () { | |
| 405 | - | console.log("[gui-bootstrap] Initializing GUI for workspace"); | |
| 406 | - | ||
| 407 | - | if (!window.__dumber_omnibox) { | |
| 408 | - | console.log( | |
| 409 | - | `[gui-bootstrap] Omnibox not yet available for webview ${currentWebViewId}, will be loaded when needed`, | |
| 410 | - | ); | |
| 411 | - | } else { | |
| 412 | - | console.log( | |
| 413 | - | `[gui-bootstrap] Omnibox already available for webview ${currentWebViewId}`, | |
| 414 | - | ); | |
| 415 | - | window.__dumber_omnibox.setActive(workspaceHasFocus); | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | console.log( | |
| 419 | - | `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`, | |
| 420 | - | ); | |
| 421 | - | }; | |
| 422 | - | } | |
D webui/src/injected/gui.ts +0 -14
| @@ -1,14 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Unified GUI bundle entry point. | |
| 3 | - | * | |
| 4 | - | * Delegates to the shared bootstrap so special pages can reuse the same setup | |
| 5 | - | * without depending on this injected build. | |
| 6 | - | * | |
| 7 | - | * Always calls bootstrapGUI() - deduplication is handled inside bootstrap.ts | |
| 8 | - | * via window.__dumber_gui_ready_for check. This ensures GUI loads in all | |
| 9 | - | * contexts including new split panes and about:blank pages. | |
| 10 | - | */ | |
| 11 | - | ||
| 12 | - | import { bootstrapGUI } from "./bootstrap"; | |
| 13 | - | ||
| 14 | - | bootstrapGUI(); | |
D webui/src/injected/main-world.ts +0 -1105
| @@ -1,1105 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Main-world bridge script for Dumber Browser | |
| 3 | - | * | |
| 4 | - | * This script is injected into the page-world context to provide: | |
| 5 | - | * - Window.open interceptor with debouncing | |
| 6 | - | * - Toast notifications bridge | |
| 7 | - | * - Theme management | |
| 8 | - | * - DOM zoom functionality | |
| 9 | - | * - Omnibox suggestions bridge | |
| 10 | - | */ | |
| 11 | - | ||
| 12 | - | // Type definitions for window extensions | |
| 13 | - | declare global { | |
| 14 | - | interface Window { | |
| 15 | - | __dumber: DumberAPI; | |
| 16 | - | __dumber_page_bridge_installed?: boolean; | |
| 17 | - | __dumber_dom_zoom_seed?: number; | |
| 18 | - | __dumber_dom_zoom_level?: number; | |
| 19 | - | __dumber_initial_theme?: string; | |
| 20 | - | __dumber_setTheme?: (theme: "light" | "dark") => void; | |
| 21 | - | __dumber_applyPalette?: (theme: "light" | "dark") => void; | |
| 22 | - | __dumber_palette?: Record<string, Record<string, string>>; | |
| 23 | - | __dumber_color_palettes?: (palettes: unknown) => void; | |
| 24 | - | __dumber_color_palettes_error?: (error: string) => void; | |
| 25 | - | __dumber_search_shortcuts?: (data: unknown) => void; | |
| 26 | - | __dumber_search_shortcuts_error?: (error: string) => void; | |
| 27 | - | __dumber_applyDomZoom?: (level: number) => void; | |
| 28 | - | __dumber_showToast?: ( | |
| 29 | - | message: string, | |
| 30 | - | duration?: number, | |
| 31 | - | type?: "info" | "success" | "error", | |
| 32 | - | ) => number | void; | |
| 33 | - | __dumber_showZoomToast?: (level: number) => void; | |
| 34 | - | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | |
| 35 | - | __dumber_omnibox_inline_suggestion?: (url: string | null) => void; | |
| 36 | - | __dumber_favorites?: (favorites: unknown[]) => void; | |
| 37 | - | __dumber_webview_id?: string | number; | |
| 38 | - | __dumber_is_active?: boolean; | |
| 39 | - | __dumber_teardown?: () => void; | |
| 40 | - | __dumber_enableKeyboardBlocking?: () => void; | |
| 41 | - | __dumber_disableKeyboardBlocking?: () => void; | |
| 42 | - | __dumber_keyboard_blocking_active?: boolean; | |
| 43 | - | __dumber_keyboard_blocker?: (e: Event) => void; | |
| 44 | - | __dumber_focus_guard?: (e: Event) => void; | |
| 45 | - | __dumber_click_blocker?: (e: Event) => void; | |
| 46 | - | webkit?: { | |
| 47 | - | messageHandlers?: { | |
| 48 | - | dumber?: { | |
| 49 | - | postMessage: (message: unknown) => void; | |
| 50 | - | }; | |
| 51 | - | }; | |
| 52 | - | }; | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | interface Suggestion { | |
| 57 | - | // Define proper suggestion types based on your needs | |
| 58 | - | [key: string]: unknown; | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | interface DumberAPI { | |
| 62 | - | omnibox: { | |
| 63 | - | suggestions: (suggestions: Suggestion[]) => void; | |
| 64 | - | }; | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | (() => { | |
| 68 | - | try { | |
| 69 | - | // Prevent multiple installations | |
| 70 | - | if (window.__dumber_page_bridge_installed) { | |
| 71 | - | return; | |
| 72 | - | } | |
| 73 | - | window.__dumber_page_bridge_installed = true; | |
| 74 | - | ||
| 75 | - | const cleanupHandlers: Array<() => void> = []; | |
| 76 | - | let teardownExecuted = false; | |
| 77 | - | ||
| 78 | - | // Initialize zoom level placeholder (will be replaced by Go) | |
| 79 | - | const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go | |
| 80 | - | window.__dumber_dom_zoom_seed = initialZoom; | |
| 81 | - | ||
| 82 | - | // Initialize WebView ID and active state (injected by Go at document-start) | |
| 83 | - | // Only set fallback values if not already injected | |
| 84 | - | if (typeof window.__dumber_webview_id === 'undefined') { | |
| 85 | - | window.__dumber_webview_id = "__WEBVIEW_ID__"; | |
| 86 | - | } | |
| 87 | - | if (typeof window.__dumber_is_active === 'undefined') { | |
| 88 | - | window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean; | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // Set up color palette callback handlers (will be called from isolated world via CustomEvent) | |
| 92 | - | window.__dumber_color_palettes = (palettes: unknown) => { | |
| 93 | - | console.log("[dumber-palette] Received palettes from backend:", palettes); | |
| 94 | - | try { | |
| 95 | - | // Type guard to ensure palettes is a valid object | |
| 96 | - | if (typeof palettes !== "object" || palettes === null) { | |
| 97 | - | console.error("[dumber-palette] ERROR: Invalid palettes received - type:", typeof palettes); | |
| 98 | - | return; | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | window.__dumber_palette = palettes as Record<string, Record<string, string>>; | |
| 102 | - | console.log("[dumber-palette] Palette keys:", Object.keys(window.__dumber_palette || {})); | |
| 103 | - | ||
| 104 | - | // Normalize palette keys to lowercase | |
| 105 | - | const normalized: Record<string, Record<string, string>> = {}; | |
| 106 | - | if (window.__dumber_palette) { | |
| 107 | - | for (const key in window.__dumber_palette) { | |
| 108 | - | if (Object.prototype.hasOwnProperty.call(window.__dumber_palette, key)) { | |
| 109 | - | const paletteValue = window.__dumber_palette[key]; | |
| 110 | - | if (paletteValue) { | |
| 111 | - | normalized[key.toLowerCase()] = paletteValue; | |
| 112 | - | console.log(`[dumber-palette] Normalized: ${key} -> ${key.toLowerCase()}, colors:`, Object.keys(paletteValue)); | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | } | |
| 116 | - | window.__dumber_palette = normalized; | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // Apply palette now that we have it | |
| 120 | - | const initialTheme: "light" | "dark" = document.documentElement.classList.contains("dark") ? "dark" : "light"; | |
| 121 | - | console.log(`[dumber-palette] Initial theme detected: ${initialTheme}`); | |
| 122 | - | window.__dumber_initial_theme = initialTheme; | |
| 123 | - | ||
| 124 | - | if (window.__dumber_applyPalette) { | |
| 125 | - | console.log("[dumber-palette] Applying palette..."); | |
| 126 | - | window.__dumber_applyPalette(initialTheme); | |
| 127 | - | } else { | |
| 128 | - | console.error("[dumber-palette] ERROR: __dumber_applyPalette function not found"); | |
| 129 | - | } | |
| 130 | - | } catch (err) { | |
| 131 | - | console.error("[dumber-palette] ERROR: Failed to apply received palette:", err); | |
| 132 | - | } | |
| 133 | - | }; | |
| 134 | - | ||
| 135 | - | window.__dumber_color_palettes_error = (error: string) => { | |
| 136 | - | console.error("[dumber-palette] ERROR from backend:", error); | |
| 137 | - | }; | |
| 138 | - | ||
| 139 | - | // Request color palettes from Go backend via isolated world bridge | |
| 140 | - | // Note: webkit.messageHandlers.dumber is only available in the isolated world, | |
| 141 | - | // not in the main world. The request will be sent from the isolated world (gui.ts). | |
| 142 | - | console.log("[dumber-palette] Color palette handlers registered, waiting for isolated world to request palettes"); | |
| 143 | - | ||
| 144 | - | // Palette application function | |
| 145 | - | window.__dumber_applyPalette = (theme: "light" | "dark") => { | |
| 146 | - | console.log(`[dumber-palette] applyPalette called with theme: ${theme}`); | |
| 147 | - | try { | |
| 148 | - | const palette = window.__dumber_palette?.[theme] || window.__dumber_palette?.["light"]; | |
| 149 | - | if (!palette) { | |
| 150 | - | console.error(`[dumber-palette] ERROR: No palette found for theme "${theme}". Available:`, Object.keys(window.__dumber_palette || {})); | |
| 151 | - | return; | |
| 152 | - | } | |
| 153 | - | console.log(`[dumber-palette] Found palette for theme "${theme}":`, palette); | |
| 154 | - | ||
| 155 | - | const tokenMap: Record<string, string> = { | |
| 156 | - | background: "--color-browser-bg", | |
| 157 | - | surface: "--color-browser-surface", | |
| 158 | - | surface_variant: "--color-browser-surface-variant", | |
| 159 | - | text: "--color-browser-text", | |
| 160 | - | muted: "--color-browser-muted", | |
| 161 | - | accent: "--color-browser-accent", | |
| 162 | - | border: "--color-browser-border", | |
| 163 | - | }; | |
| 164 | - | ||
| 165 | - | const dynamicMap: Record<string, string> = { | |
| 166 | - | background: "--dynamic-bg", | |
| 167 | - | surface: "--dynamic-surface", | |
| 168 | - | surface_variant: "--dynamic-surface-variant", | |
| 169 | - | text: "--dynamic-text", | |
| 170 | - | muted: "--dynamic-muted", | |
| 171 | - | accent: "--dynamic-accent", | |
| 172 | - | border: "--dynamic-border", | |
| 173 | - | }; | |
| 174 | - | ||
| 175 | - | const root = document.documentElement; | |
| 176 | - | let appliedCount = 0; | |
| 177 | - | for (const key in tokenMap) { | |
| 178 | - | if (Object.prototype.hasOwnProperty.call(tokenMap, key)) { | |
| 179 | - | const colorValue = palette[key]; | |
| 180 | - | const tokenVar = tokenMap[key]; | |
| 181 | - | const dynamicVar = dynamicMap[key]; | |
| 182 | - | if (colorValue && typeof colorValue === "string" && tokenVar) { | |
| 183 | - | root.style.setProperty(tokenVar, colorValue); | |
| 184 | - | if (dynamicVar) { | |
| 185 | - | root.style.setProperty(dynamicVar, colorValue); | |
| 186 | - | } | |
| 187 | - | console.log(`[dumber-palette] Applied ${key}: ${colorValue} -> ${tokenVar}`); | |
| 188 | - | appliedCount++; | |
| 189 | - | } else { | |
| 190 | - | console.warn(`[dumber-palette] Skipped ${key}: colorValue=${colorValue}, tokenVar=${tokenVar}`); | |
| 191 | - | } | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | console.log(`[dumber-palette] Applied ${appliedCount} colors successfully`); | |
| 196 | - | document.dispatchEvent( | |
| 197 | - | new CustomEvent("dumber:palette-updated", { detail: { theme } }) | |
| 198 | - | ); | |
| 199 | - | } catch (err) { | |
| 200 | - | console.error("[dumber-palette] EXCEPTION applying palette:", err); | |
| 201 | - | } | |
| 202 | - | }; | |
| 203 | - | ||
| 204 | - | // Theme setter function for GTK theme integration | |
| 205 | - | window.__dumber_setTheme = (theme: "light" | "dark") => { | |
| 206 | - | window.__dumber_initial_theme = theme; | |
| 207 | - | console.log("[dumber] Setting theme to:", theme); | |
| 208 | - | if (theme === "dark") { | |
| 209 | - | document.documentElement.classList.add("dark"); | |
| 210 | - | } else { | |
| 211 | - | document.documentElement.classList.remove("dark"); | |
| 212 | - | } | |
| 213 | - | try { | |
| 214 | - | if (typeof window.__dumber_applyPalette === "function") { | |
| 215 | - | window.__dumber_applyPalette(theme); | |
| 216 | - | } | |
| 217 | - | document.dispatchEvent( | |
| 218 | - | new CustomEvent("dumber:theme-change", { detail: { theme } }), | |
| 219 | - | ); | |
| 220 | - | } catch (error) { | |
| 221 | - | console.warn("[dumber] Failed to apply runtime palette", error); | |
| 222 | - | } | |
| 223 | - | }; | |
| 224 | - | ||
| 225 | - | // Initialize unified API object (toast functions are exposed by ToastContainer.svelte) | |
| 226 | - | window.__dumber = window.__dumber || ({} as DumberAPI); | |
| 227 | - | ||
| 228 | - | // DOM zoom functionality | |
| 229 | - | if (typeof window.__dumber_dom_zoom_level !== "number") { | |
| 230 | - | window.__dumber_dom_zoom_level = initialZoom; | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | const applyZoomStyles = (node: HTMLElement, level: number): void => { | |
| 234 | - | if (!node) return; | |
| 235 | - | ||
| 236 | - | if (Math.abs(level - 1.0) < 1e-6) { | |
| 237 | - | // Reset zoom | |
| 238 | - | node.style.removeProperty("zoom"); | |
| 239 | - | node.style.removeProperty("transform"); | |
| 240 | - | node.style.removeProperty("transform-origin"); | |
| 241 | - | node.style.removeProperty("width"); | |
| 242 | - | node.style.removeProperty("min-width"); | |
| 243 | - | node.style.removeProperty("height"); | |
| 244 | - | node.style.removeProperty("min-height"); | |
| 245 | - | return; | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | const scale = level; | |
| 249 | - | const inversePercent = 100 / scale; | |
| 250 | - | const widthValue = `${inversePercent}%`; | |
| 251 | - | ||
| 252 | - | node.style.removeProperty("zoom"); | |
| 253 | - | node.style.transform = `scale(${scale})`; | |
| 254 | - | node.style.transformOrigin = "0 0"; | |
| 255 | - | node.style.width = widthValue; | |
| 256 | - | node.style.minWidth = widthValue; | |
| 257 | - | node.style.minHeight = "100%"; | |
| 258 | - | }; | |
| 259 | - | ||
| 260 | - | window.__dumber_applyDomZoom = (level: number) => { | |
| 261 | - | try { | |
| 262 | - | window.__dumber_dom_zoom_level = level; | |
| 263 | - | window.__dumber_dom_zoom_seed = level; | |
| 264 | - | applyZoomStyles(document.documentElement, level); | |
| 265 | - | if (document.body) { | |
| 266 | - | applyZoomStyles(document.body, level); | |
| 267 | - | } | |
| 268 | - | } catch (err) { | |
| 269 | - | console.error("[dumber] DOM zoom error", err); | |
| 270 | - | } | |
| 271 | - | }; | |
| 272 | - | ||
| 273 | - | // Apply initial zoom | |
| 274 | - | window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level); | |
| 275 | - | ||
| 276 | - | if (!document.body) { | |
| 277 | - | document.addEventListener( | |
| 278 | - | "DOMContentLoaded", | |
| 279 | - | () => { | |
| 280 | - | if (typeof window.__dumber_dom_zoom_level === "number") { | |
| 281 | - | window.__dumber_applyDomZoom!(window.__dumber_dom_zoom_level); | |
| 282 | - | } | |
| 283 | - | }, | |
| 284 | - | { once: true }, | |
| 285 | - | ); | |
| 286 | - | } | |
| 287 | - | ||
| 288 | - | /** | |
| 289 | - | * ============================================================================ | |
| 290 | - | * MAIN-WORLD TO ISOLATED-WORLD BRIDGE SYSTEM | |
| 291 | - | * ============================================================================ | |
| 292 | - | * | |
| 293 | - | * Architecture Overview: | |
| 294 | - | * ---------------------- | |
| 295 | - | * WebKit runs scripts in different "worlds" for security: | |
| 296 | - | * | |
| 297 | - | * - MAIN WORLD: Where page scripts run | |
| 298 | - | * - Can access window.webkit.messageHandlers (backend communication) | |
| 299 | - | * - Can be accessed/modified by page JavaScript | |
| 300 | - | * - Security risk: page can interfere with our code | |
| 301 | - | * | |
| 302 | - | * - ISOLATED WORLD: Where our UI components run (Svelte) | |
| 303 | - | * - Cannot access window.webkit.messageHandlers directly | |
| 304 | - | * - Protected from page JavaScript interference | |
| 305 | - | * - Security benefit: page cannot read/modify our UI state | |
| 306 | - | * | |
| 307 | - | * Communication Patterns: | |
| 308 | - | * ----------------------- | |
| 309 | - | * | |
| 310 | - | * 1. WINDOW FUNCTIONS (require bridge): | |
| 311 | - | * Backend → window.__dumber_xxx() in main world | |
| 312 | - | * Main world → CustomEvent to isolated world | |
| 313 | - | * Example: __dumber_omnibox_suggestions(), __dumber_favorites() | |
| 314 | - | * | |
| 315 | - | * 2. CUSTOMEVENT DISPATCH (no bridge needed): | |
| 316 | - | * Backend → document.dispatchEvent() directly | |
| 317 | - | * Both worlds can listen (shared DOM) | |
| 318 | - | * Example: dumber:ui:shortcut (keyboard shortcuts) | |
| 319 | - | * | |
| 320 | - | * 3. ISOLATED TO BACKEND (reverse bridge): | |
| 321 | - | * Isolated world → CustomEvent "dumber:isolated-message" | |
| 322 | - | * Main world listener → window.webkit.messageHandlers.dumber | |
| 323 | - | * Example: navigation, omnibox queries | |
| 324 | - | * | |
| 325 | - | * ============================================================================ | |
| 326 | - | */ | |
| 327 | - | ||
| 328 | - | /** | |
| 329 | - | * Unified dispatcher for forwarding main-world window calls to isolated world | |
| 330 | - | * Only use this for window function bridges, NOT for events backend already dispatches | |
| 331 | - | */ | |
| 332 | - | const bridgeDispatch = (eventName: string, detail: Record<string, unknown>) => { | |
| 333 | - | try { | |
| 334 | - | document.dispatchEvent(new CustomEvent(eventName, { detail })); | |
| 335 | - | } catch (err) { | |
| 336 | - | console.error(`[dumber-bridge] Failed to dispatch ${eventName}:`, err); | |
| 337 | - | } | |
| 338 | - | }; | |
| 339 | - | ||
| 340 | - | // ============================================================================ | |
| 341 | - | // WINDOW FUNCTION BRIDGES | |
| 342 | - | // These convert window.__dumber_xxx() calls to CustomEvents for isolated world | |
| 343 | - | // ============================================================================ | |
| 344 | - | ||
| 345 | - | // Omnibox suggestions bridge (queues suggestions until component is ready) | |
| 346 | - | const omniboxQueue: Suggestion[] = []; | |
| 347 | - | let omniboxReady = false; | |
| 348 | - | ||
| 349 | - | window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => { | |
| 350 | - | if (omniboxReady) { | |
| 351 | - | bridgeDispatch("dumber:omnibox:suggestions", { suggestions }); | |
| 352 | - | bridgeDispatch("dumber:omnibox-suggestions", { suggestions }); // Legacy support | |
| 353 | - | } else { | |
| 354 | - | try { | |
| 355 | - | omniboxQueue.push(...suggestions); | |
| 356 | - | } catch (err) { | |
| 357 | - | console.error("[dumber-bridge] Omnibox queue error", err); | |
| 358 | - | } | |
| 359 | - | } | |
| 360 | - | }; | |
| 361 | - | ||
| 362 | - | // Inline suggestion bridge (fish-style ghost text) | |
| 363 | - | window.__dumber_omnibox_inline_suggestion = (url: string | null) => { | |
| 364 | - | console.log("[INLINE] main-world received inline suggestion:", url); | |
| 365 | - | bridgeDispatch("dumber:omnibox:inline-suggestion", { url }); | |
| 366 | - | }; | |
| 367 | - | ||
| 368 | - | // Favorites bridge | |
| 369 | - | window.__dumber_favorites = (favorites: unknown[]) => { | |
| 370 | - | bridgeDispatch("dumber:favorites", { favorites }); | |
| 371 | - | }; | |
| 372 | - | ||
| 373 | - | // Toast notification bridges | |
| 374 | - | window.__dumber_showToast = (message: string, duration?: number, type?: "info" | "success" | "error") => { | |
| 375 | - | bridgeDispatch("dumber:toast", { message, duration, type }); | |
| 376 | - | }; | |
| 377 | - | ||
| 378 | - | window.__dumber_showZoomToast = (level: number) => { | |
| 379 | - | bridgeDispatch("dumber:toast:zoom", { level }); | |
| 380 | - | }; | |
| 381 | - | ||
| 382 | - | // Handle omnibox ready signal and flush queued suggestions | |
| 383 | - | const handleOmniboxReady = () => { | |
| 384 | - | omniboxReady = true; | |
| 385 | - | if (omniboxQueue && omniboxQueue.length) { | |
| 386 | - | const items = omniboxQueue.slice(); | |
| 387 | - | omniboxQueue.length = 0; | |
| 388 | - | items.forEach((s) => { | |
| 389 | - | bridgeDispatch("dumber:omnibox:suggestions", { suggestions: [s] }); | |
| 390 | - | bridgeDispatch("dumber:omnibox-suggestions", { suggestions: [s] }); | |
| 391 | - | }); | |
| 392 | - | } | |
| 393 | - | }; | |
| 394 | - | ||
| 395 | - | document.addEventListener("dumber:omnibox-ready", handleOmniboxReady); | |
| 396 | - | cleanupHandlers.push(() => { | |
| 397 | - | document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady); | |
| 398 | - | }); | |
| 399 | - | ||
| 400 | - | // Unified omnibox API for backward compatibility | |
| 401 | - | window.__dumber.omnibox = window.__dumber.omnibox || { | |
| 402 | - | suggestions: (suggestions: Suggestion[]) => { | |
| 403 | - | window.__dumber_omnibox_suggestions!(suggestions); | |
| 404 | - | }, | |
| 405 | - | }; | |
| 406 | - | ||
| 407 | - | let popupMessageInterval: ReturnType<typeof setInterval> | null = null; | |
| 408 | - | let oauthUrlCheckInterval: ReturnType<typeof setInterval> | null = null; | |
| 409 | - | ||
| 410 | - | // Popup window.opener bridge using shared localStorage | |
| 411 | - | const setupPopupOpenerBridge = () => { | |
| 412 | - | try { | |
| 413 | - | // Check if this is a popup by looking for parent info in localStorage | |
| 414 | - | const findParentPopupId = () => { | |
| 415 | - | const keys = Object.keys(localStorage); | |
| 416 | - | for (const key of keys) { | |
| 417 | - | if (key.startsWith("popup_") && key.endsWith("_parent_info")) { | |
| 418 | - | const popupId = key | |
| 419 | - | .replace("popup_", "") | |
| 420 | - | .replace("_parent_info", ""); | |
| 421 | - | const parentInfo = JSON.parse(localStorage.getItem(key) || "{}"); | |
| 422 | - | ||
| 423 | - | // Check if this popup info is recent (within last 30 seconds) | |
| 424 | - | const age = Date.now() - (parentInfo.timestamp || 0); | |
| 425 | - | if (age < 30000) { | |
| 426 | - | console.log( | |
| 427 | - | `[dumber-popup] Found parent info for popup ID: ${popupId}`, | |
| 428 | - | parentInfo, | |
| 429 | - | ); | |
| 430 | - | return { popupId, parentInfo }; | |
| 431 | - | } | |
| 432 | - | } | |
| 433 | - | } | |
| 434 | - | return null; | |
| 435 | - | }; | |
| 436 | - | ||
| 437 | - | const parentData = findParentPopupId(); | |
| 438 | - | if (parentData && !window.opener) { | |
| 439 | - | const { popupId, parentInfo } = parentData; | |
| 440 | - | ||
| 441 | - | console.log( | |
| 442 | - | `[dumber-popup] Setting up window.opener bridge for popup ID: ${popupId}`, | |
| 443 | - | ); | |
| 444 | - | ||
| 445 | - | // Create window.opener proxy that uses localStorage for communication | |
| 446 | - | window.opener = { | |
| 447 | - | postMessage: (data: unknown, targetOrigin?: string) => { | |
| 448 | - | try { | |
| 449 | - | localStorage.setItem( | |
| 450 | - | `popup_${popupId}_message_to_parent`, | |
| 451 | - | JSON.stringify({ | |
| 452 | - | data, | |
| 453 | - | origin: targetOrigin || "*", | |
| 454 | - | timestamp: Date.now(), | |
| 455 | - | source: "popup", | |
| 456 | - | }), | |
| 457 | - | ); | |
| 458 | - | console.log( | |
| 459 | - | `[dumber-popup] Sent message to parent via localStorage:`, | |
| 460 | - | data, | |
| 461 | - | ); | |
| 462 | - | } catch (err) { | |
| 463 | - | console.warn( | |
| 464 | - | `[dumber-popup] Failed to send message to parent:`, | |
| 465 | - | err, | |
| 466 | - | ); | |
| 467 | - | } | |
| 468 | - | }, | |
| 469 | - | ||
| 470 | - | focus: () => { | |
| 471 | - | try { | |
| 472 | - | localStorage.setItem( | |
| 473 | - | `popup_${popupId}_parent_action`, | |
| 474 | - | JSON.stringify({ | |
| 475 | - | action: "focus", | |
| 476 | - | timestamp: Date.now(), | |
| 477 | - | }), | |
| 478 | - | ); | |
| 479 | - | } catch (err) { | |
| 480 | - | console.warn( | |
| 481 | - | `[dumber-popup] Failed to request parent focus:`, | |
| 482 | - | err, | |
| 483 | - | ); | |
| 484 | - | } | |
| 485 | - | }, | |
| 486 | - | ||
| 487 | - | blur: () => { | |
| 488 | - | try { | |
| 489 | - | localStorage.setItem( | |
| 490 | - | `popup_${popupId}_parent_action`, | |
| 491 | - | JSON.stringify({ | |
| 492 | - | action: "blur", | |
| 493 | - | timestamp: Date.now(), | |
| 494 | - | }), | |
| 495 | - | ); | |
| 496 | - | } catch (err) { | |
| 497 | - | console.warn( | |
| 498 | - | `[dumber-popup] Failed to request parent blur:`, | |
| 499 | - | err, | |
| 500 | - | ); | |
| 501 | - | } | |
| 502 | - | }, | |
| 503 | - | ||
| 504 | - | location: { | |
| 505 | - | href: parentInfo.parentUrl || "", | |
| 506 | - | origin: new URL(parentInfo.parentUrl || "about:blank").origin, | |
| 507 | - | }, | |
| 508 | - | ||
| 509 | - | closed: false, | |
| 510 | - | ||
| 511 | - | // Support for custom properties/methods that websites might use | |
| 512 | - | [Symbol.for("popup.bridge")]: true, | |
| 513 | - | } as unknown as Window; | |
| 514 | - | ||
| 515 | - | // Set up message polling to receive messages from parent | |
| 516 | - | const pollForParentMessages = () => { | |
| 517 | - | try { | |
| 518 | - | const messageKey = `popup_${popupId}_message_to_popup`; | |
| 519 | - | const messageData = localStorage.getItem(messageKey); | |
| 520 | - | ||
| 521 | - | if (messageData) { | |
| 522 | - | const { data, origin, timestamp } = JSON.parse(messageData); | |
| 523 | - | ||
| 524 | - | // Check if message is recent (within last 5 seconds) | |
| 525 | - | if (Date.now() - timestamp < 5000) { | |
| 526 | - | console.log( | |
| 527 | - | `[dumber-popup] Received message from parent:`, | |
| 528 | - | data, | |
| 529 | - | ); | |
| 530 | - | ||
| 531 | - | // Dispatch as MessageEvent to window | |
| 532 | - | const event = new MessageEvent("message", { | |
| 533 | - | data, | |
| 534 | - | origin: origin || parentInfo.parentUrl || "", | |
| 535 | - | source: window.opener, | |
| 536 | - | }); | |
| 537 | - | window.dispatchEvent(event); | |
| 538 | - | } | |
| 539 | - | ||
| 540 | - | // Clean up message | |
| 541 | - | localStorage.removeItem(messageKey); | |
| 542 | - | } | |
| 543 | - | } catch (err) { | |
| 544 | - | console.warn( | |
| 545 | - | `[dumber-popup] Failed to poll for parent messages:`, | |
| 546 | - | err, | |
| 547 | - | ); | |
| 548 | - | } | |
| 549 | - | }; | |
| 550 | - | ||
| 551 | - | // Poll for messages every 100ms | |
| 552 | - | popupMessageInterval = setInterval(pollForParentMessages, 100); | |
| 553 | - | cleanupHandlers.push(() => { | |
| 554 | - | if (popupMessageInterval) { | |
| 555 | - | clearInterval(popupMessageInterval); | |
| 556 | - | popupMessageInterval = null; | |
| 557 | - | } | |
| 558 | - | }); | |
| 559 | - | ||
| 560 | - | console.log( | |
| 561 | - | `[dumber-popup] window.opener bridge established successfully`, | |
| 562 | - | ); | |
| 563 | - | } | |
| 564 | - | } catch (err) { | |
| 565 | - | console.warn( | |
| 566 | - | `[dumber-popup] Failed to setup window.opener bridge:`, | |
| 567 | - | err, | |
| 568 | - | ); | |
| 569 | - | } | |
| 570 | - | }; | |
| 571 | - | ||
| 572 | - | // Setup popup bridge if this appears to be a popup | |
| 573 | - | setupPopupOpenerBridge(); | |
| 574 | - | ||
| 575 | - | // Parent window message polling - only for OAuth scenarios | |
| 576 | - | let parentPollingInterval: ReturnType<typeof setInterval> | null = null; | |
| 577 | - | let parentPollingHeartbeat: ReturnType<typeof setInterval> | null = null; | |
| 578 | - | ||
| 579 | - | const setupParentMessagePolling = () => { | |
| 580 | - | // Only start polling if we have popup mappings or OAuth callbacks | |
| 581 | - | const hasRelevantData = () => { | |
| 582 | - | try { | |
| 583 | - | const keys = Object.keys(localStorage); | |
| 584 | - | return keys.some( | |
| 585 | - | (key) => | |
| 586 | - | key.startsWith("popup_mapping_") || | |
| 587 | - | key.startsWith("oauth_callback_") || | |
| 588 | - | key.includes("message_to_parent"), | |
| 589 | - | ); | |
| 590 | - | } catch { | |
| 591 | - | return false; | |
| 592 | - | } | |
| 593 | - | }; | |
| 594 | - | ||
| 595 | - | const pollForPopupMessages = () => { | |
| 596 | - | try { | |
| 597 | - | // Stop polling if no relevant data exists | |
| 598 | - | if (!hasRelevantData()) { | |
| 599 | - | return; | |
| 600 | - | } | |
| 601 | - | ||
| 602 | - | const keys = Object.keys(localStorage); | |
| 603 | - | ||
| 604 | - | for (const key of keys) { | |
| 605 | - | // Handle popup messages to parent | |
| 606 | - | if (key.includes("message_to_parent")) { | |
| 607 | - | const messageData = localStorage.getItem(key); | |
| 608 | - | if (!messageData) continue; | |
| 609 | - | ||
| 610 | - | try { | |
| 611 | - | const { data, origin, timestamp, source } = | |
| 612 | - | JSON.parse(messageData); | |
| 613 | - | ||
| 614 | - | // Check if message is recent (within last 5 seconds) and from a popup | |
| 615 | - | if (Date.now() - timestamp < 5000 && source === "popup") { | |
| 616 | - | console.log( | |
| 617 | - | `[dumber-parent] Received message from popup:`, | |
| 618 | - | data, | |
| 619 | - | ); | |
| 620 | - | ||
| 621 | - | // Dispatch as MessageEvent to parent window | |
| 622 | - | const event = new MessageEvent("message", { | |
| 623 | - | data, | |
| 624 | - | origin: origin || window.location.origin, | |
| 625 | - | source: null, // popup reference would be here in real browser | |
| 626 | - | }); | |
| 627 | - | window.dispatchEvent(event); | |
| 628 | - | ||
| 629 | - | // Clean up message | |
| 630 | - | localStorage.removeItem(key); | |
| 631 | - | } | |
| 632 | - | } catch (err) { | |
| 633 | - | console.warn( | |
| 634 | - | `[dumber-parent] Failed to parse popup message:`, | |
| 635 | - | err, | |
| 636 | - | ); | |
| 637 | - | localStorage.removeItem(key); // Clean up invalid message | |
| 638 | - | } | |
| 639 | - | } | |
| 640 | - | ||
| 641 | - | // Handle OAuth callback detection from popups | |
| 642 | - | if (key.startsWith("oauth_callback_")) { | |
| 643 | - | const callbackData = localStorage.getItem(key); | |
| 644 | - | if (!callbackData) continue; | |
| 645 | - | ||
| 646 | - | try { | |
| 647 | - | const { url, webviewId, timestamp, isOAuthCallback } = | |
| 648 | - | JSON.parse(callbackData); | |
| 649 | - | ||
| 650 | - | // Check if callback is recent (within last 10 seconds) and is an OAuth callback | |
| 651 | - | if (Date.now() - timestamp < 10000 && isOAuthCallback) { | |
| 652 | - | console.log( | |
| 653 | - | `[dumber-parent] OAuth callback detected for webview ${webviewId}:`, | |
| 654 | - | url, | |
| 655 | - | ); | |
| 656 | - | ||
| 657 | - | // Send close request to backend for this popup webview | |
| 658 | - | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 659 | - | if (bridge && typeof bridge.postMessage === "function") { | |
| 660 | - | try { | |
| 661 | - | const closeMessage = { | |
| 662 | - | type: "close-popup", | |
| 663 | - | webviewId, | |
| 664 | - | reason: "oauth-callback-success", | |
| 665 | - | timestamp: Date.now(), | |
| 666 | - | }; | |
| 667 | - | ||
| 668 | - | console.log( | |
| 669 | - | `[dumber-parent] Sending popup close request:`, | |
| 670 | - | closeMessage, | |
| 671 | - | ); | |
| 672 | - | bridge.postMessage(closeMessage); | |
| 673 | - | } catch (err) { | |
| 674 | - | console.warn( | |
| 675 | - | `[dumber-parent] Failed to send popup close request:`, | |
| 676 | - | err, | |
| 677 | - | ); | |
| 678 | - | } | |
| 679 | - | } else { | |
| 680 | - | console.warn( | |
| 681 | - | `[dumber-parent] No webkit bridge available for popup close request`, | |
| 682 | - | ); | |
| 683 | - | } | |
| 684 | - | ||
| 685 | - | // Clean up OAuth callback data | |
| 686 | - | localStorage.removeItem(key); | |
| 687 | - | } | |
| 688 | - | } catch (err) { | |
| 689 | - | console.warn( | |
| 690 | - | `[dumber-parent] Failed to parse OAuth callback data:`, | |
| 691 | - | err, | |
| 692 | - | ); | |
| 693 | - | localStorage.removeItem(key); // Clean up invalid data | |
| 694 | - | } | |
| 695 | - | } | |
| 696 | - | } | |
| 697 | - | } catch (err) { | |
| 698 | - | console.warn( | |
| 699 | - | `[dumber-parent] Failed to poll for popup messages:`, | |
| 700 | - | err, | |
| 701 | - | ); | |
| 702 | - | } | |
| 703 | - | }; | |
| 704 | - | ||
| 705 | - | // Start polling only when needed | |
| 706 | - | const startPollingForOAuthCallbacks = () => { | |
| 707 | - | if (!parentPollingInterval && hasRelevantData()) { | |
| 708 | - | console.log(`[dumber-parent] Starting OAuth/popup message polling`); | |
| 709 | - | parentPollingInterval = setInterval(pollForPopupMessages, 100); | |
| 710 | - | cleanupHandlers.push(() => { | |
| 711 | - | if (parentPollingInterval) { | |
| 712 | - | clearInterval(parentPollingInterval); | |
| 713 | - | parentPollingInterval = null; | |
| 714 | - | } | |
| 715 | - | }); | |
| 716 | - | } | |
| 717 | - | }; | |
| 718 | - | ||
| 719 | - | // Check periodically if polling should start | |
| 720 | - | parentPollingHeartbeat = setInterval(() => { | |
| 721 | - | if (hasRelevantData()) { | |
| 722 | - | startPollingForOAuthCallbacks(); | |
| 723 | - | } | |
| 724 | - | }, 1000); | |
| 725 | - | cleanupHandlers.push(() => { | |
| 726 | - | if (parentPollingHeartbeat) { | |
| 727 | - | clearInterval(parentPollingHeartbeat); | |
| 728 | - | parentPollingHeartbeat = null; | |
| 729 | - | } | |
| 730 | - | }); | |
| 731 | - | }; | |
| 732 | - | ||
| 733 | - | // Setup conditional parent message polling | |
| 734 | - | setupParentMessagePolling(); | |
| 735 | - | ||
| 736 | - | // OAuth callback detection for auto-close | |
| 737 | - | const setupOAuthCallbackDetection = () => { | |
| 738 | - | let oauthCallbackHandled = false; | |
| 739 | - | ||
| 740 | - | const detectOAuthCallback = () => { | |
| 741 | - | if (oauthCallbackHandled) { | |
| 742 | - | return; | |
| 743 | - | } | |
| 744 | - | ||
| 745 | - | const url = window.location.href.toLowerCase(); | |
| 746 | - | ||
| 747 | - | // Check for OAuth callback patterns - be more specific to avoid false positives | |
| 748 | - | const isCallback = | |
| 749 | - | // OAuth callback URLs typically have these parameters | |
| 750 | - | url.includes("code=") || | |
| 751 | - | url.includes("access_token=") || | |
| 752 | - | url.includes("id_token=") || | |
| 753 | - | // OAuth error responses | |
| 754 | - | url.includes("error=access_denied") || | |
| 755 | - | url.includes("error=unauthorized") || | |
| 756 | - | // OAuth callback paths (more specific than just "callback" or "redirect") | |
| 757 | - | url.includes("/oauth/callback") || | |
| 758 | - | url.includes("/auth/callback") || | |
| 759 | - | url.includes("oauth2callback") || | |
| 760 | - | url.includes("googlepopupcallback"); | |
| 761 | - | ||
| 762 | - | if (isCallback) { | |
| 763 | - | oauthCallbackHandled = true; | |
| 764 | - | try { | |
| 765 | - | // Determine which WebView should close as part of this callback | |
| 766 | - | let targetWebViewId = window.__dumber_webview_id; | |
| 767 | - | ||
| 768 | - | // When the parent window detects the callback, look up its popup mapping | |
| 769 | - | const popupMappingKey = `popup_mapping_${window.__dumber_webview_id}`; | |
| 770 | - | try { | |
| 771 | - | const popupMappingData = localStorage.getItem(popupMappingKey); | |
| 772 | - | if (popupMappingData) { | |
| 773 | - | const mapping = JSON.parse(popupMappingData); | |
| 774 | - | const age = Date.now() - (mapping.timestamp || 0); | |
| 775 | - | if (age < 60000 && mapping.popupId) { | |
| 776 | - | targetWebViewId = mapping.popupId; | |
| 777 | - | console.log( | |
| 778 | - | `[oauth-callback] Parent detected OAuth callback, targeting popup webview: ${targetWebViewId}`, | |
| 779 | - | ); | |
| 780 | - | localStorage.removeItem(popupMappingKey); | |
| 781 | - | } | |
| 782 | - | } | |
| 783 | - | } catch (err) { | |
| 784 | - | console.warn( | |
| 785 | - | `[oauth-callback] Failed to resolve popup mapping:`, | |
| 786 | - | err, | |
| 787 | - | ); | |
| 788 | - | } | |
| 789 | - | ||
| 790 | - | console.log( | |
| 791 | - | `[oauth-callback] Detected OAuth callback, closing popup webview: ${targetWebViewId}`, | |
| 792 | - | ); | |
| 793 | - | ||
| 794 | - | // Send close request to backend for this popup webview | |
| 795 | - | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 796 | - | if (bridge && typeof bridge.postMessage === "function") { | |
| 797 | - | try { | |
| 798 | - | const closeMessage = { | |
| 799 | - | type: "close-popup", | |
| 800 | - | webviewId: targetWebViewId, | |
| 801 | - | reason: "oauth-callback-success", | |
| 802 | - | timestamp: Date.now(), | |
| 803 | - | }; | |
| 804 | - | ||
| 805 | - | console.log( | |
| 806 | - | `[oauth-callback] Sending popup close request:`, | |
| 807 | - | closeMessage, | |
| 808 | - | ); | |
| 809 | - | bridge.postMessage(closeMessage); | |
| 810 | - | } catch (err) { | |
| 811 | - | console.warn( | |
| 812 | - | `[oauth-callback] Failed to send popup close request:`, | |
| 813 | - | err, | |
| 814 | - | ); | |
| 815 | - | } | |
| 816 | - | } else { | |
| 817 | - | console.warn( | |
| 818 | - | `[oauth-callback] No webkit bridge available for popup close request`, | |
| 819 | - | ); | |
| 820 | - | } | |
| 821 | - | } catch (err) { | |
| 822 | - | console.warn( | |
| 823 | - | `[oauth-callback] Failed to handle OAuth callback:`, | |
| 824 | - | err, | |
| 825 | - | ); | |
| 826 | - | } | |
| 827 | - | } | |
| 828 | - | }; | |
| 829 | - | ||
| 830 | - | // Check immediately | |
| 831 | - | detectOAuthCallback(); | |
| 832 | - | ||
| 833 | - | // Monitor for URL changes (for SPAs that don't reload) | |
| 834 | - | let lastUrl = window.location.href; | |
| 835 | - | oauthUrlCheckInterval = setInterval(() => { | |
| 836 | - | if (window.location.href !== lastUrl) { | |
| 837 | - | lastUrl = window.location.href; | |
| 838 | - | detectOAuthCallback(); | |
| 839 | - | } | |
| 840 | - | }, 500); | |
| 841 | - | cleanupHandlers.push(() => { | |
| 842 | - | if (oauthUrlCheckInterval) { | |
| 843 | - | clearInterval(oauthUrlCheckInterval); | |
| 844 | - | oauthUrlCheckInterval = null; | |
| 845 | - | } | |
| 846 | - | }); | |
| 847 | - | ||
| 848 | - | // Also check on navigation events | |
| 849 | - | window.addEventListener("popstate", detectOAuthCallback); | |
| 850 | - | window.addEventListener("hashchange", detectOAuthCallback); | |
| 851 | - | cleanupHandlers.push(() => { | |
| 852 | - | window.removeEventListener("popstate", detectOAuthCallback); | |
| 853 | - | window.removeEventListener("hashchange", detectOAuthCallback); | |
| 854 | - | }); | |
| 855 | - | }; | |
| 856 | - | ||
| 857 | - | // Setup OAuth callback detection | |
| 858 | - | setupOAuthCallbackDetection(); | |
| 859 | - | ||
| 860 | - | const teardown = () => { | |
| 861 | - | if (teardownExecuted) { | |
| 862 | - | return; | |
| 863 | - | } | |
| 864 | - | teardownExecuted = true; | |
| 865 | - | ||
| 866 | - | while (cleanupHandlers.length) { | |
| 867 | - | const cleanup = cleanupHandlers.pop(); | |
| 868 | - | if (!cleanup) { | |
| 869 | - | continue; | |
| 870 | - | } | |
| 871 | - | try { | |
| 872 | - | cleanup(); | |
| 873 | - | } catch (cleanupErr) { | |
| 874 | - | console.warn("[dumber] Cleanup handler failed", cleanupErr); | |
| 875 | - | } | |
| 876 | - | } | |
| 877 | - | ||
| 878 | - | omniboxQueue.length = 0; | |
| 879 | - | omniboxReady = false; | |
| 880 | - | ||
| 881 | - | window.__dumber_page_bridge_installed = false; | |
| 882 | - | window.__dumber_teardown = undefined; | |
| 883 | - | window.__dumber_omnibox_suggestions = undefined; | |
| 884 | - | window.__dumber_favorites = undefined; | |
| 885 | - | window.__dumber_showToast = undefined; | |
| 886 | - | window.__dumber_showZoomToast = undefined; | |
| 887 | - | window.__dumber_applyDomZoom = undefined; | |
| 888 | - | ||
| 889 | - | const globalState = window.__dumber; | |
| 890 | - | if (globalState) { | |
| 891 | - | const mutableState = globalState as unknown as Record<string, unknown>; | |
| 892 | - | delete mutableState.omnibox; | |
| 893 | - | } | |
| 894 | - | }; | |
| 895 | - | ||
| 896 | - | window.__dumber_teardown = teardown; | |
| 897 | - | window.addEventListener("pagehide", teardown, { once: true }); | |
| 898 | - | window.addEventListener("beforeunload", teardown, { once: true }); | |
| 899 | - | cleanupHandlers.push(() => { | |
| 900 | - | window.removeEventListener("pagehide", teardown); | |
| 901 | - | window.removeEventListener("beforeunload", teardown); | |
| 902 | - | window.__dumber_teardown = undefined; | |
| 903 | - | }); | |
| 904 | - | ||
| 905 | - | // Console capture functionality | |
| 906 | - | const setupConsoleCapture = () => { | |
| 907 | - | const originalConsole = { | |
| 908 | - | log: console.log, | |
| 909 | - | warn: console.warn, | |
| 910 | - | error: console.error, | |
| 911 | - | info: console.info, | |
| 912 | - | debug: console.debug, | |
| 913 | - | }; | |
| 914 | - | ||
| 915 | - | const sendConsoleMessage = (level: string, args: unknown[]) => { | |
| 916 | - | try { | |
| 917 | - | const message = args | |
| 918 | - | .map((arg) => | |
| 919 | - | typeof arg === "object" ? JSON.stringify(arg) : String(arg), | |
| 920 | - | ) | |
| 921 | - | .join(" "); | |
| 922 | - | ||
| 923 | - | const formattedMessage = `${window.location.href} ${message}`; | |
| 924 | - | ||
| 925 | - | // Send to Go via existing message handler | |
| 926 | - | if (window.webkit?.messageHandlers?.dumber) { | |
| 927 | - | window.webkit.messageHandlers.dumber.postMessage({ | |
| 928 | - | type: "console-message", | |
| 929 | - | payload: { | |
| 930 | - | level, | |
| 931 | - | message: formattedMessage, | |
| 932 | - | url: window.location.href, | |
| 933 | - | webviewId: window.__dumber_webview_id, | |
| 934 | - | }, | |
| 935 | - | }); | |
| 936 | - | } | |
| 937 | - | } catch { | |
| 938 | - | // Silently ignore errors in console capture to avoid infinite loops | |
| 939 | - | } | |
| 940 | - | }; | |
| 941 | - | ||
| 942 | - | const createConsoleWrapper = ( | |
| 943 | - | originalMethod: (...args: unknown[]) => void, | |
| 944 | - | level: string, | |
| 945 | - | ) => { | |
| 946 | - | return function (...args: unknown[]) { | |
| 947 | - | originalMethod.apply(console, args); | |
| 948 | - | sendConsoleMessage(level, args); | |
| 949 | - | }; | |
| 950 | - | }; | |
| 951 | - | ||
| 952 | - | // Override console methods to capture messages | |
| 953 | - | console.log = createConsoleWrapper(originalConsole.log, "LOG"); | |
| 954 | - | console.warn = createConsoleWrapper(originalConsole.warn, "WARN"); | |
| 955 | - | console.error = createConsoleWrapper(originalConsole.error, "ERROR"); | |
| 956 | - | console.info = createConsoleWrapper(originalConsole.info, "INFO"); | |
| 957 | - | console.debug = createConsoleWrapper(originalConsole.debug, "DEBUG"); | |
| 958 | - | ||
| 959 | - | cleanupHandlers.push(() => { | |
| 960 | - | console.log = originalConsole.log; | |
| 961 | - | console.warn = originalConsole.warn; | |
| 962 | - | console.error = originalConsole.error; | |
| 963 | - | console.info = originalConsole.info; | |
| 964 | - | console.debug = originalConsole.debug; | |
| 965 | - | }); | |
| 966 | - | }; | |
| 967 | - | ||
| 968 | - | // Search shortcuts response bridge (backend → isolated world) | |
| 969 | - | window.__dumber_search_shortcuts = (shortcuts: unknown) => { | |
| 970 | - | console.log("[dumber-bridge] Received search shortcuts from backend"); | |
| 971 | - | bridgeDispatch("dumber:search-shortcuts", { shortcuts }); | |
| 972 | - | }; | |
| 973 | - | ||
| 974 | - | window.__dumber_search_shortcuts_error = (error: string) => { | |
| 975 | - | console.error("[dumber-bridge] Search shortcuts error:", error); | |
| 976 | - | }; | |
| 977 | - | ||
| 978 | - | // Bridge for isolated world communication | |
| 979 | - | // Listen for messages from isolated world and forward to backend via webkit.messageHandlers | |
| 980 | - | const setupIsolatedWorldBridge = () => { | |
| 981 | - | document.addEventListener("dumber:isolated-message", ((event: CustomEvent) => { | |
| 982 | - | try { | |
| 983 | - | const { payload } = event.detail; | |
| 984 | - | if (window.webkit?.messageHandlers?.dumber) { | |
| 985 | - | // Send object directly - Go's jscValue.ToJson() handles conversion | |
| 986 | - | window.webkit.messageHandlers.dumber.postMessage(payload); | |
| 987 | - | console.log("[dumber-bridge] Forwarded isolated world message:", payload.type); | |
| 988 | - | } else { | |
| 989 | - | console.warn("[dumber-bridge] webkit message handler not available"); | |
| 990 | - | } | |
| 991 | - | } catch (error) { | |
| 992 | - | console.error("[dumber-bridge] Failed to forward message:", error); | |
| 993 | - | } | |
| 994 | - | }) as EventListener); | |
| 995 | - | ||
| 996 | - | console.log("[dumber-bridge] Isolated world message bridge initialized"); | |
| 997 | - | }; | |
| 998 | - | ||
| 999 | - | // Initialize isolated world bridge | |
| 1000 | - | setupIsolatedWorldBridge(); | |
| 1001 | - | ||
| 1002 | - | // Initialize console capture | |
| 1003 | - | setupConsoleCapture(); | |
| 1004 | - | ||
| 1005 | - | // ============================================================================ | |
| 1006 | - | // KEYBOARD AND FOCUS BLOCKING FOR OMNIBOX | |
| 1007 | - | // ============================================================================ | |
| 1008 | - | ||
| 1009 | - | const SHADOW_HOST_ID = 'dumber-ui-root'; | |
| 1010 | - | ||
| 1011 | - | const isFromOmnibox = (target: EventTarget | null): boolean => { | |
| 1012 | - | if (!target || !(target instanceof Element)) { | |
| 1013 | - | return false; | |
| 1014 | - | } | |
| 1015 | - | return target.id === SHADOW_HOST_ID || target.closest(`#${SHADOW_HOST_ID}`) !== null; | |
| 1016 | - | }; | |
| 1017 | - | ||
| 1018 | - | window.__dumber_enableKeyboardBlocking = function(): void { | |
| 1019 | - | if (window.__dumber_keyboard_blocking_active) { | |
| 1020 | - | return; | |
| 1021 | - | } | |
| 1022 | - | ||
| 1023 | - | window.__dumber_keyboard_blocking_active = true; | |
| 1024 | - | ||
| 1025 | - | window.__dumber_keyboard_blocker = function(e: Event): void { | |
| 1026 | - | if (isFromOmnibox(e.target)) { | |
| 1027 | - | return; | |
| 1028 | - | } | |
| 1029 | - | e.stopPropagation(); | |
| 1030 | - | e.stopImmediatePropagation(); | |
| 1031 | - | }; | |
| 1032 | - | ||
| 1033 | - | window.__dumber_focus_guard = function(e: Event): void { | |
| 1034 | - | if (e.type === 'focus') { | |
| 1035 | - | if (!isFromOmnibox(e.target)) { | |
| 1036 | - | e.stopPropagation(); | |
| 1037 | - | e.stopImmediatePropagation(); | |
| 1038 | - | e.preventDefault(); | |
| 1039 | - | } | |
| 1040 | - | } else if (e.type === 'blur') { | |
| 1041 | - | if (isFromOmnibox(e.target)) { | |
| 1042 | - | e.stopPropagation(); | |
| 1043 | - | e.stopImmediatePropagation(); | |
| 1044 | - | e.preventDefault(); | |
| 1045 | - | setTimeout(() => { | |
| 1046 | - | const shadowHost = document.getElementById(SHADOW_HOST_ID); | |
| 1047 | - | if (shadowHost && shadowHost.shadowRoot && window.__dumber_keyboard_blocking_active) { | |
| 1048 | - | const input = shadowHost.shadowRoot.querySelector('.omnibox-input-field') as HTMLInputElement; | |
| 1049 | - | if (input) { | |
| 1050 | - | input.focus(); | |
| 1051 | - | } | |
| 1052 | - | } | |
| 1053 | - | }, 0); | |
| 1054 | - | } | |
| 1055 | - | } | |
| 1056 | - | }; | |
| 1057 | - | ||
| 1058 | - | window.__dumber_click_blocker = function(e: Event): void { | |
| 1059 | - | if (isFromOmnibox(e.target)) { | |
| 1060 | - | return; | |
| 1061 | - | } | |
| 1062 | - | e.stopPropagation(); | |
| 1063 | - | e.stopImmediatePropagation(); | |
| 1064 | - | e.preventDefault(); | |
| 1065 | - | }; | |
| 1066 | - | ||
| 1067 | - | document.addEventListener('keydown', window.__dumber_keyboard_blocker, true); | |
| 1068 | - | document.addEventListener('keyup', window.__dumber_keyboard_blocker, true); | |
| 1069 | - | document.addEventListener('keypress', window.__dumber_keyboard_blocker, true); | |
| 1070 | - | document.addEventListener('focus', window.__dumber_focus_guard, true); | |
| 1071 | - | document.addEventListener('blur', window.__dumber_focus_guard, true); | |
| 1072 | - | document.addEventListener('click', window.__dumber_click_blocker, true); | |
| 1073 | - | document.addEventListener('mousedown', window.__dumber_click_blocker, true); | |
| 1074 | - | }; | |
| 1075 | - | ||
| 1076 | - | window.__dumber_disableKeyboardBlocking = function(): void { | |
| 1077 | - | if (!window.__dumber_keyboard_blocking_active) { | |
| 1078 | - | return; | |
| 1079 | - | } | |
| 1080 | - | ||
| 1081 | - | window.__dumber_keyboard_blocking_active = false; | |
| 1082 | - | ||
| 1083 | - | if (window.__dumber_keyboard_blocker) { | |
| 1084 | - | document.removeEventListener('keydown', window.__dumber_keyboard_blocker, true); | |
| 1085 | - | document.removeEventListener('keyup', window.__dumber_keyboard_blocker, true); | |
| 1086 | - | document.removeEventListener('keypress', window.__dumber_keyboard_blocker, true); | |
| 1087 | - | delete window.__dumber_keyboard_blocker; | |
| 1088 | - | } | |
| 1089 | - | ||
| 1090 | - | if (window.__dumber_focus_guard) { | |
| 1091 | - | document.removeEventListener('focus', window.__dumber_focus_guard, true); | |
| 1092 | - | document.removeEventListener('blur', window.__dumber_focus_guard, true); | |
| 1093 | - | delete window.__dumber_focus_guard; | |
| 1094 | - | } | |
| 1095 | - | ||
| 1096 | - | if (window.__dumber_click_blocker) { | |
| 1097 | - | document.removeEventListener('click', window.__dumber_click_blocker, true); | |
| 1098 | - | document.removeEventListener('mousedown', window.__dumber_click_blocker, true); | |
| 1099 | - | delete window.__dumber_click_blocker; | |
| 1100 | - | } | |
| 1101 | - | }; | |
| 1102 | - | } catch (err) { | |
| 1103 | - | console.warn("[dumber] unified bridge init failed", err); | |
| 1104 | - | } | |
| 1105 | - | })(); | |
D webui/src/injected/modules/color-scheme.ts +0 -268
| @@ -1,268 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Color Scheme Detection Module | |
| 3 | - | * | |
| 4 | - | * Handles GTK theme detection and applies appropriate color scheme preferences | |
| 5 | - | * to web pages. This module is injected at document-start to ensure proper | |
| 6 | - | * theme initialization before page content loads. | |
| 7 | - | */ | |
| 8 | - | ||
| 9 | - | declare global { | |
| 10 | - | interface Window { | |
| 11 | - | __dumber_gtk_prefers_dark?: boolean; | |
| 12 | - | __dumber_setTheme?: (theme: "light" | "dark") => void; | |
| 13 | - | } | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | interface ColorSchemeConfig { | |
| 17 | - | isDark: boolean; | |
| 18 | - | colorScheme: "light" | "dark"; | |
| 19 | - | metaContent: string; | |
| 20 | - | rootStyle: string; | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | class ColorSchemeManager { | |
| 24 | - | private config: ColorSchemeConfig; | |
| 25 | - | private metaElement: HTMLMetaElement | null = null; | |
| 26 | - | private styleElement: HTMLStyleElement | null = null; | |
| 27 | - | private userPreference: "light" | "dark" | null = null; | |
| 28 | - | private static readonly STORAGE_KEY = "dumber.theme"; | |
| 29 | - | private static instance: ColorSchemeManager | null = null; | |
| 30 | - | private static matchMediaPatched = false; | |
| 31 | - | private static originalMatchMedia: typeof window.matchMedia | null = null; | |
| 32 | - | ||
| 33 | - | constructor() { | |
| 34 | - | ColorSchemeManager.instance = this; | |
| 35 | - | ||
| 36 | - | this.userPreference = this.getStoredPreference(); | |
| 37 | - | ||
| 38 | - | // Use GTK preference if available, otherwise detect from system | |
| 39 | - | const prefersDark = this.userPreference | |
| 40 | - | ? this.userPreference === "dark" | |
| 41 | - | : (window.__dumber_gtk_prefers_dark ?? false); | |
| 42 | - | ||
| 43 | - | this.config = { | |
| 44 | - | isDark: prefersDark, | |
| 45 | - | colorScheme: prefersDark ? "dark" : "light", | |
| 46 | - | metaContent: prefersDark ? "dark light" : "light dark", | |
| 47 | - | rootStyle: prefersDark | |
| 48 | - | ? ":root{color-scheme:dark;}" | |
| 49 | - | : ":root{color-scheme:light;}", | |
| 50 | - | }; | |
| 51 | - | ||
| 52 | - | this.initialize(); | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | private initialize(): void { | |
| 56 | - | try { | |
| 57 | - | console.log(`[dumber] color-scheme set: ${this.config.colorScheme}`); | |
| 58 | - | console.log( | |
| 59 | - | `[dumber] GTK detected color mode: ${this.config.colorScheme}`, | |
| 60 | - | ); | |
| 61 | - | ||
| 62 | - | // Notify native layer of theme preference | |
| 63 | - | this.notifyNativeLayer(); | |
| 64 | - | ||
| 65 | - | // Inject color-scheme meta tag | |
| 66 | - | this.injectColorSchemeMeta(); | |
| 67 | - | ||
| 68 | - | // Apply root styles | |
| 69 | - | this.applyRootStyles(); | |
| 70 | - | ||
| 71 | - | // Override matchMedia for proper prefers-color-scheme support | |
| 72 | - | this.overrideMatchMedia(); | |
| 73 | - | ||
| 74 | - | // Set up theme integration with existing UI components | |
| 75 | - | this.setupThemeIntegration(); | |
| 76 | - | } catch (error) { | |
| 77 | - | console.warn("[dumber] color-scheme injection failed", error); | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | private notifyNativeLayer(): void { | |
| 82 | - | try { | |
| 83 | - | (window as any).webkit?.messageHandlers?.dumber?.postMessage({ | |
| 84 | - | type: "theme", | |
| 85 | - | payload: { value: this.config.colorScheme }, | |
| 86 | - | }); | |
| 87 | - | } catch { | |
| 88 | - | // Ignore errors - native layer may not be available | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | private injectColorSchemeMeta(): void { | |
| 93 | - | if (this.metaElement && this.metaElement.isConnected) { | |
| 94 | - | this.metaElement.content = this.config.metaContent; | |
| 95 | - | return; | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | const existing = document.querySelector( | |
| 99 | - | 'meta[name="color-scheme"]', | |
| 100 | - | ) as HTMLMetaElement | null; | |
| 101 | - | const meta = existing ?? document.createElement("meta"); | |
| 102 | - | meta.name = "color-scheme"; | |
| 103 | - | meta.content = this.config.metaContent; | |
| 104 | - | if (!existing) { | |
| 105 | - | document.documentElement.appendChild(meta); | |
| 106 | - | } | |
| 107 | - | this.metaElement = meta; | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | private applyRootStyles(): void { | |
| 111 | - | if (this.styleElement && this.styleElement.isConnected) { | |
| 112 | - | this.styleElement.textContent = this.config.rootStyle; | |
| 113 | - | return; | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | const existing = document.querySelector( | |
| 117 | - | "style[data-dumber-color-scheme]", | |
| 118 | - | ) as HTMLStyleElement | null; | |
| 119 | - | const style = existing ?? document.createElement("style"); | |
| 120 | - | style.setAttribute("data-dumber-color-scheme", ""); | |
| 121 | - | style.textContent = this.config.rootStyle; | |
| 122 | - | if (!existing) { | |
| 123 | - | document.documentElement.appendChild(style); | |
| 124 | - | } | |
| 125 | - | this.styleElement = style; | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | private overrideMatchMedia(): void { | |
| 129 | - | if (ColorSchemeManager.matchMediaPatched) { | |
| 130 | - | return; | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | const darkQuery = "(prefers-color-scheme: dark)"; | |
| 134 | - | const lightQuery = "(prefers-color-scheme: light)"; | |
| 135 | - | ColorSchemeManager.originalMatchMedia = window.matchMedia.bind(window); | |
| 136 | - | ||
| 137 | - | window.matchMedia = (query: string): MediaQueryList => { | |
| 138 | - | const manager = ColorSchemeManager.instance; | |
| 139 | - | if ( | |
| 140 | - | manager && | |
| 141 | - | typeof query === "string" && | |
| 142 | - | (query.includes(darkQuery) || query.includes(lightQuery)) | |
| 143 | - | ) { | |
| 144 | - | const matches = query.includes("dark") | |
| 145 | - | ? manager.config.isDark | |
| 146 | - | : !manager.config.isDark; | |
| 147 | - | ||
| 148 | - | return { | |
| 149 | - | matches, | |
| 150 | - | media: query, | |
| 151 | - | onchange: null, | |
| 152 | - | addListener: () => {}, | |
| 153 | - | removeListener: () => {}, | |
| 154 | - | addEventListener: () => {}, | |
| 155 | - | removeEventListener: () => {}, | |
| 156 | - | dispatchEvent: () => false, | |
| 157 | - | } as MediaQueryList; | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | const original = ColorSchemeManager.originalMatchMedia; | |
| 161 | - | if (!original) { | |
| 162 | - | throw new Error("Original matchMedia not available"); | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | return original.call(window, query); | |
| 166 | - | }; | |
| 167 | - | ||
| 168 | - | ColorSchemeManager.matchMediaPatched = true; | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | private setupThemeIntegration(): void { | |
| 172 | - | // Integrate with unified theme setter if available | |
| 173 | - | if (window.__dumber_setTheme) { | |
| 174 | - | window.__dumber_setTheme(this.config.colorScheme); | |
| 175 | - | } else { | |
| 176 | - | // Retry after a short delay in case the bridge hasn't loaded yet | |
| 177 | - | setTimeout(() => { | |
| 178 | - | if (window.__dumber_setTheme) { | |
| 179 | - | window.__dumber_setTheme(this.config.colorScheme); | |
| 180 | - | } else { | |
| 181 | - | // Fallback: directly apply dark class for Tailwind compatibility | |
| 182 | - | console.warn("[dumber] Theme setter not available, using fallback"); | |
| 183 | - | if (this.config.isDark) { | |
| 184 | - | document.documentElement.classList.add("dark"); | |
| 185 | - | } else { | |
| 186 | - | document.documentElement.classList.remove("dark"); | |
| 187 | - | } | |
| 188 | - | try { | |
| 189 | - | window.__dumber_applyPalette?.( | |
| 190 | - | this.config.isDark ? "dark" : "light", | |
| 191 | - | ); | |
| 192 | - | } catch { | |
| 193 | - | /* noop */ | |
| 194 | - | } | |
| 195 | - | } | |
| 196 | - | }, 100); | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | /** | |
| 201 | - | * Updates the color scheme preference at runtime | |
| 202 | - | * @param isDark Whether to use dark mode | |
| 203 | - | */ | |
| 204 | - | public updateColorScheme(isDark: boolean): void { | |
| 205 | - | this.config.isDark = isDark; | |
| 206 | - | this.config.colorScheme = isDark ? "dark" : "light"; | |
| 207 | - | this.config.metaContent = isDark ? "dark light" : "light dark"; | |
| 208 | - | this.config.rootStyle = isDark | |
| 209 | - | ? ":root{color-scheme:dark;}" | |
| 210 | - | : ":root{color-scheme:light;}"; | |
| 211 | - | ||
| 212 | - | // Re-apply all configurations | |
| 213 | - | this.initialize(); | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | private getStoredPreference(): "light" | "dark" | null { | |
| 217 | - | try { | |
| 218 | - | const value = localStorage.getItem(ColorSchemeManager.STORAGE_KEY); | |
| 219 | - | if (value === "light" || value === "dark") { | |
| 220 | - | return value; | |
| 221 | - | } | |
| 222 | - | } catch (error) { | |
| 223 | - | console.warn("[dumber] Failed to read stored theme preference", error); | |
| 224 | - | } | |
| 225 | - | return null; | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | private storePreference(theme: "light" | "dark"): void { | |
| 229 | - | try { | |
| 230 | - | localStorage.setItem(ColorSchemeManager.STORAGE_KEY, theme); | |
| 231 | - | } catch (error) { | |
| 232 | - | console.warn("[dumber] Failed to persist theme preference", error); | |
| 233 | - | } | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | /** | |
| 237 | - | * Persist and apply an explicit user preference | |
| 238 | - | */ | |
| 239 | - | public setUserPreference(theme: "light" | "dark"): void { | |
| 240 | - | if (this.userPreference === theme && this.config.colorScheme === theme) { | |
| 241 | - | return; | |
| 242 | - | } | |
| 243 | - | this.userPreference = theme; | |
| 244 | - | this.storePreference(theme); | |
| 245 | - | this.updateColorScheme(theme === "dark"); | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | public getCurrentTheme(): "light" | "dark" { | |
| 249 | - | return this.config.colorScheme; | |
| 250 | - | } | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | // Initialize color scheme management | |
| 254 | - | const colorSchemeManager = new ColorSchemeManager(); | |
| 255 | - | ||
| 256 | - | // Export for potential runtime updates | |
| 257 | - | (window as any).__dumber_color_scheme_manager = colorSchemeManager; | |
| 258 | - | ||
| 259 | - | export default colorSchemeManager; | |
| 260 | - | ||
| 261 | - | declare global { | |
| 262 | - | interface Window { | |
| 263 | - | __dumber_color_scheme_manager?: { | |
| 264 | - | setUserPreference: (theme: "light" | "dark") => void; | |
| 265 | - | getCurrentTheme: () => "light" | "dark"; | |
| 266 | - | }; | |
| 267 | - | } | |
| 268 | - | } | |
D webui/src/injected/modules/omnibox.ts +0 -187
| @@ -1,187 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Omnibox Module Initialization | |
| 3 | - | * | |
| 4 | - | * Initializes the Svelte 5 omnibox component system | |
| 5 | - | */ | |
| 6 | - | ||
| 7 | - | import { mount } from "svelte"; | |
| 8 | - | import { Omnibox } from "../../components/omnibox"; | |
| 9 | - | import { ensureShadowMount } from "./shadowHost"; | |
| 10 | - | import type { OmniboxConfig } from "../../components/omnibox/types"; | |
| 11 | - | import { omniboxBridge } from "../../components/omnibox/messaging"; | |
| 12 | - | ||
| 13 | - | let omniboxComponent: ReturnType<typeof mount> | null = null; | |
| 14 | - | let isInitialized = false; | |
| 15 | - | ||
| 16 | - | export interface OmniboxInitConfig extends OmniboxConfig { | |
| 17 | - | // Additional initialization options if needed | |
| 18 | - | containerId?: string; | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | /** | |
| 22 | - | * Initialize the omnibox component system | |
| 23 | - | */ | |
| 24 | - | export async function initializeOmnibox( | |
| 25 | - | _config: OmniboxInitConfig = {}, | |
| 26 | - | ): Promise<void> { | |
| 27 | - | if (isInitialized) { | |
| 28 | - | console.warn("Omnibox already initialized"); | |
| 29 | - | return; | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | try { | |
| 33 | - | // Ensure we're in the main frame (not an iframe) | |
| 34 | - | if (window.self !== window.top) { | |
| 35 | - | console.log("🚫 Omnibox skipped in iframe"); | |
| 36 | - | return; | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // Ensure a <body> exists before mounting (document-start injection can run before it's available) | |
| 40 | - | if (!document.body) { | |
| 41 | - | await new Promise<void>((resolve) => { | |
| 42 | - | let resolved = false; | |
| 43 | - | const finish = () => { | |
| 44 | - | if (!resolved) { | |
| 45 | - | resolved = true; | |
| 46 | - | resolve(); | |
| 47 | - | } | |
| 48 | - | }; | |
| 49 | - | const check = () => { | |
| 50 | - | if (document.body) { | |
| 51 | - | finish(); | |
| 52 | - | } else { | |
| 53 | - | setTimeout(check, 10); | |
| 54 | - | } | |
| 55 | - | }; | |
| 56 | - | document.addEventListener("DOMContentLoaded", finish, { once: true }); | |
| 57 | - | check(); | |
| 58 | - | }); | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // Mount omnibox into the shared global Shadow DOM host | |
| 62 | - | const mountEl = ensureShadowMount("dumber-omnibox"); | |
| 63 | - | omniboxComponent = mount(Omnibox, { | |
| 64 | - | target: mountEl as unknown as Element, | |
| 65 | - | }); | |
| 66 | - | ||
| 67 | - | // Wait for the component to mount and set up the global API | |
| 68 | - | await new Promise<void>((resolve) => { | |
| 69 | - | const checkAPI = () => { | |
| 70 | - | if ( | |
| 71 | - | window.__dumber_omnibox && | |
| 72 | - | typeof window.__dumber_omnibox === "object" | |
| 73 | - | ) { | |
| 74 | - | console.log("✅ Omnibox component system initialized"); | |
| 75 | - | console.log( | |
| 76 | - | "🔧 Global API is available:", | |
| 77 | - | Object.keys(window.__dumber_omnibox), | |
| 78 | - | ); | |
| 79 | - | isInitialized = true; | |
| 80 | - | ||
| 81 | - | // Fetch search shortcuts from backend | |
| 82 | - | omniboxBridge.fetchSearchShortcuts().catch((err) => { | |
| 83 | - | console.warn("Failed to fetch search shortcuts:", err); | |
| 84 | - | }); | |
| 85 | - | ||
| 86 | - | // Notify isolated bundle listeners that omnibox is ready | |
| 87 | - | try { | |
| 88 | - | document.dispatchEvent(new CustomEvent("dumber:omnibox-ready")); | |
| 89 | - | } catch (e) { | |
| 90 | - | console.warn("Failed to dispatch omnibox-ready event", e); | |
| 91 | - | } | |
| 92 | - | resolve(); | |
| 93 | - | } else { | |
| 94 | - | setTimeout(checkAPI, 10); // Check again in 10ms | |
| 95 | - | } | |
| 96 | - | }; | |
| 97 | - | checkAPI(); | |
| 98 | - | }); | |
| 99 | - | ||
| 100 | - | // Bridge: listen for page-world events carrying suggestions and forward to API | |
| 101 | - | const handleSuggestionsEvent = (e: Event) => { | |
| 102 | - | try { | |
| 103 | - | const detail = (e as CustomEvent).detail; | |
| 104 | - | const suggestions = detail?.suggestions ?? detail; | |
| 105 | - | if ( | |
| 106 | - | Array.isArray(suggestions) && | |
| 107 | - | window.__dumber_omnibox?.setSuggestions | |
| 108 | - | ) { | |
| 109 | - | console.log( | |
| 110 | - | "🔗 [Bridge] Received page-world suggestions event:", | |
| 111 | - | suggestions.length, | |
| 112 | - | ); | |
| 113 | - | window.__dumber_omnibox.setSuggestions(suggestions); | |
| 114 | - | } else { | |
| 115 | - | console.warn( | |
| 116 | - | "🔗 [Bridge] Suggestions event received but API not ready or invalid payload", | |
| 117 | - | ); | |
| 118 | - | } | |
| 119 | - | } catch (err) { | |
| 120 | - | console.warn("🔗 [Bridge] Failed handling suggestions event:", err); | |
| 121 | - | } | |
| 122 | - | }; | |
| 123 | - | ||
| 124 | - | document.addEventListener( | |
| 125 | - | "dumber:omnibox-suggestions", | |
| 126 | - | handleSuggestionsEvent, | |
| 127 | - | false, | |
| 128 | - | ); | |
| 129 | - | // Unified event name | |
| 130 | - | document.addEventListener( | |
| 131 | - | "dumber:omnibox:suggestions", | |
| 132 | - | handleSuggestionsEvent, | |
| 133 | - | false, | |
| 134 | - | ); | |
| 135 | - | ||
| 136 | - | // Bridge: listen for inline suggestion events (fish-style ghost text) | |
| 137 | - | const handleInlineSuggestionEvent = (e: Event) => { | |
| 138 | - | try { | |
| 139 | - | const detail = (e as CustomEvent).detail; | |
| 140 | - | const url = detail?.url ?? null; | |
| 141 | - | console.log("[INLINE] omnibox module received event:", url); | |
| 142 | - | if (window.__dumber_omnibox?.setInlineSuggestion) { | |
| 143 | - | window.__dumber_omnibox.setInlineSuggestion(url); | |
| 144 | - | } else { | |
| 145 | - | console.warn("[INLINE] __dumber_omnibox.setInlineSuggestion not available"); | |
| 146 | - | } | |
| 147 | - | } catch (err) { | |
| 148 | - | console.warn("[Bridge] Failed handling inline suggestion event:", err); | |
| 149 | - | } | |
| 150 | - | }; | |
| 151 | - | ||
| 152 | - | document.addEventListener( | |
| 153 | - | "dumber:omnibox:inline-suggestion", | |
| 154 | - | handleInlineSuggestionEvent, | |
| 155 | - | false, | |
| 156 | - | ); | |
| 157 | - | } catch (error) { | |
| 158 | - | console.error("❌ Failed to initialize omnibox component:", error); | |
| 159 | - | throw error; | |
| 160 | - | } | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | /** | |
| 164 | - | * Get the current initialization status | |
| 165 | - | */ | |
| 166 | - | export function isOmniboxInitialized(): boolean { | |
| 167 | - | return isInitialized; | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | /** | |
| 171 | - | * Cleanup omnibox component (useful for hot reload during development) | |
| 172 | - | */ | |
| 173 | - | export function cleanupOmnibox(): void { | |
| 174 | - | if (omniboxComponent) { | |
| 175 | - | try { | |
| 176 | - | omniboxComponent.$destroy?.(); | |
| 177 | - | } catch (error) { | |
| 178 | - | console.warn("Error destroying omnibox component:", error); | |
| 179 | - | } | |
| 180 | - | omniboxComponent = null; | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | // The shared shadow host remains persistent; just log cleanup | |
| 184 | - | ||
| 185 | - | isInitialized = false; | |
| 186 | - | console.log("🧹 Omnibox component cleaned up"); | |
| 187 | - | } | |
D webui/src/injected/modules/shadowHost.ts +0 -252
| @@ -1,252 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Global Shadow DOM Host Utility | |
| 3 | - | * | |
| 4 | - | * Ensures a single, shared ShadowRoot is created and reused for all injected UI components | |
| 5 | - | * (omnibox, toast, etc.), with a minimal CSS reset applied once. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | // Import the compiled Tailwind bundle as a string so we can inject it | |
| 9 | - | // directly into the shadow root. The ?inline suffix makes Vite embed the | |
| 10 | - | // processed CSS, ensuring all utility classes are available without relying | |
| 11 | - | // on runtime @import resolution (which fails inside the injected shadow DOM). | |
| 12 | - | import globalStyles from "../../styles/tailwind.css?inline"; | |
| 13 | - | ||
| 14 | - | // Track shadow-root initialization to avoid duplicate reset injection | |
| 15 | - | const shadowResetApplied = new WeakSet<ShadowRoot>(); | |
| 16 | - | ||
| 17 | - | const HOST_STYLES = ` | |
| 18 | - | position: fixed !important; | |
| 19 | - | top: 0 !important; | |
| 20 | - | left: 0 !important; | |
| 21 | - | right: 0 !important; | |
| 22 | - | bottom: 0 !important; | |
| 23 | - | z-index: 2147483647 !important; | |
| 24 | - | pointer-events: none !important; | |
| 25 | - | margin: 0 !important; | |
| 26 | - | padding: 0 !important; | |
| 27 | - | border: none !important; | |
| 28 | - | background: none !important; | |
| 29 | - | isolation: isolate !important; | |
| 30 | - | contain: layout style !important; | |
| 31 | - | `; | |
| 32 | - | ||
| 33 | - | let cachedHost: HTMLElement | null = null; | |
| 34 | - | let hostObserver: MutationObserver | null = null; | |
| 35 | - | let hostKeepaliveTimer: number | null = null; | |
| 36 | - | ||
| 37 | - | function ensureHostPresence(host: HTMLElement): void { | |
| 38 | - | const docEl = document.documentElement; | |
| 39 | - | if (docEl && !host.isConnected) { | |
| 40 | - | docEl.appendChild(host); | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | if (typeof MutationObserver !== "undefined") { | |
| 44 | - | if (!hostObserver) { | |
| 45 | - | hostObserver = new MutationObserver(() => { | |
| 46 | - | if (cachedHost && !cachedHost.isConnected && document.documentElement) { | |
| 47 | - | document.documentElement.appendChild(cachedHost); | |
| 48 | - | } | |
| 49 | - | }); | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | try { | |
| 53 | - | hostObserver.disconnect(); | |
| 54 | - | if (docEl) { | |
| 55 | - | hostObserver.observe(docEl, { childList: true }); | |
| 56 | - | } | |
| 57 | - | } catch { | |
| 58 | - | // MutationObserver might not be allowed by the page; fall back to polling in that case. | |
| 59 | - | } | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | if (hostKeepaliveTimer === null && typeof window !== "undefined") { | |
| 63 | - | hostKeepaliveTimer = window.setInterval(() => { | |
| 64 | - | if (cachedHost && !cachedHost.isConnected && document.documentElement) { | |
| 65 | - | document.documentElement.appendChild(cachedHost); | |
| 66 | - | } | |
| 67 | - | }, 2000); | |
| 68 | - | } | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | export const GLOBAL_SHADOW_HOST_ID = "dumber-ui-root"; | |
| 72 | - | const TOKEN_KEYS = [ | |
| 73 | - | "--color-browser-bg", | |
| 74 | - | "--color-browser-surface", | |
| 75 | - | "--color-browser-surface-variant", | |
| 76 | - | "--color-browser-text", | |
| 77 | - | "--color-browser-muted", | |
| 78 | - | "--color-browser-accent", | |
| 79 | - | "--color-browser-border", | |
| 80 | - | "--dynamic-bg", | |
| 81 | - | "--dynamic-surface", | |
| 82 | - | "--dynamic-surface-variant", | |
| 83 | - | "--dynamic-text", | |
| 84 | - | "--dynamic-muted", | |
| 85 | - | "--dynamic-accent", | |
| 86 | - | "--dynamic-border", | |
| 87 | - | ] as const; | |
| 88 | - | ||
| 89 | - | function syncHostTokens(host: HTMLElement) { | |
| 90 | - | if (typeof window === "undefined" || typeof document === "undefined") { | |
| 91 | - | return; | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | try { | |
| 95 | - | const root = document.documentElement; | |
| 96 | - | const computed = getComputedStyle(root); | |
| 97 | - | for (const token of TOKEN_KEYS) { | |
| 98 | - | const value = computed.getPropertyValue(token); | |
| 99 | - | if (value && value.trim()) { | |
| 100 | - | host.style.setProperty(token, value.trim()); | |
| 101 | - | } | |
| 102 | - | } | |
| 103 | - | } catch (error) { | |
| 104 | - | console.warn("[shadowHost] Failed to sync color tokens", error); | |
| 105 | - | } | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | /** | |
| 109 | - | * Ensure and return the global ShadowRoot used by injected UI. | |
| 110 | - | */ | |
| 111 | - | export function getGlobalShadowRoot(): ShadowRoot { | |
| 112 | - | // Create or find the host container | |
| 113 | - | let host = document.getElementById( | |
| 114 | - | GLOBAL_SHADOW_HOST_ID, | |
| 115 | - | ) as HTMLElement | null; | |
| 116 | - | if (!host) { | |
| 117 | - | host = document.createElement("div"); | |
| 118 | - | host.id = GLOBAL_SHADOW_HOST_ID; | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | host.style.cssText = HOST_STYLES; | |
| 122 | - | ||
| 123 | - | cachedHost = host; | |
| 124 | - | ensureHostPresence(host); | |
| 125 | - | syncHostTokens(host); | |
| 126 | - | ||
| 127 | - | const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: "open" }); | |
| 128 | - | ||
| 129 | - | // Reflect current theme from document root to the shadow host so :host(.dark) works | |
| 130 | - | try { | |
| 131 | - | const isDark = document.documentElement.classList.contains("dark"); | |
| 132 | - | host.classList.toggle("dark", isDark); | |
| 133 | - | // Keep it in sync if the theme toggles later | |
| 134 | - | const observer = new MutationObserver(() => { | |
| 135 | - | host.classList.toggle( | |
| 136 | - | "dark", | |
| 137 | - | document.documentElement.classList.contains("dark"), | |
| 138 | - | ); | |
| 139 | - | syncHostTokens(host); | |
| 140 | - | }); | |
| 141 | - | observer.observe(document.documentElement, { | |
| 142 | - | attributes: true, | |
| 143 | - | attributeFilter: ["class"], | |
| 144 | - | }); | |
| 145 | - | } catch { | |
| 146 | - | /* no-op */ | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | try { | |
| 150 | - | document.addEventListener("dumber:palette-updated", () => { | |
| 151 | - | if (cachedHost) { | |
| 152 | - | syncHostTokens(cachedHost); | |
| 153 | - | } | |
| 154 | - | }); | |
| 155 | - | } catch { | |
| 156 | - | /* no-op */ | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | // Inject a minimal reset and base styles into the shadow root once | |
| 160 | - | if (!shadowResetApplied.has(shadowRoot)) { | |
| 161 | - | const resetStyle = document.createElement("style"); | |
| 162 | - | resetStyle.textContent = ` | |
| 163 | - | :host { all: initial; } | |
| 164 | - | *, *::before, *::after { box-sizing: border-box; } | |
| 165 | - | :host { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif; } | |
| 166 | - | `; | |
| 167 | - | shadowRoot.appendChild(resetStyle); | |
| 168 | - | ||
| 169 | - | // Ensure dynamic design tokens exist within the shadow tree. Tailwind v4 | |
| 170 | - | // utilities rely on these CSS variables. We set light defaults and a dark variant on :host. | |
| 171 | - | const tokensStyle = document.createElement("style"); | |
| 172 | - | tokensStyle.textContent = ` | |
| 173 | - | :host { | |
| 174 | - | --dynamic-bg: var(--color-browser-bg); | |
| 175 | - | --dynamic-surface-variant: var(--color-browser-surface-variant); | |
| 176 | - | --dynamic-surface: var(--color-browser-surface); | |
| 177 | - | --dynamic-text: var(--color-browser-text); | |
| 178 | - | --dynamic-muted: var(--color-browser-muted); | |
| 179 | - | --dynamic-accent: var(--color-browser-accent); | |
| 180 | - | --dynamic-border: var(--color-browser-border); | |
| 181 | - | } | |
| 182 | - | `; | |
| 183 | - | shadowRoot.appendChild(tokensStyle); | |
| 184 | - | ||
| 185 | - | // Inject the global GUI stylesheet (Tailwind) into shadow root | |
| 186 | - | try { | |
| 187 | - | // Use <style> tag injection for Tailwind global styles | |
| 188 | - | const styleTag = document.createElement("style"); | |
| 189 | - | styleTag.setAttribute("data-source", "dumber-tailwind-styles"); | |
| 190 | - | styleTag.textContent = globalStyles; | |
| 191 | - | shadowRoot.appendChild(styleTag); | |
| 192 | - | ||
| 193 | - | // Inject component styles from Go-provided string | |
| 194 | - | // These are Svelte component-scoped styles that must be inside the shadow root | |
| 195 | - | // due to Shadow DOM style encapsulation | |
| 196 | - | const componentStyles = (window as any).__dumber_component_styles; | |
| 197 | - | if (componentStyles) { | |
| 198 | - | const componentStyleTag = document.createElement("style"); | |
| 199 | - | componentStyleTag.setAttribute("data-source", "dumber-component-styles"); | |
| 200 | - | componentStyleTag.textContent = componentStyles; | |
| 201 | - | shadowRoot.appendChild(componentStyleTag); | |
| 202 | - | console.log( | |
| 203 | - | "[shadowHost] Component styles injected:", | |
| 204 | - | componentStyles.length, | |
| 205 | - | "chars", | |
| 206 | - | ); | |
| 207 | - | } else { | |
| 208 | - | console.warn("[shadowHost] Component styles not available on window"); | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | console.log( | |
| 212 | - | "[shadowHost] All styles injected, shadow root children:", | |
| 213 | - | shadowRoot.childNodes.length, | |
| 214 | - | ); | |
| 215 | - | } catch (error) { | |
| 216 | - | console.error("[shadowHost] Failed to inject styles:", error); | |
| 217 | - | // Fallback attempt | |
| 218 | - | try { | |
| 219 | - | const fallbackStyle = document.createElement("style"); | |
| 220 | - | fallbackStyle.textContent = globalStyles || ""; | |
| 221 | - | shadowRoot.appendChild(fallbackStyle); | |
| 222 | - | } catch (fallbackError) { | |
| 223 | - | console.error( | |
| 224 | - | "[shadowHost] Fallback style injection also failed:", | |
| 225 | - | fallbackError, | |
| 226 | - | ); | |
| 227 | - | } | |
| 228 | - | } | |
| 229 | - | shadowResetApplied.add(shadowRoot); | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | return shadowRoot; | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | /** | |
| 236 | - | * Ensure and return a dedicated mount element inside the global ShadowRoot. | |
| 237 | - | * Use a unique id per feature (e.g., "dumber-omnibox", "dumber-toast"). | |
| 238 | - | */ | |
| 239 | - | export function ensureShadowMount(mountId: string): HTMLElement { | |
| 240 | - | const root = getGlobalShadowRoot(); | |
| 241 | - | let mount = root.getElementById?.(mountId) as HTMLElement | null; | |
| 242 | - | if (!mount) { | |
| 243 | - | mount = document.createElement("div"); | |
| 244 | - | mount.id = mountId; | |
| 245 | - | mount.style.cssText = ` | |
| 246 | - | position: relative; | |
| 247 | - | pointer-events: none; /* components inside control their own pointer events */ | |
| 248 | - | `; | |
| 249 | - | root.appendChild(mount); | |
| 250 | - | } | |
| 251 | - | return mount; | |
| 252 | - | } | |
D webui/src/injected/modules/toast.ts +0 -113
| @@ -1,113 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Toast System Module | |
| 3 | - | * | |
| 4 | - | * Provides toast notification functionality using Svelte 5 components | |
| 5 | - | * with proper DOM ready checking and theme support. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | import { mount, flushSync } from "svelte"; | |
| 9 | - | import ToastContainer from "$components/toast/ToastContainer.svelte"; | |
| 10 | - | import { ensureShadowMount } from "./shadowHost"; | |
| 11 | - | ||
| 12 | - | export interface ToastConfig { | |
| 13 | - | theme?: "light" | "dark"; | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | // Type definitions for window globals | |
| 17 | - | declare global { | |
| 18 | - | interface Window { | |
| 19 | - | __dumber_toast_loaded?: boolean; | |
| 20 | - | __dumber_initial_theme?: string; | |
| 21 | - | } | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | let toastInitialized = false; | |
| 25 | - | ||
| 26 | - | // Apply theme based on current system/GTK preference | |
| 27 | - | function updateTheme(isDark: boolean): void { | |
| 28 | - | if (isDark) { | |
| 29 | - | document.documentElement.classList.add("dark"); | |
| 30 | - | } else { | |
| 31 | - | document.documentElement.classList.remove("dark"); | |
| 32 | - | } | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | export async function initializeToast(config?: ToastConfig): Promise<void> { | |
| 36 | - | if (toastInitialized || window.__dumber_toast_loaded) { | |
| 37 | - | // Verify functions still exist - if not, reinitialize | |
| 38 | - | if ( | |
| 39 | - | typeof window.__dumber_showToast === "function" && | |
| 40 | - | typeof window.__dumber_showZoomToast === "function" | |
| 41 | - | ) { | |
| 42 | - | console.log("✅ Toast system already initialized and functional"); | |
| 43 | - | return; | |
| 44 | - | } | |
| 45 | - | // If functions are missing, reinitialize | |
| 46 | - | console.log("⚠️ Toast functions missing, reinitializing..."); | |
| 47 | - | toastInitialized = false; | |
| 48 | - | window.__dumber_toast_loaded = false; | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | console.log("🚀 Initializing Svelte toast system at:", document.readyState); | |
| 52 | - | console.log( | |
| 53 | - | "📊 DOM state check - head:", | |
| 54 | - | !!document.head, | |
| 55 | - | "body:", | |
| 56 | - | !!document.body, | |
| 57 | - | ); | |
| 58 | - | window.__dumber_toast_loaded = true; | |
| 59 | - | ||
| 60 | - | // Ensure DOM is ready | |
| 61 | - | if (document.readyState === "loading") { | |
| 62 | - | return new Promise((resolve) => { | |
| 63 | - | document.addEventListener("DOMContentLoaded", async () => { | |
| 64 | - | await initializeToast(config); | |
| 65 | - | resolve(); | |
| 66 | - | }); | |
| 67 | - | }); | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | if (!document.head || !document.body) { | |
| 71 | - | console.warn( | |
| 72 | - | "❌ DOM structure not ready, deferring toast initialization. Retrying...", | |
| 73 | - | ); | |
| 74 | - | window.__dumber_toast_loaded = false; // Reset the flag so we can try again | |
| 75 | - | return new Promise((resolve) => { | |
| 76 | - | const checkDOM = () => { | |
| 77 | - | if (document.head && document.body) { | |
| 78 | - | console.log("✅ DOM structure now ready, initializing toast system"); | |
| 79 | - | initializeToast(config).then(() => resolve()); | |
| 80 | - | } else { | |
| 81 | - | console.log("⏳ DOM still not ready, retrying in 50ms..."); | |
| 82 | - | setTimeout(checkDOM, 50); | |
| 83 | - | } | |
| 84 | - | }; | |
| 85 | - | setTimeout(checkDOM, 100); | |
| 86 | - | }); | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | try { | |
| 90 | - | // Use the global Shadow DOM host for isolation | |
| 91 | - | const rootElement = ensureShadowMount("dumber-toast"); | |
| 92 | - | ||
| 93 | - | // Initialize theme | |
| 94 | - | const initialTheme = config?.theme || window.__dumber_initial_theme; | |
| 95 | - | if (initialTheme !== undefined) { | |
| 96 | - | updateTheme(initialTheme === "dark"); | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // Mount the Svelte toast container - this exposes all toast functions to window | |
| 100 | - | mount(ToastContainer, { | |
| 101 | - | target: rootElement as unknown as Element, | |
| 102 | - | }); | |
| 103 | - | ||
| 104 | - | // Force immediate effect execution to ensure component functions are exposed | |
| 105 | - | flushSync(); | |
| 106 | - | ||
| 107 | - | toastInitialized = true; | |
| 108 | - | console.log("✅ Toast system initialized - functions exposed by ToastContainer"); | |
| 109 | - | } catch (error) { | |
| 110 | - | console.error("❌ Failed to initialize toast system:", error); | |
| 111 | - | throw error; | |
| 112 | - | } | |
| 113 | - | } | |
D webui/src/injected/modules/workspace.ts +0 -8
| @@ -1,8 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Workspace UI Notifications Module | |
| 3 | - | * | |
| 4 | - | * Pane mode is now handled entirely in the Go backend with visual border feedback. | |
| 5 | - | * This module is kept for potential future UI enhancements. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | console.log("[workspace] UI notification handlers initialized"); | |
D webui/src/injected/styles-entry.css +0 -2
| @@ -1,2 +0,0 @@ | |||
| 1 | - | /* Vite/PostCSS/Tailwind processed CSS entry for Shadow DOM injection */ | |
| 2 | - | @import "../lib/styles.css"; | |
D webui/src/lib/keyboard/KeyboardService.ts +0 -432
| @@ -1,432 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Keyboard Service | |
| 3 | - | * | |
| 4 | - | * Centralized keyboard shortcut management for the Dumber Browser GUI. | |
| 5 | - | * Bridges between native GTK keyboard events and Svelte component handlers. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | import type { | |
| 9 | - | ShortcutConfig, | |
| 10 | - | ComponentShortcuts, | |
| 11 | - | KeyboardServiceConfig, | |
| 12 | - | KeyboardEventDetail, | |
| 13 | - | KeyboardEvent, | |
| 14 | - | KeyboardEventType, | |
| 15 | - | } from "./types"; | |
| 16 | - | // COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers | |
| 17 | - | ||
| 18 | - | export class KeyboardService { | |
| 19 | - | private components = new Map<string, ComponentShortcuts>(); | |
| 20 | - | private activeComponent: string | null = null; | |
| 21 | - | private globalShortcuts: ShortcutConfig[] = []; | |
| 22 | - | private eventListeners = new Map< | |
| 23 | - | KeyboardEventType, | |
| 24 | - | Set<(event: KeyboardEvent) => void> | |
| 25 | - | >(); | |
| 26 | - | private debug: boolean = false; | |
| 27 | - | // Mode-aware keymaps (e.g., 'vim', 'emacs') are deferred for now | |
| 28 | - | ||
| 29 | - | constructor(config: KeyboardServiceConfig = {}) { | |
| 30 | - | this.debug = config.debug ?? false; | |
| 31 | - | this.globalShortcuts = config.globalShortcuts ?? []; | |
| 32 | - | ||
| 33 | - | this.log("info", "KeyboardService initialized", { config }); | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | /** | |
| 37 | - | * Register shortcuts for a component | |
| 38 | - | */ | |
| 39 | - | registerShortcuts(componentId: string, shortcuts: ShortcutConfig[]): void { | |
| 40 | - | if (this.components.has(componentId)) { | |
| 41 | - | this.log( | |
| 42 | - | "warn", | |
| 43 | - | `Component ${componentId} already registered, overwriting shortcuts`, | |
| 44 | - | ); | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | const componentShortcuts: ComponentShortcuts = { | |
| 48 | - | componentId, | |
| 49 | - | shortcuts: shortcuts.map((shortcut) => ({ | |
| 50 | - | ...shortcut, | |
| 51 | - | preventDefault: shortcut.preventDefault ?? true, | |
| 52 | - | stopPropagation: shortcut.stopPropagation ?? true, | |
| 53 | - | whenFocused: shortcut.whenFocused ?? false, | |
| 54 | - | })), | |
| 55 | - | focused: false, | |
| 56 | - | }; | |
| 57 | - | ||
| 58 | - | this.components.set(componentId, componentShortcuts); | |
| 59 | - | ||
| 60 | - | this.log("info", "Shortcuts registered", { | |
| 61 | - | componentId, | |
| 62 | - | shortcuts: shortcuts.length, | |
| 63 | - | }); | |
| 64 | - | this.emit("shortcut-registered", { | |
| 65 | - | shortcut: componentId, | |
| 66 | - | timestamp: Date.now(), | |
| 67 | - | targetComponent: componentId, | |
| 68 | - | }); | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | /** | |
| 72 | - | * Unregister shortcuts for a component | |
| 73 | - | */ | |
| 74 | - | unregisterShortcuts(componentId: string): void { | |
| 75 | - | const component = this.components.get(componentId); | |
| 76 | - | if (!component) { | |
| 77 | - | this.log("warn", `Component ${componentId} not found for unregistration`); | |
| 78 | - | return; | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Call cleanup function if provided | |
| 82 | - | if (component.cleanup) { | |
| 83 | - | component.cleanup(); | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | this.components.delete(componentId); | |
| 87 | - | ||
| 88 | - | // Clear active component if it was this one | |
| 89 | - | if (this.activeComponent === componentId) { | |
| 90 | - | this.activeComponent = null; | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | this.log("info", "Shortcuts unregistered", { componentId }); | |
| 94 | - | this.emit("shortcut-unregistered", { | |
| 95 | - | shortcut: componentId, | |
| 96 | - | timestamp: Date.now(), | |
| 97 | - | targetComponent: componentId, | |
| 98 | - | }); | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | /** | |
| 102 | - | * Set which component is currently focused | |
| 103 | - | */ | |
| 104 | - | setActiveComponent(componentId: string | null): void { | |
| 105 | - | // Blur previous component | |
| 106 | - | if (this.activeComponent && this.components.has(this.activeComponent)) { | |
| 107 | - | const prevComponent = this.components.get(this.activeComponent)!; | |
| 108 | - | prevComponent.focused = false; | |
| 109 | - | this.emit("component-blurred", { | |
| 110 | - | shortcut: this.activeComponent, | |
| 111 | - | timestamp: Date.now(), | |
| 112 | - | targetComponent: this.activeComponent, | |
| 113 | - | }); | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | this.activeComponent = componentId; | |
| 117 | - | ||
| 118 | - | // Focus new component | |
| 119 | - | if (componentId && this.components.has(componentId)) { | |
| 120 | - | const newComponent = this.components.get(componentId)!; | |
| 121 | - | newComponent.focused = true; | |
| 122 | - | this.emit("component-focused", { | |
| 123 | - | shortcut: componentId, | |
| 124 | - | timestamp: Date.now(), | |
| 125 | - | targetComponent: componentId, | |
| 126 | - | }); | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | this.log("info", "Active component changed", { | |
| 130 | - | from: this.activeComponent, | |
| 131 | - | to: componentId, | |
| 132 | - | }); | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | /** | |
| 136 | - | * Handle a keyboard shortcut from native GTK layer | |
| 137 | - | */ | |
| 138 | - | handleNativeShortcut(shortcut: string): boolean { | |
| 139 | - | this.log("info", "Native shortcut received", { shortcut }); | |
| 140 | - | ||
| 141 | - | // Normalize shortcut string | |
| 142 | - | const normalizedShortcut = this.normalizeShortcut(shortcut); | |
| 143 | - | ||
| 144 | - | // Try to handle with focused component first | |
| 145 | - | if (this.activeComponent) { | |
| 146 | - | const handled = this.handleComponentShortcut( | |
| 147 | - | this.activeComponent, | |
| 148 | - | normalizedShortcut, | |
| 149 | - | true, | |
| 150 | - | ); | |
| 151 | - | if (handled) { | |
| 152 | - | return true; | |
| 153 | - | } | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // Try global shortcuts | |
| 157 | - | const globalHandled = this.handleGlobalShortcuts(normalizedShortcut); | |
| 158 | - | if (globalHandled) { | |
| 159 | - | return true; | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | // Try all components (for non-focused shortcuts) | |
| 163 | - | for (const [componentId] of this.components) { | |
| 164 | - | if (componentId === this.activeComponent) continue; // Already tried above | |
| 165 | - | ||
| 166 | - | const handled = this.handleComponentShortcut( | |
| 167 | - | componentId, | |
| 168 | - | normalizedShortcut, | |
| 169 | - | false, | |
| 170 | - | ); | |
| 171 | - | if (handled) { | |
| 172 | - | return true; | |
| 173 | - | } | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | this.log("info", "Shortcut not handled", { shortcut: normalizedShortcut }); | |
| 177 | - | return false; | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | /** | |
| 181 | - | * Handle JavaScript keyboard events (for web page events) | |
| 182 | - | */ | |
| 183 | - | handleKeyboardEvent(event: globalThis.KeyboardEvent): boolean { | |
| 184 | - | const shortcut = this.eventToShortcut(event); | |
| 185 | - | if (!shortcut) return false; | |
| 186 | - | ||
| 187 | - | this.log("info", "Keyboard event received", { shortcut }); | |
| 188 | - | return this.handleNativeShortcut(shortcut); | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | /** | |
| 192 | - | * Handle JavaScript mouse events (for navigation buttons) | |
| 193 | - | */ | |
| 194 | - | handleMouseEvent(event: globalThis.MouseEvent): boolean { | |
| 195 | - | try { | |
| 196 | - | // Mouse button 3 (back button) | |
| 197 | - | if (event.button === 3) { | |
| 198 | - | event.preventDefault(); | |
| 199 | - | event.stopPropagation(); | |
| 200 | - | ||
| 201 | - | if (window.history.length > 1) { | |
| 202 | - | console.log("⬅️ Mouse back button pressed"); | |
| 203 | - | window.history.back(); | |
| 204 | - | } | |
| 205 | - | return true; | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | // Mouse button 4 (forward button) | |
| 209 | - | if (event.button === 4) { | |
| 210 | - | event.preventDefault(); | |
| 211 | - | event.stopPropagation(); | |
| 212 | - | ||
| 213 | - | console.log("➡️ Mouse forward button pressed"); | |
| 214 | - | window.history.forward(); | |
| 215 | - | return true; | |
| 216 | - | } | |
| 217 | - | } catch (error) { | |
| 218 | - | this.log("error", "Mouse handler error", { error }); | |
| 219 | - | } | |
| 220 | - | ||
| 221 | - | return false; | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | /** | |
| 225 | - | * Add event listener for keyboard service events | |
| 226 | - | */ | |
| 227 | - | addEventListener( | |
| 228 | - | type: KeyboardEventType, | |
| 229 | - | listener: (event: KeyboardEvent) => void, | |
| 230 | - | ): void { | |
| 231 | - | if (!this.eventListeners.has(type)) { | |
| 232 | - | this.eventListeners.set(type, new Set()); | |
| 233 | - | } | |
| 234 | - | this.eventListeners.get(type)!.add(listener); | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | /** | |
| 238 | - | * Remove event listener | |
| 239 | - | */ | |
| 240 | - | removeEventListener( | |
| 241 | - | type: KeyboardEventType, | |
| 242 | - | listener: (event: KeyboardEvent) => void, | |
| 243 | - | ): void { | |
| 244 | - | const listeners = this.eventListeners.get(type); | |
| 245 | - | if (listeners) { | |
| 246 | - | listeners.delete(listener); | |
| 247 | - | } | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | /** | |
| 251 | - | * Get all registered shortcuts for debugging | |
| 252 | - | */ | |
| 253 | - | getDebugInfo(): object { | |
| 254 | - | return { | |
| 255 | - | components: Array.from(this.components.entries()).map(([id, comp]) => ({ | |
| 256 | - | id, | |
| 257 | - | shortcuts: comp.shortcuts.length, | |
| 258 | - | focused: comp.focused, | |
| 259 | - | })), | |
| 260 | - | activeComponent: this.activeComponent, | |
| 261 | - | globalShortcuts: this.globalShortcuts.length, | |
| 262 | - | }; | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | // Private methods | |
| 266 | - | ||
| 267 | - | private handleComponentShortcut( | |
| 268 | - | componentId: string, | |
| 269 | - | shortcut: string, | |
| 270 | - | respectFocus: boolean, | |
| 271 | - | ): boolean { | |
| 272 | - | const component = this.components.get(componentId); | |
| 273 | - | if (!component) return false; | |
| 274 | - | ||
| 275 | - | for (const shortcutConfig of component.shortcuts) { | |
| 276 | - | if (this.normalizeShortcut(shortcutConfig.key) === shortcut) { | |
| 277 | - | // Check focus requirement | |
| 278 | - | if (respectFocus && shortcutConfig.whenFocused && !component.focused) { | |
| 279 | - | continue; | |
| 280 | - | } | |
| 281 | - | ||
| 282 | - | try { | |
| 283 | - | shortcutConfig.handler(); | |
| 284 | - | this.log("info", "Shortcut handled", { | |
| 285 | - | shortcut, | |
| 286 | - | componentId, | |
| 287 | - | focused: component.focused, | |
| 288 | - | description: shortcutConfig.description, | |
| 289 | - | }); | |
| 290 | - | ||
| 291 | - | this.emit("shortcut-triggered", { | |
| 292 | - | shortcut, | |
| 293 | - | timestamp: Date.now(), | |
| 294 | - | targetComponent: componentId, | |
| 295 | - | }); | |
| 296 | - | ||
| 297 | - | return true; | |
| 298 | - | } catch (error) { | |
| 299 | - | this.log("error", "Shortcut handler error", { | |
| 300 | - | shortcut, | |
| 301 | - | componentId, | |
| 302 | - | error, | |
| 303 | - | }); | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | return false; | |
| 309 | - | } | |
| 310 | - | ||
| 311 | - | private handleGlobalShortcuts(shortcut: string): boolean { | |
| 312 | - | for (const shortcutConfig of this.globalShortcuts) { | |
| 313 | - | if (this.normalizeShortcut(shortcutConfig.key) === shortcut) { | |
| 314 | - | try { | |
| 315 | - | shortcutConfig.handler(); | |
| 316 | - | this.log("info", "Global shortcut handled", { | |
| 317 | - | shortcut, | |
| 318 | - | description: shortcutConfig.description, | |
| 319 | - | }); | |
| 320 | - | ||
| 321 | - | this.emit("shortcut-triggered", { | |
| 322 | - | shortcut, | |
| 323 | - | timestamp: Date.now(), | |
| 324 | - | }); | |
| 325 | - | ||
| 326 | - | return true; | |
| 327 | - | } catch (error) { | |
| 328 | - | this.log("error", "Global shortcut handler error", { | |
| 329 | - | shortcut, | |
| 330 | - | error, | |
| 331 | - | }); | |
| 332 | - | } | |
| 333 | - | } | |
| 334 | - | } | |
| 335 | - | return false; | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | private normalizeShortcut(shortcut: string): string { | |
| 339 | - | return shortcut.toLowerCase().replace(/\s+/g, ""); | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | private eventToShortcut(event: globalThis.KeyboardEvent): string | null { | |
| 343 | - | const parts: string[] = []; | |
| 344 | - | ||
| 345 | - | // Add modifiers using MODIFIER_KEYS mapping | |
| 346 | - | if (event.ctrlKey) { | |
| 347 | - | parts.push("ctrl"); | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | if (event.altKey) parts.push("alt"); | |
| 351 | - | if (event.shiftKey) parts.push("shift"); | |
| 352 | - | ||
| 353 | - | // Add key | |
| 354 | - | if (event.key) { | |
| 355 | - | parts.push(event.key.toLowerCase()); | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | return parts.length > 0 ? parts.join("+") : null; | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | private emit(type: KeyboardEventType, detail: KeyboardEventDetail): void { | |
| 362 | - | const listeners = this.eventListeners.get(type); | |
| 363 | - | if (listeners) { | |
| 364 | - | const keyboardEvent: KeyboardEvent = { type, detail }; | |
| 365 | - | listeners.forEach((listener) => { | |
| 366 | - | try { | |
| 367 | - | listener(keyboardEvent); | |
| 368 | - | } catch (error) { | |
| 369 | - | this.log("error", "Event listener error", { type, error }); | |
| 370 | - | } | |
| 371 | - | }); | |
| 372 | - | } | |
| 373 | - | } | |
| 374 | - | ||
| 375 | - | private log( | |
| 376 | - | level: "info" | "warn" | "error" | string, | |
| 377 | - | message?: string, | |
| 378 | - | data?: unknown, | |
| 379 | - | ): void { | |
| 380 | - | if (!this.debug && level !== "error") return; | |
| 381 | - | ||
| 382 | - | const logMessage = message | |
| 383 | - | ? `[KeyboardService] ${message}` | |
| 384 | - | : `[KeyboardService] ${level}`; | |
| 385 | - | ||
| 386 | - | switch (level) { | |
| 387 | - | case "error": | |
| 388 | - | console.error(logMessage, data); | |
| 389 | - | break; | |
| 390 | - | case "warn": | |
| 391 | - | console.warn(logMessage, data); | |
| 392 | - | break; | |
| 393 | - | default: | |
| 394 | - | console.log(logMessage, data); | |
| 395 | - | } | |
| 396 | - | } | |
| 397 | - | } | |
| 398 | - | ||
| 399 | - | // Create and export singleton instance with global shortcuts | |
| 400 | - | export const keyboardService = new KeyboardService({ | |
| 401 | - | debug: false, // Disable debug logging in production | |
| 402 | - | globalShortcuts: [ | |
| 403 | - | // Navigation shortcuts (migrated from controls module) | |
| 404 | - | { | |
| 405 | - | key: "ctrl+arrowleft", | |
| 406 | - | handler: () => { | |
| 407 | - | if (window.history.length > 1) { | |
| 408 | - | window.history.back(); | |
| 409 | - | } | |
| 410 | - | }, | |
| 411 | - | description: "Navigate back (Ctrl+Left Arrow)", | |
| 412 | - | }, | |
| 413 | - | { | |
| 414 | - | key: "ctrl+arrowright", | |
| 415 | - | handler: () => { | |
| 416 | - | window.history.forward(); | |
| 417 | - | }, | |
| 418 | - | description: "Navigate forward (Ctrl+Right Arrow)", | |
| 419 | - | }, | |
| 420 | - | // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled | |
| 421 | - | // directly by the native Go shortcut handlers to ensure proper active pane detection. | |
| 422 | - | // This avoids conflicts between multiple webview instances. | |
| 423 | - | ], | |
| 424 | - | }); | |
| 425 | - | ||
| 426 | - | // Export types for use in components | |
| 427 | - | export type { | |
| 428 | - | ShortcutConfig, | |
| 429 | - | ComponentShortcuts, | |
| 430 | - | KeyboardServiceConfig, | |
| 431 | - | } from "./types"; | |
| 432 | - | // COMMON_SHORTCUTS export not needed since global shortcuts moved to native handlers | |
D webui/src/lib/keyboard/index.ts +0 -17
| @@ -1,17 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Keyboard Service Module | |
| 3 | - | * | |
| 4 | - | * Centralized keyboard shortcut management for Dumber Browser. | |
| 5 | - | * Provides type-safe keyboard event handling and component integration. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | export { KeyboardService, keyboardService } from "./KeyboardService"; | |
| 9 | - | export type { | |
| 10 | - | ShortcutConfig, | |
| 11 | - | ComponentShortcuts, | |
| 12 | - | KeyboardServiceConfig, | |
| 13 | - | KeyboardEventDetail, | |
| 14 | - | KeyboardEvent, | |
| 15 | - | KeyboardEventType, | |
| 16 | - | } from "./types"; | |
| 17 | - | export { MODIFIER_KEYS, COMMON_SHORTCUTS } from "./types"; | |
D webui/src/lib/keyboard/types.ts +0 -99
| @@ -1,99 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Keyboard Service Types | |
| 3 | - | * | |
| 4 | - | * Type definitions for the unified keyboard shortcut system. | |
| 5 | - | * Provides type-safe interfaces for registering and handling keyboard shortcuts. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | export interface ShortcutConfig { | |
| 9 | - | /** The keyboard shortcut key combination (e.g., 'ctrl+l', 'escape', 'enter') */ | |
| 10 | - | key: string; | |
| 11 | - | ||
| 12 | - | /** Function to execute when the shortcut is triggered */ | |
| 13 | - | handler: () => void; | |
| 14 | - | ||
| 15 | - | /** Whether to prevent the default browser behavior (default: true) */ | |
| 16 | - | preventDefault?: boolean; | |
| 17 | - | ||
| 18 | - | /** Whether to stop event propagation (default: true) */ | |
| 19 | - | stopPropagation?: boolean; | |
| 20 | - | ||
| 21 | - | /** Only trigger when this component is focused (default: false) */ | |
| 22 | - | whenFocused?: boolean; | |
| 23 | - | ||
| 24 | - | /** Optional description for debugging */ | |
| 25 | - | description?: string; | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | export interface KeyboardEventDetail { | |
| 29 | - | /** The original keyboard shortcut string */ | |
| 30 | - | shortcut: string; | |
| 31 | - | ||
| 32 | - | /** Timestamp when the event occurred */ | |
| 33 | - | timestamp: number; | |
| 34 | - | ||
| 35 | - | /** Component that should handle this event (if specified) */ | |
| 36 | - | targetComponent?: string; | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | export interface ComponentShortcuts { | |
| 40 | - | /** Unique component identifier */ | |
| 41 | - | componentId: string; | |
| 42 | - | ||
| 43 | - | /** Array of shortcuts registered by this component */ | |
| 44 | - | shortcuts: ShortcutConfig[]; | |
| 45 | - | ||
| 46 | - | /** Whether this component is currently focused */ | |
| 47 | - | focused: boolean; | |
| 48 | - | ||
| 49 | - | /** Optional cleanup function called when component is destroyed */ | |
| 50 | - | cleanup?: () => void; | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | export interface KeyboardServiceConfig { | |
| 54 | - | /** Enable debug logging for keyboard events */ | |
| 55 | - | debug?: boolean; | |
| 56 | - | ||
| 57 | - | /** Global shortcuts that work regardless of component focus */ | |
| 58 | - | globalShortcuts?: ShortcutConfig[]; | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | /** | |
| 62 | - | * Event types for keyboard service | |
| 63 | - | */ | |
| 64 | - | export type KeyboardEventType = | |
| 65 | - | | "shortcut-triggered" | |
| 66 | - | | "component-focused" | |
| 67 | - | | "component-blurred" | |
| 68 | - | | "shortcut-registered" | |
| 69 | - | | "shortcut-unregistered"; | |
| 70 | - | ||
| 71 | - | export interface KeyboardEvent { | |
| 72 | - | type: KeyboardEventType; | |
| 73 | - | detail: KeyboardEventDetail | ComponentShortcuts | ShortcutConfig; | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | /** | |
| 77 | - | * Keyboard modifiers mapping | |
| 78 | - | */ | |
| 79 | - | export const MODIFIER_KEYS = { | |
| 80 | - | ctrl: "ctrlKey", | |
| 81 | - | alt: "altKey", | |
| 82 | - | shift: "shiftKey", | |
| 83 | - | } as const; | |
| 84 | - | ||
| 85 | - | /** | |
| 86 | - | * Common shortcut patterns used throughout the application | |
| 87 | - | */ | |
| 88 | - | export const COMMON_SHORTCUTS = { | |
| 89 | - | OMNIBOX_OPEN: "ctrl+l", | |
| 90 | - | FIND_OPEN: "ctrl+f", | |
| 91 | - | ESCAPE: "escape", | |
| 92 | - | ENTER: "enter", | |
| 93 | - | ARROW_UP: "arrowup", | |
| 94 | - | ARROW_DOWN: "arrowdown", | |
| 95 | - | ARROW_LEFT: "arrowleft", | |
| 96 | - | ARROW_RIGHT: "arrowright", | |
| 97 | - | TAB: "tab", | |
| 98 | - | SHIFT_TAB: "shift+tab", | |
| 99 | - | } as const; | |
M webui/src/pages/blocked.ts +0 -3
| @@ -1,9 +1,6 @@ | |||
| 1 | 1 | import { mount } from "svelte"; | |
| 2 | 2 | import "../styles/tailwind.css"; | |
| 3 | 3 | import BlockedPage from "./BlockedPage.svelte"; | |
| 4 | - | import { bootstrapGUI } from "../injected/bootstrap"; | |
| 5 | - | ||
| 6 | - | bootstrapGUI(); | |
| 7 | 4 | ||
| 8 | 5 | // Mount the BlockedPage component to the DOM | |
| 9 | 6 | console.log("[dumber] Mounting BlockedPage component to document.body"); | |
M webui/src/pages/homepage.ts +0 -3
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | import { mount } from "svelte"; | |
| 2 | 2 | import "../styles/tailwind.css"; | |
| 3 | 3 | import Homepage from "./Homepage.svelte"; | |
| 4 | - | import { bootstrapGUI } from "../injected/bootstrap"; | |
| 5 | 4 | ||
| 6 | 5 | // Proactively unregister any Service Workers and clear caches that may have been | |
| 7 | 6 | // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to | |
| @@ -26,8 +25,6 @@ async function cleanupServiceWorkersAndCaches() { | |||
| 26 | 25 | } | |
| 27 | 26 | } | |
| 28 | 27 | ||
| 29 | - | bootstrapGUI(); | |
| 30 | - | ||
| 31 | 28 | // Mount the Homepage component to the DOM | |
| 32 | 29 | cleanupServiceWorkersAndCaches().finally(() => { | |
| 33 | 30 | console.log("[dumber] Mounting Homepage component to document.body"); | |
D webui/src/services/app.ts +0 -141
| @@ -1,141 +0,0 @@ | |||
| 1 | - | import type { HistoryEntry, SearchShortcut } from "../types/generated.js"; | |
| 2 | - | ||
| 3 | - | export class AppService { | |
| 4 | - | private history: HistoryEntry[] = []; | |
| 5 | - | private shortcuts: Record<string, SearchShortcut> = {}; | |
| 6 | - | ||
| 7 | - | constructor() {} | |
| 8 | - | ||
| 9 | - | async initialize(): Promise<void> { | |
| 10 | - | try { | |
| 11 | - | await Promise.all([this.loadHistory(), this.loadShortcuts()]); | |
| 12 | - | } catch (error) { | |
| 13 | - | console.error("Failed to initialize app:", error); | |
| 14 | - | } | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | async loadHistory(): Promise<void> { | |
| 18 | - | try { | |
| 19 | - | // Use message bridge instead of fetch | |
| 20 | - | return new Promise((resolve, reject) => { | |
| 21 | - | // Set up response handler | |
| 22 | - | window.__dumber_history_recent = (data: HistoryEntry[]) => { | |
| 23 | - | this.history = Array.isArray(data) ? data : []; | |
| 24 | - | resolve(); | |
| 25 | - | }; | |
| 26 | - | ||
| 27 | - | window.__dumber_history_error = (error: string) => { | |
| 28 | - | console.error("Failed to load history:", error); | |
| 29 | - | reject(new Error(error)); | |
| 30 | - | }; | |
| 31 | - | ||
| 32 | - | // Send message to Go backend | |
| 33 | - | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 34 | - | if (bridge && typeof bridge.postMessage === "function") { | |
| 35 | - | bridge.postMessage( | |
| 36 | - | JSON.stringify({ | |
| 37 | - | type: "history_recent", | |
| 38 | - | limit: 50, | |
| 39 | - | offset: 0, | |
| 40 | - | }), | |
| 41 | - | ); | |
| 42 | - | } else { | |
| 43 | - | reject(new Error("WebKit message handler not available")); | |
| 44 | - | } | |
| 45 | - | }); | |
| 46 | - | } catch (error) { | |
| 47 | - | console.error("Failed to load history:", error); | |
| 48 | - | // Mock history for development/fallback | |
| 49 | - | this.history = [ | |
| 50 | - | { | |
| 51 | - | id: 1, | |
| 52 | - | url: "https://github.com/wailsapp/wails", | |
| 53 | - | title: "Wails Framework", | |
| 54 | - | visit_count: 3, | |
| 55 | - | last_visited: new Date().toISOString(), | |
| 56 | - | created_at: new Date().toISOString(), | |
| 57 | - | favicon_url: null, | |
| 58 | - | }, | |
| 59 | - | { | |
| 60 | - | id: 2, | |
| 61 | - | url: "https://go.dev", | |
| 62 | - | title: "The Go Programming Language", | |
| 63 | - | visit_count: 2, | |
| 64 | - | last_visited: new Date().toISOString(), | |
| 65 | - | created_at: new Date().toISOString(), | |
| 66 | - | favicon_url: null, | |
| 67 | - | }, | |
| 68 | - | { | |
| 69 | - | id: 3, | |
| 70 | - | url: "https://developer.mozilla.org", | |
| 71 | - | title: "MDN Web Docs", | |
| 72 | - | visit_count: 1, | |
| 73 | - | last_visited: new Date().toISOString(), | |
| 74 | - | created_at: new Date().toISOString(), | |
| 75 | - | favicon_url: null, | |
| 76 | - | }, | |
| 77 | - | ]; | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | async loadShortcuts(): Promise<void> { | |
| 82 | - | return new Promise((resolve, reject) => { | |
| 83 | - | // Set up response handler | |
| 84 | - | window.__dumber_search_shortcuts = (data: unknown) => { | |
| 85 | - | // Type guard | |
| 86 | - | if (typeof data !== "object" || data === null) { | |
| 87 | - | reject(new Error("Invalid shortcuts data")); | |
| 88 | - | return; | |
| 89 | - | } | |
| 90 | - | const dataObj = data as Record<string, unknown>; | |
| 91 | - | // Normalize field casing from backend (supports URL/Description and url/description) | |
| 92 | - | const normalized: Record<string, SearchShortcut> = {}; | |
| 93 | - | for (const [key, value] of Object.entries(dataObj)) { | |
| 94 | - | const v = value as Record<string, unknown>; | |
| 95 | - | normalized[key] = { | |
| 96 | - | id: 0, | |
| 97 | - | shortcut: key, | |
| 98 | - | url_template: (v.url ?? v.URL ?? "") as string, | |
| 99 | - | description: (v.description ?? v.Description ?? "") as string, | |
| 100 | - | created_at: null, | |
| 101 | - | }; | |
| 102 | - | } | |
| 103 | - | this.shortcuts = normalized; | |
| 104 | - | resolve(); | |
| 105 | - | }; | |
| 106 | - | ||
| 107 | - | window.__dumber_search_shortcuts_error = (error: string) => { | |
| 108 | - | console.error("Failed to load shortcuts:", error); | |
| 109 | - | reject(new Error(error)); | |
| 110 | - | }; | |
| 111 | - | ||
| 112 | - | // Send message to Go backend | |
| 113 | - | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 114 | - | if (bridge && typeof bridge.postMessage === "function") { | |
| 115 | - | bridge.postMessage({ | |
| 116 | - | type: "get_search_shortcuts", | |
| 117 | - | webview_id: (window as any).__dumber_webview_id ?? 0, | |
| 118 | - | }); | |
| 119 | - | } else { | |
| 120 | - | reject(new Error("WebKit message handler not available")); | |
| 121 | - | } | |
| 122 | - | }); | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | getHistory(): HistoryEntry[] { | |
| 126 | - | return this.history; | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | getShortcuts(): Record<string, SearchShortcut> { | |
| 130 | - | return this.shortcuts; | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | async copyToClipboard(text: string): Promise<void> { | |
| 134 | - | try { | |
| 135 | - | await navigator.clipboard.writeText(text); | |
| 136 | - | console.log("Copied to clipboard:", text); | |
| 137 | - | } catch (err) { | |
| 138 | - | console.error("Failed to copy:", err); | |
| 139 | - | } | |
| 140 | - | } | |
| 141 | - | } | |
D webui/src/services/browser-controls.ts +0 -245
| @@ -1,245 +0,0 @@ | |||
| 1 | - | import { NotificationService } from "./notifications.js"; | |
| 2 | - | import { DebugConsoleService } from "./debug-console.js"; | |
| 3 | - | ||
| 4 | - | export class BrowserControlsService { | |
| 5 | - | private currentURL = ""; | |
| 6 | - | private notifications = new NotificationService(); | |
| 7 | - | private debugConsole = new DebugConsoleService(); | |
| 8 | - | ||
| 9 | - | constructor() { | |
| 10 | - | // Debug logging with enhanced details | |
| 11 | - | this.debugConsole.addLog( | |
| 12 | - | "info", | |
| 13 | - | "frontend", | |
| 14 | - | "init", | |
| 15 | - | "BrowserControlsService initializing...", | |
| 16 | - | ); | |
| 17 | - | this.debugConsole.addLog( | |
| 18 | - | "info", | |
| 19 | - | "frontend", | |
| 20 | - | "init", | |
| 21 | - | "Running without Wails bindings", | |
| 22 | - | ); | |
| 23 | - | ||
| 24 | - | this.initializeEventListeners(); | |
| 25 | - | this.debugConsole.addLog( | |
| 26 | - | "info", | |
| 27 | - | "frontend", | |
| 28 | - | "init", | |
| 29 | - | "BrowserControlsService initialized", | |
| 30 | - | ); | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | private initializeEventListeners(): void { | |
| 34 | - | // Keyboard event handler for browser controls | |
| 35 | - | document.addEventListener("keydown", this.handleKeyboardEvent.bind(this)); | |
| 36 | - | ||
| 37 | - | // Mouse button handler for navigation | |
| 38 | - | document.addEventListener("mousedown", this.handleMouseEvent.bind(this)); | |
| 39 | - | ||
| 40 | - | // URL change listener (for SPA navigation) | |
| 41 | - | window.addEventListener("popstate", this.handleURLChange.bind(this)); | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | private async handleKeyboardEvent(event: KeyboardEvent): Promise<void> { | |
| 45 | - | // Skip F12 (debug console toggle) | |
| 46 | - | if (event.key === "F12") { | |
| 47 | - | return; | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // Log all keyboard events to debug console | |
| 51 | - | this.debugConsole.logKeyboardEvent(event); | |
| 52 | - | ||
| 53 | - | const { ctrlKey, metaKey, shiftKey, altKey, key } = event; | |
| 54 | - | const isCmd = ctrlKey || metaKey; | |
| 55 | - | ||
| 56 | - | // Zoom controls handled natively by GTK; don't intercept | |
| 57 | - | if (isCmd && (key === "+" || key === "=" || key === "-" || key === "0")) { | |
| 58 | - | return; // allow native handler to process | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // Copy URL | |
| 62 | - | if (isCmd && shiftKey && key === "C") { | |
| 63 | - | this.debugConsole.addLog( | |
| 64 | - | "info", | |
| 65 | - | "frontend", | |
| 66 | - | "action", | |
| 67 | - | "Copy URL triggered", | |
| 68 | - | ); | |
| 69 | - | event.preventDefault(); | |
| 70 | - | await this.copyCurrentURL(); | |
| 71 | - | return; | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | // Navigation | |
| 75 | - | if (altKey && key === "ArrowLeft") { | |
| 76 | - | this.debugConsole.addLog( | |
| 77 | - | "info", | |
| 78 | - | "frontend", | |
| 79 | - | "action", | |
| 80 | - | "Go Back triggered", | |
| 81 | - | ); | |
| 82 | - | event.preventDefault(); | |
| 83 | - | history.back(); | |
| 84 | - | return; | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | if (altKey && key === "ArrowRight") { | |
| 88 | - | this.debugConsole.addLog( | |
| 89 | - | "info", | |
| 90 | - | "frontend", | |
| 91 | - | "action", | |
| 92 | - | "Go Forward triggered", | |
| 93 | - | ); | |
| 94 | - | event.preventDefault(); | |
| 95 | - | history.forward(); | |
| 96 | - | return; | |
| 97 | - | } | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | private async handleMouseEvent(event: MouseEvent): Promise<void> { | |
| 101 | - | // Log all mouse events to debug console | |
| 102 | - | this.debugConsole.logMouseEvent(event); | |
| 103 | - | ||
| 104 | - | // Mouse button 3 (back button) | |
| 105 | - | if (event.button === 3) { | |
| 106 | - | this.debugConsole.addLog( | |
| 107 | - | "info", | |
| 108 | - | "frontend", | |
| 109 | - | "action", | |
| 110 | - | "Mouse Back button triggered", | |
| 111 | - | ); | |
| 112 | - | event.preventDefault(); | |
| 113 | - | await this.goBack(); | |
| 114 | - | return; | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // Mouse button 4 (forward button) | |
| 118 | - | if (event.button === 4) { | |
| 119 | - | this.debugConsole.addLog( | |
| 120 | - | "info", | |
| 121 | - | "frontend", | |
| 122 | - | "action", | |
| 123 | - | "Mouse Forward button triggered", | |
| 124 | - | ); | |
| 125 | - | event.preventDefault(); | |
| 126 | - | await this.goForward(); | |
| 127 | - | return; | |
| 128 | - | } | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | private handleURLChange(): void { | |
| 132 | - | this.setCurrentURL(window.location.href); | |
| 133 | - | this.loadSavedZoom(); | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | // Zoom is handled natively; no JS-based zoom adjustments needed. | |
| 137 | - | ||
| 138 | - | async copyCurrentURL(): Promise<void> { | |
| 139 | - | try { | |
| 140 | - | const url = this.getCurrentURL(); | |
| 141 | - | await navigator.clipboard.writeText(url); | |
| 142 | - | ||
| 143 | - | // URL copied successfully - no backend notification needed | |
| 144 | - | ||
| 145 | - | this.notifications.show("URL copied to clipboard!", "success"); | |
| 146 | - | } catch (error) { | |
| 147 | - | console.error("Failed to copy URL:", error); | |
| 148 | - | this.notifications.show("Failed to copy URL", "error"); | |
| 149 | - | } | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | async goBack(): Promise<void> { | |
| 153 | - | this.debugConsole.addLog("info", "frontend", "service", "Calling GoBack"); | |
| 154 | - | ||
| 155 | - | try { | |
| 156 | - | // await BrowserService.GoBack(); // TODO: Implement BrowserService | |
| 157 | - | this.debugConsole.logServiceCall("BrowserService", "GoBack", []); | |
| 158 | - | history.back(); | |
| 159 | - | this.debugConsole.addLog( | |
| 160 | - | "info", | |
| 161 | - | "frontend", | |
| 162 | - | "navigation", | |
| 163 | - | "Browser navigated back", | |
| 164 | - | ); | |
| 165 | - | } catch (error) { | |
| 166 | - | this.debugConsole.logServiceCall( | |
| 167 | - | "BrowserService", | |
| 168 | - | "GoBack", | |
| 169 | - | [], | |
| 170 | - | null, | |
| 171 | - | error as Error, | |
| 172 | - | ); | |
| 173 | - | console.error("Failed to go back:", error); | |
| 174 | - | } | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | async goForward(): Promise<void> { | |
| 178 | - | this.debugConsole.addLog( | |
| 179 | - | "info", | |
| 180 | - | "frontend", | |
| 181 | - | "service", | |
| 182 | - | "Calling GoForward", | |
| 183 | - | ); | |
| 184 | - | ||
| 185 | - | try { | |
| 186 | - | // await BrowserService.GoForward(); // TODO: Implement BrowserService | |
| 187 | - | this.debugConsole.logServiceCall("BrowserService", "GoForward", []); | |
| 188 | - | history.forward(); | |
| 189 | - | this.debugConsole.addLog( | |
| 190 | - | "info", | |
| 191 | - | "frontend", | |
| 192 | - | "navigation", | |
| 193 | - | "Browser navigated forward", | |
| 194 | - | ); | |
| 195 | - | } catch (error) { | |
| 196 | - | this.debugConsole.logServiceCall( | |
| 197 | - | "BrowserService", | |
| 198 | - | "GoForward", | |
| 199 | - | [], | |
| 200 | - | null, | |
| 201 | - | error as Error, | |
| 202 | - | ); | |
| 203 | - | console.error("Failed to go forward:", error); | |
| 204 | - | } | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | getCurrentURL(): string { | |
| 208 | - | return this.currentURL || window.location.href; | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | setCurrentURL(url: string): void { | |
| 212 | - | this.currentURL = url; | |
| 213 | - | this.updateWindowTitle(url); | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | private async updateWindowTitle(url: string): Promise<void> { | |
| 217 | - | try { | |
| 218 | - | // Extract domain or path for title | |
| 219 | - | let title = "Dumber Browser"; | |
| 220 | - | if (url && url !== "/") { | |
| 221 | - | const urlObj = new URL(url); | |
| 222 | - | title = `Dumber - ${urlObj.hostname}`; | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | document.title = title; | |
| 226 | - | ||
| 227 | - | // Backend title updates handled natively; no frontend call | |
| 228 | - | } catch (error) { | |
| 229 | - | console.error("Failed to update title:", error); | |
| 230 | - | } | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | async loadSavedZoom(): Promise<void> { | |
| 234 | - | // Zoom persistence applied natively on page load | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | async initialize(): Promise<void> { | |
| 238 | - | // Check if there's an initial URL from backend (direct navigation) | |
| 239 | - | // Set initial URL from browser location (homepage) | |
| 240 | - | this.setCurrentURL(window.location.href); | |
| 241 | - | ||
| 242 | - | // Load saved zoom level (only if we're staying on homepage) | |
| 243 | - | // Zoom handled natively | |
| 244 | - | } | |
| 245 | - | } | |
D webui/src/services/debug-console.ts +0 -295
| @@ -1,295 +0,0 @@ | |||
| 1 | - | export interface DebugLogEntry { | |
| 2 | - | timestamp: string; | |
| 3 | - | level: "info" | "warn" | "error" | "debug"; | |
| 4 | - | source: "frontend" | "backend"; | |
| 5 | - | category: string; | |
| 6 | - | message: string; | |
| 7 | - | data?: unknown; | |
| 8 | - | } | |
| 9 | - | ||
| 10 | - | export class DebugConsoleService { | |
| 11 | - | private logs: DebugLogEntry[] = []; | |
| 12 | - | private maxLogs = 1000; | |
| 13 | - | private isVisible = false; | |
| 14 | - | private debugElement: HTMLElement | null = null; | |
| 15 | - | private logsContainer: HTMLElement | null = null; | |
| 16 | - | ||
| 17 | - | constructor() { | |
| 18 | - | this.createDebugUI(); | |
| 19 | - | this.setupKeyboardShortcut(); | |
| 20 | - | this.interceptConsole(); | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | private createDebugUI(): void { | |
| 24 | - | // Create debug panel | |
| 25 | - | this.debugElement = document.createElement("div"); | |
| 26 | - | this.debugElement.id = "debug-console"; | |
| 27 | - | this.debugElement.style.cssText = ` | |
| 28 | - | position: fixed; | |
| 29 | - | top: 0; | |
| 30 | - | right: 0; | |
| 31 | - | width: 50%; | |
| 32 | - | height: 100%; | |
| 33 | - | background: rgba(0, 0, 0, 0.95); | |
| 34 | - | color: #00ff00; | |
| 35 | - | font-family: 'Courier New', monospace; | |
| 36 | - | font-size: 12px; | |
| 37 | - | z-index: 10000; | |
| 38 | - | display: none; | |
| 39 | - | border-left: 2px solid #00ff00; | |
| 40 | - | `; | |
| 41 | - | ||
| 42 | - | // Create header | |
| 43 | - | const header = document.createElement("div"); | |
| 44 | - | header.style.cssText = ` | |
| 45 | - | padding: 10px; | |
| 46 | - | background: rgba(0, 255, 0, 0.1); | |
| 47 | - | border-bottom: 1px solid #00ff00; | |
| 48 | - | font-weight: bold; | |
| 49 | - | display: flex; | |
| 50 | - | justify-content: space-between; | |
| 51 | - | align-items: center; | |
| 52 | - | `; | |
| 53 | - | header.innerHTML = ` | |
| 54 | - | <span>🐛 DUMBER DEBUG CONSOLE</span> | |
| 55 | - | <div style="font-size: 10px;"> | |
| 56 | - | <span style="color: #888; margin-right: 10px;">F11: Inspect Services | F12: Toggle</span> | |
| 57 | - | <button id="clear-logs" style="background: none; border: 1px solid #00ff00; color: #00ff00; padding: 2px 8px; margin-right: 5px; cursor: pointer;">Clear</button> | |
| 58 | - | <button id="close-debug" style="background: none; border: 1px solid #00ff00; color: #00ff00; padding: 2px 8px; cursor: pointer;">Close</button> | |
| 59 | - | </div> | |
| 60 | - | `; | |
| 61 | - | ||
| 62 | - | // Create logs container | |
| 63 | - | this.logsContainer = document.createElement("div"); | |
| 64 | - | this.logsContainer.style.cssText = ` | |
| 65 | - | height: calc(100% - 50px); | |
| 66 | - | overflow-y: auto; | |
| 67 | - | padding: 10px; | |
| 68 | - | `; | |
| 69 | - | ||
| 70 | - | this.debugElement.appendChild(header); | |
| 71 | - | this.debugElement.appendChild(this.logsContainer); | |
| 72 | - | document.body.appendChild(this.debugElement); | |
| 73 | - | ||
| 74 | - | // Setup button handlers | |
| 75 | - | header | |
| 76 | - | .querySelector("#clear-logs") | |
| 77 | - | ?.addEventListener("click", () => this.clearLogs()); | |
| 78 | - | header | |
| 79 | - | .querySelector("#close-debug") | |
| 80 | - | ?.addEventListener("click", () => this.hide()); | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | private setupKeyboardShortcut(): void { | |
| 84 | - | document.addEventListener("keydown", (event) => { | |
| 85 | - | // F12 to toggle debug console | |
| 86 | - | if (event.key === "F12") { | |
| 87 | - | event.preventDefault(); | |
| 88 | - | this.toggle(); | |
| 89 | - | } | |
| 90 | - | }); | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | private interceptConsole(): void { | |
| 94 | - | const originalConsole = { | |
| 95 | - | log: console.log, | |
| 96 | - | warn: console.warn, | |
| 97 | - | error: console.error, | |
| 98 | - | info: console.info, | |
| 99 | - | debug: console.debug, | |
| 100 | - | }; | |
| 101 | - | ||
| 102 | - | console.log = (...args) => { | |
| 103 | - | this.addLog("info", "frontend", "console", args.join(" ")); | |
| 104 | - | originalConsole.log(...args); | |
| 105 | - | }; | |
| 106 | - | ||
| 107 | - | console.warn = (...args) => { | |
| 108 | - | this.addLog("warn", "frontend", "console", args.join(" ")); | |
| 109 | - | originalConsole.warn(...args); | |
| 110 | - | }; | |
| 111 | - | ||
| 112 | - | console.error = (...args) => { | |
| 113 | - | this.addLog("error", "frontend", "console", args.join(" ")); | |
| 114 | - | originalConsole.error(...args); | |
| 115 | - | }; | |
| 116 | - | ||
| 117 | - | console.info = (...args) => { | |
| 118 | - | this.addLog("info", "frontend", "console", args.join(" ")); | |
| 119 | - | originalConsole.info(...args); | |
| 120 | - | }; | |
| 121 | - | ||
| 122 | - | console.debug = (...args) => { | |
| 123 | - | this.addLog("debug", "frontend", "console", args.join(" ")); | |
| 124 | - | originalConsole.debug(...args); | |
| 125 | - | }; | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | public addLog( | |
| 129 | - | level: DebugLogEntry["level"], | |
| 130 | - | source: DebugLogEntry["source"], | |
| 131 | - | category: string, | |
| 132 | - | message: string, | |
| 133 | - | data?: unknown, | |
| 134 | - | ): void { | |
| 135 | - | const entry: DebugLogEntry = { | |
| 136 | - | timestamp: new Date().toISOString().split("T")[1]?.split(".")[0] || "", // HH:MM:SS format | |
| 137 | - | level, | |
| 138 | - | source, | |
| 139 | - | category, | |
| 140 | - | message, | |
| 141 | - | data, | |
| 142 | - | }; | |
| 143 | - | ||
| 144 | - | this.logs.push(entry); | |
| 145 | - | ||
| 146 | - | // Keep only the last maxLogs entries | |
| 147 | - | if (this.logs.length > this.maxLogs) { | |
| 148 | - | this.logs.shift(); | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | this.updateDisplay(); | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | private updateDisplay(): void { | |
| 155 | - | if (!this.logsContainer) return; | |
| 156 | - | ||
| 157 | - | const logHTML = this.logs | |
| 158 | - | .map((log) => { | |
| 159 | - | const levelColor = { | |
| 160 | - | info: "#00ff00", | |
| 161 | - | warn: "#ffff00", | |
| 162 | - | error: "#ff0000", | |
| 163 | - | debug: "#00ffff", | |
| 164 | - | }[log.level]; | |
| 165 | - | ||
| 166 | - | const sourceIcon = log.source === "backend" ? "🖥️" : "🌐"; | |
| 167 | - | ||
| 168 | - | return ` | |
| 169 | - | <div style="margin-bottom: 2px; font-size: 11px;"> | |
| 170 | - | <span style="color: #888;">[${log.timestamp}]</span> | |
| 171 | - | <span style="color: ${levelColor};">${sourceIcon} ${log.level.toUpperCase()}</span> | |
| 172 | - | <span style="color: #aaa;">[${log.category}]</span> | |
| 173 | - | <span style="color: #fff;">${log.message}</span> | |
| 174 | - | ${log.data ? `<pre style="color: #ccc; font-size: 10px; margin: 2px 0;">${JSON.stringify(log.data, null, 2)}</pre>` : ""} | |
| 175 | - | </div> | |
| 176 | - | `; | |
| 177 | - | }) | |
| 178 | - | .join(""); | |
| 179 | - | ||
| 180 | - | this.logsContainer.innerHTML = logHTML; | |
| 181 | - | ||
| 182 | - | // Auto-scroll to bottom | |
| 183 | - | this.logsContainer.scrollTop = this.logsContainer.scrollHeight; | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | public logKeyboardEvent(event: KeyboardEvent): void { | |
| 187 | - | const modifiers = []; | |
| 188 | - | if (event.ctrlKey) modifiers.push("Ctrl"); | |
| 189 | - | if (event.shiftKey) modifiers.push("Shift"); | |
| 190 | - | if (event.altKey) modifiers.push("Alt"); | |
| 191 | - | if (event.metaKey) modifiers.push("Meta"); | |
| 192 | - | ||
| 193 | - | const shortcut = | |
| 194 | - | modifiers.length > 0 ? `${modifiers.join("+")}+${event.key}` : event.key; | |
| 195 | - | ||
| 196 | - | this.addLog("info", "frontend", "keyboard", `Key pressed: ${shortcut}`, { | |
| 197 | - | key: event.key, | |
| 198 | - | code: event.code, | |
| 199 | - | modifiers, | |
| 200 | - | target: event.target?.constructor.name, | |
| 201 | - | }); | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | public logMouseEvent(event: MouseEvent): void { | |
| 205 | - | const buttonNames: Record<number, string> = { | |
| 206 | - | 0: "Left", | |
| 207 | - | 1: "Middle", | |
| 208 | - | 2: "Right", | |
| 209 | - | 3: "Back", | |
| 210 | - | 4: "Forward", | |
| 211 | - | }; | |
| 212 | - | ||
| 213 | - | this.addLog( | |
| 214 | - | "info", | |
| 215 | - | "frontend", | |
| 216 | - | "mouse", | |
| 217 | - | `Mouse ${event.type}: ${buttonNames[event.button] || `Button${event.button}`}`, | |
| 218 | - | { | |
| 219 | - | button: event.button, | |
| 220 | - | clientX: event.clientX, | |
| 221 | - | clientY: event.clientY, | |
| 222 | - | target: event.target?.constructor.name, | |
| 223 | - | }, | |
| 224 | - | ); | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | public logServiceCall( | |
| 228 | - | serviceName: string, | |
| 229 | - | methodName: string, | |
| 230 | - | params: unknown[], | |
| 231 | - | result?: unknown, | |
| 232 | - | error?: Error, | |
| 233 | - | ): void { | |
| 234 | - | if (error) { | |
| 235 | - | this.addLog( | |
| 236 | - | "error", | |
| 237 | - | "frontend", | |
| 238 | - | "service", | |
| 239 | - | `${serviceName}.${methodName}() failed`, | |
| 240 | - | { | |
| 241 | - | params, | |
| 242 | - | error: error.message, | |
| 243 | - | }, | |
| 244 | - | ); | |
| 245 | - | } else { | |
| 246 | - | this.addLog( | |
| 247 | - | "info", | |
| 248 | - | "frontend", | |
| 249 | - | "service", | |
| 250 | - | `${serviceName}.${methodName}() called`, | |
| 251 | - | { | |
| 252 | - | params, | |
| 253 | - | result, | |
| 254 | - | }, | |
| 255 | - | ); | |
| 256 | - | } | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | public logBackendMessage(message: string, data?: unknown): void { | |
| 260 | - | this.addLog("info", "backend", "service", message, data); | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | public show(): void { | |
| 264 | - | if (this.debugElement) { | |
| 265 | - | this.debugElement.style.display = "block"; | |
| 266 | - | this.isVisible = true; | |
| 267 | - | this.addLog("info", "frontend", "debug", "Debug console opened"); | |
| 268 | - | } | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | public hide(): void { | |
| 272 | - | if (this.debugElement) { | |
| 273 | - | this.debugElement.style.display = "none"; | |
| 274 | - | this.isVisible = false; | |
| 275 | - | } | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | public toggle(): void { | |
| 279 | - | if (this.isVisible) { | |
| 280 | - | this.hide(); | |
| 281 | - | } else { | |
| 282 | - | this.show(); | |
| 283 | - | } | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | public clearLogs(): void { | |
| 287 | - | this.logs = []; | |
| 288 | - | this.updateDisplay(); | |
| 289 | - | this.addLog("info", "frontend", "debug", "Logs cleared"); | |
| 290 | - | } | |
| 291 | - | ||
| 292 | - | public isDebugVisible(): boolean { | |
| 293 | - | return this.isVisible; | |
| 294 | - | } | |
| 295 | - | } | |
D webui/src/services/notifications.ts +0 -115
| @@ -1,115 +0,0 @@ | |||
| 1 | - | import type { NotificationOptions } from "../types/app.js"; | |
| 2 | - | ||
| 3 | - | interface TimerElement extends HTMLElement { | |
| 4 | - | timeout?: ReturnType<typeof setTimeout>; | |
| 5 | - | } | |
| 6 | - | ||
| 7 | - | export class NotificationService { | |
| 8 | - | private zoomNotification: HTMLElement | null = null; | |
| 9 | - | private generalNotification: HTMLElement | null = null; | |
| 10 | - | ||
| 11 | - | showZoom(message: string, duration = 2000): void { | |
| 12 | - | // Create or update zoom notification | |
| 13 | - | if (!this.zoomNotification) { | |
| 14 | - | this.zoomNotification = this.createNotificationElement( | |
| 15 | - | "zoom-notification", | |
| 16 | - | { | |
| 17 | - | top: "20px", | |
| 18 | - | right: "20px", | |
| 19 | - | background: "rgba(0, 0, 0, 0.8)", | |
| 20 | - | color: "white", | |
| 21 | - | }, | |
| 22 | - | ); | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | this.updateNotification(this.zoomNotification, message, duration); | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | show( | |
| 29 | - | message: string, | |
| 30 | - | type: NotificationOptions["type"] = "info", | |
| 31 | - | duration = 3000, | |
| 32 | - | ): void { | |
| 33 | - | // Create or update general notification | |
| 34 | - | if (!this.generalNotification) { | |
| 35 | - | this.generalNotification = this.createNotificationElement( | |
| 36 | - | "general-notification", | |
| 37 | - | { | |
| 38 | - | top: "60px", | |
| 39 | - | right: "20px", | |
| 40 | - | }, | |
| 41 | - | ); | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Set colors based on type | |
| 45 | - | const colors = this.getNotificationColors(type); | |
| 46 | - | this.generalNotification.style.background = colors.background; | |
| 47 | - | this.generalNotification.style.color = colors.color; | |
| 48 | - | ||
| 49 | - | this.updateNotification(this.generalNotification, message, duration); | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | private createNotificationElement( | |
| 53 | - | id: string, | |
| 54 | - | baseStyles: Record<string, string>, | |
| 55 | - | ): HTMLElement { | |
| 56 | - | const notification = document.createElement("div"); | |
| 57 | - | notification.id = id; | |
| 58 | - | ||
| 59 | - | const styles = { | |
| 60 | - | position: "fixed", | |
| 61 | - | padding: "10px 15px", | |
| 62 | - | borderRadius: "5px", | |
| 63 | - | fontSize: "14px", | |
| 64 | - | zIndex: "10000", | |
| 65 | - | transition: "opacity 0.3s", | |
| 66 | - | opacity: "0", | |
| 67 | - | ...baseStyles, | |
| 68 | - | }; | |
| 69 | - | ||
| 70 | - | notification.style.cssText = Object.entries(styles) | |
| 71 | - | .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`) | |
| 72 | - | .join("; "); | |
| 73 | - | ||
| 74 | - | document.body.appendChild(notification); | |
| 75 | - | return notification; | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | private updateNotification( | |
| 79 | - | element: HTMLElement, | |
| 80 | - | message: string, | |
| 81 | - | duration: number, | |
| 82 | - | ): void { | |
| 83 | - | element.textContent = message; | |
| 84 | - | element.style.opacity = "1"; | |
| 85 | - | ||
| 86 | - | // Clear any existing timeout | |
| 87 | - | const timerElement = element as TimerElement; | |
| 88 | - | if (timerElement.timeout) { | |
| 89 | - | clearTimeout(timerElement.timeout); | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | // Hide after duration | |
| 93 | - | timerElement.timeout = setTimeout(() => { | |
| 94 | - | element.style.opacity = "0"; | |
| 95 | - | }, duration); | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | private getNotificationColors(type: NotificationOptions["type"]): { | |
| 99 | - | background: string; | |
| 100 | - | color: string; | |
| 101 | - | } { | |
| 102 | - | switch (type) { | |
| 103 | - | case "error": | |
| 104 | - | return { background: "rgba(220, 53, 69, 0.9)", color: "white" }; | |
| 105 | - | case "success": | |
| 106 | - | return { background: "rgba(25, 135, 84, 0.9)", color: "white" }; | |
| 107 | - | default: | |
| 108 | - | return { background: "rgba(13, 110, 253, 0.9)", color: "white" }; | |
| 109 | - | } | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | private camelToKebab(str: string): string { | |
| 113 | - | return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); | |
| 114 | - | } | |
| 115 | - | } | |
D webui/src/types/app.ts +0 -26
| @@ -1,26 +0,0 @@ | |||
| 1 | - | import type { HistoryEntry, SearchShortcut } from "./generated.js"; | |
| 2 | - | ||
| 3 | - | export interface AppState { | |
| 4 | - | currentURL: string; | |
| 5 | - | currentZoom: number; | |
| 6 | - | history: HistoryEntry[]; | |
| 7 | - | shortcuts: Record<string, SearchShortcut>; | |
| 8 | - | isLoading: boolean; | |
| 9 | - | } | |
| 10 | - | ||
| 11 | - | export interface NotificationOptions { | |
| 12 | - | message: string; | |
| 13 | - | type?: "info" | "success" | "error"; | |
| 14 | - | duration?: number; | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | export interface KeyboardShortcut { | |
| 18 | - | key: string; | |
| 19 | - | ctrlKey?: boolean; | |
| 20 | - | metaKey?: boolean; | |
| 21 | - | shiftKey?: boolean; | |
| 22 | - | altKey?: boolean; | |
| 23 | - | handler: () => void | Promise<void>; | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | export { HistoryEntry, SearchShortcut } from "./generated.js"; | |
D webui/src/types/generated.ts +0 -89
| @@ -1,89 +0,0 @@ | |||
| 1 | - | // GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT | |
| 2 | - | // Auto-generated TypeScript types from Go structs | |
| 3 | - | // Generated on: 2025-09-22T12:41:57.478Z | |
| 4 | - | // Generator script: scripts/generate-types.js | |
| 5 | - | // Source files: internal/db/models.go, internal/parser/types.go | |
| 6 | - | // Do not edit manually - run 'node scripts/generate-types.js' to regenerate | |
| 7 | - | ||
| 8 | - | export interface CertificateValidation { | |
| 9 | - | id: number; | |
| 10 | - | hostname: string; | |
| 11 | - | certificate_hash: string; | |
| 12 | - | user_decision: string; | |
| 13 | - | created_at: string | null; | |
| 14 | - | expires_at: string | null; | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | export interface History { | |
| 18 | - | id: number; | |
| 19 | - | url: string; | |
| 20 | - | title: string | null; | |
| 21 | - | visit_count: number | null; | |
| 22 | - | last_visited: string | null; | |
| 23 | - | created_at: string | null; | |
| 24 | - | favicon_url: string | null; | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | export interface Shortcut { | |
| 28 | - | id: number; | |
| 29 | - | shortcut: string; | |
| 30 | - | url_template: string; | |
| 31 | - | description: string | null; | |
| 32 | - | created_at: string | null; | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | export interface ZoomLevel { | |
| 36 | - | domain: string; | |
| 37 | - | zoom_factor: number; | |
| 38 | - | updated_at: string | null; | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | export interface ParseResult { | |
| 42 | - | type: InputType; | |
| 43 | - | url: string; | |
| 44 | - | query: string; | |
| 45 | - | confidence: number; | |
| 46 | - | fuzzy_matches?: FuzzyMatch[]; | |
| 47 | - | shortcut?: DetectedShortcut; | |
| 48 | - | processing_time: number; | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | export interface DetectedShortcut { | |
| 52 | - | key: string; | |
| 53 | - | query: string; | |
| 54 | - | url: string; | |
| 55 | - | description: string; | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | export interface FuzzyMatch { | |
| 59 | - | history_entry?: History; | |
| 60 | - | score: number; | |
| 61 | - | url_score: number; | |
| 62 | - | title_score: number; | |
| 63 | - | recency_score: number; | |
| 64 | - | visit_score: number; | |
| 65 | - | matched_field: string; | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | export interface FuzzyConfig { | |
| 69 | - | min_similarity_threshold: number; | |
| 70 | - | max_results: number; | |
| 71 | - | url_weight: number; | |
| 72 | - | title_weight: number; | |
| 73 | - | recency_weight: number; | |
| 74 | - | visit_weight: number; | |
| 75 | - | recency_decay_days: number; | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // Manual types and enums | |
| 79 | - | export enum InputType { | |
| 80 | - | InputTypeDirectURL = 0, | |
| 81 | - | InputTypeSearchShortcut = 1, | |
| 82 | - | InputTypeHistorySearch = 2, | |
| 83 | - | InputTypeFallbackSearch = 3, | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | // Type aliases for frontend usage | |
| 87 | - | export type HistoryEntry = History; | |
| 88 | - | export type SearchShortcut = Shortcut; | |
| 89 | - | export type ZoomEntry = ZoomLevel; | |
D webui/src/types/inline-css.d.ts +0 -4
| @@ -1,4 +0,0 @@ | |||
| 1 | - | declare module "*.css?inline" { | |
| 2 | - | const content: string; | |
| 3 | - | export default content; | |
| 4 | - | } | |
D webui/src/types/raw-css.d.ts +0 -4
| @@ -1,4 +0,0 @@ | |||
| 1 | - | declare module "*.css?raw" { | |
| 2 | - | const content: string; | |
| 3 | - | export default content; | |
| 4 | - | } | |
D webui/vite.config.blocked.ts +0 -46
| @@ -1,46 +0,0 @@ | |||
| 1 | - | import { defineConfig } from "vite"; | |
| 2 | - | import { svelte } from "@sveltejs/vite-plugin-svelte"; | |
| 3 | - | import { resolve } from "path"; | |
| 4 | - | import { pageGenerator } from "./vite-plugin-pages"; | |
| 5 | - | ||
| 6 | - | export default defineConfig({ | |
| 7 | - | plugins: [ | |
| 8 | - | svelte({ | |
| 9 | - | emitCss: false, // Inline component CSS for shadow DOM compatibility | |
| 10 | - | }), | |
| 11 | - | pageGenerator([ | |
| 12 | - | { | |
| 13 | - | name: "blocked", | |
| 14 | - | title: "Page Blocked", | |
| 15 | - | script: "blocked.min.js", | |
| 16 | - | filename: "blocked.html", | |
| 17 | - | }, | |
| 18 | - | ]), | |
| 19 | - | ], | |
| 20 | - | build: { | |
| 21 | - | rollupOptions: { | |
| 22 | - | input: resolve(__dirname, "src/pages/blocked.ts"), | |
| 23 | - | output: { | |
| 24 | - | dir: "../assets/webui", | |
| 25 | - | entryFileNames: "blocked.min.js", | |
| 26 | - | chunkFileNames: "[name].js", | |
| 27 | - | assetFileNames: "[name].[ext]", | |
| 28 | - | manualChunks: undefined, | |
| 29 | - | format: "iife", | |
| 30 | - | name: "DumberBlocked", | |
| 31 | - | }, | |
| 32 | - | }, | |
| 33 | - | emptyOutDir: false, | |
| 34 | - | target: ["es2020", "chrome91", "firefox90"], | |
| 35 | - | minify: true, | |
| 36 | - | sourcemap: false, | |
| 37 | - | cssCodeSplit: false, | |
| 38 | - | assetsInlineLimit: 4096, | |
| 39 | - | }, | |
| 40 | - | resolve: { | |
| 41 | - | alias: { | |
| 42 | - | $lib: resolve(__dirname, "src/lib"), | |
| 43 | - | $components: resolve(__dirname, "src/components"), | |
| 44 | - | }, | |
| 45 | - | }, | |
| 46 | - | }); | |
D webui/vite.config.color-scheme.ts +0 -28
| @@ -1,28 +0,0 @@ | |||
| 1 | - | import { defineConfig } from "vite"; | |
| 2 | - | import { resolve } from "path"; | |
| 3 | - | ||
| 4 | - | export default defineConfig({ | |
| 5 | - | build: { | |
| 6 | - | rollupOptions: { | |
| 7 | - | input: resolve(__dirname, "src/injected/modules/color-scheme.ts"), | |
| 8 | - | output: { | |
| 9 | - | dir: "../assets/webui", | |
| 10 | - | format: "iife", | |
| 11 | - | entryFileNames: "color-scheme.js", | |
| 12 | - | inlineDynamicImports: true, | |
| 13 | - | manualChunks: undefined, // Prevent chunk splitting | |
| 14 | - | }, | |
| 15 | - | }, | |
| 16 | - | emptyOutDir: false, // Don't clear the directory since GUI bundle is also built here | |
| 17 | - | target: ["es2020", "chrome91", "firefox90"], | |
| 18 | - | minify: true, | |
| 19 | - | sourcemap: false, | |
| 20 | - | }, | |
| 21 | - | resolve: { | |
| 22 | - | alias: { | |
| 23 | - | // Alias for easier imports | |
| 24 | - | $lib: resolve(__dirname, "src/lib"), | |
| 25 | - | $components: resolve(__dirname, "src/components"), | |
| 26 | - | }, | |
| 27 | - | }, | |
| 28 | - | }); | |
D webui/vite.config.gui.ts +0 -38
| @@ -1,38 +0,0 @@ | |||
| 1 | - | import { defineConfig } from "vite"; | |
| 2 | - | import { svelte } from "@sveltejs/vite-plugin-svelte"; | |
| 3 | - | import { resolve } from "path"; | |
| 4 | - | ||
| 5 | - | export default defineConfig({ | |
| 6 | - | plugins: [ | |
| 7 | - | svelte({ | |
| 8 | - | emitCss: true, // Extract component CSS to separate file for isolated world injection | |
| 9 | - | }), | |
| 10 | - | ], | |
| 11 | - | build: { | |
| 12 | - | rollupOptions: { | |
| 13 | - | input: resolve(__dirname, "src/injected/gui.ts"), | |
| 14 | - | output: { | |
| 15 | - | dir: "../assets/webui", | |
| 16 | - | format: "iife", | |
| 17 | - | entryFileNames: "gui.min.js", | |
| 18 | - | name: "__dumberGUI", | |
| 19 | - | inlineDynamicImports: true, | |
| 20 | - | manualChunks: undefined, // Prevent chunk splitting | |
| 21 | - | assetFileNames: "gui.min.[ext]", // CSS will be named gui.min.css | |
| 22 | - | }, | |
| 23 | - | }, | |
| 24 | - | emptyOutDir: false, | |
| 25 | - | target: ["es2020", "chrome91", "firefox90"], | |
| 26 | - | minify: true, | |
| 27 | - | sourcemap: false, | |
| 28 | - | cssCodeSplit: false, // Prevent CSS code splitting | |
| 29 | - | assetsInlineLimit: 100000000, // Inline all assets as data URLs (100MB limit) | |
| 30 | - | }, | |
| 31 | - | resolve: { | |
| 32 | - | alias: { | |
| 33 | - | // Alias for easier imports | |
| 34 | - | $lib: resolve(__dirname, "src/lib"), | |
| 35 | - | $components: resolve(__dirname, "src/components"), | |
| 36 | - | }, | |
| 37 | - | }, | |
| 38 | - | }); | |
D webui/vite.config.homepage.ts +0 -46
| @@ -1,46 +0,0 @@ | |||
| 1 | - | import { defineConfig } from "vite"; | |
| 2 | - | import { svelte } from "@sveltejs/vite-plugin-svelte"; | |
| 3 | - | import { resolve } from "path"; | |
| 4 | - | import { pageGenerator } from "./vite-plugin-pages"; | |
| 5 | - | ||
| 6 | - | export default defineConfig({ | |
| 7 | - | plugins: [ | |
| 8 | - | svelte({ | |
| 9 | - | emitCss: false, // Inline component CSS for shadow DOM compatibility | |
| 10 | - | }), | |
| 11 | - | pageGenerator([ | |
| 12 | - | { | |
| 13 | - | name: "homepage", | |
| 14 | - | title: "Dumber Browser", | |
| 15 | - | script: "homepage.min.js", | |
| 16 | - | filename: "index.html", | |
| 17 | - | }, | |
| 18 | - | ]), | |
| 19 | - | ], | |
| 20 | - | build: { | |
| 21 | - | rollupOptions: { | |
| 22 | - | input: resolve(__dirname, "src/pages/homepage.ts"), | |
| 23 | - | output: { | |
| 24 | - | dir: "../assets/webui", | |
| 25 | - | entryFileNames: "homepage.min.js", | |
| 26 | - | chunkFileNames: "[name].js", | |
| 27 | - | assetFileNames: "[name].[ext]", | |
| 28 | - | manualChunks: undefined, | |
| 29 | - | format: "iife", | |
| 30 | - | name: "DumberHomepage", | |
| 31 | - | }, | |
| 32 | - | }, | |
| 33 | - | emptyOutDir: false, | |
| 34 | - | target: ["es2020", "chrome91", "firefox90"], | |
| 35 | - | minify: true, | |
| 36 | - | sourcemap: false, | |
| 37 | - | cssCodeSplit: false, | |
| 38 | - | assetsInlineLimit: 4096, | |
| 39 | - | }, | |
| 40 | - | resolve: { | |
| 41 | - | alias: { | |
| 42 | - | $lib: resolve(__dirname, "src/lib"), | |
| 43 | - | $components: resolve(__dirname, "src/components"), | |
| 44 | - | }, | |
| 45 | - | }, | |
| 46 | - | }); | |
D webui/vite.config.main-world.ts +0 -28
| @@ -1,28 +0,0 @@ | |||
| 1 | - | import { defineConfig } from "vite"; | |
| 2 | - | import { resolve } from "path"; | |
| 3 | - | ||
| 4 | - | export default defineConfig({ | |
| 5 | - | build: { | |
| 6 | - | rollupOptions: { | |
| 7 | - | input: resolve(__dirname, "src/injected/main-world.ts"), | |
| 8 | - | output: { | |
| 9 | - | dir: "../assets/webui", | |
| 10 | - | format: "iife", | |
| 11 | - | entryFileNames: "main-world.min.js", | |
| 12 | - | name: "__dumberMainWorld", | |
| 13 | - | inlineDynamicImports: true, | |
| 14 | - | manualChunks: undefined, | |
| 15 | - | assetFileNames: "[name].[ext]", | |
| 16 | - | }, | |
| 17 | - | }, | |
| 18 | - | emptyOutDir: false, | |
| 19 | - | target: ["es2020", "chrome91", "firefox90"], | |
| 20 | - | minify: true, | |
| 21 | - | sourcemap: false, | |
| 22 | - | cssCodeSplit: false, | |
| 23 | - | assetsInlineLimit: 100000000, | |
| 24 | - | }, | |
| 25 | - | define: { | |
| 26 | - | __DOM_ZOOM_DEFAULT__: "1.0", // Default placeholder, will be replaced by Go | |
| 27 | - | }, | |
| 28 | - | }); | |
A webui/vite.config.ts +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | import { defineConfig } from "vite"; | |
| 2 | + | import { svelte } from "@sveltejs/vite-plugin-svelte"; | |
| 3 | + | import { resolve } from "path"; | |
| 4 | + | import { pageGenerator } from "./vite-plugin-pages"; | |
| 5 | + | ||
| 6 | + | // Page configurations for dumb:// protocol pages | |
| 7 | + | const pages = { | |
| 8 | + | homepage: { | |
| 9 | + | entry: "src/pages/homepage.ts", | |
| 10 | + | output: "homepage.min.js", | |
| 11 | + | global: "DumberHomepage", | |
| 12 | + | html: { | |
| 13 | + | name: "homepage", | |
| 14 | + | title: "Dumber Browser", | |
| 15 | + | script: "homepage.min.js", | |
| 16 | + | filename: "index.html", | |
| 17 | + | }, | |
| 18 | + | }, | |
| 19 | + | blocked: { | |
| 20 | + | entry: "src/pages/blocked.ts", | |
| 21 | + | output: "blocked.min.js", | |
| 22 | + | global: "DumberBlocked", | |
| 23 | + | html: { | |
| 24 | + | name: "blocked", | |
| 25 | + | title: "Page Blocked", | |
| 26 | + | script: "blocked.min.js", | |
| 27 | + | filename: "blocked.html", | |
| 28 | + | }, | |
| 29 | + | }, | |
| 30 | + | } as const; | |
| 31 | + | ||
| 32 | + | // Get page from VITE_PAGE env var, default to building all | |
| 33 | + | const targetPage = process.env.VITE_PAGE as keyof typeof pages | undefined; | |
| 34 | + | ||
| 35 | + | // Build single page config | |
| 36 | + | function buildPageConfig(pageName: keyof typeof pages) { | |
| 37 | + | const page = pages[pageName]; | |
| 38 | + | return defineConfig({ | |
| 39 | + | plugins: [ | |
| 40 | + | svelte({ | |
| 41 | + | emitCss: false, // Inline component CSS for shadow DOM compatibility | |
| 42 | + | }), | |
| 43 | + | pageGenerator([page.html]), | |
| 44 | + | ], | |
| 45 | + | build: { | |
| 46 | + | rollupOptions: { | |
| 47 | + | input: resolve(__dirname, page.entry), | |
| 48 | + | output: { | |
| 49 | + | dir: "../assets/webui", | |
| 50 | + | entryFileNames: page.output, | |
| 51 | + | chunkFileNames: "[name].js", | |
| 52 | + | assetFileNames: "[name].[ext]", | |
| 53 | + | manualChunks: undefined, | |
| 54 | + | format: "iife", | |
| 55 | + | name: page.global, | |
| 56 | + | }, | |
| 57 | + | }, | |
| 58 | + | emptyOutDir: false, | |
| 59 | + | target: ["es2020", "chrome91", "firefox90"], | |
| 60 | + | minify: true, | |
| 61 | + | sourcemap: false, | |
| 62 | + | cssCodeSplit: false, | |
| 63 | + | assetsInlineLimit: 4096, | |
| 64 | + | }, | |
| 65 | + | resolve: { | |
| 66 | + | alias: { | |
| 67 | + | $lib: resolve(__dirname, "src/lib"), | |
| 68 | + | $components: resolve(__dirname, "src/components"), | |
| 69 | + | }, | |
| 70 | + | }, | |
| 71 | + | }); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Export config for specified page or default to homepage | |
| 75 | + | // Use VITE_PAGE=homepage or VITE_PAGE=blocked to select | |
| 76 | + | export default buildPageConfig(targetPage || "homepage"); | |