vomebook commited on
Commit
fc7e375
·
verified ·
1 Parent(s): 7eb8d0c

Remove Reader pagination mode

Browse files
Files changed (3) hide show
  1. static/reader.css +6 -7
  2. static/reader.html +1 -2
  3. static/reader.js +6 -23
static/reader.css CHANGED
@@ -25,12 +25,14 @@ body { margin: 0; height: 100vh; height: 100dvh; overflow: hidden; display: flex
25
  .reader-panel-tabs button { flex: 1; min-height: 38px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: #aeb5ba; cursor: pointer; }
26
  .reader-panel-tabs button[aria-selected="true"] { color: #e7e9eb; border-bottom-color: #8ab4f8; }
27
  .reader-panel-view { min-height: 0; flex: 1; overflow: auto; }
28
- .reader-progress-tools { flex: none; display: grid; gap: 8px; padding: 10px 12px 12px; border-top: 1px solid #34383d; background: inherit; }
29
- .reader-progress-row { display: flex; align-items: center; gap: 8px; }
30
  .reader-progress-row input[type="range"] { min-width: 0; flex: 1; accent-color: #8ab4f8; }
31
  .reader-progress-percent { min-width: 42px; color: #aeb5ba; font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; }
32
- .reader-progress-undo, .reader-chapter-prev, .reader-chapter-next { min-width: 28px; min-height: 28px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; }
33
- .reader-chapter-nav { display: flex; justify-content: space-between; gap: 8px; }
 
 
34
  .full-search-input { width: 110px; min-width: 0; height: 28px; padding: 0 7px; border: 1px solid #464c52; border-radius: 4px; background: #25292d; color: inherit; font: inherit; font-size: 12px; }
35
  .full-search-view { display: flex; flex-direction: column; min-height: 0; }
36
  .full-search-bar { display: flex; gap: 6px; padding: 10px 12px 6px; }
@@ -86,9 +88,6 @@ body { margin: 0; height: 100vh; height: 100dvh; overflow: hidden; display: flex
86
  .history-item small { color: #929aa1; }
87
  .history-item button { grid-row: 1 / 3; grid-column: 2; border: 0; background: transparent; color: #b5bbc0; cursor: pointer; }
88
  .reader-viewport { min-height: 0; flex: 1; overflow: auto; background: #121416; }
89
- .reader-viewport.is-paginated { overflow-x: hidden; overflow-y: hidden; }
90
- .reader-content.is-paginated { width: max-content; max-width: none; height: 100%; column-width: calc(100vw - 40px); column-gap: 40px; column-fill: auto; padding: 20px; }
91
- .reader-content.is-paginated .reader-text, .reader-content.is-paginated .reader-markdown { width: max-content; max-width: none; height: calc(100% - 40px); column-width: calc(100vw - 40px); column-gap: 40px; column-fill: auto; overflow: hidden; }
92
  :root.bookmark-dialog-open .reader-viewport { overflow: hidden; }
93
  .bookmark-ribbon { position: fixed; z-index: 12; top: 44px; left: max(10px, calc((100vw - min(100vw, 1100px)) / 2 + 2px)); width: 28px; height: 42px; padding: 0; border: 0; background: #b64b50; box-shadow: 0 2px 8px #0006; cursor: pointer; clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 76%, 0 100%); }
94
  .bookmark-ribbon:hover, .bookmark-ribbon:focus-visible { background: #d35b61; outline: 2px solid #8ab4f8; outline-offset: 2px; }
 
25
  .reader-panel-tabs button { flex: 1; min-height: 38px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: #aeb5ba; cursor: pointer; }
26
  .reader-panel-tabs button[aria-selected="true"] { color: #e7e9eb; border-bottom-color: #8ab4f8; }
27
  .reader-panel-view { min-height: 0; flex: 1; overflow: auto; }
28
+ .reader-progress-tools { flex: none; padding: 10px 12px 12px; border-top: 1px solid #34383d; background: inherit; }
29
+ .reader-progress-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto auto; align-items: center; gap: 7px; min-width: 0; }
30
  .reader-progress-row input[type="range"] { min-width: 0; flex: 1; accent-color: #8ab4f8; }
31
  .reader-progress-percent { min-width: 42px; color: #aeb5ba; font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; }
32
+ .reader-progress-undo, .reader-chapter-prev, .reader-chapter-next { width: 28px; height: 28px; display: grid; place-items: center; padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent; color: #aeb5ba; cursor: pointer; }
33
+ .reader-progress-undo:hover, .reader-chapter-prev:hover, .reader-chapter-next:hover { border-color: #464c52; background: #25292d; color: #e7e9eb; }
34
+ .reader-progress-undo svg, .reader-chapter-prev svg, .reader-chapter-next svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
35
+ .reader-progress-undo[hidden], .reader-chapter-prev[hidden], .reader-chapter-next[hidden] { display: none; }
36
  .full-search-input { width: 110px; min-width: 0; height: 28px; padding: 0 7px; border: 1px solid #464c52; border-radius: 4px; background: #25292d; color: inherit; font: inherit; font-size: 12px; }
37
  .full-search-view { display: flex; flex-direction: column; min-height: 0; }
38
  .full-search-bar { display: flex; gap: 6px; padding: 10px 12px 6px; }
 
88
  .history-item small { color: #929aa1; }
89
  .history-item button { grid-row: 1 / 3; grid-column: 2; border: 0; background: transparent; color: #b5bbc0; cursor: pointer; }
90
  .reader-viewport { min-height: 0; flex: 1; overflow: auto; background: #121416; }
 
 
 
91
  :root.bookmark-dialog-open .reader-viewport { overflow: hidden; }
92
  .bookmark-ribbon { position: fixed; z-index: 12; top: 44px; left: max(10px, calc((100vw - min(100vw, 1100px)) / 2 + 2px)); width: 28px; height: 42px; padding: 0; border: 0; background: #b64b50; box-shadow: 0 2px 8px #0006; cursor: pointer; clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 76%, 0 100%); }
93
  .bookmark-ribbon:hover, .bookmark-ribbon:focus-visible { background: #d35b61; outline: 2px solid #8ab4f8; outline-offset: 2px; }
static/reader.html CHANGED
@@ -27,8 +27,7 @@
27
  <button id="zoom-in" class="icon-button" type="button" title="放大" aria-label="放大">+</button>
28
  </span>
29
  <a id="ocr" class="text-button" title="阅读 OCR 文本" target="_blank" rel="noopener noreferrer" hidden>OCR</a>
30
- <a id="download" class="icon-button" title="代理下载原文件" aria-label="代理下载原文件">&#8681;</a>
31
- <button id="reading-mode" class="text-button" type="button" title="切换阅读模式" aria-pressed="false" hidden>翻页</button>
32
  </div>
33
  <button id="history" class="icon-button" type="button" title="阅读选项" aria-label="阅读选项" aria-expanded="false">&#9776;</button>
34
  </header>
 
27
  <button id="zoom-in" class="icon-button" type="button" title="放大" aria-label="放大">+</button>
28
  </span>
29
  <a id="ocr" class="text-button" title="阅读 OCR 文本" target="_blank" rel="noopener noreferrer" hidden>OCR</a>
30
+ <a id="download" class="icon-button" title="代理下载原文件" aria-label="代理下载原文件" target="_blank" rel="noopener noreferrer">&#8681;</a>
 
31
  </div>
32
  <button id="history" class="icon-button" type="button" title="阅读选项" aria-label="阅读选项" aria-expanded="false">&#9776;</button>
33
  </header>
static/reader.js CHANGED
@@ -90,7 +90,6 @@ let epubLocation = "";
90
  let epubProgress = 0;
91
  let epubSearchIndex = [];
92
  let epubSearchIndexReady = false;
93
- let readingMode = "scroll";
94
  let htmlFrame = null;
95
  let lastSavedProgress = "";
96
  let progressSaveChain = Promise.resolve();
@@ -116,10 +115,6 @@ const bookmarksAllButton = document.createElement("button");
116
  bookmarksAllButton.id = "bookmarks-all"; bookmarksAllButton.className = "text-action"; bookmarksAllButton.type = "button"; bookmarksAllButton.textContent = "全部书签"; bookmarksAllButton.setAttribute("aria-pressed", "false");
117
  const bookmarksHeader = document.querySelector("#bookmarks-panel .panel-view-header"), bookmarksSearchButton = bookmarksHeader.querySelector(".panel-search-toggle"), bookmarksHeaderActions = document.createElement("span");
118
  bookmarksHeaderActions.append(bookmarksAllButton, bookmarksSearchButton); bookmarksHeader.appendChild(bookmarksHeaderActions);
119
- const readingModeButton = document.querySelector("#reading-mode");
120
- function updatePagination() { if (readingMode !== "paginate") return; pageCount = Math.max(1, Math.ceil((viewport.scrollWidth - viewport.clientWidth) / Math.max(1, viewport.clientWidth)) + 1); pageInput.max = String(pageCount); document.querySelector("#page-total").textContent = `/ ${pageCount}`; currentPage = Math.max(1, Math.min(pageCount, Math.round(viewport.scrollLeft / Math.max(1, viewport.clientWidth)) + 1)); pageInput.value = String(currentPage); }
121
- function setReadingMode(mode) { const paginate = mode === "paginate", ratio = readingMode === "paginate" ? viewport.scrollLeft / Math.max(1, viewport.scrollWidth - viewport.clientWidth) : viewport.scrollTop / Math.max(1, viewport.scrollHeight - viewport.clientHeight); readingMode = paginate ? "paginate" : "scroll"; if (capability.mode === "epub" && epubRendition && typeof epubRendition.flow === "function") epubRendition.flow(paginate ? "paginated" : "scrolled-doc"); viewport.classList.toggle("is-paginated", paginate && capability.mode !== "epub"); content.classList.toggle("is-paginated", paginate && capability.mode !== "epub"); readingModeButton.setAttribute("aria-pressed", String(paginate)); readingModeButton.textContent = paginate ? "滚动" : "翻页"; if (capability.mode !== "pdf" && capability.mode !== "epub") document.querySelector(".page-controls").hidden = !paginate; requestAnimationFrame(() => { if (paginate && capability.mode !== "epub") { updatePagination(); viewport.scrollLeft = ratio * Math.max(0, viewport.scrollWidth - viewport.clientWidth); updatePagination(); } else if (!paginate && capability.mode !== "epub") { viewport.scrollLeft = 0; viewport.scrollTop = ratio * Math.max(0, viewport.scrollHeight - viewport.clientHeight); } }); }
122
- if (["text", "markdown", "epub"].includes(capability.mode)) readingModeButton.addEventListener("click", () => setReadingMode(readingMode === "paginate" ? "scroll" : "paginate"));
123
  const mediaTab = document.createElement("button"), mediaPanel = document.createElement("section"); mediaTab.id = "media-tab"; mediaTab.type = "button"; mediaTab.role = "tab"; mediaTab.dataset.panel = "media"; mediaTab.textContent = "播放"; mediaTab.hidden = !["audio", "video"].includes(capability.mode); mediaPanel.id = "media-panel"; mediaPanel.className = "reader-panel-view"; mediaPanel.dataset.panelView = "media"; mediaPanel.hidden = true; mediaPanel.innerHTML = '<div class="media-panel-content"><strong>播放状态</strong><span class="media-panel-time">尚未播放</span><button class="text-action media-panel-bookmark" type="button">在当前时间添加书签</button></div>'; document.querySelector(".reader-panel-tabs").prepend(mediaTab); document.querySelector("#history-panel").insertBefore(mediaPanel, document.querySelector("#toc-panel")); mediaPanel.querySelector(".media-panel-bookmark").addEventListener("click", () => bookmarkRibbon.click());
124
  const loadingObserver = new MutationObserver(() => {
125
  if (content.querySelector(".reader-page, .reader-image, .reader-audio, .reader-video, .reader-text, .reader-markdown, .html-frame, .docx-body")) {
@@ -240,7 +235,6 @@ document.querySelector("#page-next").addEventListener("click", () => epubRenditi
240
 
241
  async function goToPage(value) {
242
  if (!pageCount) return;
243
- if (readingMode === "paginate") { const page = VoiceOfMLReader.clampNumber(value, 1, pageCount, 1); viewport.scrollTo({ left: (page - 1) * viewport.clientWidth, behavior: "smooth" }); currentPage = page; pageInput.value = String(page); scheduleSave(); return; }
244
  const page = VoiceOfMLReader.clampNumber(value, 1, pageCount, 1);
245
  await pdfShellsReady;
246
  const shell = content.querySelector(`.reader-page[data-page="${page}"], .reader-docx-page[data-page="${page}"]`);
@@ -259,7 +253,7 @@ async function saveProgress() {
259
  const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0;
260
  const htmlScrollTop = htmlFrame && htmlFrame.contentWindow ? htmlFrame.contentWindow.scrollY : 0;
261
  const readerUrl = new URL(location.href); readerUrl.searchParams.delete("return"); readerUrl.searchParams.delete("nav");
262
- const progress = { url: sourceUrl, title, extension, readerUrl: readerUrl.href, page: currentPage, pageCount, pageOffset, epubLocation, mediaTime: mediaElement && Number.isFinite(mediaElement.currentTime) ? mediaElement.currentTime : 0, readingMode, paginationScrollLeft: viewport.scrollLeft, scrollTop: viewport.scrollTop, htmlScrollTop, zoom: Math.round(zoom * 100) };
263
  const signature = JSON.stringify(progress);
264
  if (signature === lastSavedProgress) return progressSaveChain;
265
  lastSavedProgress = signature;
@@ -380,8 +374,7 @@ function readerProgressPercent() {
380
  const doc = htmlFrame.contentDocument.documentElement, win = htmlFrame.contentWindow;
381
  return Math.round(Math.max(0, Math.min(1, win.scrollY / Math.max(1, doc.scrollHeight - win.innerHeight))) * 1000) / 10;
382
  }
383
- const position = readingMode === "paginate" ? viewport.scrollLeft / Math.max(1, viewport.scrollWidth - viewport.clientWidth) : viewport.scrollTop / Math.max(1, viewport.scrollHeight - viewport.clientHeight);
384
- return Math.round(Math.max(0, Math.min(1, position)) * 1000) / 10;
385
  }
386
  function excerptFromCaret(doc, root, x, y) {
387
  let node, offset = 0;
@@ -471,15 +464,8 @@ document.querySelector("#history").addEventListener("click", () => setReaderPane
471
  document.querySelector("#history-close").addEventListener("click", () => setReaderPanelOpen(false, true));
472
  viewport.addEventListener("scroll", () => {
473
  scheduleMarkerSync();
474
- if (readingMode === "paginate") updatePagination();
475
  }, { passive: true });
476
- document.addEventListener("keydown", (event) => { if (readingMode !== "paginate" || ["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) return; if (event.key === "ArrowRight") { event.preventDefault(); goToPage(currentPage + 1); } else if (event.key === "ArrowLeft") { event.preventDefault(); goToPage(currentPage - 1); } });
477
  let paginationTouchX = 0;
478
- viewport.addEventListener("touchstart", (event) => { if (readingMode === "paginate") paginationTouchX = event.touches[0].clientX; }, { passive: true });
479
- viewport.addEventListener("touchend", (event) => { if (readingMode !== "paginate") return; const delta = event.changedTouches[0].clientX - paginationTouchX; if (Math.abs(delta) > 45) goToPage(currentPage + (delta < 0 ? 1 : -1)); }, { passive: true });
480
- window.addEventListener("resize", () => { if (readingMode !== "paginate") return; const ratio = viewport.scrollLeft / Math.max(1, viewport.scrollWidth - viewport.clientWidth); requestAnimationFrame(() => { viewport.scrollLeft = ratio * Math.max(0, viewport.scrollWidth - viewport.clientWidth); updatePagination(); }); });
481
- let edgeClickAt = 0;
482
- viewport.addEventListener("click", (event) => { if (!["text", "markdown", "epub"].includes(capability.mode) || event.target.closest("button, input, textarea, a, .bookmark-popover")) return; const width = viewport.clientWidth, edge = width * 0.16; if (event.clientX >= edge && event.clientX <= width - edge) return; const now = performance.now(); if (now - edgeClickAt < 350) return; edgeClickAt = now; const next = event.clientX < edge ? -1 : 1; if (readingMode !== "paginate") setReadingMode("paginate"); requestAnimationFrame(() => capability.mode === "epub" ? (next < 0 ? epubRendition.prev() : epubRendition.next()) : navigatePage(currentPage + next)); });
483
  window.addEventListener("pagehide", saveProgress);
484
  document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") saveProgress(); });
485
 
@@ -653,7 +639,6 @@ async function renderText(markdown, prepared) {
653
  setToc(headings.map((heading) => ({ label: heading.textContent.trim(), depth: Number(heading.tagName.slice(1)) - 1, activate: () => heading.scrollIntoView({ block: "start" }) })));
654
  }
655
  status.textContent = "已加载";
656
- if (["text", "markdown", "epub"].includes(capability.mode)) readingModeButton.hidden = false;
657
  }
658
  async function renderHtml(prepared) {
659
  const response = await prepared.response;
@@ -805,7 +790,6 @@ async function renderEpub(prepared) {
805
  await displayEpub(bytes, frame); fullSearchStatus.textContent = "正在建立 EPUB 全文索引…"; try { epubSearchIndex = await buildEpubSearchIndex(bytes); epubSearchIndexReady = true; } catch (error) { console.warn("EPUB full-text index could not be built", error); fullSearchStatus.textContent = "EPUB 全文索引不可用"; } if (epubBook && epubBook.locations && epubBook.locations.generate) { try { await epubBook.locations.generate(1600); } catch (_) {} }
806
  loadingIndicator.remove(); loadingObserver.disconnect();
807
  status.textContent = "EPUB";
808
- readingModeButton.hidden = false;
809
  }
810
  async function displayEpub(url, frame) {
811
  const book = ePub(url); epubBook = book; epubRendition = book.renderTo(frame, { width: "100%", height: "100%", manager: "continuous", spread: "none", flow: "scrolled-doc" });
@@ -889,7 +873,6 @@ async function start() {
889
  loadingIndicator.remove();
890
  loadingStatus.hidden = true;
891
  if (!pageCount && restoredEntry) viewport.scrollTop = restoredEntry.scrollTop || 0;
892
- if (restoredEntry && restoredEntry.readingMode === "paginate" && ["text", "markdown"].includes(capability.mode)) { setReadingMode("paginate"); requestAnimationFrame(() => { viewport.scrollLeft = Number.isFinite(restoredEntry.paginationScrollLeft) ? restoredEntry.paginationScrollLeft : 0; updatePagination(); }); }
893
  restorationReady = !restorationFailed;
894
  scheduleSave();
895
  } catch (error) { console.error(error); fail(error && error.message === "EPUB_INVALID" ? "源 EPUB 文件不完整或已损坏,请下载原文件检查。" : "原文件加载失败,请检查网络后重试,或下载原文件。"); }
@@ -906,10 +889,10 @@ function prepareDocument() {
906
  return Promise.resolve(null);
907
  }
908
  const documentProgressPercent = readerProgressPercent; readerProgressPercent = () => mediaElement && mediaElement.duration ? Math.round(mediaElement.currentTime / mediaElement.duration * 1000) / 10 : documentProgressPercent();
909
- const progressTools = document.createElement("div"); progressTools.className = "reader-progress-tools"; progressTools.innerHTML = '<div class="reader-progress-row"><input class="reader-progress-range" type="range" min="0" max="100" step="0.1" value="0" aria-label="阅读进度"><span class="reader-progress-percent">0.0%</span><button class="reader-progress-undo" type="button" aria-label="撤销进度调整" title="撤销进度调整">↶</button></div><div class="reader-chapter-nav"><button class="reader-chapter-prev" type="button" aria-label="上一章" title="上一章">‹</button><button class="reader-chapter-next" type="button" aria-label="下一章" title="下一章">›</button></div>'; document.querySelector("#history-panel").appendChild(progressTools);
910
  setInterval(() => { const tab = document.querySelector("#toc-tab"); if (tab && !tocEntries.length) { tab.hidden = false; tab.textContent = "阅读"; } }, 500);
911
  const progressRange = progressTools.querySelector(".reader-progress-range"), progressPercent = progressTools.querySelector(".reader-progress-percent"), progressUndo = progressTools.querySelector(".reader-progress-undo"), chapterPrev = progressTools.querySelector(".reader-chapter-prev"), chapterNext = progressTools.querySelector(".reader-chapter-next"); let previousProgressState = null;
912
- function updateProgressTools() { const percent = readerProgressPercent(); progressRange.value = String(percent); progressPercent.textContent = `${percent.toFixed(1)}%`; const hasChapters = tocEntries.length > 0; chapterPrev.hidden = chapterNext.hidden = !hasChapters; chapterPrev.disabled = !hasChapters; chapterNext.disabled = !hasChapters; progressUndo.disabled = !previousProgressState; }
913
  function captureProgressState() { return mediaElement ? { mediaTime: mediaElement.currentTime } : htmlFrame && htmlFrame.contentWindow ? { htmlScrollTop: htmlFrame.contentWindow.scrollY } : { scrollTop: viewport.scrollTop }; }
914
  function restoreProgressState(state) { if (!state) return; if (mediaElement && Number.isFinite(state.mediaTime)) mediaElement.currentTime = state.mediaTime; else if (htmlFrame && htmlFrame.contentWindow && Number.isFinite(state.htmlScrollTop)) htmlFrame.contentWindow.scrollTo(0, state.htmlScrollTop); else if (Number.isFinite(state.scrollTop)) viewport.scrollTop = state.scrollTop; updateProgressTools(); scheduleSave(); }
915
  function seekProgress(value, preserve = false) { if (!preserve || !previousProgressState) previousProgressState = captureProgressState(); const percent = Number(value); if (mediaElement && mediaElement.duration) mediaElement.currentTime = mediaElement.duration * percent / 100; else if (htmlFrame && htmlFrame.contentWindow) htmlFrame.contentWindow.scrollTo(0, Math.max(0, htmlFrame.contentDocument.documentElement.scrollHeight - htmlFrame.contentWindow.innerHeight) * percent / 100); else viewport.scrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight) * percent / 100; updateProgressTools(); scheduleSave(); }
@@ -918,7 +901,7 @@ const fullSearchView = document.createElement("section");
918
  fullSearchView.id = "full-search-view"; fullSearchView.className = "reader-panel-view full-search-view"; fullSearchView.dataset.panelView = "full-search"; fullSearchView.hidden = true;
919
  fullSearchView.innerHTML = '<div class="full-search-bar"><input id="full-search-input" class="full-search-input" type="search" placeholder="搜索正文" aria-label="搜索正文"><button id="full-search-clear" class="icon-button" type="button" aria-label="清除全文搜索" title="清除">×</button></div><div id="full-search-status" class="full-search-status" role="status">输入关键词搜索正文</div><div class="full-search-nav"><button id="full-search-prev" type="button" disabled>上一个</button><button id="full-search-next" type="button" disabled>下一个</button></div><div id="full-search-results" class="full-search-results"></div>';
920
  document.querySelector("#history-panel").insertBefore(fullSearchView, document.querySelector(".reader-panel-tabs"));
921
- const fullSearchButton = document.createElement("button"); fullSearchButton.id = "full-search-toggle"; fullSearchButton.className = "text-action"; fullSearchButton.type = "button"; fullSearchButton.textContent = "全文"; fullSearchButton.setAttribute("aria-label", "搜索正文"); document.querySelector("#history-panel > header").insertBefore(fullSearchButton, document.querySelector("#history-close")); document.querySelector("#history-panel > header").insertBefore(readingModeButton, readerThemeToggle); document.querySelector("#history-panel > header").insertBefore(fullSearchButton, readerThemeToggle);
922
  let fullSearchResults = [], fullSearchIndex = -1, fullSearchGeneration = 0, fullSearchActiveMarks = [];
923
  const fullSearchInput = fullSearchView.querySelector("#full-search-input"), fullSearchStatus = fullSearchView.querySelector("#full-search-status"), fullSearchResultsNode = fullSearchView.querySelector("#full-search-results");
924
  function fullSearchEscape(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
@@ -931,7 +914,7 @@ function fullSearchDomMatches(root, fallback) { const query = fullSearchInput.va
931
  async function fullSearchPdfMatches(query) { const output = [], pattern = new RegExp(fullSearchEscape(query), "giu"); for (let page = 1; page <= pdfDocument.numPages && output.length < 100; page++) { const textContent = await (await pdfDocument.getPage(page)).getTextContent(), text = textContent.items.map((item) => item.str + (item.hasEOL ? "\n" : " ")).join("").trim(); for (const match of text.matchAll(pattern)) { if (output.length >= 100) break; output.push({ location: `第 ${page} 页`, snippet: fullSearchSnippet(text, match.index, match[0].length), activate: () => goToPage(page) }); } } return output; }
932
  async function runFullSearch() { const query = fullSearchInput.value.trim(); ++fullSearchGeneration; const generation = fullSearchGeneration; fullSearchResults = []; fullSearchIndex = -1; fullSearchResultsNode.textContent = ""; fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => { button.disabled = true; }); if (!query) { fullSearchStatus.textContent = "输入关键词搜索正文"; return; } fullSearchStatus.textContent = "正在搜索正文…"; try { if (capability.mode === "pdf") fullSearchResults = pdfDocument ? await fullSearchPdfMatches(query) : []; else if (["text", "markdown", "docx"].includes(capability.mode)) fullSearchResults = fullSearchDomMatches(content, "阅读位置"); else if (capability.mode === "html") fullSearchResults = htmlFrame && htmlFrame.contentDocument && htmlFrame.contentDocument.body ? fullSearchDomMatches(htmlFrame.contentDocument.body, "HTML 阅读位置") : []; else if (capability.mode === "epub") { const contents = epubRendition && typeof epubRendition.getContents === "function" ? epubRendition.getContents() : []; if (!contents.length) throw new Error("EPUB 尚未加载可搜索章节"); for (const item of contents) { fullSearchResults.push(...fullSearchDomMatches(item.document.body, "EPUB 当前章节")); if (fullSearchResults.length >= 100) break; } fullSearchResults.length = Math.min(fullSearchResults.length, 100); } else throw new Error("此格式没有可搜索文本"); if (generation !== fullSearchGeneration) return; for (const [index, result] of fullSearchResults.entries()) { const row = document.createElement("button"); row.type = "button"; row.className = "full-search-result"; const location = document.createElement("small"); location.className = "full-search-location"; location.textContent = result.location; row.append(location, fullSearchSnippetDom(result.snippet)); row.addEventListener("click", async () => { fullSearchIndex = index; await result.activate(); setReaderPanelOpen(false, true); }); fullSearchResultsNode.appendChild(row); } fullSearchStatus.textContent = fullSearchResults.length ? `${fullSearchResults.length}${fullSearchResults.length === 100 ? "+" : ""} 个结果` : "未找到正文匹配"; } catch (error) { if (generation === fullSearchGeneration) fullSearchStatus.textContent = error.message || "正文搜索不可用"; } finally { if (generation === fullSearchGeneration) fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => { button.disabled = !fullSearchResults.length; }); } }
933
  function moveFullSearch(step) { if (!fullSearchResults.length) return; fullSearchIndex = (fullSearchIndex + step + fullSearchResults.length) % fullSearchResults.length; const result = fullSearchResults[fullSearchIndex]; Promise.resolve(result.activate()).then(() => fullSearchResultsNode.children[fullSearchIndex].scrollIntoView({ block: "nearest" })); }
934
- fullSearchButton.addEventListener("click", () => { setReaderPanelOpen(true); selectPanel("full-search"); fullSearchInput.focus(); }); fullSearchInput.addEventListener("input", runFullSearch); fullSearchView.querySelector("#full-search-clear").addEventListener("click", () => { fullSearchInput.value = ""; runFullSearch(); fullSearchInput.focus(); }); fullSearchView.querySelector("#full-search-prev").addEventListener("click", () => moveFullSearch(-1)); fullSearchView.querySelector("#full-search-next").addEventListener("click", () => moveFullSearch(1));
935
 
936
  async function activateIndexedEpubResult(result) {
937
  await epubRendition.display(result.href);
 
90
  let epubProgress = 0;
91
  let epubSearchIndex = [];
92
  let epubSearchIndexReady = false;
 
93
  let htmlFrame = null;
94
  let lastSavedProgress = "";
95
  let progressSaveChain = Promise.resolve();
 
115
  bookmarksAllButton.id = "bookmarks-all"; bookmarksAllButton.className = "text-action"; bookmarksAllButton.type = "button"; bookmarksAllButton.textContent = "全部书签"; bookmarksAllButton.setAttribute("aria-pressed", "false");
116
  const bookmarksHeader = document.querySelector("#bookmarks-panel .panel-view-header"), bookmarksSearchButton = bookmarksHeader.querySelector(".panel-search-toggle"), bookmarksHeaderActions = document.createElement("span");
117
  bookmarksHeaderActions.append(bookmarksAllButton, bookmarksSearchButton); bookmarksHeader.appendChild(bookmarksHeaderActions);
 
 
 
 
118
  const mediaTab = document.createElement("button"), mediaPanel = document.createElement("section"); mediaTab.id = "media-tab"; mediaTab.type = "button"; mediaTab.role = "tab"; mediaTab.dataset.panel = "media"; mediaTab.textContent = "播放"; mediaTab.hidden = !["audio", "video"].includes(capability.mode); mediaPanel.id = "media-panel"; mediaPanel.className = "reader-panel-view"; mediaPanel.dataset.panelView = "media"; mediaPanel.hidden = true; mediaPanel.innerHTML = '<div class="media-panel-content"><strong>播放状态</strong><span class="media-panel-time">尚未播放</span><button class="text-action media-panel-bookmark" type="button">在当前时间添加书签</button></div>'; document.querySelector(".reader-panel-tabs").prepend(mediaTab); document.querySelector("#history-panel").insertBefore(mediaPanel, document.querySelector("#toc-panel")); mediaPanel.querySelector(".media-panel-bookmark").addEventListener("click", () => bookmarkRibbon.click());
119
  const loadingObserver = new MutationObserver(() => {
120
  if (content.querySelector(".reader-page, .reader-image, .reader-audio, .reader-video, .reader-text, .reader-markdown, .html-frame, .docx-body")) {
 
235
 
236
  async function goToPage(value) {
237
  if (!pageCount) return;
 
238
  const page = VoiceOfMLReader.clampNumber(value, 1, pageCount, 1);
239
  await pdfShellsReady;
240
  const shell = content.querySelector(`.reader-page[data-page="${page}"], .reader-docx-page[data-page="${page}"]`);
 
253
  const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0;
254
  const htmlScrollTop = htmlFrame && htmlFrame.contentWindow ? htmlFrame.contentWindow.scrollY : 0;
255
  const readerUrl = new URL(location.href); readerUrl.searchParams.delete("return"); readerUrl.searchParams.delete("nav");
256
+ const progress = { url: sourceUrl, title, extension, readerUrl: readerUrl.href, page: currentPage, pageCount, pageOffset, epubLocation, mediaTime: mediaElement && Number.isFinite(mediaElement.currentTime) ? mediaElement.currentTime : 0, scrollTop: viewport.scrollTop, htmlScrollTop, zoom: Math.round(zoom * 100) };
257
  const signature = JSON.stringify(progress);
258
  if (signature === lastSavedProgress) return progressSaveChain;
259
  lastSavedProgress = signature;
 
374
  const doc = htmlFrame.contentDocument.documentElement, win = htmlFrame.contentWindow;
375
  return Math.round(Math.max(0, Math.min(1, win.scrollY / Math.max(1, doc.scrollHeight - win.innerHeight))) * 1000) / 10;
376
  }
377
+ return Math.round(Math.max(0, Math.min(1, viewport.scrollTop / Math.max(1, viewport.scrollHeight - viewport.clientHeight))) * 1000) / 10;
 
378
  }
379
  function excerptFromCaret(doc, root, x, y) {
380
  let node, offset = 0;
 
464
  document.querySelector("#history-close").addEventListener("click", () => setReaderPanelOpen(false, true));
465
  viewport.addEventListener("scroll", () => {
466
  scheduleMarkerSync();
 
467
  }, { passive: true });
 
468
  let paginationTouchX = 0;
 
 
 
 
 
469
  window.addEventListener("pagehide", saveProgress);
470
  document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") saveProgress(); });
471
 
 
639
  setToc(headings.map((heading) => ({ label: heading.textContent.trim(), depth: Number(heading.tagName.slice(1)) - 1, activate: () => heading.scrollIntoView({ block: "start" }) })));
640
  }
641
  status.textContent = "已加载";
 
642
  }
643
  async function renderHtml(prepared) {
644
  const response = await prepared.response;
 
790
  await displayEpub(bytes, frame); fullSearchStatus.textContent = "正在建立 EPUB 全文索引…"; try { epubSearchIndex = await buildEpubSearchIndex(bytes); epubSearchIndexReady = true; } catch (error) { console.warn("EPUB full-text index could not be built", error); fullSearchStatus.textContent = "EPUB 全文索引不可用"; } if (epubBook && epubBook.locations && epubBook.locations.generate) { try { await epubBook.locations.generate(1600); } catch (_) {} }
791
  loadingIndicator.remove(); loadingObserver.disconnect();
792
  status.textContent = "EPUB";
 
793
  }
794
  async function displayEpub(url, frame) {
795
  const book = ePub(url); epubBook = book; epubRendition = book.renderTo(frame, { width: "100%", height: "100%", manager: "continuous", spread: "none", flow: "scrolled-doc" });
 
873
  loadingIndicator.remove();
874
  loadingStatus.hidden = true;
875
  if (!pageCount && restoredEntry) viewport.scrollTop = restoredEntry.scrollTop || 0;
 
876
  restorationReady = !restorationFailed;
877
  scheduleSave();
878
  } catch (error) { console.error(error); fail(error && error.message === "EPUB_INVALID" ? "源 EPUB 文件不完整或已损坏,请下载原文件检查。" : "原文件加载失败,请检查网络后重试,或下载原文件。"); }
 
889
  return Promise.resolve(null);
890
  }
891
  const documentProgressPercent = readerProgressPercent; readerProgressPercent = () => mediaElement && mediaElement.duration ? Math.round(mediaElement.currentTime / mediaElement.duration * 1000) / 10 : documentProgressPercent();
892
+ const progressTools = document.createElement("div"); progressTools.className = "reader-progress-tools"; progressTools.innerHTML = '<div class="reader-progress-row"><button class="reader-chapter-prev" type="button" aria-label="上一章" title="上一章"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14 5-7 7 7 7"/></svg></button><input class="reader-progress-range" type="range" min="0" max="100" step="0.1" value="0" aria-label="阅读进度"><span class="reader-progress-percent">0.0%</span><button class="reader-progress-undo" type="button" aria-label="撤销进度调整" title="撤销进度调整"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 7H5v4"/><path d="M5 11a7 7 0 1 0 2-5"/></svg></button><button class="reader-chapter-next" type="button" aria-label="下一章" title="下一章"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m10 5 7 7-7 7"/></svg></button></div>'; document.querySelector("#history-panel").appendChild(progressTools);
893
  setInterval(() => { const tab = document.querySelector("#toc-tab"); if (tab && !tocEntries.length) { tab.hidden = false; tab.textContent = "阅读"; } }, 500);
894
  const progressRange = progressTools.querySelector(".reader-progress-range"), progressPercent = progressTools.querySelector(".reader-progress-percent"), progressUndo = progressTools.querySelector(".reader-progress-undo"), chapterPrev = progressTools.querySelector(".reader-chapter-prev"), chapterNext = progressTools.querySelector(".reader-chapter-next"); let previousProgressState = null;
895
+ function updateProgressTools() { const percent = readerProgressPercent(); progressRange.value = String(percent); progressPercent.textContent = `${percent.toFixed(1)}%`; const hasChapters = tocEntries.length > 1, hasPreviousChapter = hasChapters && currentChapterIndex > 0, hasNextChapter = hasChapters && currentChapterIndex >= 0 && currentChapterIndex < tocEntries.length - 1; chapterPrev.hidden = !hasPreviousChapter; chapterNext.hidden = !hasNextChapter; progressUndo.hidden = !previousProgressState; }
896
  function captureProgressState() { return mediaElement ? { mediaTime: mediaElement.currentTime } : htmlFrame && htmlFrame.contentWindow ? { htmlScrollTop: htmlFrame.contentWindow.scrollY } : { scrollTop: viewport.scrollTop }; }
897
  function restoreProgressState(state) { if (!state) return; if (mediaElement && Number.isFinite(state.mediaTime)) mediaElement.currentTime = state.mediaTime; else if (htmlFrame && htmlFrame.contentWindow && Number.isFinite(state.htmlScrollTop)) htmlFrame.contentWindow.scrollTo(0, state.htmlScrollTop); else if (Number.isFinite(state.scrollTop)) viewport.scrollTop = state.scrollTop; updateProgressTools(); scheduleSave(); }
898
  function seekProgress(value, preserve = false) { if (!preserve || !previousProgressState) previousProgressState = captureProgressState(); const percent = Number(value); if (mediaElement && mediaElement.duration) mediaElement.currentTime = mediaElement.duration * percent / 100; else if (htmlFrame && htmlFrame.contentWindow) htmlFrame.contentWindow.scrollTo(0, Math.max(0, htmlFrame.contentDocument.documentElement.scrollHeight - htmlFrame.contentWindow.innerHeight) * percent / 100); else viewport.scrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight) * percent / 100; updateProgressTools(); scheduleSave(); }
 
901
  fullSearchView.id = "full-search-view"; fullSearchView.className = "reader-panel-view full-search-view"; fullSearchView.dataset.panelView = "full-search"; fullSearchView.hidden = true;
902
  fullSearchView.innerHTML = '<div class="full-search-bar"><input id="full-search-input" class="full-search-input" type="search" placeholder="搜索正文" aria-label="搜索正文"><button id="full-search-clear" class="icon-button" type="button" aria-label="清除全文搜索" title="清除">×</button></div><div id="full-search-status" class="full-search-status" role="status">输入关键词搜索正文</div><div class="full-search-nav"><button id="full-search-prev" type="button" disabled>上一个</button><button id="full-search-next" type="button" disabled>下一个</button></div><div id="full-search-results" class="full-search-results"></div>';
903
  document.querySelector("#history-panel").insertBefore(fullSearchView, document.querySelector(".reader-panel-tabs"));
904
+ const fullSearchButton = document.createElement("button"); fullSearchButton.id = "full-search-toggle"; fullSearchButton.className = "text-action"; fullSearchButton.type = "button"; fullSearchButton.textContent = "全文"; fullSearchButton.setAttribute("aria-label", "搜索正文"); document.querySelector("#history-panel > header").insertBefore(fullSearchButton, document.querySelector("#history-close"));
905
  let fullSearchResults = [], fullSearchIndex = -1, fullSearchGeneration = 0, fullSearchActiveMarks = [];
906
  const fullSearchInput = fullSearchView.querySelector("#full-search-input"), fullSearchStatus = fullSearchView.querySelector("#full-search-status"), fullSearchResultsNode = fullSearchView.querySelector("#full-search-results");
907
  function fullSearchEscape(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
 
914
  async function fullSearchPdfMatches(query) { const output = [], pattern = new RegExp(fullSearchEscape(query), "giu"); for (let page = 1; page <= pdfDocument.numPages && output.length < 100; page++) { const textContent = await (await pdfDocument.getPage(page)).getTextContent(), text = textContent.items.map((item) => item.str + (item.hasEOL ? "\n" : " ")).join("").trim(); for (const match of text.matchAll(pattern)) { if (output.length >= 100) break; output.push({ location: `第 ${page} 页`, snippet: fullSearchSnippet(text, match.index, match[0].length), activate: () => goToPage(page) }); } } return output; }
915
  async function runFullSearch() { const query = fullSearchInput.value.trim(); ++fullSearchGeneration; const generation = fullSearchGeneration; fullSearchResults = []; fullSearchIndex = -1; fullSearchResultsNode.textContent = ""; fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => { button.disabled = true; }); if (!query) { fullSearchStatus.textContent = "输入关键词搜索正文"; return; } fullSearchStatus.textContent = "正在搜索正文…"; try { if (capability.mode === "pdf") fullSearchResults = pdfDocument ? await fullSearchPdfMatches(query) : []; else if (["text", "markdown", "docx"].includes(capability.mode)) fullSearchResults = fullSearchDomMatches(content, "阅读位置"); else if (capability.mode === "html") fullSearchResults = htmlFrame && htmlFrame.contentDocument && htmlFrame.contentDocument.body ? fullSearchDomMatches(htmlFrame.contentDocument.body, "HTML 阅读位置") : []; else if (capability.mode === "epub") { const contents = epubRendition && typeof epubRendition.getContents === "function" ? epubRendition.getContents() : []; if (!contents.length) throw new Error("EPUB 尚未加载可搜索章节"); for (const item of contents) { fullSearchResults.push(...fullSearchDomMatches(item.document.body, "EPUB 当前章节")); if (fullSearchResults.length >= 100) break; } fullSearchResults.length = Math.min(fullSearchResults.length, 100); } else throw new Error("此格式没有可搜索文本"); if (generation !== fullSearchGeneration) return; for (const [index, result] of fullSearchResults.entries()) { const row = document.createElement("button"); row.type = "button"; row.className = "full-search-result"; const location = document.createElement("small"); location.className = "full-search-location"; location.textContent = result.location; row.append(location, fullSearchSnippetDom(result.snippet)); row.addEventListener("click", async () => { fullSearchIndex = index; await result.activate(); setReaderPanelOpen(false, true); }); fullSearchResultsNode.appendChild(row); } fullSearchStatus.textContent = fullSearchResults.length ? `${fullSearchResults.length}${fullSearchResults.length === 100 ? "+" : ""} 个结果` : "未找到正文匹配"; } catch (error) { if (generation === fullSearchGeneration) fullSearchStatus.textContent = error.message || "正文搜索不可用"; } finally { if (generation === fullSearchGeneration) fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => { button.disabled = !fullSearchResults.length; }); } }
916
  function moveFullSearch(step) { if (!fullSearchResults.length) return; fullSearchIndex = (fullSearchIndex + step + fullSearchResults.length) % fullSearchResults.length; const result = fullSearchResults[fullSearchIndex]; Promise.resolve(result.activate()).then(() => fullSearchResultsNode.children[fullSearchIndex].scrollIntoView({ block: "nearest" })); }
917
+ fullSearchButton.addEventListener("click", () => { const panel = document.querySelector("#history-panel"), isOpen = panel.classList.contains("is-open"), isSelected = fullSearchView.hidden === false; if (isOpen && isSelected) { setReaderPanelOpen(false, true); return; } setReaderPanelOpen(true); selectPanel("full-search"); fullSearchInput.focus(); }); fullSearchInput.addEventListener("input", runFullSearch); fullSearchView.querySelector("#full-search-clear").addEventListener("click", () => { fullSearchInput.value = ""; runFullSearch(); fullSearchInput.focus(); }); fullSearchView.querySelector("#full-search-prev").addEventListener("click", () => moveFullSearch(-1)); fullSearchView.querySelector("#full-search-next").addEventListener("click", () => moveFullSearch(1));
918
 
919
  async function activateIndexedEpubResult(result) {
920
  await epubRendition.display(result.href);