// Shared search presentation and task ownership; format matching stays in Reader. export function createReaderSearchController({ document, runtime, signal, nextGeneration, isCurrent, updateSearch, backend, navigation, panel }) { const search = runtime.state.search; const state = { page: null, failedPage: null, complete: false, pdfInProgress: false, chapterInProgress: false, pageLoader: null }; let inputTimer = 0, disposed = false; let renderedResults = [], renderedOffset = -1; const listeners = []; const active = () => !disposed && !signal.aborted; const current = generation => active() && isCurrent(generation); const busy = () => state.pdfInProgress || state.chapterInProgress; const pagedMode = () => ["epub-chapters", "pdf", "pdf-pages", "foliate"].includes(backend.mode()); const view = document.createElement("section"); view.id = "full-search-view"; view.className = "reader-panel-view full-search-view"; view.dataset.panelView = "full-search"; view.setAttribute("role", "region"); view.setAttribute("aria-label", "全文搜索"); view.hidden = true; view.innerHTML = '
输入关键词搜索正文
'; document.querySelector("#history-panel").insertBefore(view, document.querySelector(".reader-panel-tabs")); const button = document.querySelector("#full-search-toggle"); button.textContent = "全文搜索"; button.setAttribute("aria-label", "全文搜索"); button.setAttribute("aria-controls", view.id); button.setAttribute("aria-expanded", "false"); const input = view.querySelector("#full-search-input"); const status = view.querySelector("#full-search-status"); status.setAttribute("aria-atomic", "true"); input.setAttribute("aria-describedby", status.id); const results = view.querySelector("#full-search-results"); const clear = view.querySelector("#full-search-clear"); clear.hidden = true; const pagination = document.createElement("div"); pagination.className = "full-search-pagination"; pagination.hidden = true; pagination.innerHTML = ''; results.before(pagination); const cancel = document.createElement("button"); cancel.type = "button"; cancel.id = "full-search-cancel"; cancel.className = "text-action"; cancel.textContent = "取消搜索"; cancel.hidden = true; status.after(cancel); const retry = document.createElement("button"); retry.type = "button"; retry.id = "full-search-retry"; retry.className = "text-action"; retry.textContent = "重试搜索"; retry.hidden = true; cancel.after(retry); function listen(target, type, callback) { target.addEventListener(type, callback); listeners.push(() => target.removeEventListener(type, callback)); } function clearInputTimer() { clearTimeout(inputTimer); inputTimer = 0; } function updateButton(open) { button.textContent = open ? "关闭全文搜索" : "全文搜索"; button.setAttribute("aria-label", button.textContent); button.setAttribute("aria-expanded", String(open)); } function updateClear() { clear.hidden = !input.value; } function updateNavigation() { view.querySelectorAll(".full-search-nav button").forEach(node => { node.disabled = !search.results.length; }); } function updatePagination() { const page = state.page; pagination.hidden = !page || !page.total; if (!page) return; const pages = Math.ceil(page.total / page.pageSize); const pageInput = pagination.querySelector("#full-search-page"); pageInput.value = String(Math.floor(page.offset / page.pageSize) + 1); pageInput.max = String(Math.max(1, pages)); pageInput.style.setProperty("--page-width", `${pageInput.max.length}ch`); pagination.querySelector("#full-search-pages").textContent = `/ ${pages}`; pagination.querySelector("#full-search-page-prev").disabled = busy() || page.offset === 0; pagination.querySelector("#full-search-page-next").disabled = busy() || page.offset + page.pageSize >= page.total; pageInput.disabled = busy(); } function snippetDom(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; } function render() { updatePagination(); updateNavigation(); const offset = state.page?.offset || 0; if (renderedOffset === offset && results.childElementCount === search.results.length && renderedResults.length === search.results.length && search.results.every((result, index) => result === renderedResults[index])) return; const fragment = document.createDocumentFragment(); for (const [index, result] of search.results.entries()) { const row = document.createElement("button"); const location = document.createElement("small"); row.type = "button"; row.className = "full-search-result"; location.className = "full-search-location"; location.textContent = result.location; const rank = document.createElement("span"); rank.className = "full-search-rank"; rank.textContent = `${offset + index + 1}.`; row.append(rank, location, snippetDom(result.snippet)); row.addEventListener("click", () => activate(index)); fragment.appendChild(row); } results.replaceChildren(fragment); renderedResults = search.results.slice(); renderedOffset = offset; } function resetResults(query = search.query) { state.complete = false; state.pdfInProgress = state.chapterInProgress = false; state.page = state.failedPage = state.pageLoader = null; updateSearch({ query, results: [], index: -1 }); render(); } function cancelPending() { clearInputTimer(); nextGeneration(); navigation.begin(); backend.chapterClient()?.cancel(); backend.pdfClient()?.cancel(); resetResults(); } function close() { const restoreFocus = view.contains(document.activeElement); clearInputTimer(); if (busy()) state.complete = false; nextGeneration(); if (state.chapterInProgress) backend.chapterClient()?.cancel(); if (state.pdfInProgress) backend.pdfClient()?.cancel(); state.pdfInProgress = state.chapterInProgress = false; cancel.hidden = retry.hidden = true; panel.hide(); updateButton(false); if (restoreFocus && panel.isOpen()) button.focus({ preventScroll: true }); } async function run() { if (!active()) return; clearInputTimer(); const query = input.value.trim(); const generation = nextGeneration(); backend.pdfClient()?.cancel(); navigation.begin(); backend.clearMarks(); resetResults(query); retry.hidden = true; retry.textContent = "重试搜索"; cancel.hidden = !pagedMode() || !query; state.pdfInProgress = ["pdf", "pdf-pages", "foliate"].includes(backend.mode()) && !!query; state.chapterInProgress = backend.mode() === "epub-chapters" && !!query; if (!query) { backend.chapterClient()?.cancel(); status.textContent = "输入关键词搜索正文"; return; } status.textContent = "正在搜索正文…"; try { await backend.search(query, generation); if (!current(generation)) return; state.complete = true; state.pdfInProgress = state.chapterInProgress = false; render(); const total = state.page?.total ?? search.results.length; status.textContent = total ? `${total}${!state.page && total === 100 ? "+" : ""} 个结果` : "未找到正文匹配"; } catch (error) { if (current(generation)) { status.textContent = error.message || "正文搜索不可用"; retry.hidden = !pagedMode(); } } finally { if (current(generation)) { state.pdfInProgress = state.chapterInProgress = false; updatePagination(); cancel.hidden = true; updateNavigation(); } } } function applyChapterPage(page) { state.page = page; updateSearch({ results: page.results.map(backend.chapterResult), index: -1 }); } async function loadPage(offset, selectLast = null) { if (!active() || busy() || !state.page || (!backend.chapterClient() && !state.pageLoader)) return; state.failedPage = null; const generation = nextGeneration(); navigation.begin(); if (!["foliate", "epub-chapters"].includes(backend.mode())) cancel.hidden = false; retry.hidden = true; status.textContent = `${state.page.total} 个结果`; try { const loader = state.pageLoader; const page = await (loader ? loader(offset, generation) : backend.chapterClient().page(offset)); if (!current(generation)) return; if (loader) { state.page = page; updateSearch({ results: page.results, index: -1 }); } else applyChapterPage(page); render(); status.textContent = `${page.total} 个结果`; if (selectLast !== null) await activate(selectLast ? page.results.length - 1 : 0, false); } catch (error) { if (current(generation)) { status.textContent = error.message || "搜索结果加载失败"; state.failedPage = { offset, selectLast, query: search.query }; retry.textContent = "重试本页"; retry.hidden = false; } } finally { if (current(generation)) cancel.hidden = true; } } async function activate(index, closePanel = true) { if (!active()) return; const result = search.results[index]; if (!result) return; const previousIndex = search.index; const panelSerial = panel.serial(); const searchGeneration = runtime.currentGeneration("search"); const generation = navigation.begin(); const ownsResult = () => current(searchGeneration) && search.results[index] === result; updateSearch({ index }); try { const activated = await backend.activate(result, generation); if (activated === false && navigation.current(generation) && ownsResult()) { updateSearch({ index: previousIndex }); status.textContent = "搜索结果定位失败,请重试"; return; } if (activated === false || !navigation.current(generation) || search.results[index] !== result) return; if (status.textContent === "搜索结果定位失败,请重试") status.textContent = `${state.page?.total ?? search.results.length} 个结果`; if (closePanel && panelSerial === panel.serial() && current(searchGeneration)) panel.close(); else if (!closePanel) results.children[index]?.scrollIntoView({ block: "nearest" }); } catch (error) { if (error?.name !== "AbortError" && navigation.current(generation) && ownsResult()) { updateSearch({ index: previousIndex }); status.textContent = backend.mode() === "epub-chapters" ? error.message || "搜索结果定位失败,请重试" : "搜索结果定位失败,请重试"; } navigation.reportError(error, generation); } } function move(step) { const page = state.page; if (page?.total) { const next = search.index < 0 ? (step > 0 ? 0 : -1) : search.index + step; if (next < 0) return loadPage(page.offset ? page.offset - page.pageSize : Math.floor((page.total - 1) / page.pageSize) * page.pageSize, true); if (next >= search.results.length) return loadPage( page.offset + page.pageSize < page.total ? page.offset + page.pageSize : 0, false); return activate(next, false); } if (search.results.length) return activate((search.index + step + search.results.length) % search.results.length, false); } function toggle() { if (!active()) return; if (panel.isOpen() && !view.hidden) return close(); panel.show(); updateButton(true); updateClear(); input.focus(); if (search.query !== input.value.trim() || !state.complete) run(); else if (state.failedPage) retry.hidden = false; } function queue() { if (!active()) return; cancelPending(); cancel.hidden = retry.hidden = true; status.textContent = input.value.trim() ? "正在搜索正文…" : "输入关键词搜索正文"; inputTimer = setTimeout(run, 180); } listeners.push(runtime.events.on("state:panel", ({ panel: state }) => { updateButton(state.open && state.selected === "full-search"); })); listen(retry, "click", async () => { const failed = state.failedPage; if (!failed || search.query !== failed.query) return run(); if (backend.chapterClient()?.failed) { await run(); if (!state.complete || search.query !== failed.query) return; } return loadPage(failed.offset, failed.selectLast); }); listen(cancel, "click", () => { cancelPending(); cancel.hidden = true; retry.hidden = false; retry.textContent = "重试搜索"; status.textContent = "搜索已取消"; }); listen(pagination.querySelector("#full-search-page-prev"), "click", () => loadPage(state.page.offset - state.page.pageSize)); listen(pagination.querySelector("#full-search-page-next"), "click", () => loadPage(state.page.offset + state.page.pageSize)); listen(pagination.querySelector("#full-search-page"), "change", event => { if (!state.page || !Number.isFinite(event.target.valueAsNumber)) return; const page = Math.max(1, Math.min(Math.ceil(state.page.total / state.page.pageSize), Math.floor(event.target.valueAsNumber))); const offset = (page - 1) * state.page.pageSize; if (offset !== state.page.offset) loadPage(offset); }); listen(button, "click", toggle); listen(input, "input", event => { updateClear(); if (!event.isComposing) queue(); }); listen(input, "focus", panel.focus); listen(input, "compositionend", queue); listen(clear, "click", () => { input.value = ""; updateClear(); run(); input.focus(); }); listen(view.querySelector("#full-search-prev"), "click", () => move(-1)); listen(view.querySelector("#full-search-next"), "click", () => move(1)); function dispose() { if (disposed) return; disposed = true; clearInputTimer(); for (const remove of listeners.splice(0)) remove(); renderedResults = []; state.page = state.failedPage = state.pageLoader = null; } return Object.freeze({ state, elements: Object.freeze({ view, button, input, status, results, pagination, cancel, retry }), render, updatePagination, applyChapterPage, loadPage, run, close, activate, move, dispose }); }