Remove Reader pagination mode
Browse files- static/reader.css +6 -7
- static/reader.html +1 -2
- 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;
|
| 29 |
-
.reader-progress-row { display:
|
| 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 {
|
| 33 |
-
.reader-chapter-
|
|
|
|
|
|
|
| 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="代理下载原文件">⇩</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">☰</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">⇩</a>
|
|
|
|
| 31 |
</div>
|
| 32 |
<button id="history" class="icon-button" type="button" title="阅读选项" aria-label="阅读选项" aria-expanded="false">☰</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,
|
| 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 |
-
|
| 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="撤销进度调整">
|
| 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 >
|
| 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"));
|
| 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);
|