BookChapter.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767
  1. <template>
  2. <div
  3. class="chapter-wrapper"
  4. :style="bodyTheme"
  5. :class="{ night: isNight, day: !isNight }"
  6. @click="showToolBar = !showToolBar"
  7. >
  8. <div class="tool-bar" :style="leftBarTheme">
  9. <div class="tools">
  10. <el-popover
  11. placement="right"
  12. :width="popupWidth"
  13. trigger="click"
  14. :show-arrow="false"
  15. v-model:visible="popCataVisible"
  16. popper-class="pop-cata"
  17. >
  18. <PopCatalog @getContent="getContent" class="popup" />
  19. <template #reference>
  20. <div class="tool-icon" :class="{ 'no-point': noPoint }">
  21. <div class="iconfont">&#58905;</div>
  22. <div class="icon-text">目录</div>
  23. </div>
  24. </template>
  25. </el-popover>
  26. <el-popover
  27. placement="right"
  28. :width="popupWidth"
  29. trigger="click"
  30. :show-arrow="false"
  31. v-model:visible="readSettingsVisible"
  32. popper-class="pop-setting"
  33. >
  34. <read-settings class="popup" />
  35. <template #reference>
  36. <div class="tool-icon" :class="{ 'no-point': noPoint }">
  37. <div class="iconfont">&#58971;</div>
  38. <div class="icon-text">设置</div>
  39. </div>
  40. </template>
  41. </el-popover>
  42. <div class="tool-icon" @click="toShelf">
  43. <div class="iconfont">&#58892;</div>
  44. <div class="icon-text">书架</div>
  45. </div>
  46. <div class="tool-icon" :class="{ 'no-point': noPoint }" @click="toTop">
  47. <div class="iconfont">&#58914;</div>
  48. <div class="icon-text">顶部</div>
  49. </div>
  50. <div
  51. class="tool-icon"
  52. :class="{ 'no-point': noPoint }"
  53. @click="toBottom"
  54. >
  55. <div class="iconfont">&#58915;</div>
  56. <div class="icon-text">底部</div>
  57. </div>
  58. </div>
  59. </div>
  60. <div class="read-bar" :style="rightBarTheme">
  61. <div class="tools">
  62. <div
  63. class="tool-icon"
  64. :class="{ 'no-point': noPoint }"
  65. @click="toPreChapter"
  66. >
  67. <div class="iconfont">&#58920;</div>
  68. <span v-if="miniInterface">上一章</span>
  69. </div>
  70. <div
  71. class="tool-icon"
  72. :class="{ 'no-point': noPoint }"
  73. @click="toNextChapter"
  74. >
  75. <span v-if="miniInterface">下一章</span>
  76. <div class="iconfont">&#58913;</div>
  77. </div>
  78. </div>
  79. </div>
  80. <div class="chapter-bar"></div>
  81. <div class="chapter" ref="content" :style="chapterTheme">
  82. <div class="content">
  83. <div class="top-bar" ref="top"></div>
  84. <div
  85. v-for="data in chapterData"
  86. :key="data.index"
  87. :chapterIndex="data.index"
  88. ref="chapter"
  89. >
  90. <chapter-content
  91. :contents="data.content"
  92. :title="data.title"
  93. :spacing="store.config.spacing"
  94. :fontSize="fontSize"
  95. :fontFamily="fontFamily"
  96. v-if="showContent"
  97. />
  98. </div>
  99. <div class="loading" ref="loading"></div>
  100. <div class="bottom-bar" ref="bottom"></div>
  101. </div>
  102. </div>
  103. </div>
  104. </template>
  105. <script setup>
  106. import jump from "@/plugins/jump";
  107. import settings from "@/plugins/config";
  108. import API from "@api";
  109. import loadingSvg from "@element-plus/icons-svg/loading.svg?raw";
  110. const showLoading = ref(false);
  111. const loadingSerive = ref(null);
  112. const content = ref();
  113. watch(showLoading, (loading) => {
  114. if (!loading) return loadingSerive.value?.close();
  115. loadingSerive.value = ElLoading.service({
  116. target: content.value,
  117. spinner: loadingSvg,
  118. text: "正在获取信息",
  119. lock: true,
  120. });
  121. });
  122. const store = useBookStore();
  123. // 读取阅读配置
  124. try {
  125. const browerConfig = JSON.parse(localStorage.getItem("config"));
  126. if (browerConfig != null) store.setConfig(browerConfig);
  127. } catch {
  128. localStorage.removeItem("config");
  129. }
  130. const loading = ref();
  131. const noPoint = ref(true);
  132. const showToolBar = ref(false);
  133. const chapterData = ref([]);
  134. const scrollObserve = ref(null);
  135. const readingObserve = ref(null);
  136. const {
  137. catalog,
  138. popCataVisible,
  139. readSettingsVisible,
  140. miniInterface,
  141. showContent,
  142. config,
  143. readingBook,
  144. } = storeToRefs(store);
  145. const chapterPos = computed({
  146. get: () => readingBook.value.chapterPos,
  147. set: (value) => (readingBook.value.chapterPos = value),
  148. });
  149. const chapterIndex = computed({
  150. get: () => readingBook.value.index,
  151. set: (value) => (readingBook.value.index = value),
  152. });
  153. const theme = computed(() => config.value.theme);
  154. const infiniteLoading = computed(() => config.value.infiniteLoading);
  155. // 字体
  156. const fontFamily = computed(() => {
  157. if (store.config.font >= 0) {
  158. return settings.fonts[store.config.font];
  159. }
  160. return store.config.customFontName;
  161. });
  162. const fontSize = computed(() => {
  163. return store.config.fontSize + "px";
  164. });
  165. // 主题部分
  166. const bodyColor = computed(() => settings.themes[theme.value].body);
  167. const chapterColor = computed(() => settings.themes[theme.value].content);
  168. const popupColor = computed(() => settings.themes[theme.value].popup);
  169. const readWidth = computed(() => {
  170. if (!miniInterface.value) {
  171. return store.config.readWidth - 130 + "px";
  172. } else {
  173. return window.innerWidth + "px";
  174. }
  175. });
  176. const popupWidth = computed(() => {
  177. if (!miniInterface.value) {
  178. return store.config.readWidth - 33;
  179. } else {
  180. return window.innerWidth - 33;
  181. }
  182. });
  183. const bodyTheme = computed(() => {
  184. return {
  185. background: bodyColor.value,
  186. };
  187. });
  188. const chapterTheme = computed(() => {
  189. return {
  190. background: chapterColor.value,
  191. width: readWidth.value,
  192. };
  193. });
  194. const leftBarTheme = computed(() => {
  195. return {
  196. background: popupColor.value,
  197. marginLeft: miniInterface.value
  198. ? 0
  199. : -(store.config.readWidth / 2 + 68) + "px",
  200. display: miniInterface.value && !showToolBar.value ? "none" : "block",
  201. };
  202. });
  203. const rightBarTheme = computed(() => {
  204. return {
  205. background: popupColor.value,
  206. marginRight: miniInterface.value
  207. ? 0
  208. : -(store.config.readWidth / 2 + 52) + "px",
  209. display: miniInterface.value && !showToolBar.value ? "none" : "block",
  210. };
  211. });
  212. const isNight = computed(() => theme.value == 6);
  213. watchEffect(() => {
  214. if (chapterData.value.length > 0) {
  215. store.setContentLoading(false);
  216. //添加章节内容到observe
  217. addReadingObserve();
  218. }
  219. });
  220. watchEffect(() => {
  221. document.title = catalog.value[chapterIndex.value]?.title || document.title;
  222. store.saveBookProcess();
  223. });
  224. watchEffect(() => {
  225. if (!infiniteLoading.value) {
  226. scrollObserve.value?.disconnect();
  227. } else {
  228. scrollObserve.value?.observe(loading.value);
  229. }
  230. });
  231. const top = ref();
  232. const getContent = (index, reloadChapter = true, chapterPos = 0) => {
  233. if (reloadChapter) {
  234. //展示进度条
  235. store.setShowContent(false);
  236. showLoading.value = true;
  237. //强制滚回顶层
  238. jump(top.value, { duration: 0 });
  239. //从目录,按钮切换章节时保存进度 预加载时不保存
  240. saveReadingBookProgressToBrowser(index, chapterPos);
  241. }
  242. let bookUrl = sessionStorage.getItem("bookUrl");
  243. let { title, index: chapterIndex } = catalog.value[index];
  244. API.getBookContent(bookUrl, chapterIndex).then(
  245. (res) => {
  246. if (res.data.isSuccess) {
  247. let data = res.data.data;
  248. let content = data.split(/\n+/);
  249. updateChapterData({ index, content, title }, reloadChapter);
  250. toChapterPos(chapterPos);
  251. } else {
  252. ElMessage({ message: res.data.errorMsg, type: "error" });
  253. let content = [res.data.errorMsg];
  254. updateChapterData({ index, content, title }, reloadChapter);
  255. }
  256. store.setContentLoading(true);
  257. showLoading.value = false;
  258. noPoint.value = false;
  259. store.setShowContent(true);
  260. if (!res.data.isSuccess) {
  261. throw res.data;
  262. }
  263. },
  264. (err) => {
  265. ElMessage({ message: "获取章节内容失败", type: "error" });
  266. let content = ["获取章节内容失败!"];
  267. updateChapterData({ index, content, title }, reloadChapter);
  268. showLoading.value = false;
  269. store.setShowContent(true);
  270. throw err;
  271. }
  272. );
  273. };
  274. const chapter = ref();
  275. const toChapterPos = (pos) => {
  276. nextTick(() => {
  277. let wordCount = 0;
  278. for (let element of chapter.value[0].children) {
  279. wordCount += parseInt(element.getAttribute("wordCount"));
  280. if (wordCount >= pos) {
  281. jump(element, {
  282. duration: 0,
  283. });
  284. break;
  285. }
  286. }
  287. });
  288. };
  289. //计算当前章节阅读的字数
  290. const computeChapterPos = () => {
  291. if (chapter.value.length == 0) return;
  292. //计算当前阅读进度对应的element
  293. let chapterElement = chapter.value.find(
  294. (element) => element.getAttribute("chapterIndex") == chapterIndex.value
  295. );
  296. if (!chapterElement) return;
  297. //计算已读字数
  298. let mChapterPos = 0;
  299. for (let paragraph of chapterElement.children) {
  300. mChapterPos += parseInt(paragraph.getAttribute("wordCount"));
  301. if (paragraph.getBoundingClientRect().top >= 0) {
  302. chapterPos.value = mChapterPos;
  303. break;
  304. }
  305. }
  306. };
  307. const bottom = ref();
  308. const toTop = () => {
  309. jump(top.value);
  310. };
  311. const toBottom = () => {
  312. jump(bottom.value);
  313. };
  314. const toNextChapter = () => {
  315. store.setContentLoading(true);
  316. let index = chapterIndex.value + 1;
  317. if (typeof catalog.value[index] !== "undefined") {
  318. ElMessage({
  319. message: "下一章",
  320. type: "info",
  321. });
  322. getContent(index);
  323. } else {
  324. ElMessage({
  325. message: "本章是最后一章",
  326. type: "error",
  327. });
  328. }
  329. };
  330. const toPreChapter = () => {
  331. store.setContentLoading(true);
  332. let index = chapterIndex.value - 1;
  333. if (typeof catalog.value[index] !== "undefined") {
  334. ElMessage({
  335. message: "上一章",
  336. type: "info",
  337. });
  338. getContent(index);
  339. } else {
  340. ElMessage({
  341. message: "本章是第一章",
  342. type: "error",
  343. });
  344. }
  345. };
  346. const saveReadingBookProgressToBrowser = (index, pos = chapterPos.value) => {
  347. //保存localStorage
  348. let bookUrl = sessionStorage.getItem("bookUrl");
  349. var book = JSON.parse(localStorage.getItem(bookUrl));
  350. book.index = index;
  351. book.chapterPos = pos;
  352. localStorage.setItem(bookUrl, JSON.stringify(book));
  353. //最近阅读
  354. book = JSON.parse(localStorage.getItem("readingRecent"));
  355. book.chapterIndex = index;
  356. book.chapterPos = pos;
  357. localStorage.setItem("readingRecent", JSON.stringify(book));
  358. //保存vuex
  359. chapterIndex.value = index;
  360. chapterPos.value = pos;
  361. //保存sessionStorage
  362. sessionStorage.setItem("chapterIndex", index);
  363. sessionStorage.setItem("chapterPos", String(pos));
  364. };
  365. const updateChapterData = async (data, reloadChapter) => {
  366. if (reloadChapter) {
  367. chapterData.value.splice(0);
  368. }
  369. chapterData.value.push(data);
  370. };
  371. const loadMore = () => {
  372. let index = chapterData.value.slice(-1)[0].index;
  373. if (catalog.value.length - 1 > index) {
  374. getContent(index + 1, false);
  375. }
  376. };
  377. const router = useRouter();
  378. const toShelf = () => {
  379. router.push("/");
  380. };
  381. //监听方向键
  382. const handleKeyPress = (event) => {
  383. switch (event.key) {
  384. case "ArrowLeft":
  385. event.stopPropagation();
  386. event.preventDefault();
  387. toPreChapter();
  388. break;
  389. case "ArrowRight":
  390. event.stopPropagation();
  391. event.preventDefault();
  392. toNextChapter();
  393. break;
  394. case "ArrowUp":
  395. event.stopPropagation();
  396. event.preventDefault();
  397. if (document.documentElement.scrollTop === 0) {
  398. ElMessage({
  399. message: "已到达页面顶部",
  400. type: "warn",
  401. });
  402. } else {
  403. jump(0 - document.documentElement.clientHeight + 100);
  404. }
  405. break;
  406. case "ArrowDown":
  407. event.stopPropagation();
  408. event.preventDefault();
  409. if (
  410. document.documentElement.clientHeight +
  411. document.documentElement.scrollTop ===
  412. document.documentElement.scrollHeight
  413. ) {
  414. ElMessage({
  415. message: "已到达页面底部",
  416. type: "warn",
  417. });
  418. } else {
  419. jump(document.documentElement.clientHeight - 100);
  420. }
  421. break;
  422. }
  423. };
  424. //IntersectionObserver回调 底部加载
  425. const handleIScrollObserve = (entries) => {
  426. if (showLoading.value) return;
  427. for (let { isIntersecting } of entries) {
  428. if (!isIntersecting) return;
  429. loadMore();
  430. }
  431. };
  432. //IntersectionObserver回调 当前阅读章节序号
  433. const handleIReadingObserve = (entries) => {
  434. nextTick(() => {
  435. for (let { isIntersecting, target, boundingClientRect } of entries) {
  436. let chapterTitleIndex = parseInt(target.getAttribute("chapterIndex"));
  437. if (isIntersecting) {
  438. chapterIndex.value = chapterTitleIndex;
  439. } else {
  440. if (boundingClientRect.top < 0) {
  441. chapterIndex.value = chapterTitleIndex + 1;
  442. } else {
  443. chapterIndex.value = chapterTitleIndex - 1;
  444. }
  445. }
  446. }
  447. });
  448. };
  449. //添加所有章节到observe
  450. const addReadingObserve = () => {
  451. nextTick(() => {
  452. let chapterElements = chapter.value;
  453. if (!chapterElements) return;
  454. chapterElements.forEach((el) => readingObserve.value.observe(el));
  455. });
  456. };
  457. onBeforeRouteLeave((to, from, next) => {
  458. computeChapterPos();
  459. saveReadingBookProgressToBrowser(chapterIndex.value);
  460. next();
  461. });
  462. /*
  463. window.addEventListener("beforeunload", (e) => {
  464. e.preventDefault();
  465. e.returnValue = "";
  466. alert(111);
  467. });
  468. */
  469. onMounted(() => {
  470. showLoading.value = true;
  471. //获取书籍数据
  472. let bookUrl = sessionStorage.getItem("bookUrl");
  473. let bookName = sessionStorage.getItem("bookName");
  474. let bookAuthor = sessionStorage.getItem("bookAuthor");
  475. let chapterIndex = Number(sessionStorage.getItem("chapterIndex") || 0);
  476. let chapterPos = Number(sessionStorage.getItem("chapterPos") || 0);
  477. var book = JSON.parse(localStorage.getItem(bookUrl));
  478. if (
  479. book == null ||
  480. chapterIndex != book.index ||
  481. chapterPos != book.chapterPos
  482. ) {
  483. book = {
  484. bookName: bookName,
  485. bookAuthor: bookAuthor,
  486. bookUrl: bookUrl,
  487. index: chapterIndex,
  488. chapterPos: chapterPos,
  489. };
  490. localStorage.setItem(bookUrl, JSON.stringify(book));
  491. }
  492. API.getChapterList(bookUrl).then(
  493. (res) => {
  494. showLoading.value = false;
  495. if (!res.data.isSuccess) {
  496. ElMessage({ message: res.data.errorMsg, type: "error" });
  497. setTimeout(toShelf, 500);
  498. return;
  499. }
  500. let data = res.data.data;
  501. store.setCatalog(data);
  502. store.setReadingBook(book);
  503. getContent(chapterIndex, true, chapterPos);
  504. window.addEventListener("keyup", handleKeyPress);
  505. //监听底部加载
  506. scrollObserve.value = new IntersectionObserver(handleIScrollObserve, {
  507. rootMargin: "-100% 0% 20% 0%",
  508. });
  509. infiniteLoading.value && scrollObserve.value.observe(loading.value);
  510. //监听当前阅读章节
  511. readingObserve.value = new IntersectionObserver(handleIReadingObserve);
  512. //第二次点击同一本书 页面标题不会变化
  513. document.title = null;
  514. document.title = bookName + " | " + catalog.value[chapterIndex].title;
  515. },
  516. (err) => {
  517. showLoading.value = false;
  518. ElMessage({ message: "获取书籍目录失败", type: "error" });
  519. throw err;
  520. }
  521. );
  522. });
  523. onUnmounted(() => {
  524. window.removeEventListener("keyup", handleKeyPress);
  525. readSettingsVisible.value = false;
  526. popCataVisible.value = false;
  527. scrollObserve.value?.disconnect();
  528. readingObserve.value?.disconnect();
  529. });
  530. </script>
  531. <style lang="scss" scoped>
  532. :deep(.pop-setting) {
  533. margin-left: 68px;
  534. top: 0;
  535. }
  536. :deep(.pop-cata) {
  537. margin-left: 10px;
  538. }
  539. .chapter-wrapper {
  540. padding: 0 4%;
  541. flex-direction: column;
  542. align-items: center;
  543. :deep(.no-point) {
  544. pointer-events: none;
  545. }
  546. .tool-bar {
  547. position: fixed;
  548. top: 0;
  549. left: 50%;
  550. z-index: 100;
  551. .tools {
  552. display: flex;
  553. flex-direction: column;
  554. .tool-icon {
  555. font-size: 18px;
  556. width: 58px;
  557. height: 48px;
  558. text-align: center;
  559. padding-top: 12px;
  560. cursor: pointer;
  561. outline: none;
  562. .iconfont {
  563. font-family: iconfont;
  564. width: 16px;
  565. height: 16px;
  566. font-size: 16px;
  567. margin: 0 auto 6px;
  568. }
  569. .icon-text {
  570. font-size: 12px;
  571. }
  572. }
  573. }
  574. }
  575. .read-bar {
  576. position: fixed;
  577. bottom: 0;
  578. right: 50%;
  579. z-index: 100;
  580. .tools {
  581. display: flex;
  582. flex-direction: column;
  583. .tool-icon {
  584. font-size: 18px;
  585. width: 42px;
  586. height: 31px;
  587. padding-top: 12px;
  588. text-align: center;
  589. align-items: center;
  590. cursor: pointer;
  591. outline: none;
  592. margin-top: -1px;
  593. .iconfont {
  594. font-family: iconfont;
  595. width: 16px;
  596. height: 16px;
  597. font-size: 16px;
  598. margin: 0 auto 6px;
  599. }
  600. }
  601. }
  602. }
  603. .chapter {
  604. font-family: "Microsoft YaHei", PingFangSC-Regular, HelveticaNeue-Light,
  605. "Helvetica Neue Light", sans-serif;
  606. text-align: left;
  607. padding: 0 65px;
  608. min-height: 100vh;
  609. width: 670px;
  610. margin: 0 auto;
  611. :deep(.el-loading-mask) {
  612. background-color: rgba(0, 0, 0, 0);
  613. }
  614. :deep(.el-loading-spinner) {
  615. font-size: 36px;
  616. color: #b5b5b5;
  617. }
  618. :deep(.el-loading-text) {
  619. font-weight: 500;
  620. color: #b5b5b5;
  621. }
  622. .content {
  623. overflow: hidden;
  624. font-size: 18px;
  625. line-height: 1.8;
  626. font-family: "Microsoft YaHei", PingFangSC-Regular, HelveticaNeue-Light,
  627. "Helvetica Neue Light", sans-serif;
  628. .bottom-bar,
  629. .top-bar {
  630. height: 64px;
  631. }
  632. }
  633. }
  634. }
  635. .day {
  636. :deep(.popup) {
  637. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);
  638. }
  639. :deep(.tool-icon) {
  640. border: 1px solid rgba(0, 0, 0, 0.1);
  641. margin-top: -1px;
  642. color: #000;
  643. .icon-text {
  644. color: rgba(0, 0, 0, 0.4);
  645. }
  646. }
  647. :deep(.chapter) {
  648. border: 1px solid #d8d8d8;
  649. color: #262626;
  650. }
  651. }
  652. .night {
  653. :deep(.popup) {
  654. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.48), 0 0 6px rgba(0, 0, 0, 0.16);
  655. }
  656. :deep(.tool-icon) {
  657. border: 1px solid #444;
  658. margin-top: -1px;
  659. color: #666;
  660. .icon-text {
  661. color: #666;
  662. }
  663. }
  664. :deep(.chapter) {
  665. border: 1px solid #444;
  666. color: #666;
  667. }
  668. :deep(.popper__arrow) {
  669. background: #666;
  670. }
  671. }
  672. @media screen and (max-width: 750px) {
  673. .chapter-wrapper {
  674. padding: 0;
  675. .tool-bar {
  676. left: 0;
  677. width: 100vw;
  678. margin-left: 0 !important;
  679. .tools {
  680. flex-direction: row;
  681. justify-content: space-between;
  682. .tool-icon {
  683. border: none;
  684. }
  685. }
  686. }
  687. .read-bar {
  688. right: 0;
  689. width: 100vw;
  690. margin-right: 0 !important;
  691. .tools {
  692. flex-direction: row;
  693. justify-content: space-between;
  694. padding: 0 15px;
  695. .tool-icon {
  696. border: none;
  697. width: auto;
  698. .iconfont {
  699. display: inline-block;
  700. }
  701. }
  702. }
  703. }
  704. .chapter {
  705. width: 100vw !important;
  706. padding: 0 20px;
  707. box-sizing: border-box;
  708. }
  709. }
  710. }
  711. </style>