vomebook commited on
Commit
98f7364
·
verified ·
1 Parent(s): dc796bd

Fix CHM EPUB rendering and TOC loading

Browse files
Files changed (2) hide show
  1. static/foliate-reader/epub.js +1 -1
  2. static/reader.js +9 -23
static/foliate-reader/epub.js CHANGED
@@ -1047,7 +1047,7 @@ ${doc.querySelector('parsererror').innerText}`)
1047
  return this.resources.resolveCFI(cfi)
1048
  }
1049
  resolveHref(href) {
1050
- const [path, hash] = href.split('#')
1051
  const item = this.resources.getItemByHref(decodeURI(path))
1052
  if (!item) return null
1053
  const index = this.resources.spine.findIndex(({ idref }) => idref === item.id)
 
1047
  return this.resources.resolveCFI(cfi)
1048
  }
1049
  resolveHref(href) {
1050
+ const [path, hash] = String(href ?? '').split('#')
1051
  const item = this.resources.getItemByHref(decodeURI(path))
1052
  if (!item) return null
1053
  const index = this.resources.spine.findIndex(({ idref }) => idref === item.id)
static/reader.js CHANGED
@@ -34,13 +34,12 @@ const params = new URLSearchParams(location.search);
34
  const readerId = params.get("id") || "";
35
  let localReaderData = null;
36
  try { localReaderData = JSON.parse(sessionStorage.getItem(`reader-source:${readerId}`) || "null"); if (localReaderData) sessionStorage.removeItem(`reader-source:${readerId}`); } catch (_) {}
37
- let sourceUrl = params.get("url") || (localReaderData && localReaderData.url) || "";
38
  let contentUrl = `/api/reader-content?url=${encodeURIComponent(sourceUrl)}`;
39
  let downloadUrl = params.get("download") || (localReaderData && localReaderData.download) || sourceUrl;
40
  const extension = (params.get("ext") || "").toLowerCase();
41
  const chapterManifestUrl = params.get("chapter_manifest") || "";
42
  const capability = VoiceOfMLReader.capability(extension);
43
- let foliateVisibleSections = [], foliateVisibleNodes = new Map();
44
  let resolvedReaderData = localReaderData;
45
  if (localReaderData) { if (localReaderData.title) params.set("title", localReaderData.title); if (localReaderData.extension) params.set("ext", localReaderData.extension); if (localReaderData.repo) params.set("path", [localReaderData.repo, ...(localReaderData.folder || [])].join("/")); }
46
  if (localReaderData && localReaderData.repo) params.set("path", [localReaderData.repo, ...(localReaderData.folder || [])].join("/"));
@@ -55,8 +54,6 @@ window.fetchFile = async (url) => {
55
  };
56
  const content = document.querySelector("#content");
57
  document.querySelector(".reader-panel-tabs")?.setAttribute("role", "tablist");
58
- document.querySelector("#toc-list").addEventListener("click", async (event) => { if (!foliateContinuous || !epubRendition?.book) return; const row = event.target.closest(".toc-item"), index = row ? [...row.parentElement.children].indexOf(row) : -1, item = index >= 0 ? tocEntries[index] : null; if (!item) return; const target = epubRendition.book.resolveHref(String(item.href || "")), section = target && epubRendition.book.sections[target.index], sections = epubRendition.book.sections.filter((entry) => entry.linear !== "no"), visibleIndex = section ? sections.indexOf(section) : -1; if (!section || visibleIndex < 0) return; event.preventDefault(); event.stopImmediatePropagation(); let node = document.querySelector(`.foliate-continuous > article[data-section="${visibleIndex}"]`); if (!node) { const doc = await section.createDocument(), article = document.createElement("article"); article.className = "foliate-continuous-section"; article.dataset.section = String(visibleIndex); article.innerHTML = doc.body?.innerHTML || ""; document.querySelector(".foliate-continuous").appendChild(article); node = article; } const fragment = String(item.href || "").split("#")[1] || "", anchor = fragment ? node.querySelector(`[id="${CSS.escape(decodeURIComponent(fragment))}"], [name="${CSS.escape(decodeURIComponent(fragment))}"]`) : null; viewport.scrollTop = Math.max(0, node.offsetTop + (anchor ? anchor.offsetTop : 0) - 8); currentChapterIndex = index; updateTocCurrentMark(); setReaderPanelOpen(false, true); }, true);
59
- document.querySelector("#toc-list").addEventListener("click", (event) => { if (!foliateContinuous) return; const row = event.target.closest(".toc-item"), index = row ? [...row.parentElement.children].indexOf(row) : -1, entry = index >= 0 ? tocEntries[index] : null; if (!entry || !epubRendition?.book) return; const target = epubRendition.book.resolveHref(entry.href), section = target && epubRendition.book.sections[target.index]; if (!section) return; const rows = [...document.querySelectorAll(".foliate-continuous-section, .foliate-continuous > article[data-section]")], node = rows.find((item) => item.dataset.section === String(target.index)) || rows[index]; if (node) { event.preventDefault(); setTimeout(() => { node.scrollIntoView({ block: "start" }); setReaderPanelOpen(false, true); }, 0); } }, true);
60
  const loadingIndicator = document.createElement("div");
61
  loadingIndicator.className = "reader-loading-indicator";
62
  loadingIndicator.setAttribute("role", "status");
@@ -135,7 +132,6 @@ const readerPath = document.querySelector("#reader-path");
135
  const bookmarkRibbon = document.querySelector("#bookmark-ribbon");
136
  const bookmarkPopover = document.querySelector("#bookmark-popover");
137
  bookmarkPopover.className = "bookmark-popover"; bookmarkPopover.setAttribute("role", "dialog"); bookmarkPopover.setAttribute("aria-modal", "true");
138
- { const oldToc = document.querySelector("#toc-list"), tocList = oldToc.cloneNode(false); oldToc.replaceWith(tocList); tocList.addEventListener("click", async (event) => { if (!foliateContinuous || !epubRendition?.book) return; const row = event.target.closest(".toc-item"), index = row ? [...row.parentElement.children].indexOf(row) : -1, item = index >= 0 ? tocEntries[index] : null; if (!item) return; const target = epubRendition.book.resolveHref(String(item.href || "")), section = target && epubRendition.book.sections[target.index], sections = epubRendition.book.sections.filter((entry) => entry.linear !== "no"), visibleIndex = section ? sections.indexOf(section) : -1; if (!section || visibleIndex < 0) return; event.preventDefault(); event.stopImmediatePropagation(); let node = document.querySelector(`.foliate-continuous > article[data-section="${visibleIndex}"]`); if (!node) { const doc = await section.createDocument(), article = document.createElement("article"); article.className = "foliate-continuous-section"; article.dataset.section = String(visibleIndex); article.innerHTML = doc.body?.innerHTML || ""; document.querySelector(".foliate-continuous").appendChild(article); node = article; } const fragment = String(item.href || "").split("#")[1] || "", id = fragment ? decodeURIComponent(fragment) : "", anchor = id ? node.querySelector(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`) : null; viewport.scrollTop = Math.max(0, node.offsetTop + (anchor ? anchor.offsetTop : 0) - 8); currentChapterIndex = index; updateTocCurrentMark(); setReaderPanelOpen(false, true); }, true); }
139
  const bookmarkLabelInput = document.createElement("input"), bookmarkExcerptInput = document.createElement("textarea"), bookmarkEditFields = document.createElement("div"); bookmarkLabelInput.id = "bookmark-label"; bookmarkLabelInput.maxLength = 120; bookmarkExcerptInput.id = "bookmark-excerpt-input"; bookmarkExcerptInput.maxLength = 500; bookmarkExcerptInput.rows = 3; bookmarkEditFields.className = "bookmark-edit-fields"; bookmarkEditFields.innerHTML = "<label>标题</label><label>摘要</label>"; bookmarkEditFields.children[0].appendChild(bookmarkLabelInput); bookmarkEditFields.children[1].appendChild(bookmarkExcerptInput); bookmarkPopover.insertBefore(bookmarkEditFields, bookmarkPopover.querySelector("div"));
