Просмотр исходного кода

[skip ci] web: 重构章节内容及其进度

Xwite 3 лет назад
Родитель
Сommit
cd0f20c4cf

+ 1 - 1
modules/web/src/api/index.js

@@ -4,7 +4,7 @@ import { ElMessage } from "element-plus/es";
 /** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/api */
 /** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/api */
 /** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/web */
 /** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/web */
 
 
-const { hostname, port } = new URL(import.meta.env.VITE_API || location.href);
+const { hostname, port } = new URL(import.meta.env.VITE_API || location.origin);
 
 
 const isSourecEditor = /source/i.test(location.href);
 const isSourecEditor = /source/i.test(location.href);
 const APIExceptionHandler = (error) => {
 const APIExceptionHandler = (error) => {

+ 59 - 4
modules/web/src/components/ChapterContent.vue

@@ -1,9 +1,10 @@
 <template>
 <template>
-  <div class="title" wordCount="0">{{ title }}</div>
+  <div class="title" data-chapterpos="0" ref="titleRef">{{ title }}</div>
   <div
   <div
     v-for="(para, index) in contents"
     v-for="(para, index) in contents"
     :key="index"
     :key="index"
-    :wordCount="wordCounts[index]"
+    ref="paragraphRef"
+    :data-chapterpos="chapterPos[index]"
   >
   >
     <img
     <img
       class="full"
       class="full"
@@ -18,8 +19,10 @@
 
 
 <script setup>
 <script setup>
 import { getImageFromLegado, isLegadoUrl } from "@/plugins/utils";
 import { getImageFromLegado, isLegadoUrl } from "@/plugins/utils";
+import jump from "@/plugins/jump";
 
 
 const props = defineProps({
 const props = defineProps({
+  chapterIndex: { type: Number, required: true },
   contents: { type: Array, required: true },
   contents: { type: Array, required: true },
   title: { type: String, required: true },
   title: { type: String, required: true },
   spacing: { type: Object, required: true },
   spacing: { type: Object, required: true },
@@ -43,8 +46,60 @@ const calculateWordCount = (paragraph) => {
   const imagePlaceHolder = " ";
   const imagePlaceHolder = " ";
   return paragraph.replaceAll(imgPattern, imagePlaceHolder).length;
   return paragraph.replaceAll(imgPattern, imagePlaceHolder).length;
 };
 };
-const wordCounts = computed(() => {
-  return Array.from(props.contents, (content) => calculateWordCount(content));
+const chapterPos = computed(() => {
+  let pos = -1;
+  return Array.from(props.contents, (content) => {
+    pos += calculateWordCount(content) + 1; //计算上一段的换行符
+    return pos;
+  });
+});
+
+const titleRef = ref();
+const paragraphRef = ref();
+const scrollToReadedLength = (length) => {
+  if (length === 0) return;
+  console.log("已读长度", length);
+  let paragraphIndex = chapterPos.value.findIndex(
+    (wordCount) => wordCount >= length
+  );
+  if (paragraphIndex === -1) return;
+  nextTick(() => {
+    jump(paragraphRef.value[paragraphIndex], {
+      duration: 0,
+    });
+  });
+};
+defineExpose({
+  scrollToReadedLength,
+});
+const observer = ref(null);
+const emit = defineEmits(["readedLengthChange"]);
+onMounted(() => {
+  observer.value = new IntersectionObserver(
+    (entries) => {
+      for (let { target, isIntersecting } of entries) {
+        if (isIntersecting) {
+          emit(
+            "readedLengthChange",
+            props.chapterIndex,
+            parseInt(target.dataset.chapterpos)
+          );
+        }
+      }
+    },
+    {
+      rootMargin: `0px 0px -${window.innerHeight - 24}px 0px`,
+    }
+  );
+  observer.value.observe(titleRef.value);
+  paragraphRef.value.forEach((element) => {
+    observer.value.observe(element);
+  });
+});
+
+onUnmounted(() => {
+  observer.value?.disconnect();
+  observer.value = null;
 });
 });
 </script>
 </script>
 
 

+ 20 - 15
modules/web/src/store/bookStore.js

@@ -32,6 +32,23 @@ export const useBookStore = defineStore("book", {
       readSettingsVisible: false,
       readSettingsVisible: false,
     };
     };
   },
   },
+  getters: {
+    bookProgress: (state) => {
+      if (state.catalog.length == 0) return;
+      // @ts-ignore
+      const { index, chapterPos, bookName, bookAuthor } = state.readingBook;
+      let title = state.catalog[index]?.title;
+      if (!title) return;
+      return {
+        name: bookName,
+        author: bookAuthor,
+        durChapterIndex: index,
+        durChapterPos: chapterPos,
+        durChapterTime: new Date().getTime(),
+        durChapterTitle: title,
+      };
+    },
+  },
   actions: {
   actions: {
     setConnectStatus(connectStatus) {
     setConnectStatus(connectStatus) {
       this.connectStatus = connectStatus;
       this.connectStatus = connectStatus;
@@ -81,21 +98,9 @@ export const useBookStore = defineStore("book", {
       this.searchBooks = [];
       this.searchBooks = [];
     },
     },
     //保存进度到app
     //保存进度到app
-    async saveBookProcess() {
-      if (this.catalog.length == 0) return;
-      // @ts-ignore
-      const { index, chapterPos, bookName, bookAuthor } = this.readingBook;
-      let title = this.catalog[index]?.title;
-      if (!title) return;
-
-      API.saveBookProcess({
-        name: bookName,
-        author: bookAuthor,
-        durChapterIndex: index,
-        durChapterPos: chapterPos,
-        durChapterTime: new Date().getTime(),
-        durChapterTitle: title,
-      });
+    async saveBookProgress() {
+      if (!this.bookProgress) return Promise.resolve();
+      return API.saveBookProcess(this.bookProgress);
     },
     },
   },
   },
 });
 });

+ 96 - 142
modules/web/src/views/BookChapter.vue

@@ -88,11 +88,14 @@
           ref="chapter"
           ref="chapter"
         >
         >
           <chapter-content
           <chapter-content
+            ref="chapterRef"
+            :chapterIndex="data.index"
             :contents="data.content"
             :contents="data.content"
             :title="data.title"
             :title="data.title"
             :spacing="store.config.spacing"
             :spacing="store.config.spacing"
             :fontSize="fontSize"
             :fontSize="fontSize"
             :fontFamily="fontFamily"
             :fontFamily="fontFamily"
+            @readedLengthChange="onReadedLengthChange"
             v-if="showContent"
             v-if="showContent"
           />
           />
         </div>
         </div>
@@ -109,10 +112,10 @@ import settings from "@/plugins/config";
 import API from "@api";
 import API from "@api";
 import loadingSvg from "@element-plus/icons-svg/loading.svg?raw";
 import loadingSvg from "@element-plus/icons-svg/loading.svg?raw";
 
 
+// loading spinner
 const showLoading = ref(false);
 const showLoading = ref(false);
 const loadingSerive = ref(null);
 const loadingSerive = ref(null);
 const content = ref();
 const content = ref();
-
 watch(showLoading, (loading) => {
 watch(showLoading, (loading) => {
   if (!loading) return loadingSerive.value?.close();
   if (!loading) return loadingSerive.value?.close();
   loadingSerive.value = ElLoading.service({
   loadingSerive.value = ElLoading.service({
@@ -132,13 +135,6 @@ try {
   localStorage.removeItem("config");
   localStorage.removeItem("config");
 }
 }
 
 
-const loading = ref();
-const noPoint = ref(true);
-const showToolBar = ref(false);
-const chapterData = ref([]);
-const scrollObserve = ref(null);
-const readingObserve = ref(null);
-
 const {
 const {
   catalog,
   catalog,
   popCataVisible,
   popCataVisible,
@@ -147,6 +143,7 @@ const {
   showContent,
   showContent,
   config,
   config,
   readingBook,
   readingBook,
+  bookProgress,
 } = storeToRefs(store);
 } = storeToRefs(store);
 const chapterPos = computed({
 const chapterPos = computed({
   get: () => readingBook.value.chapterPos,
   get: () => readingBook.value.chapterPos,
@@ -201,6 +198,7 @@ const chapterTheme = computed(() => {
     width: readWidth.value,
     width: readWidth.value,
   };
   };
 });
 });
+const showToolBar = ref(false);
 const leftBarTheme = computed(() => {
 const leftBarTheme = computed(() => {
   return {
   return {
     background: popupColor.value,
     background: popupColor.value,
@@ -221,28 +219,25 @@ const rightBarTheme = computed(() => {
 });
 });
 const isNight = computed(() => theme.value == 6);
 const isNight = computed(() => theme.value == 6);
 
 
-watchEffect(() => {
-  if (chapterData.value.length > 0) {
-    store.setContentLoading(false);
-    //添加章节内容到observe
-    addReadingObserve();
-  }
-});
-
-watchEffect(() => {
-  document.title = catalog.value[chapterIndex.value]?.title || document.title;
-  store.saveBookProcess();
-});
+// 顶部底部跳转
+const top = ref();
+const bottom = ref();
+const toTop = () => {
+  jump(top.value);
+};
+const toBottom = () => {
+  jump(bottom.value);
+};
 
 
-watchEffect(() => {
-  if (!infiniteLoading.value) {
-    scrollObserve.value?.disconnect();
-  } else {
-    scrollObserve.value?.observe(loading.value);
-  }
-});
+// 书架路由切换
+const router = useRouter();
+const toShelf = () => {
+  router.push("/");
+};
 
 
-const top = ref();
+// 获取章节内容
+const chapterData = ref([]);
+const noPoint = ref(true);
 const getContent = (index, reloadChapter = true, chapterPos = 0) => {
 const getContent = (index, reloadChapter = true, chapterPos = 0) => {
   if (reloadChapter) {
   if (reloadChapter) {
     //展示进度条
     //展示进度条
@@ -252,6 +247,7 @@ const getContent = (index, reloadChapter = true, chapterPos = 0) => {
     jump(top.value, { duration: 0 });
     jump(top.value, { duration: 0 });
     //从目录,按钮切换章节时保存进度 预加载时不保存
     //从目录,按钮切换章节时保存进度 预加载时不保存
     saveReadingBookProgressToBrowser(index, chapterPos);
     saveReadingBookProgressToBrowser(index, chapterPos);
+    chapterData.value = [];
   }
   }
   let bookUrl = sessionStorage.getItem("bookUrl");
   let bookUrl = sessionStorage.getItem("bookUrl");
   let { title, index: chapterIndex } = catalog.value[index];
   let { title, index: chapterIndex } = catalog.value[index];
@@ -261,12 +257,12 @@ const getContent = (index, reloadChapter = true, chapterPos = 0) => {
       if (res.data.isSuccess) {
       if (res.data.isSuccess) {
         let data = res.data.data;
         let data = res.data.data;
         let content = data.split(/\n+/);
         let content = data.split(/\n+/);
-        updateChapterData({ index, content, title }, reloadChapter);
+        chapterData.value.push({ index, content, title });
         if (reloadChapter) toChapterPos(chapterPos);
         if (reloadChapter) toChapterPos(chapterPos);
       } else {
       } else {
         ElMessage({ message: res.data.errorMsg, type: "error" });
         ElMessage({ message: res.data.errorMsg, type: "error" });
         let content = [res.data.errorMsg];
         let content = [res.data.errorMsg];
-        updateChapterData({ index, content, title }, reloadChapter);
+        chapterData.value.push({ index, content, title });
       }
       }
       store.setContentLoading(true);
       store.setContentLoading(true);
       showLoading.value = false;
       showLoading.value = false;
@@ -279,55 +275,69 @@ const getContent = (index, reloadChapter = true, chapterPos = 0) => {
     (err) => {
     (err) => {
       ElMessage({ message: "获取章节内容失败", type: "error" });
       ElMessage({ message: "获取章节内容失败", type: "error" });
       let content = ["获取章节内容失败!"];
       let content = ["获取章节内容失败!"];
-      updateChapterData({ index, content, title }, reloadChapter);
+      chapterData.value.push({ index, content, title });
       showLoading.value = false;
       showLoading.value = false;
       store.setShowContent(true);
       store.setShowContent(true);
       throw err;
       throw err;
     }
     }
   );
   );
 };
 };
+
+// 章节进度跳转和计算
 const chapter = ref();
 const chapter = ref();
+const chapterRef = ref();
 const toChapterPos = (pos) => {
 const toChapterPos = (pos) => {
   nextTick(() => {
   nextTick(() => {
-    let wordCount = 0;
-    if (chapter.value.length != 1) return;
-    for (let element of chapter.value[0].children) {
-      wordCount += parseInt(element.getAttribute("wordCount")) + 1; //计算换行符
-      if (wordCount - 1 >= pos) {
-        //最后一段没有换行符
-        jump(element, {
-          duration: 0,
-        });
-        break;
-      }
-    }
+    if (chapterRef.value.length === 1)
+      chapterRef.value[0].scrollToReadedLength(pos);
   });
   });
 };
 };
-//计算当前章节阅读的字数
-const computeChapterPos = () => {
-  if (chapter.value.length == 0) return;
-  //计算当前阅读进度对应的element
-  let chapterElement = chapter.value.find(
-    (element) => element.getAttribute("chapterIndex") == chapterIndex.value
-  );
-  if (!chapterElement) return;
-  //计算已读字数
-  let mChapterPos = 0;
-  for (let paragraph of chapterElement.children) {
-    mChapterPos += parseInt(paragraph.getAttribute("wordCount")) + 1; //计算换行符
-    if (paragraph.getBoundingClientRect().top >= 0) {
-      chapterPos.value = mChapterPos - 1; //最后一段没有换行符
-      break;
-    }
-  }
+const onReadedLengthChange = (index, pos) => {
+  saveReadingBookProgressToBrowser(index, pos);
 };
 };
-const bottom = ref();
-const toTop = () => {
-  jump(top.value);
+
+// 文档标题
+watchEffect(() => {
+  document.title = catalog.value[chapterIndex.value]?.title || document.title;
+});
+
+// 阅读记录保存浏览器
+const saveReadingBookProgressToBrowser = (index, pos) => {
+  //保存localStorage
+  let bookUrl = sessionStorage.getItem("bookUrl");
+  var book = JSON.parse(localStorage.getItem(bookUrl));
+  book.index = index;
+  book.chapterPos = pos;
+  localStorage.setItem(bookUrl, JSON.stringify(book));
+  //最近阅读
+  book = JSON.parse(localStorage.getItem("readingRecent"));
+  book.chapterIndex = index;
+  book.chapterPos = pos;
+  localStorage.setItem("readingRecent", JSON.stringify(book));
+  //保存vuex
+  chapterIndex.value = index;
+  chapterPos.value = pos;
+  //保存sessionStorage
+  sessionStorage.setItem("chapterIndex", index);
+  sessionStorage.setItem("chapterPos", String(pos));
 };
 };
-const toBottom = () => {
-  jump(bottom.value);
+
+// 进度同步
+// 返回同步 同步请求会在获取书架前完成
+// 刷新 关闭页面
+const syncBookProgress = () => {
+  console.log("page hide");
+  if (!bookProgress.value) return;
+  // 常规请求可能会被取消 使用Fetch keep-alive 或者 navigator.sendBeacon
+  navigator.sendBeacon(
+    `${import.meta.env.VITE_API || location.origin}/saveBookProgress`,
+    JSON.stringify(bookProgress.value)
+  );
 };
 };
+
+// 定时同步
+
+// 章节切换
 const toNextChapter = () => {
 const toNextChapter = () => {
   store.setContentLoading(true);
   store.setContentLoading(true);
   let index = chapterIndex.value + 1;
   let index = chapterIndex.value + 1;
@@ -360,42 +370,33 @@ const toPreChapter = () => {
     });
     });
   }
   }
 };
 };
-const saveReadingBookProgressToBrowser = (index, pos = chapterPos.value) => {
-  //保存localStorage
-  let bookUrl = sessionStorage.getItem("bookUrl");
-  var book = JSON.parse(localStorage.getItem(bookUrl));
-  book.index = index;
-  book.chapterPos = pos;
-  localStorage.setItem(bookUrl, JSON.stringify(book));
-  //最近阅读
-  book = JSON.parse(localStorage.getItem("readingRecent"));
-  book.chapterIndex = index;
-  book.chapterPos = pos;
-  localStorage.setItem("readingRecent", JSON.stringify(book));
-  //保存vuex
-  chapterIndex.value = index;
-  chapterPos.value = pos;
-  //保存sessionStorage
-  sessionStorage.setItem("chapterIndex", index);
-  sessionStorage.setItem("chapterPos", String(pos));
-};
-const updateChapterData = async (data, reloadChapter) => {
-  if (reloadChapter) {
-    chapterData.value.splice(0);
+
+// 无限滚动
+const scrollObserve = ref(null);
+const loading = ref();
+watchEffect(() => {
+  if (!infiniteLoading.value) {
+    scrollObserve.value?.disconnect();
+  } else {
+    scrollObserve.value?.observe(loading.value);
   }
   }
-  chapterData.value.push(data);
-};
+});
 const loadMore = () => {
 const loadMore = () => {
   let index = chapterData.value.slice(-1)[0].index;
   let index = chapterData.value.slice(-1)[0].index;
   if (catalog.value.length - 1 > index) {
   if (catalog.value.length - 1 > index) {
     getContent(index + 1, false);
     getContent(index + 1, false);
   }
   }
 };
 };
-const router = useRouter();
-const toShelf = () => {
-  router.push("/");
+// IntersectionObserver回调 底部加载
+const handleIScrollObserve = (entries) => {
+  if (showLoading.value) return;
+  for (let { isIntersecting } of entries) {
+    if (!isIntersecting) return;
+    loadMore();
+  }
 };
 };
-//监听方向键
+
+// 监听方向键
 const handleKeyPress = (event) => {
 const handleKeyPress = (event) => {
   switch (event.key) {
   switch (event.key) {
     case "ArrowLeft":
     case "ArrowLeft":
@@ -438,52 +439,6 @@ const handleKeyPress = (event) => {
       break;
       break;
   }
   }
 };
 };
-//IntersectionObserver回调 底部加载
-const handleIScrollObserve = (entries) => {
-  if (showLoading.value) return;
-  for (let { isIntersecting } of entries) {
-    if (!isIntersecting) return;
-    loadMore();
-  }
-};
-//IntersectionObserver回调 当前阅读章节序号
-const handleIReadingObserve = (entries) => {
-  nextTick(() => {
-    for (let { isIntersecting, target, boundingClientRect } of entries) {
-      let chapterTitleIndex = parseInt(target.getAttribute("chapterIndex"));
-      if (isIntersecting) {
-        chapterIndex.value = chapterTitleIndex;
-      } else {
-        if (boundingClientRect.top < 0) {
-          chapterIndex.value = chapterTitleIndex + 1;
-        } else {
-          chapterIndex.value = chapterTitleIndex - 1;
-        }
-      }
-    }
-  });
-};
-//添加所有章节到observe
-const addReadingObserve = () => {
-  nextTick(() => {
-    let chapterElements = chapter.value;
-    if (!chapterElements) return;
-    chapterElements.forEach((el) => readingObserve.value.observe(el));
-  });
-};
-
-onBeforeRouteLeave((to, from, next) => {
-  computeChapterPos();
-  saveReadingBookProgressToBrowser(chapterIndex.value);
-  next();
-});
-/*
-window.addEventListener("beforeunload", (e) => {
-  e.preventDefault();
-  e.returnValue = "";
-  alert(111);
-});
-*/
 
 
 onMounted(() => {
 onMounted(() => {
   showLoading.value = true;
   showLoading.value = true;
@@ -523,13 +478,12 @@ onMounted(() => {
 
 
       getContent(chapterIndex, true, chapterPos);
       getContent(chapterIndex, true, chapterPos);
       window.addEventListener("keyup", handleKeyPress);
       window.addEventListener("keyup", handleKeyPress);
+      window.addEventListener("visibilitychange", syncBookProgress);
       //监听底部加载
       //监听底部加载
       scrollObserve.value = new IntersectionObserver(handleIScrollObserve, {
       scrollObserve.value = new IntersectionObserver(handleIScrollObserve, {
         rootMargin: "-100% 0% 20% 0%",
         rootMargin: "-100% 0% 20% 0%",
       });
       });
       infiniteLoading.value && scrollObserve.value.observe(loading.value);
       infiniteLoading.value && scrollObserve.value.observe(loading.value);
-      //监听当前阅读章节
-      readingObserve.value = new IntersectionObserver(handleIReadingObserve);
       //第二次点击同一本书 页面标题不会变化
       //第二次点击同一本书 页面标题不会变化
       document.title = null;
       document.title = null;
       document.title = bookName + " | " + catalog.value[chapterIndex].title;
       document.title = bookName + " | " + catalog.value[chapterIndex].title;
@@ -544,10 +498,10 @@ onMounted(() => {
 
 
 onUnmounted(() => {
 onUnmounted(() => {
   window.removeEventListener("keyup", handleKeyPress);
   window.removeEventListener("keyup", handleKeyPress);
+  window.removeEventListener("visibilitychange", syncBookProgress);
   readSettingsVisible.value = false;
   readSettingsVisible.value = false;
   popCataVisible.value = false;
   popCataVisible.value = false;
   scrollObserve.value?.disconnect();
   scrollObserve.value?.disconnect();
-  readingObserve.value?.disconnect();
 });
 });
 </script>
 </script>
 
 

+ 5 - 3
modules/web/src/views/BookShelf.vue

@@ -187,7 +187,7 @@ const toDetail = (bookUrl, bookName, bookAuthor, chapterIndex, chapterPos) => {
   });
   });
 };
 };
 
 
-onMounted(async () => {
+onMounted(() => {
   //获取最近阅读书籍
   //获取最近阅读书籍
   let readingRecentStr = localStorage.getItem("readingRecent");
   let readingRecentStr = localStorage.getItem("readingRecent");
   if (readingRecentStr != null) {
   if (readingRecentStr != null) {
@@ -197,8 +197,10 @@ onMounted(async () => {
     }
     }
   }
   }
   showLoading.value = true;
   showLoading.value = true;
-  await store.saveBookProcess();
-  fetchBookShelfData();
+  store
+    .saveBookProgress()
+    //确保各种网络情况下同步请求先完成
+    .finally(fetchBookShelfData);
 });
 });
 const fetchBookShelfData = () => {
 const fetchBookShelfData = () => {
   API.getBookShelf()
   API.getBookShelf()