Add editable Reader bookmarks
Browse files- static/reader.css +5 -0
- static/reader.js +20 -7
static/reader.css
CHANGED
|
@@ -34,12 +34,17 @@ body { margin: 0; height: 100vh; height: 100dvh; overflow: hidden; display: flex
|
|
| 34 |
.panel-search.is-open { max-height: 34px; margin-bottom: 8px; opacity: 1; transform: translateY(0); }
|
| 35 |
.panel-list { padding-bottom: 12px; }
|
| 36 |
.panel-empty { padding: 28px 12px; color: #929aa1; text-align: center; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
.panel-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 8px; width: 100%; padding: 10px 12px; border: 0; border-bottom: 1px solid #2d3237; background: transparent; color: inherit; text-align: left; }
|
| 38 |
.panel-item-main { min-width: 0; border: 0; padding: 0; background: transparent; color: #e5e9ec; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
| 39 |
.panel-item small { color: #929aa1; }
|
| 40 |
.bookmark-excerpt { grid-column: 1 / -1; margin: 2px 0 0; color: #b8bec3; font-size: 12px; line-height: 1.55; display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
|
| 41 |
.search-match { padding: 0; background: #f0c75e; color: #1d2023; }
|
| 42 |
.panel-item-remove, .text-action { border: 0; background: transparent; color: #aeb5ba; cursor: pointer; }
|
|
|
|
| 43 |
.toc-item { padding-left: calc(12px + var(--toc-depth, 0) * 16px); }
|
| 44 |
.toc-item .panel-item-main { user-select: text; }
|
| 45 |
.reader-panel > footer { flex: none; min-height: 52px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; border-top: 1px solid #34383d; }
|
|
|
|
| 34 |
.panel-search.is-open { max-height: 34px; margin-bottom: 8px; opacity: 1; transform: translateY(0); }
|
| 35 |
.panel-list { padding-bottom: 12px; }
|
| 36 |
.panel-empty { padding: 28px 12px; color: #929aa1; text-align: center; }
|
| 37 |
+
.media-panel-content { display: grid; gap: 14px; padding: 24px 16px; text-align: center; }
|
| 38 |
+
.media-panel-time { font-variant-numeric: tabular-nums; font-size: 24px; }
|
| 39 |
+
.media-panel-content { display: grid; gap: 14px; padding: 24px 16px; text-align: center; }
|
| 40 |
+
.media-panel-time { font-variant-numeric: tabular-nums; font-size: 24px; }
|
| 41 |
.panel-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 8px; width: 100%; padding: 10px 12px; border: 0; border-bottom: 1px solid #2d3237; background: transparent; color: inherit; text-align: left; }
|
| 42 |
.panel-item-main { min-width: 0; border: 0; padding: 0; background: transparent; color: #e5e9ec; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
| 43 |
.panel-item small { color: #929aa1; }
|
| 44 |
.bookmark-excerpt { grid-column: 1 / -1; margin: 2px 0 0; color: #b8bec3; font-size: 12px; line-height: 1.55; display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
|
| 45 |
.search-match { padding: 0; background: #f0c75e; color: #1d2023; }
|
| 46 |
.panel-item-remove, .text-action { border: 0; background: transparent; color: #aeb5ba; cursor: pointer; }
|
| 47 |
+
.panel-item-edit { border: 0; background: transparent; color: #aeb5ba; cursor: pointer; }
|
| 48 |
.toc-item { padding-left: calc(12px + var(--toc-depth, 0) * 16px); }
|
| 49 |
.toc-item .panel-item-main { user-select: text; }
|
| 50 |
.reader-panel > footer { flex: none; min-height: 52px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; border-top: 1px solid #34383d; }
|
static/reader.js
CHANGED
|
@@ -97,18 +97,22 @@ let restorationFailed = false;
|
|
| 97 |
let tocEntries = [];
|
| 98 |
let markerFrame = 0;
|
| 99 |
let pendingBookmarkSnapshot = null;
|
|
|
|
| 100 |
let showingAllBookmarks = false;
|
| 101 |
let bookmarkRenderGeneration = 0;
|
|
|
|
| 102 |
const viewport = document.querySelector("#viewport");
|
| 103 |
const zoomInput = document.querySelector("#zoom");
|
| 104 |
const pageInput = document.querySelector("#page-number");
|
| 105 |
const readerPath = document.querySelector("#reader-path");
|
| 106 |
const bookmarkRibbon = document.querySelector("#bookmark-ribbon");
|
| 107 |
const bookmarkPopover = document.querySelector("#bookmark-popover");
|
|
|
|
| 108 |
const bookmarksAllButton = document.createElement("button");
|
| 109 |
bookmarksAllButton.id = "bookmarks-all"; bookmarksAllButton.className = "text-action"; bookmarksAllButton.type = "button"; bookmarksAllButton.textContent = "全部书签"; bookmarksAllButton.setAttribute("aria-pressed", "false");
|
| 110 |
const bookmarksHeader = document.querySelector("#bookmarks-panel .panel-view-header"), bookmarksSearchButton = bookmarksHeader.querySelector(".panel-search-toggle"), bookmarksHeaderActions = document.createElement("span");
|
| 111 |
bookmarksHeaderActions.append(bookmarksAllButton, bookmarksSearchButton); bookmarksHeader.appendChild(bookmarksHeaderActions);
|
|
|
|
| 112 |
const loadingObserver = new MutationObserver(() => {
|
| 113 |
if (content.querySelector(".reader-page, .reader-image, .reader-audio, .reader-video, .reader-text, .reader-markdown, .html-frame, .docx-body")) {
|
| 114 |
loadingIndicator.remove();
|
|
@@ -246,7 +250,7 @@ async function saveProgress() {
|
|
| 246 |
const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0;
|
| 247 |
const htmlScrollTop = htmlFrame && htmlFrame.contentWindow ? htmlFrame.contentWindow.scrollY : 0;
|
| 248 |
const readerUrl = new URL(location.href); readerUrl.searchParams.delete("return"); readerUrl.searchParams.delete("nav");
|
| 249 |
-
const progress = { url: sourceUrl, title, extension, readerUrl: readerUrl.href, page: currentPage, pageCount, pageOffset, epubLocation, scrollTop: viewport.scrollTop, htmlScrollTop, zoom: Math.round(zoom * 100) };
|
| 250 |
const signature = JSON.stringify(progress);
|
| 251 |
if (signature === lastSavedProgress) return progressSaveChain;
|
| 252 |
lastSavedProgress = signature;
|
|
@@ -297,7 +301,7 @@ function setReaderPanelOpen(open, restoreFocus = false) {
|
|
| 297 |
panel.hidden = false;
|
| 298 |
void panel.offsetWidth;
|
| 299 |
panel.classList.add("is-open");
|
| 300 |
-
selectPanel(tocEntries.length ? "toc" : "bookmarks");
|
| 301 |
return;
|
| 302 |
}
|
| 303 |
panel.classList.remove("is-open");
|
|
@@ -320,10 +324,12 @@ function setPanelSearchOpen(button, open) {
|
|
| 320 |
}
|
| 321 |
function selectPanel(name) {
|
| 322 |
if (name === "toc" && !tocEntries.length) name = "bookmarks";
|
|
|
|
| 323 |
for (const button of document.querySelectorAll(".reader-panel-tabs button")) { const selected = button.dataset.panel === name; button.setAttribute("aria-selected", String(selected)); button.tabIndex = selected ? 0 : -1; }
|
| 324 |
for (const view of document.querySelectorAll(".reader-panel-view")) view.hidden = view.dataset.panelView !== name;
|
| 325 |
if (name === "bookmarks") renderBookmarks();
|
| 326 |
if (name === "history") renderHistory();
|
|
|
|
| 327 |
}
|
| 328 |
function setToc(entries) {
|
| 329 |
tocEntries = entries || [];
|
|
@@ -347,6 +353,7 @@ async function renderHistory() {
|
|
| 347 |
try {
|
| 348 |
for (const entry of await VoiceOfMLReaderStore.list()) {
|
| 349 |
const row = document.createElement("div"); row.className = "panel-item";
|
|
|
|
| 350 |
const link = document.createElement("button"); link.type = "button"; link.className = "panel-item-main"; link.textContent = entry.title || entry.url; link.addEventListener("click", () => navigateReader(entry.readerUrl));
|
| 351 |
const meta = document.createElement("small"); meta.textContent = `${entry.pageCount ? `第 ${entry.page || 1} / ${entry.pageCount} 页 · ` : ""}${new Date(entry.lastReadAt).toLocaleString()}`;
|
| 352 |
const remove = document.createElement("button"); remove.type = "button"; remove.className = "panel-item-remove"; remove.textContent = "删除"; remove.addEventListener("click", async () => { await VoiceOfMLReaderStore.remove(entry.url); if (entry.url === sourceUrl) historySuppressed = true; row.remove(); if (!list.querySelector(".panel-item")) emptyPanel(list, "暂无阅读记录"); });
|
|
@@ -406,11 +413,11 @@ async function renderBookmarks() {
|
|
| 406 |
list.textContent = "";
|
| 407 |
for (const entry of entries) {
|
| 408 |
const row = document.createElement("div"); row.className = "panel-item";
|
| 409 |
-
const open = document.createElement("button"); open.type = "button"; open.className = "panel-item-main"; open.textContent = showingAllBookmarks ? `${entry.title || "未命名书籍"} · ${entry.label}` : entry.label; open.addEventListener("click", async () => { if (entry.url !== sourceUrl) { await navigateReader(entry.readerUrl); return; } if (entry.epubLocation && epubRendition) await epubRendition.display(entry.epubLocation); else if (entry.page) { await goToPage(entry.page); if (Number.isFinite(entry.pageOffset)) { const shell = content.querySelector(`.reader-page[data-page="${entry.page}"], .reader-docx-page[data-page="${entry.page}"]`); if (shell) viewport.scrollTop = shell.offsetTop + entry.pageOffset; } } else if (Number.isFinite(entry.htmlScrollTop) && htmlFrame && htmlFrame.contentWindow) htmlFrame.contentWindow.scrollTo(0, entry.htmlScrollTop); else viewport.scrollTop = entry.scrollTop || 0; });
|
| 410 |
const excerpt = document.createElement("p"); excerpt.className = "bookmark-excerpt"; excerpt.textContent = entry.excerpt || ""; excerpt.hidden = !entry.excerpt;
|
| 411 |
const meta = document.createElement("small"); meta.textContent = new Date(entry.createdAt).toLocaleString();
|
| 412 |
const remove = document.createElement("button"); remove.type = "button"; remove.className = "panel-item-remove"; remove.textContent = "删除"; remove.addEventListener("click", async () => { await VoiceOfMLReaderStore.removeBookmark(entry.id); row.remove(); if (!list.querySelector(".panel-item")) emptyPanel(list, "暂无书签"); });
|
| 413 |
-
row.append(open, remove, excerpt, meta); list.appendChild(row);
|
| 414 |
}
|
| 415 |
if (!list.childElementCount) emptyPanel(list, "暂无书签");
|
| 416 |
filterPanel(document.querySelector("#bookmarks-panel"));
|
|
@@ -419,7 +426,9 @@ async function renderBookmarks() {
|
|
| 419 |
function pageAtMarker() { const y = bookmarkRibbon.getBoundingClientRect().bottom, pages = [...content.querySelectorAll(".reader-page, .reader-docx-page")]; if (!pages.length) return null; return pages.find((page) => { const rect = page.getBoundingClientRect(); return rect.top <= y && rect.bottom > y; }) || pages.find((page) => page.getBoundingClientRect().bottom > y) || pages[pages.length - 1]; }
|
| 420 |
function syncCurrentPageFromMarker() { const page = pageAtMarker(); if (!page) return; const next = Number(page.dataset.page); if (!next || next === currentPage) return; currentPage = next; pageInput.value = String(next); }
|
| 421 |
function scheduleMarkerSync() { if (markerFrame) return; markerFrame = requestAnimationFrame(() => { markerFrame = 0; syncCurrentPageFromMarker(); scheduleSave(); }); }
|
|
|
|
| 422 |
function captureBookmarkSnapshot() {
|
|
|
|
| 423 |
syncCurrentPageFromMarker();
|
| 424 |
const shell = pageCount ? pageAtMarker() : null;
|
| 425 |
const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0;
|
|
@@ -430,16 +439,17 @@ function captureBookmarkSnapshot() {
|
|
| 430 |
return { locator, label: pageCount ? `第 ${currentPage} / ${pageCount} 页` : `阅读进度 ${progress.toFixed(1)}%`, excerpt: bookmarkExcerpt(), progress, page: pageCount ? currentPage : 0, pageOffset, epubLocation, scrollTop, htmlScrollTop };
|
| 431 |
}
|
| 432 |
function setBookmarkDialogModal(open) { document.documentElement.classList.toggle("bookmark-dialog-open", open); document.querySelector(".reader-toolbar").inert = open; bookmarkRibbon.inert = open; content.inert = open; }
|
| 433 |
-
function closeBookmarkPopover() { pendingBookmarkSnapshot = null; bookmarkPopover.hidden = true; bookmarkRibbon.setAttribute("aria-expanded", "false"); setBookmarkDialogModal(false); bookmarkRibbon.focus(); }
|
| 434 |
-
bookmarkRibbon.addEventListener("click", () => { const prompt = document.querySelector("#bookmark-prompt"); pendingBookmarkSnapshot = captureBookmarkSnapshot(); prompt.textContent = `在${pendingBookmarkSnapshot.label}添加书签?`;
|
| 435 |
document.querySelector("#bookmark-cancel").addEventListener("click", closeBookmarkPopover);
|
| 436 |
-
document.querySelector("#bookmark-add").addEventListener("click", async () => { const snapshot = pendingBookmarkSnapshot || captureBookmarkSnapshot(), now = Date.now(); await VoiceOfMLReaderStore.putBookmark({ id: `${sourceUrl}\0${snapshot.locator}`, url: sourceUrl, title, extension, readerUrl: location.href, label
|
| 437 |
bookmarkPopover.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.preventDefault(); closeBookmarkPopover(); return; } if (event.key !== "Tab") return; const buttons = [...bookmarkPopover.querySelectorAll("button")], first = buttons[0], last = buttons[buttons.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } });
|
| 438 |
for (const button of document.querySelectorAll(".reader-panel-tabs button")) button.addEventListener("click", () => selectPanel(button.dataset.panel));
|
| 439 |
document.querySelector(".reader-panel-tabs").addEventListener("keydown", (event) => { if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; const tabs = [...document.querySelectorAll(".reader-panel-tabs button:not([hidden])")], current = tabs.indexOf(document.activeElement); if (current < 0) return; event.preventDefault(); const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; selectPanel(tabs[next].dataset.panel); tabs[next].focus(); });
|
| 440 |
bookmarksAllButton.addEventListener("click", () => { showingAllBookmarks = !showingAllBookmarks; bookmarksAllButton.textContent = showingAllBookmarks ? "本书书签" : "全部书签"; bookmarksAllButton.setAttribute("aria-pressed", String(showingAllBookmarks)); renderBookmarks(); });
|
| 441 |
for (const button of document.querySelectorAll(".panel-search-toggle")) { button.classList.remove("icon-button"); button.classList.add("text-action"); button.textContent = "搜索"; button.hidden = false; button.setAttribute("aria-expanded", "false"); button.addEventListener("click", () => setPanelSearchOpen(button, button.getAttribute("aria-expanded") !== "true")); }
|
| 442 |
for (const input of document.querySelectorAll(".panel-search")) input.addEventListener("input", () => filterPanel(input.closest(".reader-panel-view")));
|
|
|
|
| 443 |
document.querySelector("#history-clear").addEventListener("click", async () => { if (!confirm("清空全部阅读历史?")) return; await VoiceOfMLReaderStore.clearHistory(); historySuppressed = true; renderHistory(); });
|
| 444 |
readerThemeToggle.addEventListener("click", () => { const theme = readerTheme === "dark" ? "light" : "dark"; applyReaderTheme(theme); if (window.parent !== window) window.parent.postMessage({ type: "voice-reader-theme", theme }, location.origin); });
|
| 445 |
window.addEventListener("storage", (event) => { if (event.key === "theme" && event.newValue) applyReaderTheme(event.newValue, false); });
|
|
@@ -829,6 +839,9 @@ function renderMedia(mode) {
|
|
| 829 |
media.controls = true;
|
| 830 |
media.preload = "metadata";
|
| 831 |
if (mode === "video") media.playsInline = true;
|
|
|
|
|
|
|
|
|
|
| 832 |
media.addEventListener("error", () => fail("媒体加载失败,请检查网络后重试,或下载原文件。"), { once: true });
|
| 833 |
media.src = contentUrl;
|
| 834 |
content.appendChild(media);
|
|
|
|
| 97 |
let tocEntries = [];
|
| 98 |
let markerFrame = 0;
|
| 99 |
let pendingBookmarkSnapshot = null;
|
| 100 |
+
let editingBookmark = null;
|
| 101 |
let showingAllBookmarks = false;
|
| 102 |
let bookmarkRenderGeneration = 0;
|
| 103 |
+
let mediaElement = null;
|
| 104 |
const viewport = document.querySelector("#viewport");
|
| 105 |
const zoomInput = document.querySelector("#zoom");
|
| 106 |
const pageInput = document.querySelector("#page-number");
|
| 107 |
const readerPath = document.querySelector("#reader-path");
|
| 108 |
const bookmarkRibbon = document.querySelector("#bookmark-ribbon");
|
| 109 |
const bookmarkPopover = document.querySelector("#bookmark-popover");
|
| 110 |
+
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"));
|
| 111 |
const bookmarksAllButton = document.createElement("button");
|
| 112 |
bookmarksAllButton.id = "bookmarks-all"; bookmarksAllButton.className = "text-action"; bookmarksAllButton.type = "button"; bookmarksAllButton.textContent = "全部书签"; bookmarksAllButton.setAttribute("aria-pressed", "false");
|
| 113 |
const bookmarksHeader = document.querySelector("#bookmarks-panel .panel-view-header"), bookmarksSearchButton = bookmarksHeader.querySelector(".panel-search-toggle"), bookmarksHeaderActions = document.createElement("span");
|
| 114 |
bookmarksHeaderActions.append(bookmarksAllButton, bookmarksSearchButton); bookmarksHeader.appendChild(bookmarksHeaderActions);
|
| 115 |
+
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());
|
| 116 |
const loadingObserver = new MutationObserver(() => {
|
| 117 |
if (content.querySelector(".reader-page, .reader-image, .reader-audio, .reader-video, .reader-text, .reader-markdown, .html-frame, .docx-body")) {
|
| 118 |
loadingIndicator.remove();
|
|
|
|
| 250 |
const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0;
|
| 251 |
const htmlScrollTop = htmlFrame && htmlFrame.contentWindow ? htmlFrame.contentWindow.scrollY : 0;
|
| 252 |
const readerUrl = new URL(location.href); readerUrl.searchParams.delete("return"); readerUrl.searchParams.delete("nav");
|
| 253 |
+
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) };
|
| 254 |
const signature = JSON.stringify(progress);
|
| 255 |
if (signature === lastSavedProgress) return progressSaveChain;
|
| 256 |
lastSavedProgress = signature;
|
|
|
|
| 301 |
panel.hidden = false;
|
| 302 |
void panel.offsetWidth;
|
| 303 |
panel.classList.add("is-open");
|
| 304 |
+
selectPanel(mediaElement ? "media" : tocEntries.length ? "toc" : "bookmarks");
|
| 305 |
return;
|
| 306 |
}
|
| 307 |
panel.classList.remove("is-open");
|
|
|
|
| 324 |
}
|
| 325 |
function selectPanel(name) {
|
| 326 |
if (name === "toc" && !tocEntries.length) name = "bookmarks";
|
| 327 |
+
if (name === "media" && mediaTab.hidden) name = "bookmarks";
|
| 328 |
for (const button of document.querySelectorAll(".reader-panel-tabs button")) { const selected = button.dataset.panel === name; button.setAttribute("aria-selected", String(selected)); button.tabIndex = selected ? 0 : -1; }
|
| 329 |
for (const view of document.querySelectorAll(".reader-panel-view")) view.hidden = view.dataset.panelView !== name;
|
| 330 |
if (name === "bookmarks") renderBookmarks();
|
| 331 |
if (name === "history") renderHistory();
|
| 332 |
+
if (name === "media" && mediaElement) mediaPanel.querySelector(".media-panel-time").textContent = `${formatMediaTime(mediaElement.currentTime)} / ${formatMediaTime(mediaElement.duration)}`;
|
| 333 |
}
|
| 334 |
function setToc(entries) {
|
| 335 |
tocEntries = entries || [];
|
|
|
|
| 353 |
try {
|
| 354 |
for (const entry of await VoiceOfMLReaderStore.list()) {
|
| 355 |
const row = document.createElement("div"); row.className = "panel-item";
|
| 356 |
+
if (entry.mediaTime != null) row.dataset.mediaTime = String(entry.mediaTime);
|
| 357 |
const link = document.createElement("button"); link.type = "button"; link.className = "panel-item-main"; link.textContent = entry.title || entry.url; link.addEventListener("click", () => navigateReader(entry.readerUrl));
|
| 358 |
const meta = document.createElement("small"); meta.textContent = `${entry.pageCount ? `第 ${entry.page || 1} / ${entry.pageCount} 页 · ` : ""}${new Date(entry.lastReadAt).toLocaleString()}`;
|
| 359 |
const remove = document.createElement("button"); remove.type = "button"; remove.className = "panel-item-remove"; remove.textContent = "删除"; remove.addEventListener("click", async () => { await VoiceOfMLReaderStore.remove(entry.url); if (entry.url === sourceUrl) historySuppressed = true; row.remove(); if (!list.querySelector(".panel-item")) emptyPanel(list, "暂无阅读记录"); });
|
|
|
|
| 413 |
list.textContent = "";
|
| 414 |
for (const entry of entries) {
|
| 415 |
const row = document.createElement("div"); row.className = "panel-item";
|
| 416 |
+
const open = document.createElement("button"); open.type = "button"; open.className = "panel-item-main"; open.textContent = showingAllBookmarks ? `${entry.title || "未命名书籍"} · ${entry.label}` : entry.label; open.addEventListener("click", async () => { if (entry.url !== sourceUrl) { await navigateReader(entry.readerUrl); return; } if (entry.mediaTime != null && mediaElement) mediaElement.currentTime = entry.mediaTime; else if (entry.epubLocation && epubRendition) await epubRendition.display(entry.epubLocation); else if (entry.page) { await goToPage(entry.page); if (Number.isFinite(entry.pageOffset)) { const shell = content.querySelector(`.reader-page[data-page="${entry.page}"], .reader-docx-page[data-page="${entry.page}"]`); if (shell) viewport.scrollTop = shell.offsetTop + entry.pageOffset; } } else if (Number.isFinite(entry.htmlScrollTop) && htmlFrame && htmlFrame.contentWindow) htmlFrame.contentWindow.scrollTo(0, entry.htmlScrollTop); else viewport.scrollTop = entry.scrollTop || 0; });
|
| 417 |
const excerpt = document.createElement("p"); excerpt.className = "bookmark-excerpt"; excerpt.textContent = entry.excerpt || ""; excerpt.hidden = !entry.excerpt;
|
| 418 |
const meta = document.createElement("small"); meta.textContent = new Date(entry.createdAt).toLocaleString();
|
| 419 |
const remove = document.createElement("button"); remove.type = "button"; remove.className = "panel-item-remove"; remove.textContent = "删除"; remove.addEventListener("click", async () => { await VoiceOfMLReaderStore.removeBookmark(entry.id); row.remove(); if (!list.querySelector(".panel-item")) emptyPanel(list, "暂无书签"); });
|
| 420 |
+
const edit = document.createElement("button"); edit.type = "button"; edit.className = "panel-item-edit"; edit.textContent = "编辑"; edit.onclick = () => { editingBookmark = { ...entry }; bookmarkLabelInput.value = entry.label || ""; bookmarkExcerptInput.value = entry.excerpt || ""; bookmarkPopover.hidden = false; setBookmarkDialogModal(true); bookmarkLabelInput.focus(); }; row.append(open, edit, remove, excerpt, meta); list.appendChild(row);
|
| 421 |
}
|
| 422 |
if (!list.childElementCount) emptyPanel(list, "暂无书签");
|
| 423 |
filterPanel(document.querySelector("#bookmarks-panel"));
|
|
|
|
| 426 |
function pageAtMarker() { const y = bookmarkRibbon.getBoundingClientRect().bottom, pages = [...content.querySelectorAll(".reader-page, .reader-docx-page")]; if (!pages.length) return null; return pages.find((page) => { const rect = page.getBoundingClientRect(); return rect.top <= y && rect.bottom > y; }) || pages.find((page) => page.getBoundingClientRect().bottom > y) || pages[pages.length - 1]; }
|
| 427 |
function syncCurrentPageFromMarker() { const page = pageAtMarker(); if (!page) return; const next = Number(page.dataset.page); if (!next || next === currentPage) return; currentPage = next; pageInput.value = String(next); }
|
| 428 |
function scheduleMarkerSync() { if (markerFrame) return; markerFrame = requestAnimationFrame(() => { markerFrame = 0; syncCurrentPageFromMarker(); scheduleSave(); }); }
|
| 429 |
+
function formatMediaTime(seconds) { if (!Number.isFinite(seconds) || seconds < 0) return "00:00"; const total = Math.floor(seconds), hours = Math.floor(total / 3600), minutes = Math.floor(total % 3600 / 60), secs = total % 60; return `${hours ? `${String(hours).padStart(2, "0")}:` : ""}${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; }
|
| 430 |
function captureBookmarkSnapshot() {
|
| 431 |
+
if (mediaElement) { const time = Number.isFinite(mediaElement.currentTime) ? mediaElement.currentTime : 0; return { locator: `media:${Math.round(time * 10) / 10}`, label: `时间 ${formatMediaTime(time)}`, excerpt: "", progress: mediaElement.duration ? Math.round(time / mediaElement.duration * 1000) / 10 : 0, mediaTime: time, page: 0, pageOffset: 0, epubLocation: "", scrollTop: 0, htmlScrollTop: 0 }; }
|
| 432 |
syncCurrentPageFromMarker();
|
| 433 |
const shell = pageCount ? pageAtMarker() : null;
|
| 434 |
const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0;
|
|
|
|
| 439 |
return { locator, label: pageCount ? `第 ${currentPage} / ${pageCount} 页` : `阅读进度 ${progress.toFixed(1)}%`, excerpt: bookmarkExcerpt(), progress, page: pageCount ? currentPage : 0, pageOffset, epubLocation, scrollTop, htmlScrollTop };
|
| 440 |
}
|
| 441 |
function setBookmarkDialogModal(open) { document.documentElement.classList.toggle("bookmark-dialog-open", open); document.querySelector(".reader-toolbar").inert = open; bookmarkRibbon.inert = open; content.inert = open; }
|
| 442 |
+
function closeBookmarkPopover() { pendingBookmarkSnapshot = null; editingBookmark = null; bookmarkPopover.hidden = true; bookmarkRibbon.setAttribute("aria-expanded", "false"); setBookmarkDialogModal(false); bookmarkRibbon.focus(); }
|
| 443 |
+
bookmarkRibbon.addEventListener("click", () => { const prompt = document.querySelector("#bookmark-prompt"); pendingBookmarkSnapshot = captureBookmarkSnapshot(); editingBookmark = null; prompt.textContent = `在${pendingBookmarkSnapshot.label}添加书签?`; bookmarkLabelInput.value = pendingBookmarkSnapshot.label; bookmarkExcerptInput.value = pendingBookmarkSnapshot.excerpt || ""; bookmarkPopover.hidden = false; bookmarkRibbon.setAttribute("aria-expanded", "true"); setBookmarkDialogModal(true); bookmarkLabelInput.focus(); });
|
| 444 |
document.querySelector("#bookmark-cancel").addEventListener("click", closeBookmarkPopover);
|
| 445 |
+
document.querySelector("#bookmark-add").addEventListener("click", async () => { const snapshot = pendingBookmarkSnapshot || captureBookmarkSnapshot(), now = Date.now(), label = bookmarkLabelInput.value.trim() || snapshot.label, excerpt = bookmarkExcerptInput.value.trim(); if (editingBookmark) await VoiceOfMLReaderStore.putBookmark({ ...editingBookmark, label, excerpt }); else await VoiceOfMLReaderStore.putBookmark({ id: `${sourceUrl}\0${snapshot.locator}`, url: sourceUrl, title, extension, readerUrl: location.href, label, excerpt, progress: snapshot.progress, mediaTime: snapshot.mediaTime, page: snapshot.page, pageOffset: snapshot.pageOffset, epubLocation: snapshot.epubLocation, scrollTop: snapshot.scrollTop, htmlScrollTop: snapshot.htmlScrollTop, createdAt: now }); pendingBookmarkSnapshot = null; editingBookmark = null; bookmarkPopover.hidden = true; bookmarkRibbon.setAttribute("aria-expanded", "false"); setBookmarkDialogModal(false); if (!document.querySelector("#bookmarks-panel").hidden) renderBookmarks(); bookmarkRibbon.focus(); });
|
| 446 |
bookmarkPopover.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.preventDefault(); closeBookmarkPopover(); return; } if (event.key !== "Tab") return; const buttons = [...bookmarkPopover.querySelectorAll("button")], first = buttons[0], last = buttons[buttons.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } });
|
| 447 |
for (const button of document.querySelectorAll(".reader-panel-tabs button")) button.addEventListener("click", () => selectPanel(button.dataset.panel));
|
| 448 |
document.querySelector(".reader-panel-tabs").addEventListener("keydown", (event) => { if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; const tabs = [...document.querySelectorAll(".reader-panel-tabs button:not([hidden])")], current = tabs.indexOf(document.activeElement); if (current < 0) return; event.preventDefault(); const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; selectPanel(tabs[next].dataset.panel); tabs[next].focus(); });
|
| 449 |
bookmarksAllButton.addEventListener("click", () => { showingAllBookmarks = !showingAllBookmarks; bookmarksAllButton.textContent = showingAllBookmarks ? "本书书签" : "全部书签"; bookmarksAllButton.setAttribute("aria-pressed", String(showingAllBookmarks)); renderBookmarks(); });
|
| 450 |
for (const button of document.querySelectorAll(".panel-search-toggle")) { button.classList.remove("icon-button"); button.classList.add("text-action"); button.textContent = "搜索"; button.hidden = false; button.setAttribute("aria-expanded", "false"); button.addEventListener("click", () => setPanelSearchOpen(button, button.getAttribute("aria-expanded") !== "true")); }
|
| 451 |
for (const input of document.querySelectorAll(".panel-search")) input.addEventListener("input", () => filterPanel(input.closest(".reader-panel-view")));
|
| 452 |
+
document.querySelector("#bookmarks-list").addEventListener("click", async (event) => { if (!mediaElement || event.target.closest(".panel-item-remove")) return; const row = event.target.closest(".panel-item"), index = row ? [...row.parentElement.children].indexOf(row) : -1; if (index < 0) return; const entries = showingAllBookmarks ? await VoiceOfMLReaderStore.listAllBookmarks() : await VoiceOfMLReaderStore.listBookmarks(sourceUrl); if (entries[index] && entries[index].mediaTime != null) mediaElement.currentTime = entries[index].mediaTime; });
|
| 453 |
document.querySelector("#history-clear").addEventListener("click", async () => { if (!confirm("清空全部阅读历史?")) return; await VoiceOfMLReaderStore.clearHistory(); historySuppressed = true; renderHistory(); });
|
| 454 |
readerThemeToggle.addEventListener("click", () => { const theme = readerTheme === "dark" ? "light" : "dark"; applyReaderTheme(theme); if (window.parent !== window) window.parent.postMessage({ type: "voice-reader-theme", theme }, location.origin); });
|
| 455 |
window.addEventListener("storage", (event) => { if (event.key === "theme" && event.newValue) applyReaderTheme(event.newValue, false); });
|
|
|
|
| 839 |
media.controls = true;
|
| 840 |
media.preload = "metadata";
|
| 841 |
if (mode === "video") media.playsInline = true;
|
| 842 |
+
media.addEventListener("loadedmetadata", () => { if (restoredEntry && Number.isFinite(restoredEntry.mediaTime)) media.currentTime = restoredEntry.mediaTime; });
|
| 843 |
+
media.addEventListener("timeupdate", () => { const time = formatMediaTime(media.currentTime), timeNode = document.querySelector(".media-panel-time"); if (timeNode) timeNode.textContent = `${time} / ${formatMediaTime(media.duration)}`; scheduleSave(); }, { passive: true });
|
| 844 |
+
mediaElement = media;
|
| 845 |
media.addEventListener("error", () => fail("媒体加载失败,请检查网络后重试,或下载原文件。"), { once: true });
|
| 846 |
media.src = contentUrl;
|
| 847 |
content.appendChild(media);
|