Carson 8 місяців тому
батько
коміт
975c964439

+ 2 - 0
.vitepress/config.mts

@@ -105,6 +105,8 @@ export default defineConfig({
   title: "CocoBlockly帮助文档",
   description: "Cococlass help documents",
   srcDir: DOC_BASE_PATH,
+  ignoreDeadLinks: true,
+  lastUpdated: true,
   themeConfig: {
     search: {
       provider: "local",

+ 1 - 1
.vitepress/theme/index.ts

@@ -9,7 +9,7 @@ import "./style.css";
 
 export default {
   extends: DefaultTheme,
-  // Layout: CustomLayout,
+  Layout: CustomLayout,
   enhanceApp({ app, router, siteData }) {
     // NOTE polyfill `process` for library `path-browserify`,@SEE: https://github.com/browserify/path-browserify/issues/34
     if (!import.meta.env.SSR) {

+ 5 - 1
components/Edit/AppendModal.vue

@@ -41,7 +41,11 @@ const onOk = async () => {
   try {
     const result = await form$.value?.validate();
     if (result) {
-      emit("ok", _.cloneDeep(toRaw(formData)));
+      const data = _.cloneDeep(toRaw(formData))
+      if (!formData.isDir) {
+        data.filename = `${data.filename}.md`
+      }
+      emit("ok", data);
     }
   } catch (e) {
     console.warn(e);

+ 10 - 14
components/Edit/index.vue

@@ -352,27 +352,23 @@ const allowDrop = (draggingNode: Node, dropNode: Node, type: AllowDropType) => {
   <div class="md-container">
     <div class="left" v-loading="sideLoading">
       <div class="toolbar">
-        <el-radio-group v-model="langSelect" size="small">
-          <el-radio-button label="简体中文" value="zh-CN" />
-          <el-radio-button label="繁体中文" value="zh-HK" />
-          <el-radio-button label="English" value="en-US" />
-        </el-radio-group>
+        <el-segmented
+          v-model="langSelect"
+          :options="[
+            { label: '简体中文', value: 'zh-CN' },
+            { label: '繁体中文', value: 'zh-HK' },
+            { label: 'English', value: 'en-US' },
+          ]"
+        />
         <el-button-group>
           <el-button
-            @click="
-              onAppend({ key: '', label: '', children: langSelectedDataSource })
-            "
+            @click="onAppend({ key: '', label: '', children: langSelectedDataSource })"
             type="primary"
             text
             :icon="Plus"
           >
           </el-button>
-          <el-button
-            @click="loadSideBar"
-            type="info"
-            text
-            :icon="Refresh"
-          ></el-button>
+          <el-button @click="loadSideBar" type="info" text :icon="Refresh"></el-button>
         </el-button-group>
       </div>
       <el-divider />

+ 204 - 40
components/Search/index.vue

@@ -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>