|
| 1 | +<script setup lang="ts"> |
| 2 | +import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue' |
| 3 | +// @ts-ignore — virtual module from vitepress-plugin-typesense |
| 4 | +import getConfig from 'virtual:typesense-config' |
| 5 | +
|
| 6 | +const isOpen = ref(false) |
| 7 | +const query = ref('') |
| 8 | +const results = ref<SearchHit[]>([]) |
| 9 | +const isLoading = ref(false) |
| 10 | +const selectedIndex = ref(0) |
| 11 | +const inputRef = ref<HTMLInputElement | null>(null) |
| 12 | +const resultsRef = ref<HTMLElement | null>(null) |
| 13 | +
|
| 14 | +interface SearchHit { |
| 15 | + url: string |
| 16 | + breadcrumbs: string[] |
| 17 | + contentHighlight: string |
| 18 | +} |
| 19 | +
|
| 20 | +let debounceTimer: ReturnType<typeof setTimeout> | null = null |
| 21 | +
|
| 22 | +const cfg = computed(() => (typeof getConfig === 'function' ? getConfig() : getConfig)) |
| 23 | +
|
| 24 | +const typesenseUrl = computed(() => { |
| 25 | + const node = cfg.value.typesenseServerConfig?.nodes?.[0] |
| 26 | + if (!node) return '' |
| 27 | + const port = (node.protocol === 'https' && node.port === '443') || (node.protocol === 'http' && node.port === '80') ? '' : `:${node.port}` |
| 28 | + return `${node.protocol}://${node.host}${port}` |
| 29 | +}) |
| 30 | +
|
| 31 | +const open = () => { isOpen.value = true; nextTick(() => inputRef.value?.focus()) } |
| 32 | +const close = () => { isOpen.value = false; query.value = ''; results.value = []; selectedIndex.value = 0 } |
| 33 | +
|
| 34 | +const search = async (q: string) => { |
| 35 | + if (!q.trim()) { results.value = []; return } |
| 36 | + isLoading.value = true |
| 37 | + try { |
| 38 | + const params = new URLSearchParams({ |
| 39 | + q, query_by: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,content', |
| 40 | + highlight_full_fields: 'content', highlight_start_tag: '<mark>', highlight_end_tag: '</mark>', |
| 41 | + per_page: '20', prioritize_exact_match: 'false', |
| 42 | + }) |
| 43 | + const res = await fetch( |
| 44 | + `${typesenseUrl.value}/collections/${cfg.value.typesenseCollectionName}/documents/search?${params}`, |
| 45 | + { headers: { 'X-TYPESENSE-API-KEY': cfg.value.typesenseServerConfig.apiKey } }, |
| 46 | + ) |
| 47 | + if (!res.ok) throw new Error(`${res.status}`) |
| 48 | + const data = await res.json() |
| 49 | + results.value = (data.hits || []).map((hit: any) => { |
| 50 | + const doc = hit.document; const hl = hit.highlights || [] |
| 51 | + const contentHl = hl.find((h: any) => h.field === 'content') |
| 52 | + const crumbs: string[] = [] |
| 53 | + if (doc['hierarchy.lvl1']) crumbs.push(doc['hierarchy.lvl1']) |
| 54 | + if (doc['hierarchy.lvl2']) crumbs.push(doc['hierarchy.lvl2']) |
| 55 | + if (doc['hierarchy.lvl3']) crumbs.push(doc['hierarchy.lvl3']) |
| 56 | + return { url: doc.url || '', breadcrumbs: crumbs, contentHighlight: contentHl?.snippet || '' } |
| 57 | + }) |
| 58 | + selectedIndex.value = 0 |
| 59 | + } catch (err) { console.error('[Search]', err); results.value = [] } |
| 60 | + finally { isLoading.value = false } |
| 61 | +} |
| 62 | +
|
| 63 | +const onInput = () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => search(query.value), 200) } |
| 64 | +
|
| 65 | +const onKeydown = (e: KeyboardEvent) => { |
| 66 | + if (e.key === 'ArrowDown') { e.preventDefault(); selectedIndex.value = Math.min(selectedIndex.value + 1, results.value.length - 1); scrollTo() } |
| 67 | + else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIndex.value = Math.max(selectedIndex.value - 1, 0); scrollTo() } |
| 68 | + else if (e.key === 'Enter' && results.value[selectedIndex.value]) { e.preventDefault(); go(results.value[selectedIndex.value].url) } |
| 69 | + else if (e.key === 'Escape') close() |
| 70 | +} |
| 71 | +
|
| 72 | +const scrollTo = () => nextTick(() => resultsRef.value?.querySelector(`[data-index="${selectedIndex.value}"]`)?.scrollIntoView({ block: 'nearest' })) |
| 73 | +
|
| 74 | +const go = (url: string) => { close(); window.location.href = url } |
| 75 | +
|
| 76 | +const handleGlobalKey = (e: KeyboardEvent) => { |
| 77 | + const el = e.target as HTMLElement |
| 78 | + if (el.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) return |
| 79 | + if ((e.key?.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') { e.preventDefault(); open() } |
| 80 | +} |
| 81 | +
|
| 82 | +onMounted(() => window.addEventListener('keydown', handleGlobalKey)) |
| 83 | +onUnmounted(() => window.removeEventListener('keydown', handleGlobalKey)) |
| 84 | +</script> |
| 85 | + |
| 86 | +<template> |
| 87 | + <div class="ts-search-wrapper"> |
| 88 | + <!-- Trigger --> |
| 89 | + <button class="ts-search-btn" aria-label="Search" @click="open"> |
| 90 | + <svg class="ts-search-btn__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> |
| 91 | + <circle cx="11" cy="11" r="8" /><path stroke-linecap="round" d="m21 21-4.3-4.3" /> |
| 92 | + </svg> |
| 93 | + <span class="ts-search-btn__text">Search</span> |
| 94 | + <span class="ts-search-btn__keys"><kbd>⌘</kbd><kbd>K</kbd></span> |
| 95 | + </button> |
| 96 | + |
| 97 | + <!-- Modal --> |
| 98 | + <Teleport to="body"> |
| 99 | + <Transition name="ts-fade"> |
| 100 | + <div v-if="isOpen" class="ts-overlay" @click.self="close"> |
| 101 | + <div class="ts-modal"> |
| 102 | + <div class="ts-header"> |
| 103 | + <svg class="ts-header__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> |
| 104 | + <circle cx="11" cy="11" r="8" /><path stroke-linecap="round" d="m21 21-4.3-4.3" /> |
| 105 | + </svg> |
| 106 | + <input ref="inputRef" v-model="query" class="ts-header__input" type="text" placeholder="Search documentation..." autocomplete="off" @input="onInput" @keydown="onKeydown" /> |
| 107 | + <button class="ts-header__close" @click="close"><kbd>esc</kbd></button> |
| 108 | + </div> |
| 109 | + <div ref="resultsRef" class="ts-results"> |
| 110 | + <div v-if="isLoading && !results.length" class="ts-empty">Searching...</div> |
| 111 | + <div v-else-if="query && !results.length && !isLoading" class="ts-empty">No results for "{{ query }}"</div> |
| 112 | + <div v-else-if="!query" class="ts-empty">Type to search documentation</div> |
| 113 | + <a v-for="(hit, i) in results" :key="hit.url + i" :href="hit.url" class="ts-hit" :class="{ 'ts-hit--sel': i === selectedIndex }" :data-index="i" @click.prevent="go(hit.url)" @mouseenter="selectedIndex = i"> |
| 114 | + <div class="ts-hit__crumbs"> |
| 115 | + <template v-for="(c, ci) in hit.breadcrumbs" :key="ci"> |
| 116 | + <span v-if="ci > 0" class="ts-hit__sep">›</span><span>{{ c }}</span> |
| 117 | + </template> |
| 118 | + </div> |
| 119 | + <div v-if="hit.contentHighlight" class="ts-hit__content" v-html="hit.contentHighlight" /> |
| 120 | + </a> |
| 121 | + </div> |
| 122 | + <div class="ts-footer"> |
| 123 | + <span class="ts-footer__hint"><kbd>↑</kbd> <kbd>↓</kbd> navigate</span> |
| 124 | + <span class="ts-footer__hint"><kbd>↵</kbd> open</span> |
| 125 | + <span class="ts-footer__hint"><kbd>esc</kbd> close</span> |
| 126 | + </div> |
| 127 | + </div> |
| 128 | + </div> |
| 129 | + </Transition> |
| 130 | + </Teleport> |
| 131 | + </div> |
| 132 | +</template> |
| 133 | + |
| 134 | +<style> |
| 135 | +/* Wrapper — matches VPNavBarSearch layout */ |
| 136 | +.ts-search-wrapper { |
| 137 | + display: flex; |
| 138 | + align-items: center; |
| 139 | +} |
| 140 | +
|
| 141 | +@media (min-width: 768px) { |
| 142 | + .ts-search-wrapper { |
| 143 | + flex-grow: 1; |
| 144 | + padding-left: 24px; |
| 145 | + } |
| 146 | +} |
| 147 | +
|
| 148 | +@media (min-width: 960px) { |
| 149 | + .ts-search-wrapper { |
| 150 | + padding-left: 32px; |
| 151 | + } |
| 152 | +} |
| 153 | +
|
| 154 | +/* Button */ |
| 155 | +.ts-search-btn { |
| 156 | + display: flex; align-items: center; gap: 6px; padding: 0 10px; height: 36px; |
| 157 | + border-radius: 8px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg-alt); |
| 158 | + color: var(--vp-c-text-3); font-size: 13px; cursor: pointer; transition: border-color 0.2s; |
| 159 | +} |
| 160 | +.ts-search-btn:hover { border-color: var(--vp-c-brand-1); } |
| 161 | +.ts-search-btn__icon { width: 15px; height: 15px; flex-shrink: 0; } |
| 162 | +.ts-search-btn__text { font-size: 13px; } |
| 163 | +.ts-search-btn__keys { display: none; } |
| 164 | +@media (min-width: 768px) { .ts-search-btn__keys { display: flex; align-items: center; gap: 2px; margin-left: 8px; } } |
| 165 | +.ts-search-btn__keys kbd { |
| 166 | + font-size: 11px; padding: 1px 4px; border-radius: 3px; |
| 167 | + border: 1px solid var(--vp-c-divider); color: var(--vp-c-text-3); font-family: inherit; background: none; |
| 168 | +} |
| 169 | +
|
| 170 | +/* Overlay */ |
| 171 | +.ts-overlay { |
| 172 | + position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.55); |
| 173 | + display: flex; align-items: flex-start; justify-content: center; padding-top: 10vh; |
| 174 | +} |
| 175 | +.ts-modal { |
| 176 | + width: 100%; max-width: 680px; max-height: 75vh; margin: 0 16px; |
| 177 | + background: var(--vp-c-bg-soft, #1e293b); border: 1px solid var(--vp-c-divider); |
| 178 | + border-radius: 12px; display: flex; flex-direction: column; |
| 179 | + box-shadow: 0 25px 60px -12px rgba(0,0,0,0.5); |
| 180 | +} |
| 181 | +
|
| 182 | +/* Header */ |
| 183 | +.ts-header { |
| 184 | + display: flex; align-items: center; gap: 10px; padding: 12px 16px; |
| 185 | + border-bottom: 1px solid var(--vp-c-divider); flex-shrink: 0; |
| 186 | +} |
| 187 | +.ts-header__icon { width: 18px; height: 18px; color: var(--vp-c-brand-1); flex-shrink: 0; } |
| 188 | +.ts-header__input { flex: 1; background: none; border: none; outline: none; font-size: 15px; color: var(--vp-c-text-1); font-family: inherit; } |
| 189 | +.ts-header__input::placeholder { color: var(--vp-c-text-3); } |
| 190 | +.ts-header__close { flex-shrink: 0; cursor: pointer; background: none; border: none; } |
| 191 | +.ts-header__close kbd { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--vp-c-bg-alt); border: 1px solid var(--vp-c-divider); color: var(--vp-c-text-3); font-family: inherit; } |
| 192 | +
|
| 193 | +/* Results */ |
| 194 | +.ts-results { flex: 1; overflow-y: auto; padding: 8px; } |
| 195 | +.ts-empty { padding: 32px 16px; text-align: center; color: var(--vp-c-text-3); font-size: 14px; } |
| 196 | +
|
| 197 | +/* Hit */ |
| 198 | +.ts-hit { |
| 199 | + display: block; padding: 12px 14px; border-radius: 8px; cursor: pointer; |
| 200 | + text-decoration: none; color: inherit; border: 1px solid transparent; transition: background 0.1s; |
| 201 | +} |
| 202 | +.ts-hit:hover, .ts-hit--sel { |
| 203 | + background: var(--vp-c-bg-alt); border-color: rgba(99,102,241,0.3); |
| 204 | +} |
| 205 | +.ts-hit__crumbs { |
| 206 | + display: flex; align-items: center; gap: 4px; flex-wrap: wrap; |
| 207 | + font-size: 13px; font-weight: 500; color: var(--vp-c-text-1); margin-bottom: 4px; |
| 208 | +} |
| 209 | +.ts-hit__sep { color: var(--vp-c-text-3); font-size: 12px; } |
| 210 | +.ts-hit__content { |
| 211 | + font-size: 13px; line-height: 1.6; color: var(--vp-c-text-2); |
| 212 | + overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; |
| 213 | +} |
| 214 | +.ts-hit__content mark { |
| 215 | + background: rgba(99,102,241,0.25); color: var(--vp-c-brand-1); padding: 1px 2px; border-radius: 2px; |
| 216 | +} |
| 217 | +
|
| 218 | +/* Footer */ |
| 219 | +.ts-footer { |
| 220 | + display: flex; align-items: center; gap: 16px; padding: 10px 16px; |
| 221 | + border-top: 1px solid var(--vp-c-divider); flex-shrink: 0; |
| 222 | +} |
| 223 | +.ts-footer__hint { font-size: 12px; color: var(--vp-c-text-3); } |
| 224 | +.ts-footer__hint kbd { font-size: 11px; padding: 1px 4px; border-radius: 3px; background: var(--vp-c-bg-alt); border: 1px solid var(--vp-c-divider); color: var(--vp-c-text-3); font-family: inherit; margin-right: 2px; } |
| 225 | +
|
| 226 | +/* Transition */ |
| 227 | +.ts-fade-enter-active { transition: opacity 0.15s ease; } |
| 228 | +.ts-fade-leave-active { transition: opacity 0.1s ease; } |
| 229 | +.ts-fade-enter-from, .ts-fade-leave-to { opacity: 0; } |
| 230 | +</style> |
0 commit comments