Download static/reader/ui/reader-bookmarks.mjs from VoiceOfML/Search: direct link, hf CLI and curl.
- Browser
- Download file 18.6 kB
-
https://huggingface.co/spaces/VoiceOfML/Search/resolve/main/static/reader/ui/reader-bookmarks.mjs
- Command line
-
hf download hf://spaces/VoiceOfML/Search/static/reader/ui/reader-bookmarks.mjs
-
curl -L -o reader-bookmarks.mjs https://huggingface.co/spaces/VoiceOfML/Search/resolve/main/static/reader/ui/reader-bookmarks.mjs
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 | |
| }; | |
| } | |