diff --git a/README.md b/README.md index 319954b..96cc115 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 译读(ImmerRead)是一款本地安装的 Chrome Side Panel 扩展,为**各类英文文章**(技术博客、新闻报道、深度长文、Substack newsletter 等)提供沉浸式的整篇中文阅读体验。它在右侧渐进翻译全文,提供章节导读、划词翻译与解释、自定义术语库,并把密钥和缓存保存在你自己的浏览器中。 -**已验证(2026-09-17):** `npm test` 32/32 通过,`npm run check` 通过。项目使用原生 HTML、CSS 和 JavaScript,无构建步骤。 +**已验证(2026-09-17):** `npm test` 34/34 通过,`npm run check` 通过。项目使用原生 HTML、CSS 和 JavaScript,无构建步骤。 ## 为什么是译读 @@ -30,7 +30,7 @@ | 沉浸式双语阅读 | 中文译文与英文原文各自完整,滚动原文时译文自动定位到对应段落 | | 自定义术语库 | 固定自己认可的译法,跨文章复用,只重翻受影响的段落 | | 章节导读 | 按原文章节生成中文标题和最多三句、150 字的概述,先把握全局再深入 | -| 划词助学 | 在原文选中英文,可即时翻译或让 LLM 结合语境解释,顺手存为固定译法 | +| 划词助学 | 在原文选中英文,可即时翻译或让 LLM 结合语境解释,顺手存为固定译法;仅在侧栏打开时启用 | | 复杂内容保护 | 图片、图表、视频和交互模块留在原网页;可识别的表格叶子单元格在原位显示小号译文 | | 本地缓存 | 译文和导读最多缓存 20 篇文章、保留 30 天,可随时清除 | | 一致排版 | 右侧译文统一为纯文本,避免链接、粗体等原文样式被模型随机继承 | @@ -145,7 +145,7 @@ Most translation tools mix the two languages on screen: overlaid paragraphs, ins - Immersive bilingual reading: scroll the source and the translation follows paragraph by paragraph. - A custom terminology glossary: pin your preferred translations, reused across articles, retranslating only affected paragraphs. - Section-based reading guides: Chinese titles and summaries of at most three sentences / 150 characters per section. -- Learning aids on selection: translate or ask the LLM to explain selected English in context, and save terms to your glossary. +- Learning aids on selection: translate or ask the LLM to explain selected English in context, and save terms to your glossary. Active only while the side panel is open. - Local translation and guide caches with a 20-article / 30-day limit. - Plain-text translation rendering for consistent and safe typography. - No account, developer-operated backend, analytics, advertising, or telemetry. @@ -175,7 +175,7 @@ npm test npm run check ``` -Verified on 2026-09-17: 32 tests passed and syntax checks passed. The project uses plain HTML, CSS, and JavaScript with no build step. +Verified on 2026-09-17: 34 tests passed and syntax checks passed. The project uses plain HTML, CSS, and JavaScript with no build step. ## License diff --git a/background.js b/background.js index 7bbff00..4c06265 100644 --- a/background.js +++ b/background.js @@ -19,17 +19,40 @@ let summaryWriteChain = Promise.resolve(); void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => undefined); +const panelOpenTabs = new Set(); + +chrome.runtime.onConnect.addListener((port) => { + const match = /^yidu-panel-open:(\d+)$/.exec(port.name || ""); + if (!match) return; + const tabId = Number(match[1]); + panelOpenTabs.add(tabId); + notifyPanelState(tabId, true); + port.onDisconnect.addListener(() => { + panelOpenTabs.delete(tabId); + notifyPanelState(tabId, false); + }); +}); + +function notifyPanelState(tabId, open) { + chrome.tabs.sendMessage(tabId, { type: "YIDU_PANEL_STATE", payload: { open } }).catch(() => undefined); +} + chrome.runtime.onInstalled.addListener((details) => { if (details.reason === "install") void chrome.runtime.openOptionsPage(); }); -chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message?.type === "YIDU_OPEN_OPTIONS") { chrome.runtime.openOptionsPage(); sendResponse({ ok: true }); return false; } + if (message?.type === "YIDU_PANEL_STATE_QUERY") { + sendResponse({ ok: true, open: panelOpenTabs.has(sender.tab?.id) }); + return false; + } + const handlers = { YIDU_PREPARE_TAB: prepareTab, YIDU_TRANSLATE_BATCH: translateBatch, diff --git a/content.js b/content.js index d0f3af3..d220458 100644 --- a/content.js +++ b/content.js @@ -13,6 +13,12 @@ let cellNotes = new Map(); chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type === "YIDU_PANEL_STATE") { + panelOpen = Boolean(message.payload?.open); + if (!panelOpen) hideSelectionUi(); + sendResponse({ ok: true }); + return false; + } if (message?.type === "YIDU_REQUEST_SCROLL_SYNC") { scheduleScrollSync(); sendResponse({ ok: true }); @@ -341,6 +347,15 @@ let selectedText = ""; let selectedRect = null; let requestId = 0; + let panelOpen = false; + + try { + void chrome.runtime.sendMessage({ type: "YIDU_PANEL_STATE_QUERY" }) + .then((response) => { panelOpen = Boolean(response?.open); }) + .catch(() => undefined); + } catch { + // 扩展上下文失效时保持浮窗关闭。 + } document.addEventListener("mouseup", onSelectionChange); document.addEventListener("keyup", (event) => { @@ -359,6 +374,10 @@ function onSelectionChange(event) { if (event?.target && selectionRoot?.contains(event.target)) return; emitSelectionSync(); + if (!panelOpen) { + hideSelectionUi(); + return; + } if (selectionShadow?.querySelector(".yidu-result")) return; const selection = window.getSelection(); const text = (selection?.toString() || "").replace(/\s+/g, " ").trim(); diff --git a/sidepanel.js b/sidepanel.js index 68c60c9..1e96c8b 100644 --- a/sidepanel.js +++ b/sidepanel.js @@ -1,654 +1,665 @@ -import { containsTerm } from "./lib/glossary.js"; -import { groupArticleModules } from "./lib/summary.js"; - -(() => { - const BATCH_SIZE = 4; - const VIEWPORT_MARGIN = "520px 0px"; - const content = document.getElementById("content"); - const status = document.getElementById("status"); - const summaryContent = document.getElementById("summary-content"); - const glossaryForm = document.getElementById("glossary-form"); - const glossarySource = document.getElementById("glossary-source"); - const glossaryTarget = document.getElementById("glossary-target"); - const glossaryList = document.getElementById("glossary-list"); - const glossaryStatus = document.getElementById("glossary-status"); - const glossaryCancel = document.getElementById("glossary-cancel"); - const views = { - translation: document.getElementById("view-translation"), - summary: document.getElementById("view-summary"), - glossary: document.getElementById("view-glossary") - }; - const tabs = { - translation: document.getElementById("tab-translation"), - summary: document.getElementById("tab-summary"), - glossary: document.getElementById("tab-glossary") - }; - let activeView = "translation"; - let editingSource = ""; - let session = null; +import { containsTerm } from "./lib/glossary.js"; +import { groupArticleModules } from "./lib/summary.js"; + +(() => { + const BATCH_SIZE = 4; + const VIEWPORT_MARGIN = "520px 0px"; + const content = document.getElementById("content"); + const status = document.getElementById("status"); + const summaryContent = document.getElementById("summary-content"); + const glossaryForm = document.getElementById("glossary-form"); + const glossarySource = document.getElementById("glossary-source"); + const glossaryTarget = document.getElementById("glossary-target"); + const glossaryList = document.getElementById("glossary-list"); + const glossaryStatus = document.getElementById("glossary-status"); + const glossaryCancel = document.getElementById("glossary-cancel"); + const views = { + translation: document.getElementById("view-translation"), + summary: document.getElementById("view-summary"), + glossary: document.getElementById("view-glossary") + }; + const tabs = { + translation: document.getElementById("tab-translation"), + summary: document.getElementById("tab-summary"), + glossary: document.getElementById("tab-glossary") + }; + let activeView = "translation"; + let editingSource = ""; + let session = null; let reloadTimer = 0; - - document.getElementById("settings").addEventListener("click", () => { - chrome.runtime.sendMessage({ type: "YIDU_OPEN_OPTIONS" }); - }); - for (const [name, tab] of Object.entries(tabs)) { - tab.addEventListener("click", () => selectView(name)); - tab.addEventListener("keydown", (event) => { - const names = Object.keys(tabs); - const index = names.indexOf(name); - const next = event.key === "ArrowRight" ? names[(index + 1) % names.length] - : event.key === "ArrowLeft" ? names[(index + names.length - 1) % names.length] - : event.key === "Home" ? names[0] : event.key === "End" ? names[names.length - 1] : ""; - if (!next) return; - event.preventDefault(); - selectView(next); - tabs[next].focus(); - }); - } - glossaryForm.addEventListener("submit", saveGlossaryFromForm); - glossaryCancel.addEventListener("click", resetGlossaryForm); - chrome.tabs.onActivated.addListener(scheduleReload); - chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => { - if (changeInfo.status === "complete" && tab.active) scheduleReload(); - }); - chrome.runtime.onMessage.addListener((message, sender) => { - if (message?.type === "YIDU_SOURCE_SCROLL") handleSourceScroll(message.payload, sender.tab?.id); - if (message?.type === "YIDU_SOURCE_SELECTION") handleSourceSelection(message.payload, sender.tab?.id); - if (message?.type === "YIDU_GLOSSARY_CHANGED") handleGlossaryChanged(message.payload); - return false; - }); - + let panelPort = null; + + document.getElementById("settings").addEventListener("click", () => { + chrome.runtime.sendMessage({ type: "YIDU_OPEN_OPTIONS" }); + }); + for (const [name, tab] of Object.entries(tabs)) { + tab.addEventListener("click", () => selectView(name)); + tab.addEventListener("keydown", (event) => { + const names = Object.keys(tabs); + const index = names.indexOf(name); + const next = event.key === "ArrowRight" ? names[(index + 1) % names.length] + : event.key === "ArrowLeft" ? names[(index + names.length - 1) % names.length] + : event.key === "Home" ? names[0] : event.key === "End" ? names[names.length - 1] : ""; + if (!next) return; + event.preventDefault(); + selectView(next); + tabs[next].focus(); + }); + } + glossaryForm.addEventListener("submit", saveGlossaryFromForm); + glossaryCancel.addEventListener("click", resetGlossaryForm); + chrome.tabs.onActivated.addListener(scheduleReload); + chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => { + if (changeInfo.status === "complete" && tab.active) scheduleReload(); + }); + chrome.runtime.onMessage.addListener((message, sender) => { + if (message?.type === "YIDU_SOURCE_SCROLL") handleSourceScroll(message.payload, sender.tab?.id); + if (message?.type === "YIDU_SOURCE_SELECTION") handleSourceSelection(message.payload, sender.tab?.id); + if (message?.type === "YIDU_GLOSSARY_CHANGED") handleGlossaryChanged(message.payload); + return false; + }); + + void announcePanelOpen(); void loadActiveArticle(); - function selectView(name) { - activeView = name; - for (const [key, view] of Object.entries(views)) { - const selected = key === name; - view.hidden = !selected; - tabs[key].setAttribute("aria-selected", String(selected)); - tabs[key].tabIndex = selected ? 0 : -1; - } - if (name === "glossary") void loadGlossaryEntries(); - if (name === "summary") { - if (session) void runSummaries(session); - else if (!summaryContent.childElementCount) showSummaryMessage("请先打开一篇英文网页文章。"); - } - } - - function showSummaryMessage(message) { - const note = document.createElement("p"); - note.className = "yidu-summary-note"; - note.textContent = message; - summaryContent.replaceChildren(note); - } - - function renderSummaryOutline(current) { - const modules = groupArticleModules(current.article); - current.summaryModules = modules; - summaryContent.replaceChildren(); - const title = document.createElement("h1"); - title.className = "yidu-summary-title"; - title.textContent = current.article.title; - const originalTitle = document.createElement("p"); - originalTitle.className = "yidu-summary-article-original"; - originalTitle.textContent = current.article.title; - originalTitle.hidden = true; - current.summaryTitle = title; - current.summaryOriginalTitle = originalTitle; - const intro = document.createElement("p"); - intro.className = "yidu-view-intro"; - intro.textContent = modules.length + " 个大模块 · 按原文章节组织"; - summaryContent.append(title, originalTitle, intro); - if (!modules.length) { - intro.textContent = "文章中没有可生成导读的正文。"; - return; - } - for (const [index, module] of modules.entries()) { - const section = document.createElement("section"); - section.className = "yidu-summary-module"; - section.dataset.moduleId = module.id; - section.tabIndex = 0; - section.setAttribute("role", "link"); - section.setAttribute("aria-label", "定位英文原文:" + module.title); - section.addEventListener("click", (event) => { - if (!event.target.closest("button, a, input, textarea, select")) void locateSourceModule(current, module); - }); - section.addEventListener("keydown", (event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - void locateSourceModule(current, module); - }); - const number = document.createElement("span"); - number.className = "yidu-summary-number"; - number.textContent = String(index + 1).padStart(2, "0"); - const details = document.createElement("div"); - details.className = "yidu-summary-details"; - const heading = document.createElement("h2"); - heading.textContent = module.title; - const body = document.createElement("div"); - body.className = "yidu-summary-body"; - const pending = document.createElement("p"); - pending.className = "yidu-summary-pending"; - pending.textContent = "等待导读…"; - body.append(pending); - details.append(heading, body); - section.append(number, details); - summaryContent.append(section); - } - if (activeView === "summary") void runSummaries(current); - } - - async function locateSourceModule(current, module) { - if (current.stopped) return; + async function announcePanelOpen() { try { - await chrome.tabs.sendMessage(current.tabId, { - type: "YIDU_SCROLL_TO_SEGMENT", - payload: { segmentId: module.startId } - }); + const tab = await chrome.tabs.getCurrent(); + if (tab?.id != null) panelPort = chrome.runtime.connect({ name: `yidu-panel-open:${tab.id}` }); } catch { - // 页面连接失效时,保留导读内容供用户继续阅读。 - } - } - function updateSummaryArticleTitle(current, segment) { - if (segment?.kind !== "h1" || !current.summaryTitle || !current.summaryOriginalTitle) return; - const translated = current.rows.get(segment.id)?.textContent?.trim(); - if (!translated || translated === current.article.title) return; - current.summaryTitle.textContent = translated; - current.summaryOriginalTitle.hidden = false; - } - function summaryBody(module) { - return [...summaryContent.querySelectorAll(".yidu-summary-module")] - .find((section) => section.dataset.moduleId === module.id) - ?.querySelector(".yidu-summary-body"); - } - - async function runSummaries(current) { - if (current.stopped || current.summaryRunning || activeView !== "summary") return; - current.summaryRunning = true; - try { - for (const module of current.summaryModules) { - if (current.stopped || activeView !== "summary") break; - if (current.summaryResults.has(module.id) || current.summaryFailed.has(module.id)) continue; - const outcome = await summarizeOne(current, module); - if (outcome?.code === "SETUP_REQUIRED") break; - } - } finally { - current.summaryRunning = false; - } - } - - async function summarizeOne(current, module) { - if (current.stopped || current.summaryInFlight.has(module.id)) return null; - const body = summaryBody(module); - if (!body) return null; - current.summaryInFlight.add(module.id); - body.setAttribute("aria-busy", "true"); - const pending = document.createElement("p"); - pending.className = "yidu-summary-pending"; - pending.textContent = "正在生成导读…"; - body.replaceChildren(pending); - let outcome; - try { - outcome = await chrome.runtime.sendMessage({ - type: "YIDU_SUMMARIZE_MODULE", - payload: { url: current.article.url, title: current.article.title, module } - }); - if (current.stopped) return outcome; - if (!outcome?.ok) throw new Error(outcome?.message || "导读生成失败,请重试。"); - current.summaryResults.set(module.id, outcome.result); - const details = body.parentElement; - details.querySelector("h2").textContent = outcome.result.title; - const original = document.createElement("p"); - original.className = "yidu-summary-original"; - original.textContent = module.title; - details.querySelector(".yidu-summary-original")?.remove(); - details.insertBefore(original, body); - const summary = document.createElement("p"); - summary.className = "yidu-summary-overview"; - summary.textContent = outcome.result.summary; - body.replaceChildren(summary); - } catch (error) { - if (!current.stopped) { - current.summaryFailed.add(module.id); - const message = document.createElement("p"); - message.className = "yidu-summary-failure"; - message.textContent = error?.message || "导读生成失败,请重试。"; - const actions = document.createElement("div"); - actions.className = "yidu-actions"; - if (outcome?.code === "SETUP_REQUIRED") { - const settings = document.createElement("button"); - settings.className = "yidu-action secondary"; - settings.type = "button"; - settings.textContent = "打开设置"; - settings.addEventListener("click", () => chrome.runtime.sendMessage({ type: "YIDU_OPEN_OPTIONS" })); - actions.append(settings); - } - const retry = document.createElement("button"); - retry.className = "yidu-action secondary"; - retry.type = "button"; - retry.textContent = "重试"; - retry.addEventListener("click", () => { - current.summaryFailed.delete(module.id); - void summarizeOne(current, module).then((result) => { - if (result?.ok) void runSummaries(current); - }); - }); - actions.append(retry); - body.replaceChildren(message, actions); - } - } finally { - current.summaryInFlight.delete(module.id); - body.removeAttribute("aria-busy"); - } - return outcome; - } - - function resetGlossaryForm() { - editingSource = ""; - glossaryForm.reset(); - glossarySource.readOnly = false; - glossaryCancel.hidden = true; - glossaryForm.querySelector('button[type="submit"]').textContent = "保存译法"; - } - - async function saveGlossaryFromForm(event) { - event.preventDefault(); - const button = glossaryForm.querySelector('button[type="submit"]'); - button.disabled = true; - glossaryStatus.textContent = "正在保存…"; - try { - const result = await chrome.runtime.sendMessage({ - type: "YIDU_GLOSSARY_UPSERT", - payload: { source: editingSource || glossarySource.value, target: glossaryTarget.value } - }); - if (!result?.ok) throw new Error(result?.message || "保存失败"); - resetGlossaryForm(); - await loadGlossaryEntries(); - glossaryStatus.textContent = "固定译法已保存。"; - } catch (error) { - glossaryStatus.textContent = error?.message || "保存失败,请重试。"; - } finally { - button.disabled = false; - } - } - - async function loadGlossaryEntries() { - try { - const result = await chrome.runtime.sendMessage({ type: "YIDU_GLOSSARY_GET" }); - if (!result?.ok) throw new Error(result?.message || "读取失败"); - glossaryList.replaceChildren(); - const entries = (result.entries || []).sort((left, right) => - left.source.toLocaleLowerCase().localeCompare(right.source.toLocaleLowerCase())); - if (!entries.length) { - const empty = document.createElement("p"); - empty.className = "yidu-glossary-empty"; - empty.textContent = "还没有固定译法。可在这里添加,也可在原网页选词后保存。"; - glossaryList.append(empty); - } - for (const entry of entries) { - const row = document.createElement("div"); - row.className = "yidu-glossary-entry"; - const words = document.createElement("div"); - words.className = "yidu-glossary-words"; - const source = document.createElement("strong"); - source.textContent = entry.source; - const arrow = document.createElement("span"); - arrow.textContent = "→"; - const target = document.createElement("span"); - target.textContent = entry.target; - words.append(source, arrow, target); - const actions = document.createElement("div"); - actions.className = "yidu-glossary-actions"; - const edit = document.createElement("button"); - edit.type = "button"; - edit.textContent = "编辑"; - edit.addEventListener("click", () => { - editingSource = entry.source; - glossarySource.value = entry.source; - glossarySource.readOnly = true; - glossaryTarget.value = entry.target; - glossaryCancel.hidden = false; - glossaryForm.querySelector('button[type="submit"]').textContent = "更新译法"; - glossaryTarget.focus(); - }); - const remove = document.createElement("button"); - remove.type = "button"; - remove.textContent = "删除"; - remove.addEventListener("click", async () => { - remove.disabled = true; - const result = await chrome.runtime.sendMessage({ - type: "YIDU_GLOSSARY_DELETE", - payload: { source: entry.source } - }).catch(() => null); - if (result?.ok) { - if (editingSource.toLocaleLowerCase() === entry.source.toLocaleLowerCase()) resetGlossaryForm(); - await loadGlossaryEntries(); - glossaryStatus.textContent = "固定译法已删除。"; - } else { - glossaryStatus.textContent = result?.message || "删除失败,请重试。"; - remove.disabled = false; - } - }); - actions.append(edit, remove); - row.append(words, actions); - glossaryList.append(row); - } - } catch (error) { - glossaryStatus.textContent = error?.message || "读取固定译法失败。"; - } - } - - function scheduleReload() { - window.clearTimeout(reloadTimer); - reloadTimer = window.setTimeout(() => void loadActiveArticle(), 120); - } - - function handleSourceScroll(payload, tabId) { - const current = session; - if (!current || current.stopped || tabId !== current.tabId) return; - const row = current.rows.get(String(payload?.segmentId || "")); - if (!row || activeView !== "translation") return; - const index = current.article.segments.findIndex((segment) => segment.id === row.dataset.segmentId); - current.article.segments.slice(index, index + BATCH_SIZE).forEach((segment) => enqueue(current, segment)); - requestAnimationFrame(() => { - if (current.stopped) return; - const rect = row.getBoundingClientRect(); - const ratio = Math.max(0, Math.min(1, Number(payload?.ratio || 0))); - const target = window.scrollY + rect.top + rect.height * ratio - window.innerHeight * 0.35; - const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight); - window.scrollTo(0, Math.max(0, Math.min(maxScroll, target))); - }); - } - function handleSourceSelection(payload, tabId) { - const current = session; - if (!current || current.stopped || tabId !== current.tabId) return; - const ids = new Set( - (Array.isArray(payload?.segmentIds) ? payload.segmentIds : []) - .map(String) - .filter((id) => current.segmentsById.get(id)?.kind !== "skipped" && current.rows.has(id)) - ); - const previous = current.selectedSegmentIds; - if (ids.size === previous.size && [...ids].every((id) => previous.has(id))) return; - for (const id of previous) current.rows.get(id)?.classList.remove("yidu-source-selected"); - for (const id of ids) current.rows.get(id)?.classList.add("yidu-source-selected"); - current.selectedSegmentIds = ids; - const first = current.rows.get(ids.values().next().value); - if (first && activeView === "translation") { - requestAnimationFrame(() => { - if (!current.stopped && current.selectedSegmentIds.has(first.dataset.segmentId)) { - first.scrollIntoView({ block: "center", behavior: "auto" }); - } - }); - } - } - function handleGlossaryChanged(payload) { - if (activeView === "glossary") void loadGlossaryEntries(); - const current = session; - const source = String(payload?.source || "").trim(); - if (!current || current.stopped || !source) return; - for (const key of Object.keys(current.glossary)) { - if (key.toLocaleLowerCase() === source.toLocaleLowerCase()) delete current.glossary[key]; - } - if (payload?.target) current.glossary[source] = String(payload.target); - current.glossaryEpoch += 1; - if (current.failed) { - current.failed = false; - document.querySelector(".yidu-error")?.remove(); - current.failedSegments.splice(0).forEach((segment) => enqueue(current, segment)); + // 无法确认所属标签页时,划词浮窗保持关闭。 } - for (const segment of current.article.segments) { - if (segment.kind === "skipped" || !containsTerm(segment.text, source)) continue; - if (current.completed.delete(segment.id)) { - const row = current.rows.get(segment.id); - row?.classList.add("yidu-refreshing"); - row?.setAttribute("aria-busy", "true"); - enqueue(current, segment); - } else if (current.selectedSegmentIds.has(segment.id)) { - enqueue(current, segment); - } - } - } - - async function loadActiveArticle() { - stopSession(); - setStatus("正在读取文章…"); - content.setAttribute("aria-busy", "true"); - content.replaceChildren(); - summaryContent.replaceChildren(); - try { - const tab = await findArticleTab(); - if (!tab?.id) throw new Error("请先打开一篇英文网页文章。"); - const response = await getArticleFromTab(tab.id); - if (!response?.ok || !response.article?.segments?.length) { - throw new Error(response?.message || "没有识别到可翻译的英文文章正文。"); - } - startSession(response.article, tab.id); - } catch (error) { - const message = error?.message || "无法读取当前页面。"; - showEmpty(message, true); - showSummaryMessage(message); - } - } - - async function findArticleTab() { - const tabs = await chrome.tabs.query({ currentWindow: true }); - return tabs - .filter((tab) => /^https?:/i.test(tab.url || "")) - .sort((left, right) => Number(right.active) - Number(left.active) || Number(right.lastAccessed || 0) - Number(left.lastAccessed || 0))[0]; - } - - async function getArticleFromTab(tabId) { - await waitForTabReady(tabId); - try { - return await chrome.tabs.sendMessage(tabId, { type: "YIDU_GET_ARTICLE" }); - } catch { - // The receiver disappears when an unpacked extension is reloaded while the - // article tab stays open. Ask the service worker to inject it again. - } - - const prepared = await chrome.runtime.sendMessage({ - type: "YIDU_PREPARE_TAB", - payload: { tabId } - }); - if (!prepared?.ok) { - return prepared || { ok: false, message: "无法读取当前页面,请刷新页面后重试。" }; - } - - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - return await chrome.tabs.sendMessage(tabId, { type: "YIDU_GET_ARTICLE" }); - } catch { - await delay(180); - } - } - return { ok: false, message: "页面连接失败,请刷新文章页面后重试。" }; - } - - async function waitForTabReady(tabId) { - for (let attempt = 0; attempt < 12; attempt += 1) { - const tab = await chrome.tabs.get(tabId); - if (tab.status === "complete") return; - await delay(150); - } - } - - function delay(milliseconds) { - return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); - } - - function startSession(article, tabId) { - const next = { - article, - tabId, - rows: new Map(), - segmentsById: new Map(article.segments.map((segment) => [segment.id, segment])), - translations: new Map(), - completed: new Set(), - queued: new Set(), - queue: [], - glossary: {}, - observer: null, - working: false, - stopped: false, - failed: false, - failedSegments: [], - cachedCount: 0, - summaryModules: [], - summaryResults: new Map(), - summaryFailed: new Set(), - summaryInFlight: new Set(), - summaryRunning: false, - summaryTitle: null, - summaryOriginalTitle: null, - selectedSegmentIds: new Set(), - glossaryEpoch: 0 - }; - session = next; - renderArticle(next); - renderSummaryOutline(next); - void restoreCache(next).then(() => { - if (next.stopped) return; - startViewportTranslation(next); - void chrome.tabs.sendMessage(next.tabId, { type: "YIDU_REQUEST_SCROLL_SYNC" }).catch(() => undefined); - void chrome.tabs.sendMessage(next.tabId, { type: "YIDU_REQUEST_SELECTION_SYNC" }).catch(() => undefined); - }); - } - - function renderArticle(current) { - content.replaceChildren(); - let list = null; - let listKind = ""; - for (const segment of current.article.segments) { - if (segment.kind === "cell") continue; - const isListItem = segment.kind === "ul-item" || segment.kind === "ol-item"; - let element; - if (isListItem) { - if (!list || listKind !== segment.kind) { - list = document.createElement(segment.kind === "ol-item" ? "ol" : "ul"); - list.className = "yidu-list"; - content.append(list); - listKind = segment.kind; - } - element = document.createElement("li"); - list.append(element); - } else { - list = null; - listKind = ""; - const tag = /^h[1-6]$/.test(segment.kind) ? segment.kind : segment.kind === "blockquote" ? "blockquote" : "p"; - element = document.createElement(tag); - content.append(element); - } - element.className = `yidu-segment yidu-${segment.kind} yidu-pending`; - if (segment.kind === "skipped") { - element.textContent = segment.text; - element.classList.remove("yidu-pending"); - element.removeAttribute("aria-busy"); - current.completed.add(segment.id); - } - element.dataset.segmentId = segment.id; - if (segment.kind !== "skipped") element.setAttribute("aria-busy", "true"); - current.rows.set(segment.id, element); - } - content.setAttribute("aria-busy", "false"); - } - - async function restoreCache(current) { - try { - const result = await chrome.runtime.sendMessage({ - type: "YIDU_CACHE_GET", - payload: { url: current.article.url, segments: current.article.segments } - }); - if (!result?.ok || current.stopped) return; - for (const item of result.items || []) applyTranslation(current, item, true); - } catch { - // 缓存不可用时继续翻译。 - } - } - - function startViewportTranslation(current) { - current.article.segments.slice(0, BATCH_SIZE).forEach((segment) => enqueue(current, segment)); - current.article.segments.filter((segment) => segment.kind === "cell").forEach((segment) => enqueue(current, segment)); - if (!("IntersectionObserver" in window)) { - current.article.segments.forEach((segment) => enqueue(current, segment)); - return; - } - current.observer = new IntersectionObserver((entries) => { - entries.filter((entry) => entry.isIntersecting).forEach((entry) => { - const segment = current.segmentsById.get(entry.target.dataset.segmentId); - if (segment) enqueue(current, segment); - current.observer?.unobserve(entry.target); - }); - }, { rootMargin: VIEWPORT_MARGIN, threshold: 0 }); - for (const row of current.rows.values()) { - if (!current.completed.has(row.dataset.segmentId)) current.observer.observe(row); - } - updateProgress(current); - } - - function enqueue(current, segment) { - if (current.stopped || current.failed || segment.kind === "skipped" || current.completed.has(segment.id) || current.queued.has(segment.id)) return; - current.queue.push(segment); - current.queued.add(segment.id); - queueMicrotask(() => void processQueue(current)); - } - - async function processQueue(current) { - if (current.working || current.stopped || current.failed) return; - current.working = true; - try { - while (current.queue.length && !current.stopped && !current.failed) { - const segments = current.queue.splice(0, BATCH_SIZE); - segments.forEach((segment) => current.queued.delete(segment.id)); - current.failedSegments = segments; - updateProgress(current, segments.length); - const epoch = current.glossaryEpoch; - const result = await chrome.runtime.sendMessage({ - type: "YIDU_TRANSLATE_BATCH", - payload: { title: current.article.title, segments, glossary: current.glossary } - }); - if (!result?.ok) throw Object.assign(new Error(result?.message || "翻译失败"), { code: result?.code }); - if (current.stopped) return; - if (epoch !== current.glossaryEpoch) { - segments.forEach((segment) => enqueue(current, segment)); - continue; - } - for (const item of result.items) applyTranslation(current, item, false); - void chrome.runtime.sendMessage({ - type: "YIDU_CACHE_PUT", - payload: { url: current.article.url, segments, items: result.items, glossarySnapshot: result.glossarySnapshot } - }).catch(() => undefined); - current.failedSegments = []; - } - } catch (error) { - current.failed = true; - showError(current, error?.message || "翻译失败,请稍后重试。", error?.code === "SETUP_REQUIRED"); - } finally { - current.working = false; - updateProgress(current); - } - } - - function applyTranslation(current, item, fromCache) { - if (current.completed.has(item.id)) return; - const segment = current.segmentsById.get(item.id); - if (!segment) return; - current.translations.set(item.id, item); - for (const term of item.terms || []) current.glossary[term.source] = term.target; - current.completed.add(item.id); - if (fromCache) current.cachedCount += 1; - const translatedRow = current.rows.get(item.id); - if (translatedRow) current.observer?.unobserve(translatedRow); - renderTranslation(current, item.id, item); - if (segment.kind === "cell") { - void chrome.tabs.sendMessage(current.tabId, { - type: "YIDU_RENDER_CELLS", - payload: { items: [{ id: item.id, translation: item.translation }] } - }).catch(() => undefined); - } - updateSummaryArticleTitle(current, segment); - updateProgress(current); - } - + } + + function selectView(name) { + activeView = name; + for (const [key, view] of Object.entries(views)) { + const selected = key === name; + view.hidden = !selected; + tabs[key].setAttribute("aria-selected", String(selected)); + tabs[key].tabIndex = selected ? 0 : -1; + } + if (name === "glossary") void loadGlossaryEntries(); + if (name === "summary") { + if (session) void runSummaries(session); + else if (!summaryContent.childElementCount) showSummaryMessage("请先打开一篇英文网页文章。"); + } + } + + function showSummaryMessage(message) { + const note = document.createElement("p"); + note.className = "yidu-summary-note"; + note.textContent = message; + summaryContent.replaceChildren(note); + } + + function renderSummaryOutline(current) { + const modules = groupArticleModules(current.article); + current.summaryModules = modules; + summaryContent.replaceChildren(); + const title = document.createElement("h1"); + title.className = "yidu-summary-title"; + title.textContent = current.article.title; + const originalTitle = document.createElement("p"); + originalTitle.className = "yidu-summary-article-original"; + originalTitle.textContent = current.article.title; + originalTitle.hidden = true; + current.summaryTitle = title; + current.summaryOriginalTitle = originalTitle; + const intro = document.createElement("p"); + intro.className = "yidu-view-intro"; + intro.textContent = modules.length + " 个大模块 · 按原文章节组织"; + summaryContent.append(title, originalTitle, intro); + if (!modules.length) { + intro.textContent = "文章中没有可生成导读的正文。"; + return; + } + for (const [index, module] of modules.entries()) { + const section = document.createElement("section"); + section.className = "yidu-summary-module"; + section.dataset.moduleId = module.id; + section.tabIndex = 0; + section.setAttribute("role", "link"); + section.setAttribute("aria-label", "定位英文原文:" + module.title); + section.addEventListener("click", (event) => { + if (!event.target.closest("button, a, input, textarea, select")) void locateSourceModule(current, module); + }); + section.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + void locateSourceModule(current, module); + }); + const number = document.createElement("span"); + number.className = "yidu-summary-number"; + number.textContent = String(index + 1).padStart(2, "0"); + const details = document.createElement("div"); + details.className = "yidu-summary-details"; + const heading = document.createElement("h2"); + heading.textContent = module.title; + const body = document.createElement("div"); + body.className = "yidu-summary-body"; + const pending = document.createElement("p"); + pending.className = "yidu-summary-pending"; + pending.textContent = "等待导读…"; + body.append(pending); + details.append(heading, body); + section.append(number, details); + summaryContent.append(section); + } + if (activeView === "summary") void runSummaries(current); + } + + async function locateSourceModule(current, module) { + if (current.stopped) return; + try { + await chrome.tabs.sendMessage(current.tabId, { + type: "YIDU_SCROLL_TO_SEGMENT", + payload: { segmentId: module.startId } + }); + } catch { + // 页面连接失效时,保留导读内容供用户继续阅读。 + } + } + function updateSummaryArticleTitle(current, segment) { + if (segment?.kind !== "h1" || !current.summaryTitle || !current.summaryOriginalTitle) return; + const translated = current.rows.get(segment.id)?.textContent?.trim(); + if (!translated || translated === current.article.title) return; + current.summaryTitle.textContent = translated; + current.summaryOriginalTitle.hidden = false; + } + function summaryBody(module) { + return [...summaryContent.querySelectorAll(".yidu-summary-module")] + .find((section) => section.dataset.moduleId === module.id) + ?.querySelector(".yidu-summary-body"); + } + + async function runSummaries(current) { + if (current.stopped || current.summaryRunning || activeView !== "summary") return; + current.summaryRunning = true; + try { + for (const module of current.summaryModules) { + if (current.stopped || activeView !== "summary") break; + if (current.summaryResults.has(module.id) || current.summaryFailed.has(module.id)) continue; + const outcome = await summarizeOne(current, module); + if (outcome?.code === "SETUP_REQUIRED") break; + } + } finally { + current.summaryRunning = false; + } + } + + async function summarizeOne(current, module) { + if (current.stopped || current.summaryInFlight.has(module.id)) return null; + const body = summaryBody(module); + if (!body) return null; + current.summaryInFlight.add(module.id); + body.setAttribute("aria-busy", "true"); + const pending = document.createElement("p"); + pending.className = "yidu-summary-pending"; + pending.textContent = "正在生成导读…"; + body.replaceChildren(pending); + let outcome; + try { + outcome = await chrome.runtime.sendMessage({ + type: "YIDU_SUMMARIZE_MODULE", + payload: { url: current.article.url, title: current.article.title, module } + }); + if (current.stopped) return outcome; + if (!outcome?.ok) throw new Error(outcome?.message || "导读生成失败,请重试。"); + current.summaryResults.set(module.id, outcome.result); + const details = body.parentElement; + details.querySelector("h2").textContent = outcome.result.title; + const original = document.createElement("p"); + original.className = "yidu-summary-original"; + original.textContent = module.title; + details.querySelector(".yidu-summary-original")?.remove(); + details.insertBefore(original, body); + const summary = document.createElement("p"); + summary.className = "yidu-summary-overview"; + summary.textContent = outcome.result.summary; + body.replaceChildren(summary); + } catch (error) { + if (!current.stopped) { + current.summaryFailed.add(module.id); + const message = document.createElement("p"); + message.className = "yidu-summary-failure"; + message.textContent = error?.message || "导读生成失败,请重试。"; + const actions = document.createElement("div"); + actions.className = "yidu-actions"; + if (outcome?.code === "SETUP_REQUIRED") { + const settings = document.createElement("button"); + settings.className = "yidu-action secondary"; + settings.type = "button"; + settings.textContent = "打开设置"; + settings.addEventListener("click", () => chrome.runtime.sendMessage({ type: "YIDU_OPEN_OPTIONS" })); + actions.append(settings); + } + const retry = document.createElement("button"); + retry.className = "yidu-action secondary"; + retry.type = "button"; + retry.textContent = "重试"; + retry.addEventListener("click", () => { + current.summaryFailed.delete(module.id); + void summarizeOne(current, module).then((result) => { + if (result?.ok) void runSummaries(current); + }); + }); + actions.append(retry); + body.replaceChildren(message, actions); + } + } finally { + current.summaryInFlight.delete(module.id); + body.removeAttribute("aria-busy"); + } + return outcome; + } + + function resetGlossaryForm() { + editingSource = ""; + glossaryForm.reset(); + glossarySource.readOnly = false; + glossaryCancel.hidden = true; + glossaryForm.querySelector('button[type="submit"]').textContent = "保存译法"; + } + + async function saveGlossaryFromForm(event) { + event.preventDefault(); + const button = glossaryForm.querySelector('button[type="submit"]'); + button.disabled = true; + glossaryStatus.textContent = "正在保存…"; + try { + const result = await chrome.runtime.sendMessage({ + type: "YIDU_GLOSSARY_UPSERT", + payload: { source: editingSource || glossarySource.value, target: glossaryTarget.value } + }); + if (!result?.ok) throw new Error(result?.message || "保存失败"); + resetGlossaryForm(); + await loadGlossaryEntries(); + glossaryStatus.textContent = "固定译法已保存。"; + } catch (error) { + glossaryStatus.textContent = error?.message || "保存失败,请重试。"; + } finally { + button.disabled = false; + } + } + + async function loadGlossaryEntries() { + try { + const result = await chrome.runtime.sendMessage({ type: "YIDU_GLOSSARY_GET" }); + if (!result?.ok) throw new Error(result?.message || "读取失败"); + glossaryList.replaceChildren(); + const entries = (result.entries || []).sort((left, right) => + left.source.toLocaleLowerCase().localeCompare(right.source.toLocaleLowerCase())); + if (!entries.length) { + const empty = document.createElement("p"); + empty.className = "yidu-glossary-empty"; + empty.textContent = "还没有固定译法。可在这里添加,也可在原网页选词后保存。"; + glossaryList.append(empty); + } + for (const entry of entries) { + const row = document.createElement("div"); + row.className = "yidu-glossary-entry"; + const words = document.createElement("div"); + words.className = "yidu-glossary-words"; + const source = document.createElement("strong"); + source.textContent = entry.source; + const arrow = document.createElement("span"); + arrow.textContent = "→"; + const target = document.createElement("span"); + target.textContent = entry.target; + words.append(source, arrow, target); + const actions = document.createElement("div"); + actions.className = "yidu-glossary-actions"; + const edit = document.createElement("button"); + edit.type = "button"; + edit.textContent = "编辑"; + edit.addEventListener("click", () => { + editingSource = entry.source; + glossarySource.value = entry.source; + glossarySource.readOnly = true; + glossaryTarget.value = entry.target; + glossaryCancel.hidden = false; + glossaryForm.querySelector('button[type="submit"]').textContent = "更新译法"; + glossaryTarget.focus(); + }); + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "删除"; + remove.addEventListener("click", async () => { + remove.disabled = true; + const result = await chrome.runtime.sendMessage({ + type: "YIDU_GLOSSARY_DELETE", + payload: { source: entry.source } + }).catch(() => null); + if (result?.ok) { + if (editingSource.toLocaleLowerCase() === entry.source.toLocaleLowerCase()) resetGlossaryForm(); + await loadGlossaryEntries(); + glossaryStatus.textContent = "固定译法已删除。"; + } else { + glossaryStatus.textContent = result?.message || "删除失败,请重试。"; + remove.disabled = false; + } + }); + actions.append(edit, remove); + row.append(words, actions); + glossaryList.append(row); + } + } catch (error) { + glossaryStatus.textContent = error?.message || "读取固定译法失败。"; + } + } + + function scheduleReload() { + window.clearTimeout(reloadTimer); + reloadTimer = window.setTimeout(() => void loadActiveArticle(), 120); + } + + function handleSourceScroll(payload, tabId) { + const current = session; + if (!current || current.stopped || tabId !== current.tabId) return; + const row = current.rows.get(String(payload?.segmentId || "")); + if (!row || activeView !== "translation") return; + const index = current.article.segments.findIndex((segment) => segment.id === row.dataset.segmentId); + current.article.segments.slice(index, index + BATCH_SIZE).forEach((segment) => enqueue(current, segment)); + requestAnimationFrame(() => { + if (current.stopped) return; + const rect = row.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, Number(payload?.ratio || 0))); + const target = window.scrollY + rect.top + rect.height * ratio - window.innerHeight * 0.35; + const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight); + window.scrollTo(0, Math.max(0, Math.min(maxScroll, target))); + }); + } + function handleSourceSelection(payload, tabId) { + const current = session; + if (!current || current.stopped || tabId !== current.tabId) return; + const ids = new Set( + (Array.isArray(payload?.segmentIds) ? payload.segmentIds : []) + .map(String) + .filter((id) => current.segmentsById.get(id)?.kind !== "skipped" && current.rows.has(id)) + ); + const previous = current.selectedSegmentIds; + if (ids.size === previous.size && [...ids].every((id) => previous.has(id))) return; + for (const id of previous) current.rows.get(id)?.classList.remove("yidu-source-selected"); + for (const id of ids) current.rows.get(id)?.classList.add("yidu-source-selected"); + current.selectedSegmentIds = ids; + const first = current.rows.get(ids.values().next().value); + if (first && activeView === "translation") { + requestAnimationFrame(() => { + if (!current.stopped && current.selectedSegmentIds.has(first.dataset.segmentId)) { + first.scrollIntoView({ block: "center", behavior: "auto" }); + } + }); + } + } + function handleGlossaryChanged(payload) { + if (activeView === "glossary") void loadGlossaryEntries(); + const current = session; + const source = String(payload?.source || "").trim(); + if (!current || current.stopped || !source) return; + for (const key of Object.keys(current.glossary)) { + if (key.toLocaleLowerCase() === source.toLocaleLowerCase()) delete current.glossary[key]; + } + if (payload?.target) current.glossary[source] = String(payload.target); + current.glossaryEpoch += 1; + if (current.failed) { + current.failed = false; + document.querySelector(".yidu-error")?.remove(); + current.failedSegments.splice(0).forEach((segment) => enqueue(current, segment)); + } + for (const segment of current.article.segments) { + if (segment.kind === "skipped" || !containsTerm(segment.text, source)) continue; + if (current.completed.delete(segment.id)) { + const row = current.rows.get(segment.id); + row?.classList.add("yidu-refreshing"); + row?.setAttribute("aria-busy", "true"); + enqueue(current, segment); + } else if (current.selectedSegmentIds.has(segment.id)) { + enqueue(current, segment); + } + } + } + + async function loadActiveArticle() { + stopSession(); + setStatus("正在读取文章…"); + content.setAttribute("aria-busy", "true"); + content.replaceChildren(); + summaryContent.replaceChildren(); + try { + const tab = await findArticleTab(); + if (!tab?.id) throw new Error("请先打开一篇英文网页文章。"); + const response = await getArticleFromTab(tab.id); + if (!response?.ok || !response.article?.segments?.length) { + throw new Error(response?.message || "没有识别到可翻译的英文文章正文。"); + } + startSession(response.article, tab.id); + } catch (error) { + const message = error?.message || "无法读取当前页面。"; + showEmpty(message, true); + showSummaryMessage(message); + } + } + + async function findArticleTab() { + const tabs = await chrome.tabs.query({ currentWindow: true }); + return tabs + .filter((tab) => /^https?:/i.test(tab.url || "")) + .sort((left, right) => Number(right.active) - Number(left.active) || Number(right.lastAccessed || 0) - Number(left.lastAccessed || 0))[0]; + } + + async function getArticleFromTab(tabId) { + await waitForTabReady(tabId); + try { + return await chrome.tabs.sendMessage(tabId, { type: "YIDU_GET_ARTICLE" }); + } catch { + // The receiver disappears when an unpacked extension is reloaded while the + // article tab stays open. Ask the service worker to inject it again. + } + + const prepared = await chrome.runtime.sendMessage({ + type: "YIDU_PREPARE_TAB", + payload: { tabId } + }); + if (!prepared?.ok) { + return prepared || { ok: false, message: "无法读取当前页面,请刷新页面后重试。" }; + } + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await chrome.tabs.sendMessage(tabId, { type: "YIDU_GET_ARTICLE" }); + } catch { + await delay(180); + } + } + return { ok: false, message: "页面连接失败,请刷新文章页面后重试。" }; + } + + async function waitForTabReady(tabId) { + for (let attempt = 0; attempt < 12; attempt += 1) { + const tab = await chrome.tabs.get(tabId); + if (tab.status === "complete") return; + await delay(150); + } + } + + function delay(milliseconds) { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); + } + + function startSession(article, tabId) { + const next = { + article, + tabId, + rows: new Map(), + segmentsById: new Map(article.segments.map((segment) => [segment.id, segment])), + translations: new Map(), + completed: new Set(), + queued: new Set(), + queue: [], + glossary: {}, + observer: null, + working: false, + stopped: false, + failed: false, + failedSegments: [], + cachedCount: 0, + summaryModules: [], + summaryResults: new Map(), + summaryFailed: new Set(), + summaryInFlight: new Set(), + summaryRunning: false, + summaryTitle: null, + summaryOriginalTitle: null, + selectedSegmentIds: new Set(), + glossaryEpoch: 0 + }; + session = next; + renderArticle(next); + renderSummaryOutline(next); + void restoreCache(next).then(() => { + if (next.stopped) return; + startViewportTranslation(next); + void chrome.tabs.sendMessage(next.tabId, { type: "YIDU_REQUEST_SCROLL_SYNC" }).catch(() => undefined); + void chrome.tabs.sendMessage(next.tabId, { type: "YIDU_REQUEST_SELECTION_SYNC" }).catch(() => undefined); + }); + } + + function renderArticle(current) { + content.replaceChildren(); + let list = null; + let listKind = ""; + for (const segment of current.article.segments) { + if (segment.kind === "cell") continue; + const isListItem = segment.kind === "ul-item" || segment.kind === "ol-item"; + let element; + if (isListItem) { + if (!list || listKind !== segment.kind) { + list = document.createElement(segment.kind === "ol-item" ? "ol" : "ul"); + list.className = "yidu-list"; + content.append(list); + listKind = segment.kind; + } + element = document.createElement("li"); + list.append(element); + } else { + list = null; + listKind = ""; + const tag = /^h[1-6]$/.test(segment.kind) ? segment.kind : segment.kind === "blockquote" ? "blockquote" : "p"; + element = document.createElement(tag); + content.append(element); + } + element.className = `yidu-segment yidu-${segment.kind} yidu-pending`; + if (segment.kind === "skipped") { + element.textContent = segment.text; + element.classList.remove("yidu-pending"); + element.removeAttribute("aria-busy"); + current.completed.add(segment.id); + } + element.dataset.segmentId = segment.id; + if (segment.kind !== "skipped") element.setAttribute("aria-busy", "true"); + current.rows.set(segment.id, element); + } + content.setAttribute("aria-busy", "false"); + } + + async function restoreCache(current) { + try { + const result = await chrome.runtime.sendMessage({ + type: "YIDU_CACHE_GET", + payload: { url: current.article.url, segments: current.article.segments } + }); + if (!result?.ok || current.stopped) return; + for (const item of result.items || []) applyTranslation(current, item, true); + } catch { + // 缓存不可用时继续翻译。 + } + } + + function startViewportTranslation(current) { + current.article.segments.slice(0, BATCH_SIZE).forEach((segment) => enqueue(current, segment)); + current.article.segments.filter((segment) => segment.kind === "cell").forEach((segment) => enqueue(current, segment)); + if (!("IntersectionObserver" in window)) { + current.article.segments.forEach((segment) => enqueue(current, segment)); + return; + } + current.observer = new IntersectionObserver((entries) => { + entries.filter((entry) => entry.isIntersecting).forEach((entry) => { + const segment = current.segmentsById.get(entry.target.dataset.segmentId); + if (segment) enqueue(current, segment); + current.observer?.unobserve(entry.target); + }); + }, { rootMargin: VIEWPORT_MARGIN, threshold: 0 }); + for (const row of current.rows.values()) { + if (!current.completed.has(row.dataset.segmentId)) current.observer.observe(row); + } + updateProgress(current); + } + + function enqueue(current, segment) { + if (current.stopped || current.failed || segment.kind === "skipped" || current.completed.has(segment.id) || current.queued.has(segment.id)) return; + current.queue.push(segment); + current.queued.add(segment.id); + queueMicrotask(() => void processQueue(current)); + } + + async function processQueue(current) { + if (current.working || current.stopped || current.failed) return; + current.working = true; + try { + while (current.queue.length && !current.stopped && !current.failed) { + const segments = current.queue.splice(0, BATCH_SIZE); + segments.forEach((segment) => current.queued.delete(segment.id)); + current.failedSegments = segments; + updateProgress(current, segments.length); + const epoch = current.glossaryEpoch; + const result = await chrome.runtime.sendMessage({ + type: "YIDU_TRANSLATE_BATCH", + payload: { title: current.article.title, segments, glossary: current.glossary } + }); + if (!result?.ok) throw Object.assign(new Error(result?.message || "翻译失败"), { code: result?.code }); + if (current.stopped) return; + if (epoch !== current.glossaryEpoch) { + segments.forEach((segment) => enqueue(current, segment)); + continue; + } + for (const item of result.items) applyTranslation(current, item, false); + void chrome.runtime.sendMessage({ + type: "YIDU_CACHE_PUT", + payload: { url: current.article.url, segments, items: result.items, glossarySnapshot: result.glossarySnapshot } + }).catch(() => undefined); + current.failedSegments = []; + } + } catch (error) { + current.failed = true; + showError(current, error?.message || "翻译失败,请稍后重试。", error?.code === "SETUP_REQUIRED"); + } finally { + current.working = false; + updateProgress(current); + } + } + + function applyTranslation(current, item, fromCache) { + if (current.completed.has(item.id)) return; + const segment = current.segmentsById.get(item.id); + if (!segment) return; + current.translations.set(item.id, item); + for (const term of item.terms || []) current.glossary[term.source] = term.target; + current.completed.add(item.id); + if (fromCache) current.cachedCount += 1; + const translatedRow = current.rows.get(item.id); + if (translatedRow) current.observer?.unobserve(translatedRow); + renderTranslation(current, item.id, item); + if (segment.kind === "cell") { + void chrome.tabs.sendMessage(current.tabId, { + type: "YIDU_RENDER_CELLS", + payload: { items: [{ id: item.id, translation: item.translation }] } + }).catch(() => undefined); + } + updateSummaryArticleTitle(current, segment); + updateProgress(current); + } + function renderTranslation(current, id, item) { const row = current.rows.get(id); const segment = current.segmentsById.get(id); @@ -656,13 +667,13 @@ import { groupArticleModules } from "./lib/summary.js"; let text = plainTranslationText(item.translation); if (/^h[1-6]$/.test(segment.kind)) text = cleanHeadingText(text); row.textContent = text; - if (current.completed.has(id)) { - row.classList.remove("yidu-pending", "yidu-refreshing"); - row.removeAttribute("aria-busy"); - row.dataset.translated = "true"; - } - } - + if (current.completed.has(id)) { + row.classList.remove("yidu-pending", "yidu-refreshing"); + row.removeAttribute("aria-busy"); + row.dataset.translated = "true"; + } + } + function cleanHeadingText(value) { return String(value || "").replace(/^\s*[::]\s*/, ""); } @@ -674,71 +685,71 @@ import { groupArticleModules } from "./lib/summary.js"; template.content.querySelectorAll("br").forEach((node) => node.replaceWith("\n")); return template.content.textContent || ""; } - - function showError(current, message, setupRequired) { - setStatus(""); - document.querySelector(".yidu-error")?.remove(); - const box = document.createElement("section"); - box.className = "yidu-error"; - const title = document.createElement("strong"); - title.textContent = message; - const detail = document.createElement("span"); - detail.textContent = setupRequired ? "填写密钥后即可继续。" : "已完成的翻译会保留。"; - const actions = document.createElement("div"); - actions.className = "yidu-actions"; - const button = document.createElement("button"); - button.className = "yidu-action"; - button.type = "button"; - button.textContent = setupRequired ? "打开设置" : "重试"; - button.addEventListener("click", () => { - if (setupRequired) { - chrome.runtime.sendMessage({ type: "YIDU_OPEN_OPTIONS" }); - return; - } - box.remove(); - current.failed = false; - current.failedSegments.splice(0).forEach((segment) => enqueue(current, segment)); - }); - actions.append(button); - box.append(title, detail, actions); - content.prepend(box); - } - - function showEmpty(message, retryable) { - content.setAttribute("aria-busy", "false"); - setStatus(""); - const box = document.createElement("section"); - box.className = "yidu-empty"; - const title = document.createElement("strong"); - title.textContent = message; - box.append(title); - if (retryable) { - const actions = document.createElement("div"); - actions.className = "yidu-actions"; - const retry = document.createElement("button"); - retry.className = "yidu-action secondary"; - retry.type = "button"; - retry.textContent = "重新读取"; - retry.addEventListener("click", () => void loadActiveArticle()); - actions.append(retry); - box.append(actions); - } - content.replaceChildren(box); - } - - function updateProgress(current, activeBatchSize = 0) { - if (current.stopped || current.failed) return; - setStatus(activeBatchSize || current.working ? "正在翻译…" : ""); - } - - function setStatus(message) { - status.textContent = message; - } - - function stopSession() { - if (!session) return; - session.stopped = true; - session.observer?.disconnect(); - session = null; - } -})(); + + function showError(current, message, setupRequired) { + setStatus(""); + document.querySelector(".yidu-error")?.remove(); + const box = document.createElement("section"); + box.className = "yidu-error"; + const title = document.createElement("strong"); + title.textContent = message; + const detail = document.createElement("span"); + detail.textContent = setupRequired ? "填写密钥后即可继续。" : "已完成的翻译会保留。"; + const actions = document.createElement("div"); + actions.className = "yidu-actions"; + const button = document.createElement("button"); + button.className = "yidu-action"; + button.type = "button"; + button.textContent = setupRequired ? "打开设置" : "重试"; + button.addEventListener("click", () => { + if (setupRequired) { + chrome.runtime.sendMessage({ type: "YIDU_OPEN_OPTIONS" }); + return; + } + box.remove(); + current.failed = false; + current.failedSegments.splice(0).forEach((segment) => enqueue(current, segment)); + }); + actions.append(button); + box.append(title, detail, actions); + content.prepend(box); + } + + function showEmpty(message, retryable) { + content.setAttribute("aria-busy", "false"); + setStatus(""); + const box = document.createElement("section"); + box.className = "yidu-empty"; + const title = document.createElement("strong"); + title.textContent = message; + box.append(title); + if (retryable) { + const actions = document.createElement("div"); + actions.className = "yidu-actions"; + const retry = document.createElement("button"); + retry.className = "yidu-action secondary"; + retry.type = "button"; + retry.textContent = "重新读取"; + retry.addEventListener("click", () => void loadActiveArticle()); + actions.append(retry); + box.append(actions); + } + content.replaceChildren(box); + } + + function updateProgress(current, activeBatchSize = 0) { + if (current.stopped || current.failed) return; + setStatus(activeBatchSize || current.working ? "正在翻译…" : ""); + } + + function setStatus(message) { + status.textContent = message; + } + + function stopSession() { + if (!session) return; + session.stopped = true; + session.observer?.disconnect(); + session = null; + } +})(); diff --git a/tests/extension.test.mjs b/tests/extension.test.mjs index 8f3a845..cd3c6bb 100644 --- a/tests/extension.test.mjs +++ b/tests/extension.test.mjs @@ -149,3 +149,16 @@ test("loads settings before enabling input and verifies persistence", () => { assert.match(optionsScript, /SUMMARY_STORAGE_KEY/); assert.match(optionsScript, /chrome\.storage\.local\.remove/); }); + +test("gates the selection menu behind an open side panel", () => { + assert.match(panelScript, /chrome\.runtime\.connect\(\{ name: `yidu-panel-open:\$\{tab\.id\}` \}\)/); + assert.match(background, /chrome\.runtime\.onConnect\.addListener/); + assert.match(background, /yidu-panel-open:/); + assert.match(background, /panelOpenTabs\.add\(tabId\)/); + assert.match(background, /panelOpenTabs\.delete\(tabId\)/); + assert.match(background, /YIDU_PANEL_STATE_QUERY/); + assert.match(content, /YIDU_PANEL_STATE/); + assert.match(content, /YIDU_PANEL_STATE_QUERY/); + assert.match(content, /let panelOpen = false/); + assert.match(content, /if \(!panelOpen\) \{\s*hideSelectionUi\(\);/); +}); diff --git a/tests/release.test.mjs b/tests/release.test.mjs index 74ff3b8..38fb003 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.mjs @@ -20,7 +20,7 @@ test("public release metadata is complete and consistent", async () => { assert.match(readme, /^# 译读 ImmerRead/m); assert.match(readme, /\*\*中文\*\* \| \[English\]\(#english\)/); - assert.match(readme, /demo\/article-translator\/final-desktop\.png/); + assert.match(readme, /demo\/article-translator\/[\w-]+\.(png|jpe?g)/); assert.match(readme, /github\.com\/dolphin-boop\/immerread/); assert.doesNotMatch(readme, /github\.com\/dolphin-boop\/yidu/); assert.doesNotMatch(readme, /github\.com\/joeseesun\//);