All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
370 lines
14 KiB
Vue
370 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import type { ArticleMeta } from './ArticleCard.vue'
|
|
import type { ToolPage } from '~/utils/routes'
|
|
|
|
/* Centered search popup: the sidebar and home search bars (or Ctrl/⌘+K,
|
|
or "/" anywhere but a text field) open it, then it live-searches
|
|
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes.
|
|
The wiki's own pages — the link map and the graphing calculator — answer
|
|
the search too, and ↵ on one lays that surface on the book beside the
|
|
open articles rather than turning the page away to it. */
|
|
const api = `${useRuntimeConfig().public.apiBase}/api`
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { t } = useI18n()
|
|
const { th, locale } = useAppLocale()
|
|
const { open, show, hide, toggle } = useSearch()
|
|
const { hide: hideSettings } = useSettings()
|
|
const { hide: hideProfile } = useProfileMenu()
|
|
const { viewed } = useRecentlyViewed()
|
|
const { openTool } = useArticleTabs()
|
|
|
|
const MAX_RESULTS = 8
|
|
|
|
/* the wiki's own pages join the search under their names and a few plain
|
|
words for what they are; each answers at its own address */
|
|
const PAGE_TOOLS: { tool: ToolPage; terms: string[] }[] = [
|
|
{ tool: 'graph', terms: ['graph', 'map', 'link map', 'network', 'constellation'] },
|
|
{
|
|
tool: 'calculator',
|
|
terms: ['calculator', 'calc', 'plot', 'plotter', 'graphing', 'plane'],
|
|
},
|
|
]
|
|
|
|
const q = ref('')
|
|
const results = ref<ArticleMeta[]>([])
|
|
const loading = ref(false)
|
|
const failed = ref(false)
|
|
const activeIndex = ref(0)
|
|
|
|
const trimmed = computed(() => q.value.trim())
|
|
|
|
/* which of the wiki's own pages the term names — matched against the words
|
|
and the page's own name, either reading into the other, so "calc" leads
|
|
to the Calculator as surely as the whole word does */
|
|
const pageRows = computed(() => {
|
|
const term = trimmed.value.toLowerCase()
|
|
if (!term) return []
|
|
return PAGE_TOOLS.filter(({ tool, terms }) =>
|
|
terms.some((w) => w.includes(term) || term.includes(w)) || tool.includes(term)
|
|
)
|
|
})
|
|
|
|
/* --- focus: the field takes focus on open, focus returns on close --- */
|
|
const inputEl = ref<HTMLInputElement | null>(null)
|
|
let previouslyFocused: HTMLElement | null = null
|
|
|
|
watch(open, async (isOpen) => {
|
|
if (isOpen) {
|
|
previouslyFocused =
|
|
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
|
await nextTick()
|
|
inputEl.value?.focus()
|
|
} else {
|
|
previouslyFocused?.focus()
|
|
}
|
|
})
|
|
|
|
/* --- live search: debounce typing, discard stale responses --- */
|
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
let requestSeq = 0
|
|
|
|
watch(trimmed, () => {
|
|
clearTimeout(debounceTimer)
|
|
activeIndex.value = 0
|
|
if (!trimmed.value) {
|
|
results.value = []
|
|
loading.value = false
|
|
failed.value = false
|
|
return
|
|
}
|
|
loading.value = true
|
|
debounceTimer = setTimeout(runSearch, 180)
|
|
})
|
|
|
|
async function runSearch() {
|
|
const seq = ++requestSeq
|
|
try {
|
|
/* the popup never shows more than MAX_RESULTS, so that is what it asks
|
|
the shelf for rather than every match it has; `prefer` asks each answer
|
|
to stand with the reader's own language when the article speaks it, and
|
|
`preferFirst` ranks the articles that truly speak it above the ones
|
|
shown only through the fallback */
|
|
const page = await $fetch<{ articles: ArticleMeta[] }>(`${api}/articles`, {
|
|
query: { q: trimmed.value, limit: MAX_RESULTS, prefer: locale.value, preferFirst: '1' },
|
|
})
|
|
if (seq !== requestSeq) return // a newer search already won
|
|
results.value = page.articles
|
|
failed.value = false
|
|
} catch {
|
|
if (seq !== requestSeq) return
|
|
failed.value = true
|
|
} finally {
|
|
if (seq === requestSeq) loading.value = false
|
|
}
|
|
}
|
|
|
|
/* --- keyboard: global shortcuts, plus arrows + enter inside the field --- */
|
|
function onGlobalKeydown(e: KeyboardEvent) {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
e.preventDefault()
|
|
hideSettings()
|
|
hideProfile()
|
|
toggle()
|
|
return
|
|
}
|
|
if (e.key === 'Escape' && open.value) {
|
|
hide()
|
|
return
|
|
}
|
|
/* "/" also opens the popup anywhere — except while typing in a field */
|
|
if (e.key === '/' && !open.value) {
|
|
const t = e.target
|
|
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return
|
|
e.preventDefault()
|
|
hideSettings()
|
|
hideProfile()
|
|
show()
|
|
}
|
|
}
|
|
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('keydown', onGlobalKeydown)
|
|
clearTimeout(debounceTimer)
|
|
})
|
|
|
|
/* where a result leads: the language address of the version it shows */
|
|
function articleHref(a: ArticleMeta): string {
|
|
return versionHref(identityOf(a), articleLanguage(a))
|
|
}
|
|
|
|
function openArticle(article: ArticleMeta) {
|
|
hide()
|
|
void router.push(articleHref(article))
|
|
}
|
|
|
|
function openAllResults() {
|
|
hide()
|
|
void router.push({ path: '/', query: { q: trimmed.value } })
|
|
}
|
|
|
|
/* open one of the wiki's own pages: lay the page on the book first, so it
|
|
arrives with the surface already holding it, then turn the desk to its
|
|
address — where the page is already open, this simply turns back to it */
|
|
function openPageTool(tool: ToolPage) {
|
|
hide()
|
|
openTool(tool)
|
|
void router.push(toolHref(tool))
|
|
}
|
|
|
|
/* ↑/↓ walk the page rows and the articles as one list — the wiki's own
|
|
pages lead when they match */
|
|
function onFieldKeydown(e: KeyboardEvent) {
|
|
const n = pageRows.value.length + results.value.length
|
|
if (e.key === 'ArrowDown' && n) {
|
|
e.preventDefault()
|
|
activeIndex.value = (activeIndex.value + 1) % n
|
|
} else if (e.key === 'ArrowUp' && n) {
|
|
e.preventDefault()
|
|
activeIndex.value = (activeIndex.value - 1 + n) % n
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
const page = pageRows.value[activeIndex.value]
|
|
if (page) {
|
|
openPageTool(page.tool)
|
|
return
|
|
}
|
|
const chosen = results.value[activeIndex.value - pageRows.value.length] ?? results.value[0]
|
|
if (chosen) openArticle(chosen)
|
|
else if (trimmed.value) openAllResults()
|
|
}
|
|
}
|
|
|
|
/* keep the highlighted row visible while arrowing through the list */
|
|
watch(activeIndex, async () => {
|
|
await nextTick()
|
|
document
|
|
.querySelector('.search-modal .search-result.active')
|
|
?.scrollIntoView({ block: 'nearest' })
|
|
})
|
|
|
|
/* navigating away (clicking any link in the popup) closes it */
|
|
watch(() => route.fullPath, () => {
|
|
if (open.value) hide()
|
|
})
|
|
|
|
/* split text into plain / matched parts so results can highlight the term */
|
|
interface Part {
|
|
text: string
|
|
hit: boolean
|
|
}
|
|
function highlight(text: string): Part[] {
|
|
const term = trimmed.value.toLowerCase()
|
|
if (!term) return [{ text, hit: false }]
|
|
const parts: Part[] = []
|
|
const lower = text.toLowerCase()
|
|
let from = 0
|
|
for (;;) {
|
|
const idx = lower.indexOf(term, from)
|
|
if (idx === -1) break
|
|
if (idx > from) parts.push({ text: text.slice(from, idx), hit: false })
|
|
parts.push({ text: text.slice(idx, idx + term.length), hit: true })
|
|
from = idx + term.length
|
|
}
|
|
if (from < text.length) parts.push({ text: text.slice(from), hit: false })
|
|
return parts
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition name="search-popup">
|
|
<div v-if="open" class="search-overlay" @click.self="hide()">
|
|
<div class="search-modal" role="dialog" aria-modal="true" :aria-label="t('common.searchArticles')">
|
|
<div class="search-modal-field">
|
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m20 20-3.2-3.2" />
|
|
</svg>
|
|
<input
|
|
ref="inputEl"
|
|
v-model="q"
|
|
class="search-modal-input"
|
|
type="text"
|
|
:placeholder="t('search.placeholder')"
|
|
:aria-label="t('common.searchArticles')"
|
|
autocomplete="off"
|
|
@keydown="onFieldKeydown"
|
|
/>
|
|
<span v-if="loading" class="search-spinner" aria-hidden="true" />
|
|
<button
|
|
v-else-if="q"
|
|
type="button"
|
|
class="panel-icon-btn"
|
|
:aria-label="t('search.clear')"
|
|
@click="q = ''"
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
|
<path d="M6 6l12 12M18 6L6 18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="search-modal-body">
|
|
<!-- one list, arrowed as one: the wiki's own pages lead when the
|
|
term names them, the articles answer under them -->
|
|
<ul v-if="pageRows.length || results.length" class="search-results" role="listbox">
|
|
<li v-for="(p, i) in pageRows" :key="p.tool" role="option" :aria-selected="i === activeIndex">
|
|
<NuxtLink
|
|
:to="toolHref(p.tool)"
|
|
class="search-result"
|
|
:class="{ active: i === activeIndex }"
|
|
@mouseenter="activeIndex = i"
|
|
@click.prevent="openPageTool(p.tool)"
|
|
>
|
|
<span class="sr-title">
|
|
<span class="sr-page-glyph" aria-hidden="true">
|
|
<svg v-if="p.tool === 'graph'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="18" cy="5" r="3" />
|
|
<circle cx="6" cy="12" r="3" />
|
|
<circle cx="18" cy="19" r="3" />
|
|
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
|
|
</svg>
|
|
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M4 3v17a1 1 0 0 0 1 1h17" />
|
|
<path d="M4.5 16.5c2.6 0 3.4-10.5 6.8-10.5s3.9 7 8.2 7" />
|
|
</svg>
|
|
</span>
|
|
<template v-for="(p2, pi) in highlight(t(TOOL_LABEL_KEYS[p.tool]))" :key="pi">
|
|
<mark v-if="p2.hit">{{ p2.text }}</mark>
|
|
<template v-else>{{ p2.text }}</template>
|
|
</template>
|
|
</span>
|
|
<span class="sr-meta">
|
|
<span>{{ toolHref(p.tool) }}</span>
|
|
</span>
|
|
</NuxtLink>
|
|
</li>
|
|
|
|
<li
|
|
v-for="(a, i) in results"
|
|
:key="a._id"
|
|
role="option"
|
|
:aria-selected="i + pageRows.length === activeIndex"
|
|
>
|
|
<NuxtLink
|
|
:to="articleHref(a)"
|
|
class="search-result"
|
|
:class="{ active: i + pageRows.length === activeIndex }"
|
|
@mouseenter="activeIndex = i + pageRows.length"
|
|
@click.prevent="openArticle(a)"
|
|
>
|
|
<span class="sr-title" :lang="articleLanguage(a)">
|
|
<template v-for="(p, pi) in highlight(a.title)" :key="pi">
|
|
<mark v-if="p.hit">{{ p.text }}</mark>
|
|
<template v-else>{{ p.text }}</template>
|
|
</template>
|
|
</span>
|
|
<span class="sr-meta">
|
|
<span>{{ articleHref(a) }}</span>
|
|
<span v-if="a.tags?.length" class="tags">
|
|
<span v-for="t in a.tags.slice(0, 3)" :key="t" class="tag">{{ t }}</span>
|
|
</span>
|
|
<span
|
|
v-if="articleLanguage(a) !== 'en'"
|
|
class="lang-badge"
|
|
:title="t('common.writtenIn', { lang: languageName(articleLanguage(a)) })"
|
|
>{{ languageName(articleLanguage(a)) }}</span
|
|
>
|
|
</span>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
|
|
<div v-else-if="failed" class="banner">
|
|
<span class="glyph">!</span>
|
|
<span>{{ t('search.apiDown') }}</span>
|
|
</div>
|
|
|
|
<div v-else-if="loading">
|
|
<div v-for="i in 3" :key="i" class="search-skeleton" />
|
|
</div>
|
|
|
|
<!-- idle state: quick jumps to recently viewed articles -->
|
|
<div v-else-if="!trimmed && viewed.length" class="search-quick">
|
|
<div class="search-quick-title">{{ t('recent.title') }}</div>
|
|
<ul class="search-results">
|
|
<li v-for="v in viewed.slice(0, 6)" :key="v.slug">
|
|
<NuxtLink :to="viewedHref(v)" class="search-result">
|
|
<span class="sr-title">{{ v.title }}</span>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div v-else-if="trimmed" class="search-none" v-html="th('search.noMatch', { q: trimmed })"></div>
|
|
|
|
<div v-else class="search-none">{{ t('search.idle') }}</div>
|
|
</div>
|
|
|
|
<footer class="search-modal-foot">
|
|
<span class="foot-hints">
|
|
<span><kbd class="kbd-hint">↑</kbd><kbd class="kbd-hint">↓</kbd> {{ t('search.navigate') }}</span>
|
|
<span><kbd class="kbd-hint">↵</kbd> {{ t('search.open') }}</span>
|
|
<span><kbd class="kbd-hint">esc</kbd> {{ t('search.close') }}</span>
|
|
</span>
|
|
<a
|
|
v-if="trimmed"
|
|
class="foot-all"
|
|
href="#"
|
|
@click.prevent="openAllResults"
|
|
>
|
|
{{ t('search.allResults', { q: trimmed }) }}
|
|
</a>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|