Files
mathew/frontend/app/components/SearchModal.vue
Aran Roig 68b74eb60b
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
Graph calculator improvmentes
2026-10-02 14:53:00 +02:00

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>