207 lines
4.9 KiB
JavaScript
207 lines
4.9 KiB
JavaScript
const BLOCKED_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "INPUT"]);
|
|
const SCROLL_DEBOUNCE_MS = 250;
|
|
const MAX_NODES_PER_PASS = 30;
|
|
|
|
const originalTextMap = new Map();
|
|
let autoTranslateEnabled = false;
|
|
let scrollTimer = null;
|
|
let translating = false;
|
|
let needRerun = false;
|
|
|
|
function isVisible(element) {
|
|
let el = element;
|
|
while (el) {
|
|
const style = window.getComputedStyle(el);
|
|
if (style.display === "none" || style.visibility === "hidden") {
|
|
return false;
|
|
}
|
|
el = el.parentElement;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isInViewport(element) {
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.bottom > 0 && rect.right > 0 && rect.top < window.innerHeight && rect.left < window.innerWidth;
|
|
}
|
|
|
|
function collectTextNodes(onlyViewport) {
|
|
const root = document.body || document.documentElement;
|
|
if (!root) {
|
|
return [];
|
|
}
|
|
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
acceptNode(node) {
|
|
const text = node.nodeValue?.trim();
|
|
const parent = node.parentElement;
|
|
if (!text || !parent) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
if (BLOCKED_TAGS.has(parent.tagName) || parent.isContentEditable) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
if (originalTextMap.has(node)) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
if (!isVisible(parent)) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
if (onlyViewport && !isInViewport(parent)) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
});
|
|
|
|
const nodes = [];
|
|
while (walker.nextNode()) {
|
|
nodes.push(walker.currentNode);
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
function requestTranslation(text) {
|
|
return new Promise((resolve, reject) => {
|
|
chrome.runtime.sendMessage({ action: "translate", text }, (response) => {
|
|
if (chrome.runtime.lastError) {
|
|
reject(new Error(chrome.runtime.lastError.message));
|
|
return;
|
|
}
|
|
if (!response?.ok) {
|
|
reject(new Error(response?.error || "翻译失败"));
|
|
return;
|
|
}
|
|
resolve(response.text);
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopAutoTranslate() {
|
|
autoTranslateEnabled = false;
|
|
if (scrollTimer) {
|
|
clearTimeout(scrollTimer);
|
|
scrollTimer = null;
|
|
}
|
|
window.removeEventListener("scroll", handleScroll);
|
|
window.removeEventListener("resize", handleScroll);
|
|
}
|
|
|
|
async function translateVisibleOnce() {
|
|
const nodes = collectTextNodes(true);
|
|
if (!nodes.length) {
|
|
return 0;
|
|
}
|
|
|
|
const batch = nodes.slice(0, MAX_NODES_PER_PASS);
|
|
for (const node of batch) {
|
|
if (!autoTranslateEnabled) {
|
|
break;
|
|
}
|
|
const original = node.nodeValue;
|
|
const translated = await requestTranslation(original);
|
|
originalTextMap.set(node, original);
|
|
node.nodeValue = translated;
|
|
}
|
|
|
|
if (nodes.length > batch.length) {
|
|
needRerun = true;
|
|
}
|
|
return batch.length;
|
|
}
|
|
|
|
async function runTranslateCycle() {
|
|
if (!autoTranslateEnabled) {
|
|
return;
|
|
}
|
|
if (translating) {
|
|
needRerun = true;
|
|
return;
|
|
}
|
|
|
|
translating = true;
|
|
try {
|
|
await translateVisibleOnce();
|
|
} catch (err) {
|
|
stopAutoTranslate();
|
|
alert(`Transly: ${err.message || "翻译失败"}`);
|
|
} finally {
|
|
translating = false;
|
|
if (autoTranslateEnabled && needRerun) {
|
|
needRerun = false;
|
|
setTimeout(() => {
|
|
runTranslateCycle().catch(() => {});
|
|
}, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleScroll() {
|
|
if (!autoTranslateEnabled) {
|
|
return;
|
|
}
|
|
if (scrollTimer) {
|
|
clearTimeout(scrollTimer);
|
|
}
|
|
scrollTimer = setTimeout(() => {
|
|
runTranslateCycle().catch(() => {});
|
|
}, SCROLL_DEBOUNCE_MS);
|
|
}
|
|
|
|
async function translatePage() {
|
|
const hasCurrentNodes = collectTextNodes(true).length > 0;
|
|
|
|
if (!autoTranslateEnabled) {
|
|
autoTranslateEnabled = true;
|
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
window.addEventListener("resize", handleScroll);
|
|
}
|
|
|
|
await runTranslateCycle();
|
|
if (!hasCurrentNodes && !originalTextMap.size) {
|
|
alert("Transly: 当前可视区域没有可翻译文本");
|
|
}
|
|
}
|
|
|
|
function restorePage() {
|
|
stopAutoTranslate();
|
|
|
|
if (!originalTextMap.size) {
|
|
alert("Transly: 没有可恢复内容");
|
|
return;
|
|
}
|
|
|
|
for (const [node, text] of originalTextMap.entries()) {
|
|
if (node?.isConnected) {
|
|
node.nodeValue = text;
|
|
}
|
|
}
|
|
originalTextMap.clear();
|
|
}
|
|
|
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
if (message?.action === "translatePage") {
|
|
translatePage()
|
|
.then(() => sendResponse({ ok: true }))
|
|
.catch((err) => sendResponse({ ok: false, error: err.message || "翻译失败" }));
|
|
return true;
|
|
}
|
|
|
|
if (message?.action === "restorePage") {
|
|
restorePage();
|
|
sendResponse({ ok: true });
|
|
return;
|
|
}
|
|
|
|
if (message?.action === "showTranslation") {
|
|
alert(message.text || "");
|
|
sendResponse({ ok: true });
|
|
return;
|
|
}
|
|
|
|
if (message?.action === "showError") {
|
|
alert(`Transly: ${message.error || "翻译失败"}`);
|
|
sendResponse({ ok: true });
|
|
}
|
|
});
|