|
@@ -5,6 +5,7 @@ export default {
|
|
|
</script>
|
|
|
<script setup lang="ts">
|
|
|
import { ref, computed, watch, shallowRef, markRaw, nextTick } from "vue";
|
|
|
+import Mark from 'mark.js/src/vanilla.js'
|
|
|
import localSearchIndex from '@localSearchIndex'
|
|
|
import MiniSearch, { type SearchResult } from 'minisearch'
|
|
|
import { Search } from "@element-plus/icons-vue";
|
|
@@ -13,6 +14,7 @@ import { useI18n } from "vue-i18n";
|
|
|
import { useData } from 'vitepress'
|
|
|
import { LRUCache } from 'vitepress/dist/client/theme-default/support/lru'
|
|
|
import { pathToFile } from 'vitepress/dist/client/app/utils'
|
|
|
+import { escapeRegExp } from 'vitepress/dist/client/shared'
|
|
|
import _ from "lodash";
|
|
|
|
|
|
const { t } = useI18n();
|
|
@@ -65,24 +67,37 @@ const { focused } = useFocus(computed(() => input$.value?.input));
|
|
|
const results = shallowRef<(SearchResult & Result)[]>([])
|
|
|
const loading = ref(false);
|
|
|
|
|
|
-const showDetailedList = computed(() => {
|
|
|
+const suggestionVisible = computed(() => {
|
|
|
// TEST
|
|
|
// return true
|
|
|
const isValidData = results.value.length > 0;
|
|
|
- return focused.value && (isValidData || loading.value);
|
|
|
+ return !!( focused.value && (isValidData || loading.value || filterText.value) );
|
|
|
});
|
|
|
|
|
|
|
|
|
-const cache = new LRUCache<string, Map<string, string>>(16) // 16 files
|
|
|
+const resultsEl = shallowRef<HTMLElement>()
|
|
|
+const mark = computedAsync(async () => {
|
|
|
+ if (!resultsEl.value) return
|
|
|
+ return markRaw(new Mark(resultsEl.value))
|
|
|
+}, null)
|
|
|
|
|
|
+function formMarkRegex(terms: Set<string>) {
|
|
|
+ return new RegExp(
|
|
|
+ [...terms]
|
|
|
+ .sort((a, b) => b.length - a.length)
|
|
|
+ .map((term) => `(${escapeRegExp(term)})`)
|
|
|
+ .join('|'),
|
|
|
+ 'gi'
|
|
|
+ )
|
|
|
+}
|
|
|
+watch(() => filterText.value, () => {
|
|
|
+ if (!results.value.length) {
|
|
|
+ loading.value = true
|
|
|
+ }
|
|
|
+})
|
|
|
watchDebounced(
|
|
|
- () => [searchIndex.value, filterText.value, showDetailedList.value] as const,
|
|
|
- async ([index, filterTextValue, showDetailedListValue], old, onCleanup) => {
|
|
|
- if (old?.[0] !== index) {
|
|
|
- // in case of hmr
|
|
|
- cache.clear()
|
|
|
- }
|
|
|
-
|
|
|
+ () => [searchIndex.value, filterText.value] as const,
|
|
|
+ async ([index, filterTextValue], old, onCleanup) => {
|
|
|
let canceled = false
|
|
|
onCleanup(() => {
|
|
|
canceled = true
|
|
@@ -99,74 +114,26 @@ watchDebounced(
|
|
|
|
|
|
// enableNoResults.value = true
|
|
|
|
|
|
- // Highlighting
|
|
|
- // const mods = showDetailedListValue
|
|
|
- // ? await Promise.all(results.value.map((r) => fetchExcerpt(r.id)))
|
|
|
- // : []
|
|
|
- // if (canceled) return
|
|
|
- // for (const { id, mod } of mods) {
|
|
|
- // const mapId = id.slice(0, id.indexOf('#'))
|
|
|
- // let map = cache.get(mapId)
|
|
|
- // if (map) continue
|
|
|
- // map = new Map()
|
|
|
- // cache.set(mapId, map)
|
|
|
- // const comp = mod.default ?? mod
|
|
|
- // if (comp?.render || comp?.setup) {
|
|
|
- // const app = createApp(comp)
|
|
|
- // // Silence warnings about missing components
|
|
|
- // app.config.warnHandler = () => {}
|
|
|
- // app.provide(dataSymbol, vitePressData)
|
|
|
- // Object.defineProperties(app.config.globalProperties, {
|
|
|
- // $frontmatter: {
|
|
|
- // get() {
|
|
|
- // return vitePressData.frontmatter.value
|
|
|
- // }
|
|
|
- // },
|
|
|
- // $params: {
|
|
|
- // get() {
|
|
|
- // return vitePressData.page.value.params
|
|
|
- // }
|
|
|
- // }
|
|
|
- // })
|
|
|
- // const div = document.createElement('div')
|
|
|
- // app.mount(div)
|
|
|
- // const headings = div.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
|
|
- // headings.forEach((el) => {
|
|
|
- // const href = el.querySelector('a')?.getAttribute('href')
|
|
|
- // const anchor = href?.startsWith('#') && href.slice(1)
|
|
|
- // if (!anchor) return
|
|
|
- // let html = ''
|
|
|
- // while ((el = el.nextElementSibling!) && !/^h[1-6]$/i.test(el.tagName))
|
|
|
- // html += el.outerHTML
|
|
|
- // map!.set(anchor, html)
|
|
|
- // })
|
|
|
- // app.unmount()
|
|
|
- // }
|
|
|
- // if (canceled) return
|
|
|
- // }
|
|
|
-
|
|
|
- // const terms = new Set<string>()
|
|
|
+ const terms = new Set<string>()
|
|
|
|
|
|
- // results.value = results.value.map((r) => {
|
|
|
- // const [id, anchor] = r.id.split('#')
|
|
|
- // const map = cache.get(id)
|
|
|
- // const text = map?.get(anchor) ?? ''
|
|
|
- // for (const term in r.match) {
|
|
|
- // terms.add(term)
|
|
|
- // }
|
|
|
- // return { ...r, text }
|
|
|
- // })
|
|
|
+ results.value = results.value.map((r) => {
|
|
|
+ for (const term in r.match) {
|
|
|
+ terms.add(term)
|
|
|
+ }
|
|
|
+ return r
|
|
|
+ })
|
|
|
+ loading.value = false
|
|
|
|
|
|
- // await nextTick()
|
|
|
- // if (canceled) return
|
|
|
+ await nextTick()
|
|
|
+ if (canceled) return
|
|
|
|
|
|
- // await new Promise((r) => {
|
|
|
- // mark.value?.unmark({
|
|
|
- // done: () => {
|
|
|
- // mark.value?.markRegExp(formMarkRegex(terms), { done: r })
|
|
|
- // }
|
|
|
- // })
|
|
|
- // })
|
|
|
+ await new Promise((r) => {
|
|
|
+ mark.value?.unmark({
|
|
|
+ done: () => {
|
|
|
+ mark.value?.markRegExp(formMarkRegex(terms), { done: r })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
// const excerpts = el.value?.querySelectorAll('.result .excerpt') ?? []
|
|
|
// for (const excerpt of excerpts) {
|
|
@@ -179,29 +146,18 @@ watchDebounced(
|
|
|
},
|
|
|
{ debounce: 200, immediate: true }
|
|
|
);
|
|
|
-
|
|
|
-async function fetchExcerpt(id: string) {
|
|
|
- const file = pathToFile(id.slice(0, id.indexOf('#')))
|
|
|
- try {
|
|
|
- if (!file) throw new Error(`Cannot find file for id: ${id}`)
|
|
|
- return { id, mod: await import(/*@vite-ignore*/ file) }
|
|
|
- } catch (e) {
|
|
|
- console.error(e)
|
|
|
- return { id, mod: {} }
|
|
|
- }
|
|
|
-}
|
|
|
</script>
|
|
|
<template>
|
|
|
<div class="search-container">
|
|
|
<el-popover
|
|
|
- :visible="showDetailedList"
|
|
|
+ :visible="suggestionVisible"
|
|
|
:show-arrow="false"
|
|
|
:offset="0"
|
|
|
:teleported="false"
|
|
|
width="100%"
|
|
|
>
|
|
|
<template #reference>
|
|
|
- <div class="search-trigger" :class="{ 'has-content': showDetailedList }">
|
|
|
+ <div class="search-trigger" :class="{ 'has-content': suggestionVisible }">
|
|
|
<el-input
|
|
|
:ref="(el) => (input$ = el)"
|
|
|
v-model="filterText"
|
|
@@ -213,10 +169,10 @@ async function fetchExcerpt(id: string) {
|
|
|
</template>
|
|
|
<div class="search-content">
|
|
|
<template v-if="loading">
|
|
|
- <span>loading</span>
|
|
|
+ <el-skeleton animated />
|
|
|
</template>
|
|
|
- <template v-else>
|
|
|
- <ul class="results">
|
|
|
+ <template v-else-if="results.length">
|
|
|
+ <ul ref="resultsEl" class="results">
|
|
|
<li v-for="(p, index) in results" :key="index">
|
|
|
<a
|
|
|
:href="p.id"
|
|
@@ -234,19 +190,20 @@ async function fetchExcerpt(id: string) {
|
|
|
<span class="text" v-html="p.title" />
|
|
|
</span>
|
|
|
</div>
|
|
|
-
|
|
|
- <div v-if="showDetailedList" class="excerpt-wrapper">
|
|
|
- <div v-if="p.text" class="excerpt" inert>
|
|
|
- <div class="vp-doc" v-html="p.text" />
|
|
|
- </div>
|
|
|
- <div class="excerpt-gradient-bottom" />
|
|
|
- <div class="excerpt-gradient-top" />
|
|
|
- </div>
|
|
|
</div>
|
|
|
</a>
|
|
|
</li>
|
|
|
</ul>
|
|
|
</template>
|
|
|
+ <template v-else-if="filterText">
|
|
|
+ <el-empty :image-size="80">
|
|
|
+ <template #description>
|
|
|
+ <span
|
|
|
+ >无法找到相关结果 <strong>"{{ filterText }}"</strong>
|
|
|
+ </span>
|
|
|
+ </template>
|
|
|
+ </el-empty>
|
|
|
+ </template>
|
|
|
</div>
|
|
|
</el-popover>
|
|
|
</div>
|
|
@@ -290,17 +247,30 @@ async function fetchExcerpt(id: string) {
|
|
|
border-radius: 0 0 26px 26px;
|
|
|
border: none;
|
|
|
clip-path: inset(0px -10px -10px -10px);
|
|
|
+ padding: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ max-height: 300px;
|
|
|
+ overflow-y: scroll;
|
|
|
}
|
|
|
.search-content {
|
|
|
+ .el-skeleton {
|
|
|
+ padding: 10px 20px;
|
|
|
+ }
|
|
|
.results {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- gap: 6px;
|
|
|
overflow-x: hidden;
|
|
|
overflow-y: auto;
|
|
|
overscroll-behavior: contain;
|
|
|
|
|
|
list-style-type: none;
|
|
|
+ padding: 0 0 5px;
|
|
|
+ margin: 0;
|
|
|
+
|
|
|
+ li {
|
|
|
+ margin: 0;
|
|
|
+ }
|
|
|
+
|
|
|
.result {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
@@ -308,8 +278,49 @@ async function fetchExcerpt(id: string) {
|
|
|
border-radius: 4px;
|
|
|
transition: none;
|
|
|
line-height: 1rem;
|
|
|
- border: solid 2px var(--vp-local-search-result-border);
|
|
|
outline: none;
|
|
|
+
|
|
|
+ min-height: 40px;
|
|
|
+ padding: 5px 20px;
|
|
|
+ color: #333;
|
|
|
+
|
|
|
+ :deep(mark) {
|
|
|
+ color: #3681fc;
|
|
|
+ background: transparent;
|
|
|
+ }
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background-color: #ecf5ff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .titles {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 4px;
|
|
|
+ position: relative;
|
|
|
+ z-index: 1001;
|
|
|
+ padding: 2px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .title.main {
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+
|
|
|
+ .title-icon {
|
|
|
+ opacity: 0.5;
|
|
|
+ font-weight: 500;
|
|
|
+ color: var(--vp-c-brand-1);
|
|
|
+ }
|
|
|
+
|
|
|
+ .title svg {
|
|
|
+ opacity: 0.5;
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|