chenlei 10 ماه پیش
والد
کامیت
a66d4d8f7f

+ 2 - 0
.npmrc

@@ -0,0 +1,2 @@
+registry=https://registry.npmmirror.com/
+@dage:registry=http://192.168.20.245:4873/

+ 1 - 0
README.md

@@ -0,0 +1 @@
+# WHS2211650-1 刘少奇同志纪念馆-文物数字化

+ 90 - 11
packages/base/src/stores/epub.js

@@ -7,6 +7,7 @@ export const EPUB_THEME_KEY = "epub-theme";
 export const EPUB_FONT_KEY = "epub-font";
 export const EPUB_FONTSIZE_KEY = "epub-fontsize";
 export const EPUB_SIMPLIFIED = "epub-simplified";
+export const EPUB_LOCATION = "epub-location";
 
 export const useEpubStore = defineStore("epub", () => {
   let bodyNode = null;
@@ -16,6 +17,10 @@ export const useEpubStore = defineStore("epub", () => {
    */
   const rendition = ref(null);
   /**
+   * 当前主题
+   */
+  const curTheme = ref(localStorage.getItem(EPUB_THEME_KEY) ?? THEMES[0].key);
+  /**
    * 是否简体
    */
   const isSimplified = ref(
@@ -23,14 +28,29 @@ export const useEpubStore = defineStore("epub", () => {
       ? Boolean(Number(localStorage.getItem(EPUB_SIMPLIFIED)))
       : true
   );
+  /**
+   * 目录
+   * @type { id: string;
+   *  href: string;
+   *  label: string;
+   * }[]
+   */
+  const navigation = ref([]);
+  const metadata = ref(null);
 
   const init = (url) => {
+    initEpub(url);
+    initTheme();
+    goToChapter(localStorage.getItem(`${EPUB_LOCATION}-1`));
+  };
+
+  const initEpub = (url) => {
     const _book = ePub(url);
     const _rendition = _book.renderTo("reader", {
       width: "100%",
       height: "100%",
     });
-    console.log(_rendition);
+    console.log(_book, _rendition);
 
     _rendition.hooks.render.register((v) => {
       bodyNode = v.document.body;
@@ -39,14 +59,23 @@ export const useEpubStore = defineStore("epub", () => {
 
     _book.loaded.metadata.then((res) => {
       console.log("metadata: ", res);
+      metadata.value = res;
     });
 
     _book.loaded.navigation.then((res) => {
-      console.log("navigation: ", res);
+      navigation.value = res.toc.map((item) => ({
+        ...item,
+        label: item.label.replace(/\s+/g, ""),
+      }));
     });
 
+    book.value = _book;
+    rendition.value = _rendition;
+  };
+
+  const initTheme = () => {
     THEMES.forEach((theme) => {
-      _rendition.themes.register(theme.key, {
+      rendition.value.themes.register(theme.key, {
         body: {
           color: theme.textColor,
           background: theme.color,
@@ -54,18 +83,16 @@ export const useEpubStore = defineStore("epub", () => {
       });
     });
 
-    _rendition.themes.font(
+    rendition.value.themes.font(
       localStorage.getItem(EPUB_FONT_KEY) ?? FONT_FAMILYS[0].value
     );
 
-    _rendition.themes.fontSize(
+    rendition.value.themes.fontSize(
       (localStorage.getItem(EPUB_FONTSIZE_KEY) ?? EPUB_FONTSIZE_KEY[0].value) +
         "px"
     );
 
-    _rendition.display();
-    book.value = _book;
-    rendition.value = _rendition;
+    toggleTheme(curTheme.value);
   };
 
   const convertText = (node = bodyNode) => {
@@ -78,6 +105,12 @@ export const useEpubStore = defineStore("epub", () => {
     }
   };
 
+  const refreshLocation = async () => {
+    const curLocation = rendition.value.currentLocation();
+    const startCfi = curLocation.start.cfi;
+    localStorage.setItem(`${EPUB_LOCATION}-1`, startCfi);
+  };
+
   /**
    * 简/繁体转换
    */
@@ -89,16 +122,17 @@ export const useEpubStore = defineStore("epub", () => {
 
   /**
    * 修改主题色
-   * @param theme {String}
+   * @param {String} theme
    */
   const toggleTheme = (theme) => {
     rendition.value.themes.select(theme);
+    curTheme.value = theme;
     localStorage.setItem(EPUB_THEME_KEY, theme);
   };
 
   /**
    * 修改字体
-   * @param font {String}
+   * @param {String} font
    */
   const toggleFont = (font) => {
     rendition.value.themes.font(font);
@@ -107,22 +141,67 @@ export const useEpubStore = defineStore("epub", () => {
 
   /**
    * 修改字体大小
-   * @param size {String | Number}
+   * @param {String | Number} size
    */
   const toggleFontSize = (size) => {
     rendition.value.themes.fontSize(size + "px");
     localStorage.setItem(EPUB_FONTSIZE_KEY, size);
   };
 
+  /**
+   * 跳转目录
+   * @param {String} cfi
+   */
+  const goToChapter = (cfi) => {
+    rendition.value.display(cfi).then(() => {
+      refreshLocation();
+    });
+  };
+
+  /**
+   * 搜索关键字
+   * @param {String} keyword
+   * @returns {Array}
+   */
+  const searchKeyword = async (keyword) => {
+    const res = await Promise.all(
+      book.value.spine.spineItems.map((section) =>
+        section
+          .load(book.value.load.bind(book.value))
+          .then(section.find.bind(section, keyword))
+          .finally(section.unload.bind(section))
+      )
+    );
+
+    return res.flat();
+  };
+
+  const prePage = () => {
+    rendition.value.prev().then(() => {
+      refreshLocation();
+    });
+  };
+  const nextPage = () => {
+    rendition.value.next().then(() => {
+      refreshLocation();
+    });
+  };
+
   return {
     book,
+    curTheme,
     rendition,
     isSimplified,
+    navigation,
     init,
     convertText,
     toggleText,
     toggleTheme,
     toggleFont,
     toggleFontSize,
+    goToChapter,
+    searchKeyword,
+    prePage,
+    nextPage,
   };
 });

+ 6 - 1
packages/base/src/theme.scss

@@ -2,7 +2,9 @@ html {
   --topnav-bg-color: #f8f6f2;
   --page-bg-color: #f3f3f3;
   --pane-bg-color: white;
+  --text-color: #464646;
   --text-color-secondary: rgba(70, 70, 70, 0.8);
+  --text-color-placeholder: rgba(70, 70, 70, 0.5);
   --color-primary-opacity-5: rgba(209, 187, 158, 0.5);
   --icon-color: #d1bb9e;
 }
@@ -16,11 +18,14 @@ html.dark {
   --topnav-bg-color: #414141;
   --page-bg-color: #373737;
   --pane-bg-color: #2c2c2c;
+  --text-color: #ececec;
   --text-color-secondary: rgba(236, 236, 236, 0.8);
+  --text-color-placeholder: rgba(236, 236, 236, 0.5);
   --color-primary-opacity-5: rgba(209, 187, 158, 0.5);
   --icon-color: #2c2c2c;
 
-  body {
+  body,
+  input {
     color: white;
   }
 }

+ 0 - 29
packages/pc/README.md

@@ -1,29 +0,0 @@
-# pc
-
-This template should help get you started developing with Vue 3 in Vite.
-
-## Recommended IDE Setup
-
-[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
-
-## Customize configuration
-
-See [Vite Configuration Reference](https://vitejs.dev/config/).
-
-## Project Setup
-
-```sh
-npm install
-```
-
-### Compile and Hot-Reload for Development
-
-```sh
-npm run dev
-```
-
-### Compile and Minify for Production
-
-```sh
-npm run build
-```

+ 5 - 1
packages/pc/package.json

@@ -9,13 +9,17 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@dage/utils": "^1.0.2",
     "@element-plus/icons-vue": "^2.3.1",
     "@lsq/base": "workspace:^",
     "@vueuse/core": "^11.1.0",
     "element-plus": "^2.8.3",
+    "lodash": "^4.17.21",
     "pinia": "^2.1.7",
     "vue": "^3.4.29",
-    "vue-router": "^4.3.3"
+    "vue-clipboard3": "^2.0.0",
+    "vue-router": "^4.3.3",
+    "vue-virtual-scroller": "2.0.0-beta.8"
   },
   "devDependencies": {
     "@vitejs/plugin-vue": "^5.0.5",

+ 4 - 4
packages/pc/src/App.vue

@@ -4,9 +4,11 @@ import TopNav from "@/components/TopNav/index.vue";
 </script>
 
 <template>
-  <TopNav />
+  <el-scrollbar wrap-style="min-width: 1100px;" height="100vh">
+    <TopNav />
 
-  <RouterView />
+    <RouterView />
+  </el-scrollbar>
 </template>
 
 <style lang="scss">
@@ -16,8 +18,6 @@ html.dark {
 
 #app {
   --topnav-height: 80px;
-
-  min-width: 1100px;
 }
 
 .w1100 {

+ 2 - 1
packages/pc/src/assets/main.css

@@ -100,7 +100,8 @@ nav,
 section {
   display: block;
 }
-body {
+body,
+input {
   font-size: 14px;
   color: #464646;
   font-family: "Source Han Sans CN-Regular";

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
packages/pc/src/assets/svgs/icon_copy.svg


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
packages/pc/src/assets/svgs/icon_delete.svg


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
packages/pc/src/assets/svgs/icon_search.svg


+ 13 - 1
packages/pc/src/components/PagePane.vue

@@ -1,11 +1,23 @@
 <template>
   <div class="page-pane">
-    <div class="page-pane-container">
+    <div
+      class="page-pane-container"
+      :style="containerColor ? { background: containerColor } : ''"
+    >
       <slot />
     </div>
   </div>
 </template>
 
+<script setup>
+defineProps({
+  containerColor: {
+    type: String,
+    required: false,
+  },
+});
+</script>
+
 <style lang="scss" scoped>
 .page-pane {
   padding: 27px 0;

+ 1 - 0
packages/pc/src/main.js

@@ -1,6 +1,7 @@
 import "./assets/main.css";
 import "@lsq/base/src/theme.scss";
 import "element-plus/theme-chalk/dark/css-vars.css";
+import "element-plus/theme-chalk/el-notification.css";
 import "virtual:svg-icons-register";
 
 import { createApp } from "vue";

+ 8 - 1
packages/pc/src/stores/detail.js

@@ -3,6 +3,13 @@ import { defineStore } from "pinia";
 
 export const useDetailStore = defineStore("detail", () => {
   const curNav = ref(0);
+  const searchVisible = ref(false);
+  const searchKey = ref("");
 
-  return { curNav };
+  const openSearchDrawer = (key = "") => {
+    searchKey.value = key;
+    searchVisible.value = true;
+  };
+
+  return { curNav, searchVisible, searchKey, openSearchDrawer };
 });

+ 119 - 0
packages/pc/src/views/Detail/components/Bookmark.vue

@@ -0,0 +1,119 @@
+<template>
+  <Drawer v-model:visible="show" title="书签" @close="emits('close')">
+    <div class="bookmark">
+      <el-button class="bookmark__add" :icon="Plus" @click="addBookmark" />
+
+      <p class="bookmark__title">总数:{{ list.length }}</p>
+
+      <div class="bookmark-list">
+        <div v-for="(item, idx) in list" :key="idx" class="bookmark-item">
+          <div class="bookmark-item__inner" @click="goToDetail(item)">
+            <p>书签一</p>
+            <p>{{ item.time }}</p>
+          </div>
+
+          <svg-icon
+            class="bookmark-item__close"
+            name="icon_delete"
+            width="24px"
+            height="24px"
+            color="var(--el-color-primary)"
+          />
+        </div>
+      </div>
+    </div>
+  </Drawer>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import { formatDate } from "@dage/utils";
+import { Plus } from "@element-plus/icons-vue";
+import { useEpubStore } from "@/stores";
+import Drawer from "./Drawer.vue";
+
+const props = defineProps(["visible"]);
+const emits = defineEmits(["update:visible", "close"]);
+const epubStore = useEpubStore();
+const list = ref([
+  {
+    location: {
+      start: {
+        cfi: "epubcfi(/6/8[x02.xhtml]!/4/22/1:15)",
+      },
+    },
+    time: "2022-01-01",
+  },
+]);
+
+const show = computed({
+  get() {
+    return props.visible;
+  },
+  set(v) {
+    emits("update:visible", v);
+  },
+});
+
+const addBookmark = () => {
+  const curLocation = epubStore.book?.rendition.currentLocation();
+
+  list.value.push({
+    location: curLocation,
+    time: formatDate(new Date(), "YYYY-MM-DD HH:mm:ss"),
+  });
+};
+
+const goToDetail = (item) => {
+  epubStore.goToChapter(item.location.start.cfi);
+};
+</script>
+
+<style lang="scss" scoped>
+.bookmark {
+  position: relative;
+  padding: 0 30px;
+
+  &__add {
+    position: absolute;
+    top: -15px;
+    right: 30px;
+  }
+  &__title {
+    padding-bottom: 3px;
+    border-bottom: 1px solid #d9d9d9;
+  }
+  &-list {
+    padding: 7.5px 0;
+  }
+  &-item {
+    position: relative;
+    margin: 7.5px 0;
+    padding-left: 17px;
+    display: flex;
+    align-items: center;
+
+    &__inner {
+      flex: 1;
+      cursor: pointer;
+
+      p:last-child {
+        color: var(--text-color-placeholder);
+      }
+    }
+    &__close {
+      cursor: pointer;
+    }
+    &::before {
+      content: "";
+      position: absolute;
+      top: 50%;
+      left: 0;
+      width: 5px;
+      height: 31px;
+      background: var(--el-color-primary);
+      transform: translateY(-50%);
+    }
+  }
+}
+</style>

+ 19 - 1
packages/pc/src/views/Detail/components/Directory.vue

@@ -12,7 +12,13 @@
       </div>
 
       <ul class="directory-list">
-        <li v-for="item in 20" :key="item">一、农家子弟</li>
+        <li
+          v-for="item in epubStore.navigation"
+          :key="item.id"
+          @click="goToChapter(item.href)"
+        >
+          {{ item.label }}
+        </li>
       </ul>
     </div>
   </Drawer>
@@ -20,11 +26,13 @@
 
 <script setup>
 import { computed } from "vue";
+import { useEpubStore } from "@/stores";
 import Drawer from "./Drawer.vue";
 
 const props = defineProps(["visible"]);
 const emits = defineEmits(["update:visible", "close"]);
 
+const epubStore = useEpubStore();
 const show = computed({
   get() {
     return props.visible;
@@ -33,6 +41,12 @@ const show = computed({
     emits("update:visible", v);
   },
 });
+
+const goToChapter = (href) => {
+  epubStore.goToChapter(href);
+  show.value = false;
+  emits("close");
+};
 </script>
 
 <style lang="scss" scoped>
@@ -68,6 +82,10 @@ const show = computed({
       line-height: 48px;
       cursor: pointer;
       border-bottom: 1px solid #d9d9d9;
+
+      &:hover {
+        color: var(--el-color-primary);
+      }
     }
   }
 }

+ 13 - 2
packages/pc/src/views/Detail/components/Drawer.vue

@@ -1,6 +1,6 @@
 <template>
   <transition name="slide-left">
-    <div v-if="show" class="detail-drawer">
+    <div v-if="show" class="detail-drawer" :class="class">
       <el-icon
         class="detail-drawer__close"
         color="#585757"
@@ -18,7 +18,7 @@
   </transition>
 
   <transition name="fade">
-    <div v-if="show" class="detail-drawer-mask" />
+    <div v-if="show && !hideMask" class="detail-drawer-mask" />
   </transition>
 </template>
 
@@ -27,6 +27,10 @@ import { computed } from "vue";
 import { Close } from "@element-plus/icons-vue";
 
 const props = defineProps({
+  class: {
+    type: String,
+    default: "",
+  },
   visible: {
     type: Boolean,
     required: true,
@@ -36,6 +40,10 @@ const props = defineProps({
     default: "",
     required: false,
   },
+  hideMask: {
+    type: Boolean,
+    default: false,
+  },
 });
 const emits = defineEmits(["update:visible", "close"]);
 
@@ -86,6 +94,9 @@ const handleClose = () => {
     background: rgba(0, 0, 0, 0.5);
     z-index: 998;
   }
+  :deep(.el-scrollbar__view) {
+    height: 100%;
+  }
 }
 
 @media screen and (max-width: 1555px) {

+ 101 - 0
packages/pc/src/views/Detail/components/Note.vue

@@ -0,0 +1,101 @@
+<template>
+  <Drawer v-model:visible="show" title="笔记" @close="close">
+    <div class="note">
+      <p style="margin-bottom: 3px">总数:2</p>
+
+      <div class="note-item" @click="openEdit">
+        <p class="note-item__date">2024-08-18</p>
+        <p class="note-item__inner limit-line line-2">
+          像一条绿色的玉带,从南到北缓缓穿越湖南全省,注入中国第二大淡水湖洞庭湖。
+        </p>
+        <p class="note-item__note">暂无笔记</p>
+      </div>
+    </div>
+  </Drawer>
+
+  <Drawer
+    v-model:visible="editVisible"
+    title="编辑笔记"
+    :hide-mask="true"
+    class="note-edit-drawer"
+  >
+    <div class="note-edit">
+      <p class="note-item__date">2024-08-18</p>
+      <p class="note-item__inner">
+        像一条绿色的玉带,从南到北缓缓穿越湖南全省,注入中国第二大淡水湖洞庭湖。
+      </p>
+
+      <el-input
+        type="textarea"
+        style="margin-top: 10px"
+        :autosize="{ minRows: 8 }"
+      />
+    </div>
+  </Drawer>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import Drawer from "./Drawer.vue";
+
+const props = defineProps(["visible"]);
+const emits = defineEmits(["update:visible", "close"]);
+const editVisible = ref(false);
+
+const show = computed({
+  get() {
+    return props.visible;
+  },
+  set(v) {
+    emits("update:visible", v);
+  },
+});
+
+const openEdit = () => {
+  editVisible.value = true;
+};
+
+const close = () => {
+  editVisible.value = false;
+  emits("close");
+};
+</script>
+
+<style lang="scss" scoped>
+.note {
+  padding: 0 30px;
+
+  &-item {
+    padding: 7px 0 10px;
+    border-top: 1px solid #d9d9d9;
+    cursor: pointer;
+
+    &__date,
+    &__note {
+      color: var(--text-color-placeholder);
+    }
+    &__inner {
+      margin: 10px 0;
+      padding-left: 12px;
+      border-left: 5px solid var(--el-color-primary);
+    }
+  }
+}
+
+.note-edit {
+  padding: 0 30px;
+}
+</style>
+
+<style lang="scss">
+.detail-drawer.note-edit-drawer {
+  right: calc(8vw + 44px + 60px + 355px);
+  box-shadow: 0px 4px 25px 0px rgba(0, 0, 0, 0.25);
+}
+
+@media screen and (max-width: 1555px) {
+  .detail-drawer.note-edit-drawer {
+    right: calc(10px + 44px + 60px + 355px);
+  }
+}
+</style>

+ 8 - 0
packages/pc/src/views/Detail/components/Reader/constants.js

@@ -0,0 +1,8 @@
+export const COLOR_MAP = [
+  "#FFBA84",
+  "#E2943B",
+  "#F7C242",
+  "#86C166",
+  "#3CAABC",
+  "#8A6BBE",
+];

+ 46 - 0
packages/pc/src/views/Detail/components/Reader/index.scss

@@ -8,6 +8,52 @@
     flex: 1;
     width: 100%;
   }
+
+  .select-menu {
+    position: absolute;
+    display: flex;
+    align-items: center;
+    gap: 15px;
+    padding: 15px;
+    background: var(--el-bg-color);
+    border-radius: 5px;
+    visibility: hidden;
+    white-space: nowrap;
+    box-shadow: 0px 0px 11px 0px rgba(0, 0, 0, 0.25);
+
+    &-colors {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+
+      div {
+        width: 24px;
+        height: 24px;
+        border-radius: 50%;
+        cursor: pointer;
+      }
+    }
+    &__driver {
+      width: 1px;
+      height: 28px;
+      background: var(--text-color);
+    }
+    &-toolbar {
+      display: flex;
+      align-items: center;
+      gap: 15px;
+      font-size: 18px;
+      color: var(--el-color-primary);
+
+      div {
+        display: flex;
+        align-items: center;
+        gap: 5px;
+        cursor: pointer;
+      }
+    }
+  }
+
   &-pagination {
     display: flex;
     align-items: center;

+ 144 - 8
packages/pc/src/views/Detail/components/Reader/index.vue

@@ -2,13 +2,53 @@
   <div class="detail-text">
     <div id="reader" />
 
+    <Teleport v-if="selectMenuStyle.visibility === 'visible'" to=".epub-view">
+      <div class="select-menu" :style="selectMenuStyle">
+        <div class="select-menu-colors">
+          <div
+            v-for="color in COLOR_MAP"
+            :key="color"
+            :style="{ background: color }"
+            @click="setHighlight(color)"
+          />
+        </div>
+        <div class="select-menu__driver" />
+        <div class="select-menu-toolbar">
+          <div @click="removeHighlight">
+            <svg-icon
+              name="icon_delete"
+              width="24px"
+              height="24px"
+              color="var(--el-color-primary)"
+            />删除标记
+          </div>
+          <div @click="copyText">
+            <svg-icon
+              name="icon_copy"
+              width="24px"
+              height="24px"
+              color="var(--el-color-primary)"
+            />复制
+          </div>
+          <div @click="handleSearch">
+            <svg-icon
+              name="icon_search"
+              width="24px"
+              height="24px"
+              color="var(--el-color-primary)"
+            />搜索
+          </div>
+        </div>
+      </div>
+    </Teleport>
+
     <div class="detail-text-pagination">
-      <div class="detail-text-pagination__btn" @click="prePage">
+      <div class="detail-text-pagination__btn" @click="epubStore.prePage">
         <el-icon><ArrowLeft /></el-icon>
         上一页
       </div>
 
-      <div class="detail-text-pagination__btn" @click="nextPage">
+      <div class="detail-text-pagination__btn" @click="epubStore.nextPage">
         下一页
         <el-icon><ArrowRight /></el-icon>
       </div>
@@ -17,21 +57,117 @@
 </template>
 
 <script setup>
-import { onMounted } from "vue";
+import { onMounted, ref } from "vue";
+import { ElNotification } from "element-plus";
+import useClipboard from "vue-clipboard3";
 import { ArrowLeft, ArrowRight } from "@element-plus/icons-vue";
-import { useEpubStore } from "@/stores";
+import { useEpubStore, useDetailStore } from "@/stores";
+import { COLOR_MAP } from "./constants";
 
 const epubStore = useEpubStore();
+const detailStore = useDetailStore();
+const { toClipboard } = useClipboard();
+const selectMenuStyle = ref({});
+const selectedCfi = ref("");
+const selectedCfiStack = ref([]);
 
 onMounted(() => {
   epubStore.init("./test2.epub");
+
+  epubStore.rendition.hooks.render.register((v) => {
+    v.document.body.addEventListener("mouseup", (e) => {
+      const selection = v.window.getSelection();
+
+      setTimeout(() => {
+        if (!selection.isCollapsed) selected(e);
+        else hideSelectMenu();
+      }, 50);
+    });
+  });
+
+  epubStore.rendition.on("selected", (v) => {
+    selectedCfi.value = v;
+  });
 });
 
-const prePage = () => {
-  epubStore.rendition?.prev();
+const hideSelectMenu = () => {
+  selectMenuStyle.value = { visibility: "hidden" };
+};
+
+const selected = (e) => {
+  const sty = {
+    visibility: "visible",
+  };
+  const x = e.clientX;
+  const y = e.clientY;
+  const menuWidth = 506;
+  const menuHeight = 58;
+  const screenWidth = 1354;
+  const screenHeight = 665;
+  const offset = Math.floor(x / screenWidth) + 1;
+  const top = y + Math.round(menuHeight / 2) + 25;
+
+  if (x + menuWidth > screenWidth * offset) {
+    sty.left = screenWidth * offset - menuWidth - 11 + "px";
+  } else {
+    sty.left = x + "px";
+  }
+  if (top + menuHeight > screenHeight) {
+    sty.top = screenHeight - menuHeight - 11 + "px";
+  } else {
+    sty.top = top + "px";
+  }
+
+  selectMenuStyle.value = sty;
 };
-const nextPage = () => {
-  epubStore.rendition?.next();
+
+const setHighlight = (color) => {
+  epubStore.rendition.annotations.highlight(
+    selectedCfi.value,
+    {},
+    () => {},
+    "",
+    {
+      fill: color,
+    }
+  );
+
+  hideSelectMenu();
+  selectedCfiStack.value.push(selectedCfi.value);
+};
+
+const removeHighlight = () => {
+  epubStore.rendition.annotations.remove(selectedCfi.value, "highlight");
+  hideSelectMenu();
+};
+
+const copyText = async () => {
+  try {
+    const range = epubStore.rendition.getRange(selectedCfi.value);
+    const selectedText = range.toString();
+    await toClipboard(selectedText);
+    ElNotification({
+      type: "success",
+      message: "复制成功",
+      position: "bottom-right",
+      offset: 20,
+    });
+  } catch (err) {
+    ElNotification({
+      type: "error",
+      message: "复制失败",
+      position: "bottom-right",
+      offset: 20,
+    });
+  }
+  hideSelectMenu();
+};
+
+const handleSearch = () => {
+  const range = epubStore.rendition.getRange(selectedCfi.value);
+  const selectedText = range.toString();
+  detailStore.openSearchDrawer(selectedText);
+  hideSelectMenu();
 };
 </script>
 

+ 135 - 0
packages/pc/src/views/Detail/components/Search.vue

@@ -0,0 +1,135 @@
+<template>
+  <Drawer v-model:visible="show" title="全文搜索" @close="emits('close')">
+    <div class="detail-search">
+      <div style="padding: 0 30px">
+        <search-input v-model="detailStore.searchKey" style="width: 100%" />
+      </div>
+
+      <DynamicScroller
+        v-if="list.length"
+        class="detail-search-inner"
+        :items="list"
+        key-field="cfi"
+        :min-item-size="63"
+      >
+        <template #default="{ item, index, active }">
+          <DynamicScrollerItem
+            :item="item"
+            :active="active"
+            :size-dependencies="[item.status, item.type]"
+            :data-index="index"
+          >
+            <div
+              v-html="item.excerpt"
+              class="detail-search-item"
+              @click="goToDetail(item.cfi)"
+            />
+          </DynamicScrollerItem>
+        </template>
+      </DynamicScroller>
+
+      <el-empty
+        v-if="!list.length && detailStore.searchKey && !loading"
+        :image-size="50"
+        description="搜索不到结果"
+      />
+    </div>
+  </Drawer>
+</template>
+
+<script setup>
+import { computed, ref, watch } from "vue";
+import { debounce } from "lodash";
+import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
+import { useEpubStore, useDetailStore } from "@/stores";
+import SearchInput from "@/views/Stack/components/SearchInput/index.vue";
+import Drawer from "./Drawer.vue";
+import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
+
+const props = defineProps(["visible"]);
+const emits = defineEmits(["update:visible", "close"]);
+
+const epubStore = useEpubStore();
+const detailStore = useDetailStore();
+const list = ref([]);
+const loading = ref(false);
+
+const show = computed({
+  get() {
+    return props.visible;
+  },
+  set(v) {
+    emits("update:visible", v);
+  },
+});
+
+const debounceSearch = debounce(async () => {
+  if (!detailStore.searchKey) {
+    list.value = [];
+    return;
+  }
+
+  try {
+    const res = await epubStore.searchKeyword(detailStore.searchKey);
+    const reg = new RegExp(detailStore.searchKey, "gi");
+    list.value = res.map((item) => {
+      return {
+        ...item,
+        excerpt: item.excerpt.replace(
+          reg,
+          `<span style="color:var(--el-color-primary);padding:4px">${detailStore.searchKey}</span>`
+        ),
+      };
+    });
+  } finally {
+    loading.value = false;
+  }
+}, 500);
+
+const goToDetail = (cfi) => {
+  epubStore.goToChapter(cfi);
+  show.value = false;
+  emits("close");
+};
+
+watch(
+  () => detailStore.searchKey,
+  () => {
+    loading.value = true;
+    debounceSearch();
+  }
+);
+</script>
+
+<style lang="scss" scoped>
+.detail-search {
+  padding-bottom: 30px;
+  height: calc(100% - 97px);
+  box-sizing: border-box;
+
+  &-inner {
+    margin-top: 20px;
+    height: calc(100% - 40px - 20px);
+    padding: 0 30px;
+
+    &::-webkit-scrollbar {
+      width: 6px;
+      height: 6px;
+      background-color: var(--el-bg-color-page);
+    }
+    &::-webkit-scrollbar-track {
+      border-radius: 4px;
+      background-color: var(--el-bg-color-page);
+    }
+    &::-webkit-scrollbar-thumb {
+      border-radius: 4px;
+      background-color: var(--el-color-primary);
+    }
+  }
+  &-item {
+    padding: 10px;
+    border-bottom: 1px solid var(--el-border-color);
+    cursor: pointer;
+  }
+}
+</style>

+ 43 - 6
packages/pc/src/views/Detail/components/Toolbar/index.vue

@@ -3,7 +3,7 @@
     v-for="(item, idx) in list"
     :key="item.key"
     class="detail-toolbar-item"
-    :class="{ active: active === idx }"
+    :class="{ active: active === idx && active !== 5 }"
     :style="{
       top: item.top + 'px',
     }"
@@ -20,16 +20,29 @@
 
   <directory v-model:visible="directoryVisible" @close="handleClose" />
   <setting v-model:visible="settingVisible" @close="handleClose" />
+  <search v-model:visible="detailStore.searchVisible" @close="handleClose" />
+  <note-drawer v-model:visible="noteVisible" @close="handleClose" />
+  <bookmark v-model:visible="bookmarkVisible" @close="handleClose" />
 </template>
 
 <script setup>
 import { ref, computed } from "vue";
+import { useFullscreen } from "@vueuse/core";
+import { useEpubStore, useDetailStore } from "@/stores";
 import Directory from "../Directory.vue";
 import Setting from "../Setting.vue";
+import Search from "../Search.vue";
+import NoteDrawer from "../Note.vue";
+import Bookmark from "../Bookmark.vue";
 
+const { isSupported, toggle } = useFullscreen();
+const epubStore = useEpubStore();
+const detailStore = useDetailStore();
 const active = ref(-1);
 const directoryVisible = ref(false);
 const settingVisible = ref(false);
+const noteVisible = ref(false);
+const bookmarkVisible = ref(false);
 const list = computed(() => {
   const stack = [
     {
@@ -48,13 +61,13 @@ const list = computed(() => {
       top: 0,
       label: "笔记",
       icon: "icon_mark_yellow",
-      key: "mark",
+      key: "note",
     },
     {
       top: 0,
       label: "书签",
       icon: "icon_like_yellow",
-      key: "like",
+      key: "bookmark",
     },
     {
       top: 0,
@@ -62,13 +75,15 @@ const list = computed(() => {
       icon: "icon_setting_yellow",
       key: "setting",
     },
-    {
+  ];
+  if (isSupported.value) {
+    stack.push({
       top: 0,
       label: "全屏",
       icon: "icon_fullscreen_yellow",
       key: "fullscreen",
-    },
-  ];
+    });
+  }
   const itemHeight = 60;
   const listHeight = itemHeight * stack.length;
   let top = (window.innerHeight - listHeight) / 2;
@@ -89,6 +104,28 @@ const handleToolbar = (key, idx) => {
     case "setting":
       settingVisible.value = true;
       break;
+    case "search":
+      detailStore.searchVisible = true;
+      break;
+    case "note":
+      noteVisible.value = true;
+      break;
+    case "bookmark":
+      bookmarkVisible.value = true;
+      break;
+    case "fullscreen":
+      toggle().then(() => {
+        setTimeout(() => {
+          const dom = document.getElementsByClassName("detail-text");
+          if (dom.length) {
+            epubStore.rendition.resize(
+              dom[0].clientWidth,
+              dom[0].clientHeight - 68
+            );
+          }
+        }, 50);
+      });
+      break;
   }
 
   active.value = idx;

+ 8 - 1
packages/pc/src/views/Detail/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <page-pane>
+  <page-pane :container-color="paneBgColor">
     <component :is="comp" />
   </page-pane>
 
@@ -7,9 +7,16 @@
 </template>
 
 <script setup>
+import { computed } from "vue";
+import { THEMES } from "@lsq/base";
+import { useEpubStore } from "@/stores";
 import Toolbar from "./components/Toolbar/index.vue";
 import Reader from "./components/Reader/index.vue";
 
+const epubStore = useEpubStore();
+const paneBgColor = computed(
+  () => THEMES.find((theme) => theme.key === epubStore.curTheme).color
+);
 const comp = Reader;
 </script>
 

+ 1 - 1
packages/pc/src/views/Stack/components/SearchInput/index.vue

@@ -34,8 +34,8 @@ const emit = defineEmits(["update:modelValue"]);
     height: 100%;
     outline: none;
     font-size: 16px;
-    color: #464646;
     font-family: "Source Han Sans CN-Regular";
+    background: transparent;
   }
   img {
     cursor: pointer;

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 2356 - 1795
pnpm-lock.yaml