217 lines
6.7 KiB
Svelte
217 lines
6.7 KiB
Svelte
<script>
|
|
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'
|
|
import { t } from './i18n'
|
|
import { expandKeyboardVariants } from './util/keyboardLayout'
|
|
|
|
export let wailsCall = async () => []
|
|
export let typeLabel = (type) => type || ''
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
let query = ''
|
|
let results = []
|
|
let open = false
|
|
let loading = false
|
|
let selectedIndex = 0
|
|
let inputEl
|
|
let searchTimer
|
|
|
|
function clean(value) {
|
|
return String(value || '').trim()
|
|
}
|
|
|
|
function resultPath(result) {
|
|
return clean(result.path)
|
|
}
|
|
|
|
function resultMeta(result) {
|
|
const parts = []
|
|
const type = typeLabel(result.type)
|
|
const path = resultPath(result)
|
|
if (type) parts.push(type)
|
|
if (path) parts.push(path)
|
|
return parts.join(' · ')
|
|
}
|
|
|
|
async function runSearch() {
|
|
const q = clean(query)
|
|
if (q.length < 2) {
|
|
results = []
|
|
open = false
|
|
loading = false
|
|
return
|
|
}
|
|
loading = true
|
|
try {
|
|
// Build query variants: original + keyboard-swapped + lowercased
|
|
const variants = expandKeyboardVariants(q)
|
|
// Deduplicate preserving order
|
|
const seenVariants = new Set()
|
|
const queries = []
|
|
for (const v of variants) {
|
|
if (!seenVariants.has(v)) {
|
|
seenVariants.add(v)
|
|
queries.push(v)
|
|
}
|
|
}
|
|
// Execute all variant queries in parallel, collect results
|
|
const allResults = await Promise.all(
|
|
queries.map(v => wailsCall('Search', v).catch(() => []))
|
|
)
|
|
// Merge and deduplicate by node ID (or type+title for links/actions)
|
|
const merged = new Map()
|
|
// First pass: results from the original query get priority
|
|
for (let qi = 0; qi < allResults.length; qi++) {
|
|
const arr = allResults[qi] || []
|
|
for (const r of arr) {
|
|
const key = r.type + ':' + r.nodeId + ':' + (r.targetId || '') + ':' + r.title
|
|
if (!merged.has(key)) {
|
|
merged.set(key, r)
|
|
}
|
|
}
|
|
}
|
|
results = Array.from(merged.values())
|
|
selectedIndex = 0
|
|
open = results.length > 0
|
|
} catch (e) {
|
|
results = []
|
|
open = false
|
|
} finally {
|
|
loading = false
|
|
}
|
|
}
|
|
|
|
function scheduleSearch() {
|
|
clearTimeout(searchTimer)
|
|
searchTimer = setTimeout(runSearch, 180)
|
|
}
|
|
|
|
function closeResults() {
|
|
open = false
|
|
selectedIndex = 0
|
|
}
|
|
|
|
function clearSearch() {
|
|
query = ''
|
|
results = []
|
|
closeResults()
|
|
}
|
|
|
|
function openResult(result) {
|
|
if (!result) return
|
|
dispatch('open', result)
|
|
clearSearch()
|
|
inputEl?.blur()
|
|
}
|
|
|
|
function handleInput() {
|
|
scheduleSearch()
|
|
}
|
|
|
|
function handleKeydown(e) {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
clearSearch()
|
|
inputEl?.blur()
|
|
return
|
|
}
|
|
if (!open || results.length === 0) return
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
selectedIndex = Math.min(results.length - 1, selectedIndex + 1)
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
selectedIndex = Math.max(0, selectedIndex - 1)
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
openResult(results[selectedIndex])
|
|
}
|
|
}
|
|
|
|
async function focusSearch() {
|
|
await tick()
|
|
inputEl?.focus()
|
|
inputEl?.select()
|
|
}
|
|
|
|
function handleWindowKeydown(e) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
|
e.preventDefault()
|
|
focusSearch()
|
|
return
|
|
}
|
|
if (e.key === '/' && !isEditableTarget(e.target)) {
|
|
e.preventDefault()
|
|
focusSearch()
|
|
}
|
|
}
|
|
|
|
function isEditableTarget(target) {
|
|
if (!target || !(target instanceof Element)) return false
|
|
return !!target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
|
|
}
|
|
|
|
onMount(() => {
|
|
window.addEventListener('keydown', handleWindowKeydown)
|
|
})
|
|
|
|
onDestroy(() => {
|
|
window.removeEventListener('keydown', handleWindowKeydown)
|
|
clearTimeout(searchTimer)
|
|
})
|
|
</script>
|
|
|
|
<div class="global-search">
|
|
<input
|
|
class="global-search-input"
|
|
bind:this={inputEl}
|
|
bind:value={query}
|
|
type="search"
|
|
placeholder={t('search.placeholder')}
|
|
aria-label={t('search.placeholder')}
|
|
autocomplete="off"
|
|
on:input={handleInput}
|
|
on:focus={() => { if (results.length > 0) open = true }}
|
|
on:keydown={handleKeydown}
|
|
on:blur={() => setTimeout(closeResults, 160)}
|
|
/>
|
|
{#if open}
|
|
<div class="global-search-results">
|
|
{#if loading}
|
|
<div class="global-search-empty">{t('common.loading')}</div>
|
|
{:else if results.length === 0}
|
|
<div class="global-search-empty">{t('search.noResults')}</div>
|
|
{:else}
|
|
{#each results as result, index}
|
|
<button
|
|
class="global-search-result"
|
|
class:selected={index === selectedIndex}
|
|
on:mousedown|preventDefault={() => openResult(result)}
|
|
>
|
|
<span class="global-search-title">{result.title}</span>
|
|
{#if resultMeta(result)}
|
|
<span class="global-search-meta">{resultMeta(result)}</span>
|
|
{/if}
|
|
{#if result.snippet}
|
|
<span class="global-search-snippet">{result.snippet}</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.global-search { position: relative; flex: 1 1 360px; min-width: 180px; max-width: 520px; }
|
|
.global-search-input { width: 100%; height: 32px; padding: 0 12px; border: 1px solid #2a2a3c; border-radius: 6px; background: #10101a; color: #e4e4ef; font: inherit; font-size: 13px; }
|
|
.global-search-input:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.18); }
|
|
.global-search-results { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 160; max-height: min(360px, calc(100vh - 88px)); overflow-y: auto; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; box-shadow: 0 14px 36px rgba(0,0,0,0.38); padding: 4px; }
|
|
.global-search-result { display: flex; flex-direction: column; gap: 2px; width: 100%; border: 0; background: transparent; color: #e4e4ef; text-align: left; padding: 8px 10px; border-radius: 6px; cursor: pointer; font: inherit; }
|
|
.global-search-result:hover, .global-search-result.selected { background: #252545; }
|
|
.global-search-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.global-search-meta { font-size: 11px; color: #a0a0bc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.global-search-snippet { font-size: 12px; color: #767690; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.global-search-empty { padding: 10px 12px; color: #8888a0; font-size: 12px; }
|
|
</style>
|