|
@@ -4,13 +4,19 @@ export default {
|
|
|
};
|
|
|
</script>
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, computed, watch, shallowRef } from "vue";
|
|
|
+import { ref, computed, watch, shallowRef, markRaw, nextTick } from "vue";
|
|
|
import localSearchIndex from '@localSearchIndex'
|
|
|
+import MiniSearch, { type SearchResult } from 'minisearch'
|
|
|
import { Search } from "@element-plus/icons-vue";
|
|
|
-import { watchDebounced, useFocus } from "@vueuse/core";
|
|
|
+import { watchDebounced, useFocus, computedAsync } from "@vueuse/core";
|
|
|
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 _ from "lodash";
|
|
|
|
|
|
+const { t } = useI18n();
|
|
|
+
|
|
|
/* Search */
|
|
|
|
|
|
const searchIndexData = shallowRef(localSearchIndex)
|
|
@@ -24,70 +30,181 @@ if (import.meta.hot) {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-const { t } = useI18n();
|
|
|
+interface Result {
|
|
|
+ title: string
|
|
|
+ titles: string[]
|
|
|
+ text?: string
|
|
|
+}
|
|
|
|
|
|
-const input = ref("");
|
|
|
+const vitePressData = useData()
|
|
|
+const { localeIndex, theme } = vitePressData
|
|
|
+const searchIndex = computedAsync(async () =>
|
|
|
+ markRaw(
|
|
|
+ MiniSearch.loadJSON<Result>(
|
|
|
+ (await searchIndexData.value[localeIndex.value]?.())?.default,
|
|
|
+ {
|
|
|
+ fields: ['title', 'titles', 'text'],
|
|
|
+ storeFields: ['title', 'titles'],
|
|
|
+ searchOptions: {
|
|
|
+ fuzzy: 0.2,
|
|
|
+ prefix: true,
|
|
|
+ boost: { title: 4, text: 2, titles: 1 },
|
|
|
+ ...(theme.value.search?.provider === 'local' &&
|
|
|
+ theme.value.search.options?.miniSearch?.searchOptions)
|
|
|
+ },
|
|
|
+ ...(theme.value.search?.provider === 'local' &&
|
|
|
+ theme.value.search.options?.miniSearch?.options)
|
|
|
+ }
|
|
|
+ )
|
|
|
+ )
|
|
|
+)
|
|
|
+
|
|
|
+const filterText = ref("");
|
|
|
const input$ = ref();
|
|
|
const { focused } = useFocus(computed(() => input$.value?.input));
|
|
|
-const suggestions = ref<unknown[]>([]);
|
|
|
+const results = shallowRef<(SearchResult & Result)[]>([])
|
|
|
const loading = ref(false);
|
|
|
|
|
|
-const suggestionVisible = computed(() => {
|
|
|
+const showDetailedList = computed(() => {
|
|
|
// TEST
|
|
|
// return true
|
|
|
- const isValidData = suggestions.value.length > 0;
|
|
|
+ const isValidData = results.value.length > 0;
|
|
|
return focused.value && (isValidData || loading.value);
|
|
|
});
|
|
|
|
|
|
-watch(
|
|
|
- () => input.value,
|
|
|
- (val) => {
|
|
|
- loading.value = !!val;
|
|
|
- if (!val) {
|
|
|
- suggestions.value = [];
|
|
|
- }
|
|
|
- }
|
|
|
-);
|
|
|
|
|
|
-const fetchSuggestions = (mock) => {
|
|
|
- return new Promise((resolve, reject) => {
|
|
|
- setTimeout(() => {
|
|
|
- // MOCK
|
|
|
- // TODO should we use local search and gpt search together?
|
|
|
- resolve(["vue", "react", mock]);
|
|
|
- }, 1000);
|
|
|
- });
|
|
|
-};
|
|
|
+const cache = new LRUCache<string, Map<string, string>>(16) // 16 files
|
|
|
|
|
|
watchDebounced(
|
|
|
- () => input.value,
|
|
|
- async (taggedInput) => {
|
|
|
- if (!taggedInput) {
|
|
|
- return;
|
|
|
- }
|
|
|
- const result = await fetchSuggestions(taggedInput);
|
|
|
- if (taggedInput === input.value) {
|
|
|
- suggestions.value = result as unknown[];
|
|
|
- loading.value = false;
|
|
|
+ () => [searchIndex.value, filterText.value, showDetailedList.value] as const,
|
|
|
+ async ([index, filterTextValue, showDetailedListValue], old, onCleanup) => {
|
|
|
+ if (old?.[0] !== index) {
|
|
|
+ // in case of hmr
|
|
|
+ cache.clear()
|
|
|
}
|
|
|
+
|
|
|
+ let canceled = false
|
|
|
+ onCleanup(() => {
|
|
|
+ canceled = true
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!index) return
|
|
|
+
|
|
|
+ // Search
|
|
|
+ results.value = index
|
|
|
+ .search(filterTextValue)
|
|
|
+ .slice(0, 16) as (SearchResult & Result)[]
|
|
|
+
|
|
|
+ console.log(results.value)
|
|
|
+
|
|
|
+ // 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>()
|
|
|
+
|
|
|
+ // 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 }
|
|
|
+ // })
|
|
|
+
|
|
|
+ // await nextTick()
|
|
|
+ // if (canceled) return
|
|
|
+
|
|
|
+ // 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) {
|
|
|
+ // excerpt
|
|
|
+ // .querySelector('mark[data-markjs="true"]')
|
|
|
+ // ?.scrollIntoView({ block: 'center' })
|
|
|
+ // }
|
|
|
+ // FIXME: without this whole page scrolls to the bottom
|
|
|
+ // resultsEl.value?.firstElementChild?.scrollIntoView({ block: 'start' })
|
|
|
},
|
|
|
- { debounce: 500 }
|
|
|
+ { 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="suggestionVisible"
|
|
|
+ :visible="showDetailedList"
|
|
|
:show-arrow="false"
|
|
|
:offset="0"
|
|
|
:teleported="false"
|
|
|
width="100%"
|
|
|
>
|
|
|
<template #reference>
|
|
|
- <div class="search-trigger" :class="{ 'has-content': suggestionVisible }">
|
|
|
+ <div class="search-trigger" :class="{ 'has-content': showDetailedList }">
|
|
|
<el-input
|
|
|
:ref="(el) => (input$ = el)"
|
|
|
- v-model="input"
|
|
|
+ v-model="filterText"
|
|
|
clearable
|
|
|
:prefix-icon="Search"
|
|
|
:placeholder="t('请输入关键词,如:课程、协同、AI')"
|
|
@@ -99,8 +216,35 @@ watchDebounced(
|
|
|
<span>loading</span>
|
|
|
</template>
|
|
|
<template v-else>
|
|
|
- <ul>
|
|
|
- <li v-for="(suggest, _index) in suggestions" :key="_index">{{ suggest }}</li>
|
|
|
+ <ul class="results">
|
|
|
+ <li v-for="(p, index) in results" :key="index">
|
|
|
+ <a
|
|
|
+ :href="p.id"
|
|
|
+ class="result"
|
|
|
+ :aria-label="[...p.titles, p.title].join(' > ')"
|
|
|
+ >
|
|
|
+ <div>
|
|
|
+ <div class="titles">
|
|
|
+ <span class="title-icon">#</span>
|
|
|
+ <span v-for="(t, index) in p.titles" :key="index" class="title">
|
|
|
+ <span class="text" v-html="t" />
|
|
|
+ <span class="vpi-chevron-right local-search-icon" />
|
|
|
+ </span>
|
|
|
+ <span class="title main">
|
|
|
+ <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>
|
|
|
</div>
|
|
@@ -148,6 +292,26 @@ watchDebounced(
|
|
|
clip-path: inset(0px -10px -10px -10px);
|
|
|
}
|
|
|
.search-content {
|
|
|
+ .results {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+ overflow-x: hidden;
|
|
|
+ overflow-y: auto;
|
|
|
+ overscroll-behavior: contain;
|
|
|
+
|
|
|
+ list-style-type: none;
|
|
|
+ .result {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: none;
|
|
|
+ line-height: 1rem;
|
|
|
+ border: solid 2px var(--vp-local-search-result-border);
|
|
|
+ outline: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
</style>
|