瀏覽代碼

feat: search components

Carson 10 月之前
父節點
當前提交
12f31ea449
共有 2 個文件被更改,包括 121 次插入103 次删除
  1. 9 2
      components/CustomNavBar.vue
  2. 112 101
      components/Search/index.vue

+ 9 - 2
components/CustomNavBar.vue

@@ -1,6 +1,6 @@
 <script lang="ts" setup>
 import { useWindowScroll } from "@vueuse/core";
-import { ref, watchPostEffect } from "vue";
+import { ref, watchPostEffect, computed } from "vue";
 import { useData } from "vitepress";
 import { useSidebar } from "vitepress/theme";
 import VPNavBarAppearance from "vitepress/dist/client/theme-default/components/VPNavBarAppearance.vue";
@@ -33,6 +33,10 @@ watchPostEffect(() => {
     top: y.value === 0,
   };
 });
+
+const hasSearch = computed(() => {
+  return frontmatter.value.layout !== 'home'
+})
 </script>
 
 <template>
@@ -51,7 +55,7 @@ watchPostEffect(() => {
         <div class="content">
           <div class="content-body">
             <slot name="nav-bar-content-before" />
-            <VPNavBarSearch class="search" />
+            <VPNavBarSearch class="search" :class="{ 'hide-search': !hasSearch }" />
             <VPNavBarMenu class="menu" />
             <VPNavBarTranslations class="translations" />
             <VPNavBarAppearance class="appearance" />
@@ -77,6 +81,9 @@ watchPostEffect(() => {
 <style scoped lang="scss">
 .search {
   justify-content: end;
+  &.hide-search > :deep(div) {
+    display: none;
+  }
 }
 </style>
 

+ 112 - 101
components/Search/index.vue

@@ -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;
+        }
       }
     }
   }