vomebook commited on
Commit
f0913ee
·
verified ·
1 Parent(s): 6b85b4e

Add editable Reader bookmarks

Browse files
Files changed (2) hide show
  1. static/reader.css +5 -0
  2. 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}添加书签?`; if (pendingBookmarkSnapshot.excerpt) { const summary = document.createElement("small"); summary.textContent = `摘要:${pendingBookmarkSnapshot.excerpt}`; prompt.appendChild(summary); } bookmarkPopover.hidden = false; bookmarkRibbon.setAttribute("aria-expanded", "true"); setBookmarkDialogModal(true); document.querySelector("#bookmark-add").focus(); });
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: snapshot.label, excerpt: snapshot.excerpt, progress: snapshot.progress, page: snapshot.page, pageOffset: snapshot.pageOffset, epubLocation: snapshot.epubLocation, scrollTop: snapshot.scrollTop, htmlScrollTop: snapshot.htmlScrollTop, createdAt: now }); pendingBookmarkSnapshot = null; bookmarkPopover.hidden = true; bookmarkRibbon.setAttribute("aria-expanded", "false"); setBookmarkDialogModal(false); if (!document.querySelector("#bookmarks-panel").hidden) renderBookmarks(); bookmarkRibbon.focus(); });
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);