BookChapter.vue 19 KB

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