Improve Reader full-text search
Browse files- static/reader.css +1 -0
- static/reader.js +9 -4
static/reader.css
CHANGED
|
@@ -34,6 +34,7 @@ body { margin: 0; height: 100vh; height: 100dvh; overflow: hidden; display: flex
|
|
| 34 |
.reader-progress-undo svg, .reader-chapter-prev svg, .reader-chapter-next svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
| 35 |
.reader-progress-undo[hidden], .reader-chapter-prev[hidden], .reader-chapter-next[hidden] { display: none; }
|
| 36 |
.full-search-input { width: 110px; min-width: 0; height: 28px; padding: 0 7px; border: 1px solid #464c52; border-radius: 4px; background: #25292d; color: inherit; font: inherit; font-size: 12px; }
|
|
|
|
| 37 |
.full-search-view { display: flex; flex-direction: column; min-height: 0; }
|
| 38 |
.full-search-bar { display: flex; gap: 6px; padding: 10px 12px 6px; }
|
| 39 |
.full-search-bar .full-search-input { width: auto; flex: 1; height: 34px; }
|
|
|
|
| 34 |
.reader-progress-undo svg, .reader-chapter-prev svg, .reader-chapter-next svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
| 35 |
.reader-progress-undo[hidden], .reader-chapter-prev[hidden], .reader-chapter-next[hidden] { display: none; }
|
| 36 |
.full-search-input { width: 110px; min-width: 0; height: 28px; padding: 0 7px; border: 1px solid #464c52; border-radius: 4px; background: #25292d; color: inherit; font: inherit; font-size: 12px; }
|
| 37 |
+
.full-search-input::-webkit-search-cancel-button { display: none; }
|
| 38 |
.full-search-view { display: flex; flex-direction: column; min-height: 0; }
|
| 39 |
.full-search-bar { display: flex; gap: 6px; padding: 10px 12px 6px; }
|
| 40 |
.full-search-bar .full-search-input { width: auto; flex: 1; height: 34px; }
|
static/reader.js
CHANGED
|
@@ -336,7 +336,7 @@ function selectPanel(name) {
|
|
| 336 |
if (name === "media" && mediaElement) mediaPanel.querySelector(".media-panel-time").textContent = `${formatMediaTime(mediaElement.currentTime)} / ${formatMediaTime(mediaElement.duration)}`;
|
| 337 |
}
|
| 338 |
function setToc(entries) {
|
| 339 |
-
tocEntries = entries || []; currentChapterIndex = tocEntries.length ? 0 : -1;
|
| 340 |
document.querySelector("#toc-tab").hidden = !tocEntries.length;
|
| 341 |
const list = document.querySelector("#toc-list"); list.textContent = "";
|
| 342 |
for (const [index, entry] of tocEntries.entries()) {
|
|
@@ -347,6 +347,7 @@ function setToc(entries) {
|
|
| 347 |
row.appendChild(link); list.appendChild(row);
|
| 348 |
}
|
| 349 |
}
|
|
|
|
| 350 |
async function navigateReader(rawUrl) {
|
| 351 |
await saveProgress();
|
| 352 |
if (window.parent !== window) window.parent.postMessage({ type: "voice-reader-open", url: rawUrl }, location.origin);
|
|
@@ -969,12 +970,16 @@ const fullSearchButton = document.createElement("button"); fullSearchButton.id =
|
|
| 969 |
let fullSearchResults = [], fullSearchIndex = -1, fullSearchGeneration = 0;
|
| 970 |
const fullSearchInput = fullSearchView.querySelector("#full-search-input"), fullSearchStatus = fullSearchView.querySelector("#full-search-status"), fullSearchResultsNode = fullSearchView.querySelector("#full-search-results");
|
| 971 |
function fullSearchEscape(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
| 972 |
-
|
|
|
|
|
|
|
| 973 |
function fullSearchSnippet(text, index, length) { const start = Math.max(0, index - 72), end = Math.min(text.length, index + length + 88); return { text: text.slice(start, end), matchStart: index - start, matchLength: length, prefix: start ? "…" : "", suffix: end < text.length ? "…" : "" }; }
|
| 974 |
function fullSearchSnippetDom(snippet) { const node = document.createElement("span"); node.className = "full-search-snippet"; if (snippet.prefix) node.append(snippet.prefix); node.append(snippet.text.slice(0, snippet.matchStart)); const mark = document.createElement("mark"); mark.className = "search-match"; mark.textContent = snippet.text.slice(snippet.matchStart, snippet.matchStart + snippet.matchLength); node.append(mark, snippet.text.slice(snippet.matchStart + snippet.matchLength)); if (snippet.suffix) node.append(snippet.suffix); return node; }
|
| 975 |
function fullSearchLocation(node, fallback) { const page = node.parentElement && node.parentElement.closest("[data-page]"); return page ? `第 ${page.dataset.page} 页` : fallback; }
|
| 976 |
-
function fullSearchDomMatches(root, fallback) { const query = fullSearchInput.value.trim(), pattern = new RegExp(fullSearchEscape(query), "giu"), output = []; for (const node of fullSearchTextNodes(root))
|
| 977 |
async function fullSearchPdfMatches(query) { const output = [], pattern = new RegExp(fullSearchEscape(query), "giu"); for (let page = 1; page <= pdfDocument.numPages && output.length < 100; page++) { const textContent = await (await pdfDocument.getPage(page)).getTextContent(), text = textContent.items.map((item) => item.str + (item.hasEOL ? "\n" : " ")).join("").trim(); for (const match of text.matchAll(pattern)) { if (output.length >= 100) break; output.push({ location: `第 ${page} 页`, snippet: fullSearchSnippet(text, match.index, match[0].length), activate: () => goToPage(page) }); } } return output; }
|
| 978 |
async function runFullSearch() { const query = fullSearchInput.value.trim(); ++fullSearchGeneration; const generation = fullSearchGeneration; fullSearchResults = []; fullSearchIndex = -1; fullSearchResultsNode.textContent = ""; fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => { button.disabled = true; }); if (!query) { fullSearchStatus.textContent = "输入关键词搜索正文"; return; } fullSearchStatus.textContent = "正在搜索正文…"; try { if (capability.mode === "pdf") fullSearchResults = pdfDocument ? await fullSearchPdfMatches(query) : []; else if (["text", "markdown", "docx"].includes(capability.mode)) fullSearchResults = fullSearchDomMatches(content, "阅读位置"); else if (capability.mode === "html") fullSearchResults = htmlFrame && htmlFrame.contentDocument && htmlFrame.contentDocument.body ? fullSearchDomMatches(htmlFrame.contentDocument.body, "HTML 阅读位置") : []; else if (capability.mode === "epub") { const contents = epubRendition && typeof epubRendition.getContents === "function" ? epubRendition.getContents() : []; if (!contents.length) throw new Error("EPUB 尚未加载可搜索章节"); for (const item of contents) { fullSearchResults.push(...fullSearchDomMatches(item.document.body, "EPUB 当前章节")); if (fullSearchResults.length >= 100) break; } fullSearchResults.length = Math.min(fullSearchResults.length, 100); } else throw new Error("此格式没有可搜索文本"); if (generation !== fullSearchGeneration) return; for (const [index, result] of fullSearchResults.entries()) { const row = document.createElement("button"); row.type = "button"; row.className = "full-search-result"; const location = document.createElement("small"); location.className = "full-search-location"; location.textContent = result.location; row.append(location, fullSearchSnippetDom(result.snippet)); row.addEventListener("click", async () => { fullSearchIndex = index; await result.activate(); setReaderPanelOpen(false, true); }); fullSearchResultsNode.appendChild(row); } fullSearchStatus.textContent = fullSearchResults.length ? `${fullSearchResults.length}${fullSearchResults.length === 100 ? "+" : ""} 个结果` : "未找到正文匹配"; } catch (error) { if (generation === fullSearchGeneration) fullSearchStatus.textContent = error.message || "正文搜索不可用"; } finally { if (generation === fullSearchGeneration) fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => { button.disabled = !fullSearchResults.length; }); } }
|
| 979 |
function moveFullSearch(step) { if (!fullSearchResults.length) return; fullSearchIndex = (fullSearchIndex + step + fullSearchResults.length) % fullSearchResults.length; const result = fullSearchResults[fullSearchIndex]; Promise.resolve(result.activate()).then(() => fullSearchResultsNode.children[fullSearchIndex].scrollIntoView({ block: "nearest" })); }
|
| 980 |
-
|
|
|
|
|
|
|
|
|
| 336 |
if (name === "media" && mediaElement) mediaPanel.querySelector(".media-panel-time").textContent = `${formatMediaTime(mediaElement.currentTime)} / ${formatMediaTime(mediaElement.duration)}`;
|
| 337 |
}
|
| 338 |
function setToc(entries) {
|
| 339 |
+
tocEntries = (entries || []).map((entry) => ({ ...entry, label: cleanTocLabel(entry.label) })); currentChapterIndex = tocEntries.length ? 0 : -1;
|
| 340 |
document.querySelector("#toc-tab").hidden = !tocEntries.length;
|
| 341 |
const list = document.querySelector("#toc-list"); list.textContent = "";
|
| 342 |
for (const [index, entry] of tocEntries.entries()) {
|
|
|
|
| 347 |
row.appendChild(link); list.appendChild(row);
|
| 348 |
}
|
| 349 |
}
|
| 350 |
+
function cleanTocLabel(value) { return String(value || "未命名章节").replace(/[\uFFFD\u2610-\u2612\u25a1\u0000-\u001F\u007F]/gu, "").replace(/\s{2,}/gu, " ").trim() || "未命名章节"; }
|
| 351 |
async function navigateReader(rawUrl) {
|
| 352 |
await saveProgress();
|
| 353 |
if (window.parent !== window) window.parent.postMessage({ type: "voice-reader-open", url: rawUrl }, location.origin);
|
|
|
|
| 970 |
let fullSearchResults = [], fullSearchIndex = -1, fullSearchGeneration = 0;
|
| 971 |
const fullSearchInput = fullSearchView.querySelector("#full-search-input"), fullSearchStatus = fullSearchView.querySelector("#full-search-status"), fullSearchResultsNode = fullSearchView.querySelector("#full-search-results");
|
| 972 |
function fullSearchEscape(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
| 973 |
+
let fullSearchActiveMarks = [];
|
| 974 |
+
function clearFullSearchMarks() { for (const mark of fullSearchActiveMarks) mark.replaceWith(mark.textContent); fullSearchActiveMarks = []; content.normalize(); }
|
| 975 |
+
function fullSearchTextNodes(root) { const nodes = [], walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT); while (walker.nextNode() && nodes.length < 20000) { const node = walker.currentNode; if (node.data && !node.parentElement.closest("script,style,mark.full-search-highlight")) nodes.push(node); } return nodes; }
|
| 976 |
function fullSearchSnippet(text, index, length) { const start = Math.max(0, index - 72), end = Math.min(text.length, index + length + 88); return { text: text.slice(start, end), matchStart: index - start, matchLength: length, prefix: start ? "…" : "", suffix: end < text.length ? "…" : "" }; }
|
| 977 |
function fullSearchSnippetDom(snippet) { const node = document.createElement("span"); node.className = "full-search-snippet"; if (snippet.prefix) node.append(snippet.prefix); node.append(snippet.text.slice(0, snippet.matchStart)); const mark = document.createElement("mark"); mark.className = "search-match"; mark.textContent = snippet.text.slice(snippet.matchStart, snippet.matchStart + snippet.matchLength); node.append(mark, snippet.text.slice(snippet.matchStart + snippet.matchLength)); if (snippet.suffix) node.append(snippet.suffix); return node; }
|
| 978 |
function fullSearchLocation(node, fallback) { const page = node.parentElement && node.parentElement.closest("[data-page]"); return page ? `第 ${page.dataset.page} 页` : fallback; }
|
| 979 |
+
function fullSearchDomMatches(root, fallback) { const query = fullSearchInput.value.trim(), pattern = new RegExp(fullSearchEscape(query), "giu"), output = []; for (const node of fullSearchTextNodes(root)) { const matches = [...node.data.matchAll(pattern)]; if (!matches.length) continue; const fragment = node.ownerDocument.createDocumentFragment(); let start = 0; for (const match of matches) { if (output.length >= 100) { fragment.append(node.data.slice(start)); break; } fragment.append(node.data.slice(start, match.index)); const mark = node.ownerDocument.createElement("mark"); mark.className = "full-search-highlight"; mark.textContent = match[0]; fullSearchActiveMarks.push(mark); fragment.append(mark); output.push({ location: fullSearchLocation(node, fallback), snippet: fullSearchSnippet(node.data, match.index, match[0].length), activate: () => mark.scrollIntoView({ block: "center" }) }); start = match.index + match[0].length; } if (start < node.data.length && output.length < 100) fragment.append(node.data.slice(start)); node.replaceWith(fragment); if (output.length >= 100) return output; } return output; }
|
| 980 |
async function fullSearchPdfMatches(query) { const output = [], pattern = new RegExp(fullSearchEscape(query), "giu"); for (let page = 1; page <= pdfDocument.numPages && output.length < 100; page++) { const textContent = await (await pdfDocument.getPage(page)).getTextContent(), text = textContent.items.map((item) => item.str + (item.hasEOL ? "\n" : " ")).join("").trim(); for (const match of text.matchAll(pattern)) { if (output.length >= 100) break; output.push({ location: `第 ${page} 页`, snippet: fullSearchSnippet(text, match.index, match[0].length), activate: () => goToPage(page) }); } } return output; }
|
| 981 |
async function runFullSearch() { const query = fullSearchInput.value.trim(); ++fullSearchGeneration; const generation = fullSearchGeneration; fullSearchResults = []; fullSearchIndex = -1; fullSearchResultsNode.textContent = ""; fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => { button.disabled = true; }); if (!query) { fullSearchStatus.textContent = "输入关键词搜索正文"; return; } fullSearchStatus.textContent = "正在搜索正文…"; try { if (capability.mode === "pdf") fullSearchResults = pdfDocument ? await fullSearchPdfMatches(query) : []; else if (["text", "markdown", "docx"].includes(capability.mode)) fullSearchResults = fullSearchDomMatches(content, "阅读位置"); else if (capability.mode === "html") fullSearchResults = htmlFrame && htmlFrame.contentDocument && htmlFrame.contentDocument.body ? fullSearchDomMatches(htmlFrame.contentDocument.body, "HTML 阅读位置") : []; else if (capability.mode === "epub") { const contents = epubRendition && typeof epubRendition.getContents === "function" ? epubRendition.getContents() : []; if (!contents.length) throw new Error("EPUB 尚未加载可搜索章节"); for (const item of contents) { fullSearchResults.push(...fullSearchDomMatches(item.document.body, "EPUB 当前章节")); if (fullSearchResults.length >= 100) break; } fullSearchResults.length = Math.min(fullSearchResults.length, 100); } else throw new Error("此格式没有可搜索文本"); if (generation !== fullSearchGeneration) return; for (const [index, result] of fullSearchResults.entries()) { const row = document.createElement("button"); row.type = "button"; row.className = "full-search-result"; const location = document.createElement("small"); location.className = "full-search-location"; location.textContent = result.location; row.append(location, fullSearchSnippetDom(result.snippet)); row.addEventListener("click", async () => { fullSearchIndex = index; await result.activate(); setReaderPanelOpen(false, true); }); fullSearchResultsNode.appendChild(row); } fullSearchStatus.textContent = fullSearchResults.length ? `${fullSearchResults.length}${fullSearchResults.length === 100 ? "+" : ""} 个结果` : "未找到正文匹配"; } catch (error) { if (generation === fullSearchGeneration) fullSearchStatus.textContent = error.message || "正文搜索不可用"; } finally { if (generation === fullSearchGeneration) fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => { button.disabled = !fullSearchResults.length; }); } }
|
| 982 |
function moveFullSearch(step) { if (!fullSearchResults.length) return; fullSearchIndex = (fullSearchIndex + step + fullSearchResults.length) % fullSearchResults.length; const result = fullSearchResults[fullSearchIndex]; Promise.resolve(result.activate()).then(() => fullSearchResultsNode.children[fullSearchIndex].scrollIntoView({ block: "nearest" })); }
|
| 983 |
+
fullSearchButton.addEventListener("click", () => { const panel = document.querySelector("#history-panel"), isOpen = panel.classList.contains("is-open"), isSelected = fullSearchView.hidden === false; if (isOpen && isSelected) { setReaderPanelOpen(false, true); return; } setReaderPanelOpen(true); selectPanel("full-search"); fullSearchInput.focus(); }); fullSearchInput.addEventListener("input", runFullSearch); fullSearchView.querySelector("#full-search-clear").addEventListener("click", () => { fullSearchInput.value = ""; runFullSearch(); fullSearchInput.focus(); }); fullSearchView.querySelector("#full-search-prev").addEventListener("click", () => moveFullSearch(-1)); fullSearchView.querySelector("#full-search-next").addEventListener("click", () => moveFullSearch(1));
|
| 984 |
+
fullSearchButton.addEventListener("click", (event) => { const panel = document.querySelector("#history-panel"); if (panel.classList.contains("is-open") && fullSearchView.hidden === false) { event.preventDefault(); event.stopImmediatePropagation(); clearFullSearchMarks(); selectPanel(tocEntries.length ? "toc" : "bookmarks"); } }, true); fullSearchInput.addEventListener("beforeinput", clearFullSearchMarks);
|
| 985 |
+
fullSearchView.querySelector("#full-search-clear").addEventListener("click", clearFullSearchMarks, true);
|