140
  const bookmarksAllButton = document.createElement("button");
141
  if (!document.querySelector("#bookmarks-panel .panel-search-toggle")) { const button = document.createElement("button"); button.className = "panel-search-toggle"; button.type = "button"; document.querySelector("#bookmarks-panel .panel-view-header").appendChild(button); }
@@ -151,7 +147,7 @@ const loadingObserver = new MutationObserver(() => {
151
  });
152
  loadingObserver.observe(content, { childList: true });
153
  document.querySelector(".page-controls").hidden = !["pdf", "pdf-pages", "docx"].includes(capability.mode);
154
- document.querySelector(".zoom-controls").hidden = ["audio", "video"].includes(capability.mode);
155
 
156
  document.querySelector("#title").textContent = title + (extension && !title.toLowerCase().endsWith(`.${extension}`) ? `.${extension}` : "");
157
  readerPath.textContent = readerPathLabel;
@@ -254,6 +250,7 @@ function setZoom(percent, persist = true) {
254
  localStorage.setItem("reader-zoom", String(normalized));
255
  if (persist) scheduleSave();
256
  }
 
257
  setZoom(zoom * 100, false); for (const [id, delta] of [["#zoom-out", -10], ["#zoom-in", 10]]) {
258
  document.querySelector(id).addEventListener("click", () => {
259
  setZoom(Number(zoomInput.value) + delta);
@@ -261,12 +258,12 @@ function setZoom(percent, persist = true) {
261
  }
262
  zoomInput.addEventListener("change", () => setZoom(zoomInput.value));
263
  zoomInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { setZoom(zoomInput.value); zoomInput.blur(); } });
264
- pageInput.addEventListener("change", () => goToPage(pageInput.value));
265
- pageInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { goToPage(pageInput.value); pageInput.blur(); } });
266
  function turnViewport(direction) { viewport.scrollBy({ top: Math.max(160, viewport.clientHeight * 0.86) * direction, behavior: "smooth" }); }
267
  document.addEventListener("keydown", event => { if (event.defaultPrevented || event.target.matches("input,textarea,select,button,a")) return; if (event.key === "PageDown" || event.key === " " || event.key === "ArrowDown") { event.preventDefault(); turnViewport(1); } else if (event.key === "PageUp" || event.key === "ArrowUp") { event.preventDefault(); turnViewport(-1); } });
268
- document.querySelector("#page-prev").addEventListener("click", () => pageCount ? goToPage(currentPage - 1) : turnViewport(-1));
269
- document.querySelector("#page-next").addEventListener("click", () => pageCount ? goToPage(currentPage + 1) : turnViewport(1));
270
 
