diff --git a/docs/configuration.md b/docs/configuration.md index 174de834..be51fe36 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1181,13 +1181,20 @@ widgets: To register an app on Reddit, go to [this page](https://ssl.reddit.com/prefs/apps/). ### Search Widget -Display a search bar that can be used to search for specific terms on various search engines. +Display a search bar that can be used to search for specific terms on various search engines. Features include shortcuts for quick site navigation, search suggestions, and search bangs. Example: ```yaml - type: search search-engine: duckduckgo + suggestions: true + shortcuts: + - title: Gmail + url: https://mail.google.com + alias: gm + - title: GitHub + url: https://github.com bangs: - title: YouTube shortcut: "!yt" @@ -1206,6 +1213,9 @@ Preview: | Ctrl + Enter | Perform search in a new tab | Search input is focused and not empty | | Escape | Leave focus | Search input is focused | | Up | Insert the last search query since the page was opened into the input field | Search input is focused | +| Down / Tab | Navigate down in shortcuts/suggestions dropdown | Dropdown is visible | +| Up / Shift + Tab | Navigate up in shortcuts/suggestions dropdown | Dropdown is visible | +| Enter | Select highlighted shortcut or suggestion | Dropdown is visible and item is highlighted | > [!TIP] > @@ -1219,6 +1229,9 @@ Preview: | autofocus | boolean | no | false | | target | string | no | _blank | | placeholder | string | no | Type here to search… | +| suggestions | boolean | no | false | +| suggestion-engine | string | no | | +| shortcuts | array | no | | | bangs | array | no | | ##### `search-engine` @@ -1245,6 +1258,38 @@ The target to use when opening the search results in a new tab. Possible values ##### `placeholder` When set, modifies the text displayed in the input field before typing. +##### `suggestions` +When set to `true`, enables search suggestions from the configured suggestion engine. Suggestions appear in a dropdown as you type. + +##### `suggestion-engine` +The engine to use for search suggestions. Can be a preset value from the table below or a custom URL. If not specified and suggestions are enabled, defaults to the same value as `search-engine`. Use `{QUERY}` to indicate where the query value gets placed in custom URLs. + +| Name | URL | +| ---- | --- | +| google | `https://suggestqueries.google.com/complete/search?output=firefox&q={QUERY}` | +| duckduckgo | `https://duckduckgo.com/ac/?q={QUERY}&type=list` | +| bing | `https://www.bing.com/osjson.aspx?query={QUERY}` | +| startpage | `https://startpage.com/suggestions?q={QUERY}&format=opensearch` | + +##### `shortcuts` +An array of shortcuts to websites that appear in a dropdown as you type. Shortcuts are matched against the title and optional shortcut text using fuzzy matching. + +##### Properties for each shortcut +| Name | Type | Required | +| ---- | ---- | -------- | +| title | string | yes | +| url | string | yes | +| alias | string | no | + +###### `title` +The display name for the shortcut that will appear in the dropdown. + +###### `url` +The URL to navigate to when the shortcut is selected. + +###### `alias` +Optional short alias for the shortcut that can be typed to quickly find it. For example, "gm" for Gmail. + ##### `bangs` What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube: diff --git a/glance b/glance new file mode 100755 index 00000000..ced775f3 Binary files /dev/null and b/glance differ diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 28771fa5..20feb43a 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -4,9 +4,12 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "fmt" + "io" "log" "net/http" + "net/url" "path/filepath" "slices" "strconv" @@ -401,6 +404,123 @@ func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("Page not found")) } +type searchSuggestionsResponse struct { + Query string `json:"query"` + Suggestions []string `json:"suggestions"` +} + +func (a *application) handleSearchSuggestionsRequest(w http.ResponseWriter, r *http.Request) { + if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) { + return + } + + query := r.URL.Query().Get("query") + widgetIDStr := r.URL.Query().Get("widget_id") + + if query == "" { + http.Error(w, "Missing query parameter", http.StatusBadRequest) + return + } + + if widgetIDStr == "" { + http.Error(w, "Missing widget_id parameter", http.StatusBadRequest) + return + } + + widgetID, err := strconv.ParseUint(widgetIDStr, 10, 64) + if err != nil { + http.Error(w, "Invalid widget_id parameter", http.StatusBadRequest) + return + } + + widget, exists := a.widgetByID[widgetID] + if !exists { + http.Error(w, "Widget not found", http.StatusNotFound) + return + } + + searchWidget, ok := widget.(*searchWidget) + if !ok { + http.Error(w, "Widget is not a search widget", http.StatusBadRequest) + return + } + + if !searchWidget.Suggestions { + http.Error(w, "Widget does not have suggestions enabled", http.StatusBadRequest) + return + } + + suggestions, err := a.fetchSearchSuggestions(query, searchWidget.SuggestionEngine) + if err != nil { + log.Printf("Error fetching search suggestions: %v", err) + // Set error on the widget to show the red dot indicator + searchWidget.withError(fmt.Errorf("suggestion service error: %v", err)) + http.Error(w, "Failed to fetch suggestions", http.StatusInternalServerError) + return + } + + // Clear any previous errors on successful response + searchWidget.withError(nil) + + response := searchSuggestionsResponse{ + Query: query, + Suggestions: suggestions, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (a *application) fetchSearchSuggestions(query, engineURL string) ([]string, error) { + suggestionURL := strings.ReplaceAll(engineURL, "{QUERY}", url.QueryEscape(query)) + + client := &http.Client{ + Timeout: 5 * time.Second, + } + + resp, err := client.Get(suggestionURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch suggestions: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("suggestion API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + return parseSuggestionResponse(body) +} + +func parseSuggestionResponse(body []byte) ([]string, error) { + var suggestions []string + + // Try to parse as standard OpenSearch format: [query, [suggestions...]] + // This works for all the supported engines (Google, DuckDuckGo, Bing, Startpage) + var response []any + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %v", err) + } + + if len(response) < 2 { + return suggestions, nil + } + + if suggestionsList, ok := response[1].([]any); ok { + for _, item := range suggestionsList { + if suggestion, ok := item.(string); ok { + suggestions = append(suggestions, suggestion) + } + } + } + + return suggestions, nil +} + func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) { // TODO: this requires a rework of the widget update logic so that rather // than locking the entire page we lock individual widgets @@ -449,6 +569,7 @@ func (a *application) server() (func() error, func() error) { mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("POST /api/search/suggestions", a.handleSearchSuggestionsRequest) if a.RequiresAuth { mux.HandleFunc("GET /login", a.handleLoginPageRequest) diff --git a/internal/glance/static/css/widget-search.css b/internal/glance/static/css/widget-search.css index ebf5cbbb..987f2dc8 100644 --- a/internal/glance/static/css/widget-search.css +++ b/internal/glance/static/css/widget-search.css @@ -57,6 +57,130 @@ } .search-bangs { display: none; } +.search-shortcuts { display: none; } + +.search-input-container { + position: relative; + width: 100%; +} + +.search-shortcuts-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--color-widget-background); + border: 1px solid var(--color-separator); + border-radius: var(--border-radius); + z-index: 1000; + max-height: 300px; + overflow-y: auto; + margin: 0.5rem 2em 0; +} + +.search-shortcuts-dropdown.hidden { + display: none; +} + +.search-shortcut-item { + padding: 0.8rem 1.2rem; + cursor: pointer; + border-bottom: 1px solid var(--color-separator); + transition: background-color 0.15s ease; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.search-shortcut-item:last-child { + border-bottom: none; +} + +.search-shortcut-item:hover, +.search-shortcut-item.highlighted { + background: var(--color-widget-background-highlight); + color: var(--color-text-highlight); +} + +.search-shortcut-item:hover .search-shortcut-title, +.search-shortcut-item.highlighted .search-shortcut-title, +.search-shortcut-item[data-type="suggestion"]:hover .search-shortcut-title, +.search-shortcut-item[data-type="suggestion"].highlighted .search-shortcut-title { + color: var(--color-text-highlight); +} + +.search-shortcut-item:hover .search-shortcut-url, +.search-shortcut-item.highlighted .search-shortcut-url { + color: var(--color-text-highlight); + opacity: 0.8; +} + +.search-shortcut-item:hover .search-shortcut-alias, +.search-shortcut-item.highlighted .search-shortcut-alias { + background: rgba(255, 255, 255, 0.2); + color: var(--color-text-highlight); +} + +.search-shortcut-item.exact-match .search-shortcut-alias{ + background: var(--color-primary); + color: var(--color-background); +} + +.search-shortcut-item:hover .search-shortcut-icon, +.search-shortcut-item.highlighted .search-shortcut-icon { + stroke: var(--color-text-highlight); +} + +.search-shortcut-item.exact-match { + background: var(--color-widget-background-highlight); +} + +.search-shortcut-item.exact-match .search-shortcut-title { + color: var(--color-primary); +} + +.search-shortcut-icon { + width: 1.2rem; + height: 1.2rem; + flex-shrink: 0; + stroke: var(--color-text-subdue); +} + +.search-shortcut-content { + flex-grow: 1; + min-width: 0; + display: flex; +} + +.search-shortcut-title { + font-weight: 500; + color: var(--color-text-highlight); +} + +.search-shortcut-url { + font-size: 0.85em; + color: var(--color-text-subdue); + margin-top: 0.2rem; + margin-left: 1em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 300px; +} + +.search-shortcut-alias { + font-size: 0.8em; + color: var(--color-text-base); + background: var(--color-widget-background-highlight); + padding: 0.2rem 0.4rem; + border-radius: calc(var(--border-radius) * 0.5); + flex-shrink: 0; +} + +.search-shortcut-item[data-type="suggestion"] .search-shortcut-title { + font-weight: normal; + color: var(--color-text-base); +} .search-bang { border-radius: calc(var(--border-radius) * 2); diff --git a/internal/glance/static/js/page.js b/internal/glance/static/js/page.js index 0212a4fa..6b744a5e 100644 --- a/internal/glance/static/js/page.js +++ b/internal/glance/static/js/page.js @@ -95,114 +95,17 @@ function updateRelativeTimeForElements(elements) } } -function setupSearchBoxes() { +async function setupSearchBoxes() { const searchWidgets = document.getElementsByClassName("search"); if (searchWidgets.length == 0) { return; } - for (let i = 0; i < searchWidgets.length; i++) { - const widget = searchWidgets[i]; - const defaultSearchUrl = widget.dataset.defaultSearchUrl; - const target = widget.dataset.target || "_blank"; - const newTab = widget.dataset.newTab === "true"; - const inputElement = widget.getElementsByClassName("search-input")[0]; - const bangElement = widget.getElementsByClassName("search-bang")[0]; - const bangs = widget.querySelectorAll(".search-bangs > input"); - const bangsMap = {}; - const kbdElement = widget.getElementsByTagName("kbd")[0]; - let currentBang = null; - let lastQuery = ""; - - for (let j = 0; j < bangs.length; j++) { - const bang = bangs[j]; - bangsMap[bang.dataset.shortcut] = bang; - } - - const handleKeyDown = (event) => { - if (event.key == "Escape") { - inputElement.blur(); - return; - } - - if (event.key == "Enter") { - const input = inputElement.value.trim(); - let query; - let searchUrlTemplate; - - if (currentBang != null) { - query = input.slice(currentBang.dataset.shortcut.length + 1); - searchUrlTemplate = currentBang.dataset.url; - } else { - query = input; - searchUrlTemplate = defaultSearchUrl; - } - if (query.length == 0 && currentBang == null) { - return; - } + const search = await import('./search.js'); - const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query)); - - if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) { - window.open(url, target).focus(); - } else { - window.location.href = url; - } - - lastQuery = query; - inputElement.value = ""; - - return; - } - - if (event.key == "ArrowUp" && lastQuery.length > 0) { - inputElement.value = lastQuery; - return; - } - }; - - const changeCurrentBang = (bang) => { - currentBang = bang; - bangElement.textContent = bang != null ? bang.dataset.title : ""; - } - - const handleInput = (event) => { - const value = event.target.value.trim(); - if (value in bangsMap) { - changeCurrentBang(bangsMap[value]); - return; - } - - const words = value.split(" "); - if (words.length >= 2 && words[0] in bangsMap) { - changeCurrentBang(bangsMap[words[0]]); - return; - } - - changeCurrentBang(null); - }; - - inputElement.addEventListener("focus", () => { - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("input", handleInput); - }); - inputElement.addEventListener("blur", () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("input", handleInput); - }); - - document.addEventListener("keydown", (event) => { - if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; - if (event.code != "KeyS") return; - - inputElement.focus(); - event.preventDefault(); - }); - - kbdElement.addEventListener("mousedown", () => { - requestAnimationFrame(() => inputElement.focus()); - }); + for (let i = 0; i < searchWidgets.length; i++) { + search.default(searchWidgets[i]); } } diff --git a/internal/glance/static/js/search.js b/internal/glance/static/js/search.js new file mode 100644 index 00000000..1b58dd82 --- /dev/null +++ b/internal/glance/static/js/search.js @@ -0,0 +1,511 @@ +export default function SearchBox(widget) { + const defaultSearchUrl = widget.dataset.defaultSearchUrl; + const target = widget.dataset.target || "_blank"; + const newTab = widget.dataset.newTab === "true"; + const suggestionsEnabled = widget.dataset.suggestionsEnabled === "true"; + const widgetId = widget.dataset.widgetId; + const inputElement = widget.getElementsByClassName("search-input")[0]; + const bangElement = widget.getElementsByClassName("search-bang")[0]; + const dropdownElement = widget.getElementsByClassName("search-shortcuts-dropdown")[0]; + const shortcutsListElement = widget.getElementsByClassName("search-shortcuts-list")[0]; + const kbdElement = widget.getElementsByTagName("kbd")[0]; + + const bangsMap = {}; + const shortcutsArray = []; + let currentBang = null; + let lastQuery = ""; + let highlightedIndex = -1; + let filteredResults = []; + let currentSuggestions = []; + let debounceTimer = null; + + // Initialize bangs + const bangs = widget.querySelectorAll(".search-bangs > input"); + for (let j = 0; j < bangs.length; j++) { + const bang = bangs[j]; + bangsMap[bang.dataset.shortcut] = bang; + } + + // Initialize shortcuts + const shortcuts = widget.querySelectorAll(".search-shortcuts > input"); + for (let j = 0; j < shortcuts.length; j++) { + const shortcut = shortcuts[j]; + shortcutsArray.push({ + title: shortcut.dataset.title, + url: shortcut.dataset.url, + alias: shortcut.dataset.alias || "" + }); + } + + // URL detection function + function isUrl(input) { + const trimmed = input.trim(); + + // Check for protocol-prefixed URLs + if (/^https?:\/\/.+/.test(trimmed)) { + return trimmed; + } + + // Check for IP addresses with optional port + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(trimmed)) { + return `http://${trimmed}`; + } + + // Check for domain patterns (including localhost) + if (/^(www\.)?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/.test(trimmed) || + /^localhost(:\d+)?$/.test(trimmed)) { + return `https://${trimmed}`; + } + + // Check for domain with port (like example.com:8080) + if (/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+:\d+$/.test(trimmed)) { + return `https://${trimmed}`; + } + + return null; + } + + // Fuzzy matching function + function fuzzyMatch(text, query) { + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + + // Exact match gets highest priority + if (lowerText === lowerQuery) return { score: 1000, type: 'exact' }; + + // Check for substring matches with position-based scoring + const substringIndex = lowerText.indexOf(lowerQuery); + if (substringIndex !== -1) { + // Higher score for matches at the beginning + const positionBonus = Math.max(0, 100 - substringIndex * 5); + return { score: 500 + positionBonus, type: 'contains' }; + } + + // Fuzzy matching with position-aware scoring + let score = 0; + let textIndex = 0; + let consecutiveMatches = 0; + let firstMatchIndex = -1; + + for (let i = 0; i < lowerQuery.length; i++) { + const char = lowerQuery[i]; + const foundIndex = lowerText.indexOf(char, textIndex); + + if (foundIndex === -1) return { score: 0, type: 'none' }; + + // Track first match position + if (firstMatchIndex === -1) { + firstMatchIndex = foundIndex; + } + + // Bonus for consecutive characters + if (foundIndex === textIndex) { + consecutiveMatches++; + score += 15; // Higher bonus for consecutive chars + } else { + consecutiveMatches = 0; + score += 2; + } + + // Extra bonus for consecutive streaks + if (consecutiveMatches > 1) { + score += consecutiveMatches * 3; + } + + textIndex = foundIndex + 1; + } + + // Position bonus: higher score for matches starting earlier + const positionBonus = Math.max(0, 50 - firstMatchIndex * 3); + score += positionBonus; + + return { score, type: 'fuzzy' }; + } + + function hideDropdown() { + dropdownElement.classList.add("hidden"); + highlightedIndex = -1; + } + + function showDropdown() { + if (filteredResults.length > 0) { + dropdownElement.classList.remove("hidden"); + } + } + + function updateDropdown(query) { + if (!query) { + hideDropdown(); + return; + } + + // Filter and score shortcuts + const shortcutMatches = shortcutsArray.map(shortcut => { + const titleMatch = fuzzyMatch(shortcut.title, query); + const aliasMatch = shortcut.alias ? fuzzyMatch(shortcut.alias, query) : { score: 0, type: 'none' }; + const bestMatch = titleMatch.score > aliasMatch.score ? titleMatch : aliasMatch; + + return { + ...shortcut, + type: 'shortcut', + score: bestMatch.score, + matchType: bestMatch.type, + isExact: titleMatch.type === 'exact' || aliasMatch.type === 'exact' + }; + }).filter(item => item.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 5); + + // Start with shortcuts + filteredResults = [...shortcutMatches]; + highlightedIndex = -1; + + renderDropdown(); + if (filteredResults.length > 0) { + showDropdown(); + } else if (!suggestionsEnabled || !widgetId || currentBang !== null) { + hideDropdown(); + } + + // Fetch search suggestions if enabled and no bang is active (with debouncing) + if (suggestionsEnabled && widgetId && currentBang === null) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + fetchSuggestions(query).then(suggestions => { + currentSuggestions = suggestions.map(suggestion => ({ + type: 'suggestion', + title: suggestion, + url: null, + score: 1 // Lower priority than shortcuts + })); + + // Combine shortcuts and suggestions + filteredResults = [...shortcutMatches, ...currentSuggestions]; + renderDropdown(); + showDropdown(); + }); + }, 200); + } + } + + function renderDropdown() { + const shortcutIcon = ` + + `; + + const suggestionIcon = ` + + `; + + shortcutsListElement.innerHTML = filteredResults.map((item, index) => { + if (item.type === 'shortcut') { + return ` +
+ ${shortcutIcon} +
+
${item.title}
+
${item.url}
+
+ ${item.alias ? `
${item.alias}
` : ''} +
+ `; + } else { + return ` +
+ ${suggestionIcon} +
${item.title}
+
+ `; + } + }).join(''); + + // Add click event listeners + shortcutsListElement.querySelectorAll('.search-shortcut-item').forEach((item, index) => { + item.addEventListener('click', () => { + const result = filteredResults[index]; + if (result.type === 'shortcut') { + navigateToShortcut(result); + } else { + performSearch(result.title); + } + }); + }); + } + + function showErrorIndicator() { + // Find the widget header in the parent widget container + const widgetContainer = widget.closest('.widget'); + const widgetHeader = widgetContainer?.querySelector('.widget-header'); + + if (!widgetHeader) return; + + // Check if error indicator already exists + if (widgetHeader.querySelector('.notice-icon-major')) { + return; + } + + const errorIndicator = document.createElement('div'); + errorIndicator.className = 'notice-icon notice-icon-major'; + errorIndicator.title = 'Search suggestions service error'; + widgetHeader.appendChild(errorIndicator); + } + + function hideErrorIndicator() { + const widgetContainer = widget.closest('.widget'); + const errorIndicator = widgetContainer?.querySelector('.widget-header .notice-icon-major'); + if (errorIndicator) { + errorIndicator.remove(); + } + } + + async function fetchSuggestions(query) { + try { + const response = await fetch(`/api/search/suggestions?query=${encodeURIComponent(query)}&widget_id=${encodeURIComponent(widgetId)}`, { + method: 'POST' + }); + + if (!response.ok) { + showErrorIndicator(); + return []; + } + + // Clear error indicator on successful response + hideErrorIndicator(); + + const data = await response.json(); + return data.suggestions || []; + } catch (error) { + console.error('Failed to fetch suggestions:', error); + showErrorIndicator(); + return []; + } + } + + function performSearch(query) { + const url = defaultSearchUrl.replace("!QUERY!", encodeURIComponent(query)); + if (newTab) { + window.open(url, target).focus(); + } else { + window.location.href = url; + } + inputElement.value = ""; + hideDropdown(); + } + + function navigateToShortcut(shortcut) { + if (newTab) { + window.open(shortcut.url, target).focus(); + } else { + window.location.href = shortcut.url; + } + inputElement.value = ""; + hideDropdown(); + } + + function highlightItem(index) { + const items = shortcutsListElement.querySelectorAll('.search-shortcut-item'); + items.forEach(item => item.classList.remove('highlighted')); + + if (index >= 0 && index < items.length) { + items[index].classList.add('highlighted'); + highlightedIndex = index; + + // Scroll the highlighted item into view + items[index].scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest' + }); + } else { + highlightedIndex = -1; + } + } + + const changeCurrentBang = (bang) => { + currentBang = bang; + bangElement.textContent = currentBang?.dataset.title || ""; + }; + + const handleKeyDown = (event) => { + if (event.key == "Escape") { + hideDropdown(); + inputElement.blur(); + return; + } + + // Handle dropdown navigation + if (!dropdownElement.classList.contains("hidden")) { + if (event.key === "ArrowDown" || (event.key === "Tab" && !event.shiftKey)) { + event.preventDefault(); + const newIndex = Math.min(highlightedIndex + 1, filteredResults.length - 1); + highlightItem(newIndex); + return; + } + + if (event.key === "ArrowUp" || (event.key === "Tab" && event.shiftKey)) { + event.preventDefault(); + const newIndex = Math.max(highlightedIndex - 1, -1); + highlightItem(newIndex); + return; + } + + if (event.key === "Enter" && highlightedIndex >= 0) { + event.preventDefault(); + const result = filteredResults[highlightedIndex]; + if (result.type === 'shortcut') { + navigateToShortcut(result); + } else { + performSearch(result.title); + } + return; + } + } + + if (event.key == "Enter") { + const input = inputElement.value.trim(); + + // Check for exact shortcut match first + const exactMatch = shortcutsArray.find(s => + s.title.toLowerCase() === input.toLowerCase() || + (s.alias && s.alias.toLowerCase() === input.toLowerCase()) + ); + + if (exactMatch) { + navigateToShortcut(exactMatch); + return; + } + + // Check if input is a URL + const detectedUrl = isUrl(input); + if (detectedUrl) { + if (newTab) { + window.open(detectedUrl, target).focus(); + } else { + window.location.href = detectedUrl; + } + inputElement.value = ""; + hideDropdown(); + return; + } + + let query; + let searchUrlTemplate; + + if (currentBang == null) { + query = input; + searchUrlTemplate = defaultSearchUrl; + } else { + // Extract query after the bang shortcut + const bangShortcut = currentBang.dataset.shortcut; + if (input.startsWith(bangShortcut + " ")) { + query = input.substring(bangShortcut.length + 1); + } else { + query = ""; + } + searchUrlTemplate = currentBang.dataset.url; + } + + if (query.length == 0 && currentBang == null) { + return; + } + + const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query)); + + if (newTab) { + window.open(url, target).focus(); + } else { + window.location.href = url; + } + + lastQuery = query; + inputElement.value = ""; + hideDropdown(); + return; + } + + if (event.key == "ArrowUp" && lastQuery.length > 0 && dropdownElement.classList.contains("hidden")) { + inputElement.value = lastQuery; + return; + } + }; + + const handleInput = (event) => { + const value = event.target.value.trim(); + + // Check for bangs first + if (value in bangsMap) { + changeCurrentBang(bangsMap[value]); + hideDropdown(); + return; + } + + const words = value.split(" "); + if (words.length >= 2 && words[0] in bangsMap) { + changeCurrentBang(bangsMap[words[0]]); + hideDropdown(); + return; + } + + changeCurrentBang(null); + + // Update shortcuts dropdown + updateDropdown(value); + }; + + // Close dropdown when clicking outside + document.addEventListener('click', (event) => { + if (!widget.contains(event.target)) { + hideDropdown(); + } + }); + + const attachFocusListeners = () => { + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("input", handleInput); + if (inputElement.value.trim()) { + updateDropdown(inputElement.value.trim()); + } + }; + + const detachFocusListeners = () => { + hideDropdown(); + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("input", handleInput); + }; + + inputElement.addEventListener("focus", () => { + attachFocusListeners(); + }); + + inputElement.addEventListener("blur", (event) => { + // Delay hiding dropdown to allow for clicks + setTimeout(() => { + if (!widget.contains(document.activeElement)) { + detachFocusListeners(); + } + }, 150); + }); + + // Check if input is already focused (e.g., due to autofocus) + if (document.activeElement === inputElement) { + attachFocusListeners(); + } + + document.addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key == "/") { + event.preventDefault(); + inputElement.focus(); + return; + } + + if (event.key == kbdElement.textContent.toLowerCase() && !event.metaKey && !event.ctrlKey && !event.altKey && document.activeElement != inputElement) { + event.preventDefault(); + inputElement.focus(); + return; + } + }); + + kbdElement.addEventListener("mousedown", () => { + requestAnimationFrame(() => inputElement.focus()); + }); +} \ No newline at end of file diff --git a/internal/glance/templates/search.html b/internal/glance/templates/search.html index ae981c63..52c98623 100644 --- a/internal/glance/templates/search.html +++ b/internal/glance/templates/search.html @@ -3,20 +3,32 @@ {{ define "widget-content-classes" }}widget-content-frameless{{ end }} {{ define "widget-content" }} -