Search / static /reader /ui /reader-bookmarks.mjs
vomebook's picture
fix: remaining review items (backend proxies/OCR/order, Reader framing/storage/early Back, SW fallback, search UI)
b01d918 verified
Raw History Blame Contribute Delete
18.6 kB
export function createReaderBookmarks(readerContext) {
function consumeBookmarkHandoff(entry) {
if (!entry || entry.id !== readerContext.params.get("bookmark") || entry.url !== readerContext.params.get("bookmark_source"))
return;
const opening = new URL(location.href);
const shareKey = (url) => {
const key = new URL(url);
key.searchParams.delete("return");
key.searchParams.delete("nav");
return key.href;
};
const cleanMatchingUrl = (raw) => {
if (typeof raw !== "string" || !raw) return null;
try {
const url = new URL(raw, location.origin);
if (
url.origin !== location.origin ||
url.pathname !== opening.pathname ||
shareKey(url) !== shareKey(opening)
)
return null;
url.searchParams.delete("bookmark");
url.searchParams.delete("bookmark_source");
return url.href;
} catch (_) {
return null;
}
};
const cleanHistory = (owner) => {
const url = cleanMatchingUrl(owner.location.href);
if (!url) return;
const state = owner.history.state;
const readerUrl = state?.voiceReaderOverlay && cleanMatchingUrl(state.readerUrl);
owner.history.replaceState(readerUrl ? { ...state, readerUrl } : state, "", url);
};
// Clean only this navigation, not a bookmark-ID cache. A new deliberate click
// can reuse the same bookmark; overlay reload/Back uses the cleaned records.
try {
if (
readerContext.isReaderOverlayFrame() &&
window.parent.history.state?.voiceReaderOverlay === true &&
cleanMatchingUrl(window.parent.history.state.readerUrl)
)
cleanHistory(window.parent);
} catch (_) {}
try {
const saved = JSON.parse(sessionStorage.getItem("reader-navigation-current") || "null");
const readerUrl = cleanMatchingUrl(saved?.readerUrl),
shareUrl = cleanMatchingUrl(saved?.shareUrl);
if (readerUrl && shareUrl)
sessionStorage.setItem(
"reader-navigation-current",
JSON.stringify({ ...saved, readerUrl, shareUrl })
);
} catch (_) {}
try {
cleanHistory(window);
} catch (_) {}
readerContext.params.delete("bookmark");
readerContext.params.delete("bookmark_source");
}
function excerptFromCaret(doc, root, x, y) {
let node,
offset = 0;
const position = doc.caretPositionFromPoint ? doc.caretPositionFromPoint(x, y) : null;
if (position) {
node = position.offsetNode;
offset = position.offset;
} else if (doc.caretRangeFromPoint) {
const range = doc.caretRangeFromPoint(x, y);
if (range) {
node = range.startContainer;
offset = range.startOffset;
}
}
if (!node || !root.contains(node)) return "";
if (node.nodeType !== Node.TEXT_NODE) {
const first = doc.createTreeWalker(node, NodeFilter.SHOW_TEXT).nextNode();
if (!first) return "";
node = first;
offset = 0;
}
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
walker.currentNode = node;
let excerpt = node.data.slice(offset);
while (excerpt.length < 220 && walker.nextNode()) excerpt += ` ${walker.currentNode.data}`;
return excerpt.replace(/\s+/g, " ").trim().slice(0, 160);
}
function bookmarkExcerpt() {
if (["image", "audio", "video", "pdf-pages"].includes(readerContext.capability.mode)) return "";
if (readerContext.capability.mode === "pdf") {
const shell = readerContext.pageAtMarker(),
items = shell && shell._bookmarkTextItems;
if (items && items.length) {
const rect = shell.getBoundingClientRect(),
targetY =
Math.max(0, readerContext.bookmarkRibbon.getBoundingClientRect().bottom - rect.top) /
Math.max(1, rect.height);
let nearest = 0,
distance = Infinity;
items.forEach((item, index) => {
const nextDistance = Math.abs(item.y - targetY);
if (nextDistance < distance) {
nearest = index;
distance = nextDistance;
}
});
return items
.slice(nearest)
.map((item) => item.text)
.join(" ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 160);
}
// A scanned/unloaded page has no excerpt. Never fall back to other pages' UI text.
return "";
}
const x = Math.round(readerContext.viewport.getBoundingClientRect().width / 2),
y = Math.round(readerContext.bookmarkRibbon.getBoundingClientRect().bottom + 8);
const frames = [...document.querySelectorAll("iframe")].filter((frame) => {
const rect = frame.getBoundingClientRect();
return rect.left <= x && rect.right >= x && rect.top <= y && rect.bottom >= y;
});
const frame = frames[frames.length - 1];
try {
if (frame && frame.contentDocument && frame.contentDocument.body) {
const rect = frame.getBoundingClientRect();
return readerContext.excerptFromCaret(
frame.contentDocument,
frame.contentDocument.body,
x - rect.left,
y - rect.top
);
}
} catch (_) {}
const exact = readerContext.excerptFromCaret(document, readerContext.content, x, y);
if (exact) return exact;
const sourceText = readerContext.foliateContinuous
? [
...readerContext.content.querySelectorAll(
".foliate-continuous article[data-section]:not(.foliate-section-placeholder)"
)
]
.map((article) => readerContext.foliateSectionRoot(article).textContent)
.join(" ")
: readerContext.content.textContent,
text = sourceText.replace(/\s+/g, " ").trim(),
start = Math.floor((text.length * readerContext.readerProgressPercent()) / 100);
return text.slice(start, start + 160).trim();
}
function captureFoliateBookmarkPosition() {
if (!readerContext.foliateContinuous) return null;
const marker = readerContext.viewport.getBoundingClientRect().top + 8,
rows = [...document.querySelectorAll(".foliate-continuous article[data-section]")];
const article =
rows.find((row) => {
const rect = row.getBoundingClientRect();
return rect.top <= marker && rect.bottom > marker;
}) ||
rows.find((row) => row.getBoundingClientRect().top > marker) ||
rows[rows.length - 1];
return article
? {
foliateSection: Number(article.dataset.section),
foliateOffset: marker - article.getBoundingClientRect().top,
foliateTocIndex: readerContext.navigationState.currentChapterIndex
}
: null;
}
async function restoreFoliateBookmarkPosition(entry, generation = readerContext.beginReaderNavigation()) {
try {
if (
!readerContext.foliateSectionLoader ||
!Number.isInteger(entry.foliateSection) ||
!readerContext.isReaderGenerationCurrent("navigation", generation)
)
return false;
let article = await readerContext.foliateSectionLoader(entry.foliateSection);
if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false;
await readerContext.waitForReader(0, true);
if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return false;
if (!article?.isConnected) article = await readerContext.foliateSectionLoader(entry.foliateSection);
if (!article?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return false;
const marker = readerContext.viewport.getBoundingClientRect().top + 8;
const offset = Number(entry.foliateOffset) || 0;
const start = !offset
? readerContext.foliateSectionRoot(article).querySelector("h1,h2,h3,h4,h5,h6,p,li,blockquote,pre") || article
: article;
readerContext.foliateScrollAnchors.invalidate();
readerContext.viewport.scrollTop = Math.max(
0,
readerContext.viewport.scrollTop +
start.getBoundingClientRect().top -
marker +
offset
);
readerContext.syncFoliateScrollLocation();
readerContext.updateTocCurrentMark();
readerContext.updateProgressTools();
readerContext.scheduleSave();
readerContext.setReaderPanelOpen(false, true);
readerContext.foliateScrollAnchors.remember();
return true;
} catch (error) {
readerContext.reportNavigationError(error, generation);
return false;
}
}
async function seekFoliateProgress(percent, generation) {
try {
const sections = readerContext.epubBook?.sections?.filter((section) => section.linear !== "no") || [];
if (
!sections.length ||
!readerContext.foliateSectionLoader ||
!readerContext.isReaderGenerationCurrent("navigation", generation)
)
return;
const position = Math.max(0, Math.min(0.999999, Number(percent) / 100)) * sections.length,
index = Math.min(sections.length - 1, Math.floor(position));
let article = await readerContext.foliateSectionLoader(index);
if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return;
await readerContext.waitForReader(0, true);
if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return;
if (!article?.isConnected) article = await readerContext.foliateSectionLoader(index);
if (!article?.isConnected || !readerContext.isReaderGenerationCurrent("navigation", generation)) return;
const marker = readerContext.viewport.getBoundingClientRect().top + 8,
offset = article.getBoundingClientRect().height * (position - index);
readerContext.foliateScrollAnchors.invalidate();
readerContext.viewport.scrollTop = Math.max(
0,
readerContext.viewport.scrollTop + article.getBoundingClientRect().top - marker + offset
);
await readerContext.waitForReader(0, true);
if (!readerContext.isReaderGenerationCurrent("navigation", generation)) return;
readerContext.viewport.scrollTop += article.getBoundingClientRect().top - marker + offset + 4;
readerContext.syncFoliateScrollLocation();
readerContext.updateTocCurrentMark();
readerContext.updateProgressTools();
readerContext.scheduleSave();
readerContext.foliateScrollAnchors.remember();
} catch (error) {
readerContext.reportNavigationError(error, generation);
}
}
async function renderBookmarks() {
const list = document.querySelector("#bookmarks-list"),
generation = readerContext.nextReaderGeneration("bookmarks");
try {
const entries = readerContext.panelState.showingAllBookmarks
? await VoiceOfMLReaderStore.listAllBookmarks()
: await VoiceOfMLReaderStore.listBookmarks(readerContext.sourceUrl);
if (!readerContext.isReaderGenerationCurrent("bookmarks", generation)) return;
list.textContent = "";
for (const entry of entries) {
const row = document.createElement("div");
row.className = "panel-item";
row.dataset.bookmarkId = entry.id;
const open = document.createElement("button");
open.type = "button";
open.className = "panel-item-main";
open.textContent = readerContext.panelState.showingAllBookmarks
? `${entry.title || "未命名书籍"} · ${entry.label}`
: entry.label;
open.addEventListener("click", () =>
entry.url !== readerContext.sourceUrl
? readerContext.navigateReader(entry.readerUrl, entry)
: readerContext.restoreFormat(readerContext.capability.mode, entry)
);
const excerpt = document.createElement("p");
excerpt.className = "bookmark-excerpt";
excerpt.textContent = entry.excerpt || "";
excerpt.hidden = !entry.excerpt;
const meta = document.createElement("small");
meta.textContent = new Date(entry.createdAt).toLocaleString();
const remove = document.createElement("button");
remove.type = "button";
remove.className = "panel-item-remove";
remove.textContent = "删除";
remove.setAttribute("aria-label", `删除书签:${open.textContent}`);
remove.addEventListener("click", async () => {
await VoiceOfMLReaderStore.removeBookmark(entry.id);
const restoreFocus = row.contains(document.activeElement);
const next = row.nextElementSibling?.querySelector("button") || row.previousElementSibling?.querySelector("button") ||
readerContext.bookmarksAllButton;
row.remove();
if (!list.querySelector(".panel-item")) readerContext.emptyPanel(list, "暂无书签");
if (restoreFocus) next.focus({ preventScroll: true });
});
const edit = document.createElement("button");
edit.type = "button";
edit.className = "panel-item-edit";
edit.textContent = "编辑";
edit.setAttribute("aria-label", `编辑书签:${open.textContent}`);
edit.addEventListener("click", () => readerContext.openBookmarkPopover(edit, entry));
row.append(open, edit, remove, excerpt, meta);
list.appendChild(row);
}
if (!list.childElementCount) readerContext.emptyPanel(list, "暂无书签");
readerContext.filterPanel(document.querySelector("#bookmarks-panel"));
} catch (_) {
if (readerContext.isReaderGenerationCurrent("bookmarks", generation)) readerContext.emptyPanel(list, "无法读取书签");
}
}
function captureBookmarkSnapshot() {
if (readerContext.mediaElement) {
const time = Number.isFinite(readerContext.mediaElement.currentTime) ? readerContext.mediaElement.currentTime : 0;
return {
locator: `media:${Math.round(time * 10) / 10}`,
label: `时间 ${readerContext.formatMediaTime(time)}`,
excerpt: "",
progress: readerContext.mediaElement.duration ? Math.round((time / readerContext.mediaElement.duration) * 1000) / 10 : 0,
mediaTime: time,
page: 0,
pageOffset: 0,
scrollTop: 0,
htmlScrollTop: 0
};
}
readerContext.syncCurrentPageFromMarker();
const foliatePosition = readerContext.captureFoliateBookmarkPosition() || readerContext.captureChapterPosition();
const shell = readerContext.documentState.pageCount ? readerContext.pageAtMarker() : null;
const pageOffset = shell ? Math.max(0, readerContext.viewport.scrollTop - readerContext.readerPageScrollTop(shell)) : 0;
const progress = readerContext.readerProgressPercent();
const htmlScrollTop = readerContext.htmlFrame && readerContext.htmlFrame.contentWindow ? readerContext.htmlFrame.contentWindow.scrollY : 0;
const scrollTop = readerContext.viewport.scrollTop;
const pagedDocument = readerContext.documentState.pageCount && !["text", "markdown"].includes(readerContext.capability.mode);
const locator = Number.isInteger(foliatePosition?.chapterIndex)
? `chapter:${foliatePosition.chapterIndex}:${Math.round(foliatePosition.chapterOffset)}`
: foliatePosition
? `foliate:${foliatePosition.foliateSection}:${Math.round(foliatePosition.foliateOffset)}`
: pagedDocument
? `page:${readerContext.documentState.page}:${Math.round(pageOffset)}`
: `progress:${progress}:${Math.round(readerContext.htmlFrame ? htmlScrollTop : scrollTop)}`;
return {
locator,
label: pagedDocument
? `第 ${readerContext.documentState.page} / ${readerContext.documentState.pageCount} 页`
: `阅读进度 ${progress.toFixed(1)}%`,
excerpt: readerContext.bookmarkExcerpt(),
progress,
page: pagedDocument ? readerContext.documentState.page : 0,
pageOffset,
scrollTop,
htmlScrollTop,
...(foliatePosition || {})
};
}
function setBookmarkDialogModal(open) {
document.documentElement.classList.toggle("bookmark-dialog-open", open);
if (open) {
for (let node = readerContext.bookmarkPopover; node && node !== document.body; node = node.parentElement) {
for (const sibling of node.parentElement.children)
if (sibling !== node && !readerContext.bookmarkInertSiblings.has(sibling)) {
readerContext.bookmarkInertSiblings.set(sibling, sibling.inert);
sibling.inert = true;
}
}
} else {
for (const [node, inert] of readerContext.bookmarkInertSiblings) node.inert = inert;
readerContext.bookmarkInertSiblings.clear();
}
}
function openBookmarkPopover(invoker = readerContext.bookmarkRibbon, entry = null) {
if (!readerContext.bookmarkPopover.hidden) return;
readerContext.bookmarkInvoker = invoker;
readerContext.pendingBookmarkSnapshot = entry ? null : readerContext.captureBookmarkSnapshot();
readerContext.updatePanelState({ editingBookmark: entry ? { ...entry } : null });
const snapshot = entry || readerContext.pendingBookmarkSnapshot;
document.querySelector("#bookmark-prompt").textContent = entry
? `编辑书签 · ${entry.label}`
: `在${snapshot.label}添加书签?`;
readerContext.bookmarkLabelInput.value = snapshot.label || "";
readerContext.bookmarkExcerptInput.value = snapshot.excerpt || "";
document.querySelector("#bookmark-add").textContent = entry ? "保存书签" : "添加书签";
readerContext.bookmarkPopover.hidden = false;
invoker.setAttribute("aria-expanded", "true");
invoker.setAttribute("aria-controls", readerContext.bookmarkPopover.id);
readerContext.bookmarkLabelInput.focus();
readerContext.setBookmarkDialogModal(true);
}
function closeBookmarkPopover() {
readerContext.pendingBookmarkSnapshot = null;
readerContext.updatePanelState({ editingBookmark: null });
readerContext.bookmarkPopover.hidden = true;
readerContext.setBookmarkDialogModal(false);
readerContext.bookmarkInvoker?.setAttribute("aria-expanded", "false");
(readerContext.bookmarkInvoker?.isConnected ? readerContext.bookmarkInvoker : readerContext.bookmarkRibbon).focus({ preventScroll: true });
readerContext.bookmarkInvoker = null;
}
return {
consumeBookmarkHandoff,
excerptFromCaret,
bookmarkExcerpt,
captureFoliateBookmarkPosition,
restoreFoliateBookmarkPosition,
seekFoliateProgress,
renderBookmarks,
captureBookmarkSnapshot,
setBookmarkDialogModal,
openBookmarkPopover,
closeBookmarkPopover
};
}