271
  async function goToPage(value) {
272
  if (!pageCount) return;
@@ -388,12 +385,6 @@ function syncEpubTocLocation(location) { const href = location && location.start
388
  async function activateFoliateTocEntry(entry) { const target = epubRendition?.book?.resolveHref(String(entry.href || "")), section = target && epubRendition.book.sections[target.index], sections = epubRendition.book.sections.filter((item) => item.linear !== "no"), visibleIndex = section ? sections.indexOf(section) : -1; if (!section || visibleIndex < 0) return; let node = document.querySelector(`.foliate-continuous > article[data-section="${visibleIndex}"]`); if (!node) { const doc = await section.createDocument(), article = document.createElement("article"); article.className = "foliate-continuous-section"; article.dataset.section = String(visibleIndex); article.innerHTML = doc.body?.innerHTML || ""; document.querySelector(".foliate-continuous").appendChild(article); node = article; } const fragment = String(entry.href || "").split("#")[1] || "", id = fragment ? decodeURIComponent(fragment) : "", anchor = id ? node.querySelector(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`) : null; viewport.scrollTop = Math.max(0, node.offsetTop + (anchor ? anchor.offsetTop : 0) - 8); currentChapterIndex = tocEntries.indexOf(entry); updateTocCurrentMark(); setReaderPanelOpen(false, true); }
389
  setInterval(updateTocCurrentMark, 250);
390
  setInterval(() => { if (!foliateContinuous || !epubRendition?.book || !tocEntries.length) return; const rows = [...document.querySelectorAll(".foliate-continuous > article[data-section]")], top = viewport.getBoundingClientRect().top + 80, sections = epubRendition.book.sections.filter((entry) => entry.linear !== "no"); let best = -1; tocEntries.forEach((entry, index) => { const target = epubRendition.book.resolveHref(String(entry.href || "")), section = target && epubRendition.book.sections[target.index], visible = section ? sections.indexOf(section) : -1, node = rows.find((row) => Number(row.dataset.section) === visible); if (node) { const fragment = String(entry.href || "").split("#")[1] || "", anchor = fragment ? node.querySelector(`[id="${CSS.escape(decodeURIComponent(fragment))}"], [name="${CSS.escape(decodeURIComponent(fragment))}"]`) : null, position = node.getBoundingClientRect().top + (anchor ? anchor.getBoundingClientRect().top - node.getBoundingClientRect().top : 0); if (position <= top) best = index; } }); if (best >= 0 && best !== currentChapterIndex) { currentChapterIndex = best; updateTocCurrentMark(); } }, 250);
391
- document.querySelector("#toc-list").addEventListener("click", async (event) => { if (!foliateContinuous || !epubRendition?.book) return; const row = event.target.closest(".toc-item"), tocIndex = row ? [...row.parentElement.children].indexOf(row) : -1, item = tocIndex >= 0 ? tocEntries[tocIndex] : null; if (!item) return; const target = epubRendition.book.resolveHref(String(item.href || "")), section = target && epubRendition.book.sections[target.index], sections = epubRendition.book.sections.filter((entry) => entry.linear !== "no"), visibleIndex = section ? sections.indexOf(section) : -1; if (!section || visibleIndex < 0) return; event.preventDefault(); event.stopImmediatePropagation(); let node = document.querySelector(`.foliate-continuous > article[data-section="${visibleIndex}"]`); if (!node) { const doc = await section.createDocument(), article = document.createElement("article"); article.className = "foliate-continuous-section"; article.dataset.section = String(visibleIndex); article.innerHTML = doc.body?.innerHTML || ""; document.querySelector(".foliate-continuous").appendChild(article); node = article; } const fragment = String(item.href || "").split("#")[1] || "", anchor = fragment ? node.querySelector(`[id="${CSS.escape(decodeURIComponent(fragment))}"], [name="${CSS.escape(decodeURIComponent(fragment))}"]`) : null; viewport.scrollTop = Math.max(0, node.offsetTop + (anchor ? anchor.offsetTop : 0) - 8); currentChapterIndex = tocIndex; updateTocCurrentMark(); setReaderPanelOpen(false, true); }, true);
392
- setInterval(() => { for (const entry of tocEntries) entry.href = String(entry.href || ""); }, 250);
393
- setInterval(() => { if (!foliateContinuous || !epubRendition?.book || !tocEntries.length) return; const rows = [...document.querySelectorAll(".foliate-continuous > article[data-section]")], top = viewport.getBoundingClientRect().top + 80, sections = epubRendition.book.sections.filter((entry) => entry.linear !== "no"); let best = -1; tocEntries.forEach((entry, index) => { const target = epubRendition.book.resolveHref(String(entry.href || "")), section = target && epubRendition.book.sections[target.index], visible = section ? sections.indexOf(section) : -1, node = rows.find((row) => Number(row.dataset.section) === visible); if (node && node.getBoundingClientRect().top <= top) { const fragment = String(entry.href || "").split("#")[1] || "", anchor = fragment ? node.querySelector(`[id="${CSS.escape(decodeURIComponent(fragment))}"], [name="${CSS.escape(decodeURIComponent(fragment))}"]`) : null, position = node.getBoundingClientRect().top + (anchor ? anchor.getBoundingClientRect().top - node.getBoundingClientRect().top : 0); if (position <= top) best = index; } }); if (best >= 0 && best !== currentChapterIndex) { currentChapterIndex = best; updateTocCurrentMark(); } }, 250);
394
- document.querySelector("#toc-list").addEventListener("click", async (event) => { if (!foliateContinuous || !epubRendition?.book) return; const row = event.target.closest(".toc-item"), tocIndex = row ? [...row.parentElement.children].indexOf(row) : -1, item = tocIndex >= 0 ? tocEntries[tocIndex] : null; if (!item) return; const target = epubRendition.book.resolveHref(item.href), section = target && epubRendition.book.sections[target.index], sections = epubRendition.book.sections.filter((entry) => entry.linear !== "no"), visibleIndex = section ? sections.indexOf(section) : -1; if (!section || visibleIndex < 0) return; event.preventDefault(); event.stopImmediatePropagation(); let node = document.querySelector(`.foliate-continuous > article[data-section="${visibleIndex}"]`); if (!node) { const doc = await section.createDocument(), article = document.createElement("article"); article.className = "foliate-continuous-section"; article.dataset.section = String(visibleIndex); article.innerHTML = doc.body?.innerHTML || ""; document.querySelector(".foliate-continuous").appendChild(article); node = article; } const anchor = target.anchor && node.querySelector(`[id="${CSS.escape(target.anchor)}"]`); viewport.scrollTop = Math.max(0, node.offsetTop + (anchor ? anchor.offsetTop : 0) - 8); currentChapterIndex = tocIndex; updateTocCurrentMark(); setReaderPanelOpen(false, true); }, true);
395
- document.querySelector("#toc-list").addEventListener("click", async (event) => { if (!foliateContinuous || !epubRendition?.book) return; const row = event.target.closest(".toc-item"), tocIndex = row ? [...row.parentElement.children].indexOf(row) : -1, item = tocIndex >= 0 ? tocEntries[tocIndex] : null; if (!item) return; const target = epubRendition.book.resolveHref(item.href), section = target && epubRendition.book.sections[target.index], sections = epubRendition.book.sections.filter((entry) => entry.linear !== "no"), visibleIndex = section ? sections.indexOf(section) : -1; if (!section || visibleIndex < 0) return; let node = document.querySelector(`.foliate-continuous > article[data-section="${visibleIndex}"]`); if (!node) { const doc = await section.createDocument(), article = document.createElement("article"); article.className = "foliate-continuous-section"; article.dataset.section = String(visibleIndex); article.innerHTML = doc.body?.innerHTML || ""; document.querySelector(".foliate-continuous").appendChild(article); node = article; } event.preventDefault(); event.stopImmediatePropagation(); viewport.scrollTop = Math.max(0, node.offsetTop - 8); setReaderPanelOpen(false, true); }, true);
396
- document.querySelector("#toc-list").addEventListener("click", (event) => { if (!foliateContinuous || !epubRendition?.book) return; const row = event.target.closest(".toc-item"), index = row ? [...row.parentElement.children].indexOf(row) : -1, entry = index >= 0 ? tocEntries[index] : null; if (!entry) return; const target = epubRendition.book.resolveHref(entry.href), section = target && epubRendition.book.sections[target.index], sections = epubRendition.book.sections.filter((item) => item.linear !== "no"), visibleIndex = section ? sections.indexOf(section) : -1, node = document.querySelector(`.foliate-continuous > article[data-section="${visibleIndex}"]`); if (node) { event.stopImmediatePropagation(); event.preventDefault(); node.scrollIntoView({ block: "start" }); setReaderPanelOpen(false, true); } }, true);
397
  setInterval(() => { if (!foliateContinuous || !epubRendition?.book || !tocEntries.length) return; const sections = epubRendition.book.sections.filter((section) => section.linear !== "no"), rows = [...document.querySelectorAll(".foliate-continuous-section, .foliate-continuous > article[data-section]")], top = viewport.getBoundingClientRect().top + 80; let visible = 0; rows.forEach((row) => { if (row.getBoundingClientRect().top <= top) visible = Number(row.dataset.section); }); const entry = tocEntries.find((item) => { const target = epubRendition.book.resolveHref(item.href), section = target && epubRendition.book.sections[target.index]; return section && sections.indexOf(section) === visible; }); if (entry) { currentChapterIndex = tocEntries.indexOf(entry); updateTocCurrentMark(); } }, 250);
398
  function placeReadingProgress() { const panel = document.querySelector("#history-panel"), tocPanel = document.querySelector("#toc-panel"), tocList = tocPanel.querySelector(".panel-list"), noToc = loadingStatus.hidden && !tocEntries.length && !mediaElement; progressTools.classList.toggle("reader-no-toc", noToc); tocPanel.querySelector(".panel-view-header strong").textContent = noToc ? "阅读状态" : "目录"; if (noToc && (progressTools.parentElement !== tocPanel || progressTools.nextElementSibling !== tocList)) tocPanel.insertBefore(progressTools, tocList); else if (!noToc && panel.lastElementChild !== progressTools) panel.appendChild(progressTools); }
399
  setInterval(placeReadingProgress, 250);
@@ -985,7 +976,7 @@ function renderMedia(mode) {
985
  status.textContent = mode === "audio" ? "音频" : "视频";
986
  }
987
  async function start() {
988
- if (readerId && !sourceUrl) { const resolved = resolvedReaderData || await fetch(`/api/reader-resolve?id=${encodeURIComponent(readerId)}`).then((response) => { if (!response.ok) throw new Error(`READER_RESOLVE_HTTP_${response.status}`); return response.json(); }); sourceUrl = resolved.url || ""; contentUrl = `/api/reader-content?url=${encodeURIComponent(sourceUrl)}`; downloadUrl = resolved.download || sourceUrl; const resolvedTitle = resolved.title && resolved.title !== "在线阅读" ? resolved.title : sourceName, resolvedExtension = String(resolved.extension || extension).toLowerCase(); document.querySelector("#title").textContent = resolvedTitle + (resolvedExtension && !String(resolvedTitle).toLowerCase().endsWith(`.${resolvedExtension}`) ? `.${resolvedExtension}` : ""); readerPath.textContent = resolved.path || ""; readerPath.hidden = !readerPath.textContent; if (readerPath.textContent && resolved.repo) { const folderTarget = new URL("/" + encodeURIComponent(resolved.repo), location.origin); if (resolved.folder) folderTarget.searchParams.set("folder_self", resolved.folder); readerPath.onclick = () => window.parent !== window ? window.parent.postMessage({ type: "voice-reader-navigate", url: folderTarget.href }, location.origin) : location.assign(folderTarget.href); } status.hidden = true; }
989
  if ((!validSource(sourceUrl) && !readerId) || capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED) return fail("此文件暂不支持在线阅读,请下载原文件。");
990
  if (resolvedReaderData && resolvedReaderData.title && resolvedReaderData.original_extension) { const originalExtension = String(resolvedReaderData.original_extension).toLowerCase().replace(/^\./, ""), originalTitle = String(resolvedReaderData.title); title = originalTitle + (originalExtension && !originalTitle.toLowerCase().endsWith(`.${originalExtension}`) ? `.${originalExtension}` : ""); document.querySelector("#title").textContent = title; document.title = `${title} - VoiceOfML Reader`; }
991
  document.querySelector("#download").href = `/api/download?file=${encodeURIComponent(title)}&link=${encodeURIComponent(downloadUrl)}`;
@@ -1038,9 +1029,7 @@ function prepareDocument() {
1038
  return Promise.resolve(null);
1039
  }
1040
 
1041
- function setupFoliateToc(view) { const entries = []; const append = (items, depth = 0) => { for (const item of items || []) { entries.push({ label: item.label || item.title || "未命名章节", href: item.href, depth, activate: () => view.goTo(item.href) }); append(item.subitems, depth + 1); } }; append(view.book && view.book.toc); if (entries.length) setToc(entries); }
1042
- setInterval(() => { if (epubRendition && epubRendition.book && epubRendition.book.toc && !tocEntries.length) setupFoliateToc(epubRendition); }, 250);
1043
- async function renderFoliate(prepared) { const response = await fetchWithReaderTimeout(contentUrl, READER_PROXY_TIMEOUT_MS); if (!response.ok) throw new Error(`HTTP ${response.status}`); await import("/static/foliate-reader/view.js"); const bytes = await response.arrayBuffer(), view = document.createElement("foliate-view"), stream = document.createElement("div"); view.className = "foliate-reader-view"; stream.className = "foliate-continuous"; content.replaceChildren(view, stream); epubRendition = view; await view.open(new File([bytes], new URL(sourceUrl).pathname.split("/").pop() || "book.epub", { type: response.headers.get("content-type") || "application/epub+zip" })); epubBook = view.book; view.style.display = "none"; const sections = view.book.sections.filter((section) => section.linear !== "no"); const loaded = new Set(), load = async (section, index) => { if (loaded.has(index)) return; const doc = await section.createDocument(); const article = document.createElement("article"); article.dataset.section = String(index); article.innerHTML = doc.body ? doc.body.innerHTML : ""; stream.appendChild(article); loaded.add(index); observer.observe(article); }; const observer = new IntersectionObserver((entries) => entries.filter((entry) => entry.isIntersecting).forEach((entry) => { const index = Number(entry.target.dataset.section) + 1; if (sections[index]) load(sections[index], index).catch(() => {}); }), { root: viewport, rootMargin: "1000px" }); await load(sections[0], 0); const entries = []; const append = (items, depth = 0) => { for (const item of items || []) { entries.push({ label: item.label || "未命名章节", href: item.href, depth, activate: () => view.goTo(item.href) }); append(item.subitems, depth + 1); } }; append(view.book.toc); if (entries.length) setToc(entries); loadingIndicator.remove(); loadingStatus.hidden = true; loadingObserver.disconnect(); status.textContent = "EPUB"; }
1044
  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="M7.2 8.3A6.5 6.5 0 1 1 6 16"/><path d="m7.2 8.3 3-.2"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/></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);
1045
  const readingProgressSummary = document.createElement("div"); readingProgressSummary.className = "reader-progress-summary"; readingProgressSummary.innerHTML = '<strong>阅读状态</strong><span class="reader-progress-summary-value">0.0%</span><button class="reader-progress-bookmark text-action" type="button">在当前位置添加书签</button>'; progressTools.prepend(readingProgressSummary); readingProgressSummary.querySelector(".reader-progress-bookmark").addEventListener("click", () => bookmarkRibbon.click()); function updateReadingProgressSummary() { const noToc = !tocEntries.length && !mediaElement; readingProgressSummary.hidden = !noToc; if (noToc) readingProgressSummary.querySelector(".reader-progress-summary-value").textContent = `${readerProgressPercent().toFixed(1)}%`; } setInterval(updateReadingProgressSummary, 250); updateReadingProgressSummary();
1046
  setInterval(() => { const tab = document.querySelector("#toc-tab"); if (tab && !tocEntries.length) { tab.hidden = false; tab.textContent = "阅读"; } }, 500);
@@ -1075,6 +1064,3 @@ function moveFullSearch(step) { if (!fullSearchResults.length) return; fullSearc
1075
  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));
1076
  fullSearchButton.addEventListener("click", (event) => { const panel = document.querySelector("#history-panel"); if (panel.classList.contains("is-open") && fullSearchView.hidden === false) { event.preventDefault(); event.stopImmediatePropagation(); clearFullSearchMarks(); selectPanel(tocEntries.length ? "toc" : "bookmarks"); } }, true); fullSearchInput.addEventListener("beforeinput", clearFullSearchMarks);
1077
  fullSearchView.querySelector("#full-search-clear").addEventListener("click", clearFullSearchMarks, true);
1078
- async function activateFoliateTocEntryLatest(entry) { const target = epubRendition?.book?.resolveHref(String(entry.href || "")), section = target && epubRendition.book.sections[target.index], sections = epubRendition.book.sections.filter((item) => item.linear !== "no"), visibleIndex = section ? sections.indexOf(section) : -1; if (!section || visibleIndex < 0) return; let node = document.querySelector(`.foliate-continuous > article[data-section="${visibleIndex}"]`); if (!node) { const doc = await section.createDocument(), article = document.createElement("article"); article.className = "foliate-continuous-section"; article.dataset.section = String(visibleIndex); article.innerHTML = doc.body?.innerHTML || ""; document.querySelector(".foliate-continuous").appendChild(article); node = article; } const anchor = target.anchor ? target.anchor(document) : null, offset = anchor?.getBoundingClientRect ? anchor.getBoundingClientRect().top - node.getBoundingClientRect().top : 0; viewport.scrollTop = Math.max(0, node.offsetTop + offset - 8); currentChapterIndex = tocEntries.indexOf(entry); updateTocCurrentMark(); setReaderPanelOpen(false, true); }
1079
- setInterval(() => { if (!foliateContinuous || !epubRendition?.book || !tocEntries.length) return; const top = viewport.getBoundingClientRect().top + 80, sections = epubRendition.book.sections.filter((item) => item.linear !== "no"), rows = [...document.querySelectorAll(".foliate-continuous > article[data-section]")]; let best = -1; tocEntries.forEach((entry, index) => { const target = epubRendition.book.resolveHref(String(entry.href || "")), section = target && epubRendition.book.sections[target.index], visible = section ? sections.indexOf(section) : -1, node = rows.find((row) => Number(row.dataset.section) === visible); if (!node) return; const anchor = target.anchor ? target.anchor(document) : null, position = node.getBoundingClientRect().top + (anchor?.getBoundingClientRect ? anchor.getBoundingClientRect().top - node.getBoundingClientRect().top : 0); if (position <= top) best = index; }); if (best >= 0 && best !== currentChapterIndex) { currentChapterIndex = best; updateTocCurrentMark(); } }, 250);
1080
- document.addEventListener("click", (event) => { if (!foliateContinuous || !event.target.closest("#toc-list .toc-item")) return; event.preventDefault(); event.stopImmediatePropagation(); const row = event.target.closest("#toc-list .toc-item"), index = [...row.parentElement.children].indexOf(row), entry = tocEntries[index]; if (entry) activateFoliateTocEntryLatest(entry); }, true);
 
34
  const readerId = params.get("id") || "";
35
  let localReaderData = null;
36
  try { localReaderData = JSON.parse(sessionStorage.getItem(`reader-source:${readerId}`) || "null"); if (localReaderData) sessionStorage.removeItem(`reader-source:${readerId}`); } catch (_) {}
37
+ let sourceUrl = params.get("url") || (!readerId && localReaderData && localReaderData.url) || "";
38
  let contentUrl = `/api/reader-content?url=${encodeURIComponent(sourceUrl)}`;
39
  let downloadUrl = params.get("download") || (localReaderData && localReaderData.download) || sourceUrl;
40
  const extension = (params.get("ext") || "").toLowerCase();
41
  const chapterManifestUrl = params.get("chapter_manifest") || "";
42
  const capability = VoiceOfMLReader.capability(extension);
 
43
  let resolvedReaderData = localReaderData;
44
  if (localReaderData) { if (localReaderData.title) params.set("title", localReaderData.title); if (localReaderData.extension) params.set("ext", localReaderData.extension); if (localReaderData.repo) params.set("path", [localReaderData.repo, ...(localReaderData.folder || [])].join("/")); }
45
  if (localReaderData && localReaderData.repo) params.set("path", [localReaderData.repo, ...(localReaderData.folder || [])].join("/"));
 
54
  };
55
  const content = document.querySelector("#content");
56
  document.querySelector(".reader-panel-tabs")?.setAttribute("role", "tablist");
 
 
57
  const loadingIndicator = document.createElement("div");
58
  loadingIndicator.className = "reader-loading-indicator";
59
  loadingIndicator.setAttribute("role", "status");
 
132
  const bookmarkRibbon = document.querySelector("#bookmark-ribbon");
133
  const bookmarkPopover = document.querySelector("#bookmark-popover");
134
  bookmarkPopover.className = "bookmark-popover"; bookmarkPopover.setAttribute("role", "dialog"); bookmarkPopover.setAttribute("aria-modal", "true");
 
135
  const bookmarkLabelInput = document.createElement("input"), bookmarkExcerptInput = document.createElement("textarea"), bookmarkEditFields = document.createElement("div"); bookmarkLabelInput.id = "bookmark-label"; bookmarkLabelInput.maxLength = 120; bookmarkExcerptInput.id = "bookmark-excerpt-input"; bookmarkExcerptInput.maxLength = 500; bookmarkExcerptInput.rows = 3; bookmarkEditFields.className = "bookmark-edit-fields"; bookmarkEditFields.innerHTML = "<label>标题</label><label>摘要</label>"; bookmarkEditFields.children[0].appendChild(bookmarkLabelInput); bookmarkEditFields.children[1].appendChild(bookmarkExcerptInput); bookmarkPopover.insertBefore(bookmarkEditFields, bookmarkPopover.querySelector("div"));
136
  const bookmarksAllButton = document.createElement("button");
137
  if (!document.querySelector("#bookmarks-panel .panel-search-toggle")) { const button = document.createElement("button"); button.className = "panel-search-toggle"; button.type = "button"; document.querySelector("#bookmarks-panel .panel-view-header").appendChild(button); }
 
147
  });
148
  loadingObserver.observe(content, { childList: true });
149
  document.querySelector(".page-controls").hidden = !["pdf", "pdf-pages", "docx"].includes(capability.mode);
150
+ document.querySelector(".zoom-controls").hidden = !capability.mode || ["audio", "video"].includes(capability.mode);
151
 
152
  document.querySelector("#title").textContent = title + (extension && !title.toLowerCase().endsWith(`.${extension}`) ? `.${extension}` : "");
153
  readerPath.textContent = readerPathLabel;
 
250
  localStorage.setItem("reader-zoom", String(normalized));
251
  if (persist) scheduleSave();
252
  }
253
+ const handlePageNavigationFailure = () => fail("原文件加载失败,请检查网络后重试,或下载原文件。");
254
  setZoom(zoom * 100, false); for (const [id, delta] of [["#zoom-out", -10], ["#zoom-in", 10]]) {
255
  document.querySelector(id).addEventListener("click", () => {
256
  setZoom(Number(zoomInput.value) + delta);
 
258
  }
259
  zoomInput.addEventListener("change", () => setZoom(zoomInput.value));
260
  zoomInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { setZoom(zoomInput.value); zoomInput.blur(); } });
261
+ pageInput.addEventListener("change", () => goToPage(pageInput.value).catch(handlePageNavigationFailure));
262
+ pageInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { goToPage(pageInput.value).catch(handlePageNavigationFailure); pageInput.blur(); } });
263
  function turnViewport(direction) { viewport.scrollBy({ top: Math.max(160, viewport.clientHeight * 0.86) * direction, behavior: "smooth" }); }
264
  document.addEventListener("keydown", event => { if (event.defaultPrevented || event.target.matches("input,textarea,select,button,a")) return; if (event.key === "PageDown" || event.key === " " || event.key === "ArrowDown") { event.preventDefault(); turnViewport(1); } else if (event.key === "PageUp" || event.key === "ArrowUp") { event.preventDefault(); turnViewport(-1); } });
265
+ document.querySelector("#page-prev").addEventListener("click", () => pageCount ? goToPage(currentPage - 1).catch(handlePageNavigationFailure) : turnViewport(-1));
266
+ document.querySelector("#page-next").addEventListener("click", () => pageCount ? goToPage(currentPage + 1).catch(handlePageNavigationFailure) : turnViewport(1));
267
 
268
  async function goToPage(value) {
269
  if (!pageCount) return;
 
385
  async function activateFoliateTocEntry(entry) { const target = epubRendition?.book?.resolveHref(String(entry.href || "")), section = target && epubRendition.book.sections[target.index], sections = epubRendition.book.sections.filter((item) => item.linear !== "no"), visibleIndex = section ? sections.indexOf(section) : -1; if (!section || visibleIndex < 0) return; let node = document.querySelector(`.foliate-continuous > article[data-section="${visibleIndex}"]`); if (!node) { const doc = await section.createDocument(), article = document.createElement("article"); article.className = "foliate-continuous-section"; article.dataset.section = String(visibleIndex); article.innerHTML = doc.body?.innerHTML || ""; document.querySelector(".foliate-continuous").appendChild(article); node = article; } const fragment = String(entry.href || "").split("#")[1] || "", id = fragment ? decodeURIComponent(fragment) : "", anchor = id ? node.querySelector(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`) : null; viewport.scrollTop = Math.max(0, node.offsetTop + (anchor ? anchor.offsetTop : 0) - 8); currentChapterIndex = tocEntries.indexOf(entry); updateTocCurrentMark(); setReaderPanelOpen(false, true); }
386
  setInterval(updateTocCurrentMark, 250);
387
  setInterval(() => { if (!foliateContinuous || !epubRendition?.book || !tocEntries.length) return; const rows = [...document.querySelectorAll(".foliate-continuous > article[data-section]")], top = viewport.getBoundingClientRect().top + 80, sections = epubRendition.book.sections.filter((entry) => entry.linear !== "no"); let best = -1; tocEntries.forEach((entry, index) => { const target = epubRendition.book.resolveHref(String(entry.href || "")), section = target && epubRendition.book.sections[target.index], visible = section ? sections.indexOf(section) : -1, node = rows.find((row) => Number(row.dataset.section) === visible); if (node) { const fragment = String(entry.href || "").split("#")[1] || "", anchor = fragment ? node.querySelector(`[id="${CSS.escape(decodeURIComponent(fragment))}"], [name="${CSS.escape(decodeURIComponent(fragment))}"]`) : null, position = node.getBoundingClientRect().top + (anchor ? anchor.getBoundingClientRect().top - node.getBoundingClientRect().top : 0); if (position <= top) best = index; } }); if (best >= 0 && best !== currentChapterIndex) { currentChapterIndex = best; updateTocCurrentMark(); } }, 250);
 
 
 
 
 
 
388
  setInterval(() => { if (!foliateContinuous || !epubRendition?.book || !tocEntries.length) return; const sections = epubRendition.book.sections.filter((section) => section.linear !== "no"), rows = [...document.querySelectorAll(".foliate-continuous-section, .foliate-continuous > article[data-section]")], top = viewport.getBoundingClientRect().top + 80; let visible = 0; rows.forEach((row) => { if (row.getBoundingClientRect().top <= top) visible = Number(row.dataset.section); }); const entry = tocEntries.find((item) => { const target = epubRendition.book.resolveHref(item.href), section = target && epubRendition.book.sections[target.index]; return section && sections.indexOf(section) === visible; }); if (entry) { currentChapterIndex = tocEntries.indexOf(entry); updateTocCurrentMark(); } }, 250);
389
  function placeReadingProgress() { const panel = document.querySelector("#history-panel"), tocPanel = document.querySelector("#toc-panel"), tocList = tocPanel.querySelector(".panel-list"), noToc = loadingStatus.hidden && !tocEntries.length && !mediaElement; progressTools.classList.toggle("reader-no-toc", noToc); tocPanel.querySelector(".panel-view-header strong").textContent = noToc ? "阅读状态" : "目录"; if (noToc && (progressTools.parentElement !== tocPanel || progressTools.nextElementSibling !== tocList)) tocPanel.insertBefore(progressTools, tocList); else if (!noToc && panel.lastElementChild !== progressTools) panel.appendChild(progressTools); }
390
  setInterval(placeReadingProgress, 250);
 
976
  status.textContent = mode === "audio" ? "音频" : "视频";
977
  }
978
  async function start() {
979
+ if (readerId && !sourceUrl) { const resolved = await fetch(`/api/reader-resolve?id=${encodeURIComponent(readerId)}`).then((response) => { if (!response.ok) throw new Error(`READER_RESOLVE_HTTP_${response.status}`); return response.json(); }); sourceUrl = resolved.url || ""; contentUrl = `/api/reader-content?url=${encodeURIComponent(sourceUrl)}`; downloadUrl = resolved.download || sourceUrl; const resolvedTitle = resolved.title && resolved.title !== "在线阅读" ? resolved.title : sourceName, resolvedExtension = String(resolved.extension || extension).toLowerCase(); document.querySelector("#title").textContent = resolvedTitle + (resolvedExtension && !String(resolvedTitle).toLowerCase().endsWith(`.${resolvedExtension}`) ? `.${resolvedExtension}` : ""); readerPath.textContent = resolved.path || ""; readerPath.hidden = !readerPath.textContent; if (readerPath.textContent && resolved.repo) { const folderTarget = new URL("/" + encodeURIComponent(resolved.repo), location.origin); if (resolved.folder) folderTarget.searchParams.set("folder_self", resolved.folder); readerPath.onclick = () => window.parent !== window ? window.parent.postMessage({ type: "voice-reader-navigate", url: folderTarget.href }, location.origin) : location.assign(folderTarget.href); } status.hidden = true; }
980
  if ((!validSource(sourceUrl) && !readerId) || capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED) return fail("此文件暂不支持在线阅读,请下载原文件。");
981
  if (resolvedReaderData && resolvedReaderData.title && resolvedReaderData.original_extension) { const originalExtension = String(resolvedReaderData.original_extension).toLowerCase().replace(/^\./, ""), originalTitle = String(resolvedReaderData.title); title = originalTitle + (originalExtension && !originalTitle.toLowerCase().endsWith(`.${originalExtension}`) ? `.${originalExtension}` : ""); document.querySelector("#title").textContent = title; document.title = `${title} - VoiceOfML Reader`; }
982
  document.querySelector("#download").href = `/api/download?file=${encodeURIComponent(title)}&link=${encodeURIComponent(downloadUrl)}`;
 
1029
  return Promise.resolve(null);
1030
  }
1031
 
1032
+ async function renderFoliate(prepared) { const response = await fetchWithReaderTimeout(contentUrl, READER_PROXY_TIMEOUT_MS); if (!response.ok) throw new Error(`HTTP ${response.status}`); await import("/static/foliate-reader/view.js"); const bytes = await response.arrayBuffer(), view = document.createElement("foliate-view"), stream = document.createElement("div"); view.className = "foliate-reader-view"; stream.className = "foliate-continuous"; content.replaceChildren(view, stream); epubRendition = view; await view.open(new File([bytes], new URL(sourceUrl).pathname.split("/").pop() || "book.epub", { type: response.headers.get("content-type") || "application/epub+zip" })); epubBook = view.book; view.style.display = "none"; const sections = view.book.sections.filter((section) => section.linear !== "no"); const loaded = new Set(), load = async (section, index) => { if (loaded.has(index)) return; const doc = await section.createDocument(); const article = document.createElement("article"); article.dataset.section = String(index); const styles = [...(doc.head?.querySelectorAll("style,link[rel=stylesheet]") || [])].map((node) => node.outerHTML).join(""); article.innerHTML = styles + (doc.body ? doc.body.innerHTML : ""); stream.appendChild(article); loaded.add(index); observer.observe(article); }; const observer = new IntersectionObserver((entries) => entries.filter((entry) => entry.isIntersecting).forEach((entry) => { const index = Number(entry.target.dataset.section) + 1; if (sections[index]) load(sections[index], index).catch(() => {}); }), { root: viewport, rootMargin: "1000px" }); if (sections[0]) await load(sections[0], 0); const entries = []; const append = (items, depth = 0) => { for (const item of items || []) { const href = item?.href ? String(item.href) : ""; if (href) entries.push({ label: item.label || "未命名章节", href, depth, activate: () => view.goTo(href) }); append(item.subitems, depth + 1); } }; append(view.book.toc); if (entries.length) setToc(entries); loadingIndicator.remove(); loadingStatus.hidden = true; loadingObserver.disconnect(); status.textContent = "EPUB"; }
 
 
1033
  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="M7.2 8.3A6.5 6.5 0 1 1 6 16"/><path d="m7.2 8.3 3-.2"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/></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);
1034
  const readingProgressSummary = document.createElement("div"); readingProgressSummary.className = "reader-progress-summary"; readingProgressSummary.innerHTML = '<strong>阅读状态</strong><span class="reader-progress-summary-value">0.0%</span><button class="reader-progress-bookmark text-action" type="button">在当前位置添加书签</button>'; progressTools.prepend(readingProgressSummary); readingProgressSummary.querySelector(".reader-progress-bookmark").addEventListener("click", () => bookmarkRibbon.click()); function updateReadingProgressSummary() { const noToc = !tocEntries.length && !mediaElement; readingProgressSummary.hidden = !noToc; if (noToc) readingProgressSummary.querySelector(".reader-progress-summary-value").textContent = `${readerProgressPercent().toFixed(1)}%`; } setInterval(updateReadingProgressSummary, 250); updateReadingProgressSummary();
1035
  setInterval(() => { const tab = document.querySelector("#toc-tab"); if (tab && !tocEntries.length) { tab.hidden = false; tab.textContent = "阅读"; } }, 500);
 
1064
  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));
1065
  fullSearchButton.addEventListener("click", (event) => { const panel = document.querySelector("#history-panel"); if (panel.classList.contains("is-open") && fullSearchView.hidden === false) { event.preventDefault(); event.stopImmediatePropagation(); clearFullSearchMarks(); selectPanel(tocEntries.length ? "toc" : "bookmarks"); } }, true); fullSearchInput.addEventListener("beforeinput", clearFullSearchMarks);
1066
  fullSearchView.querySelector("#full-search-clear").addEventListener("click", clearFullSearchMarks, true);