// ==UserScript==
// @name Chrome Translator
// @namespace https://ndllz.cn/
// @version 1.2.0
// @description Chrome 浏览器原生翻译功能的沉浸式翻译脚本,支持整页翻译、保留原文对照和自动翻译新增内容
// @author ndllz
// @match *://*/*
// @run-at document-end
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// ==/UserScript==
(function () {
'use strict';
// --------------------------
// Config
// --------------------------
const LANGUAGES = [
'zh-Hans','zh-Hant','en','ja','ru','ko','es','fr','de','pt','it','nl','sv',
'da','fi','no','id','th','pl','tr','vi','ar','hi','bn','kn','mr','hr','cs',
'hu','uk','he','bg','ro','te','lt','sl','el','ta'
];
const STORAGE_KEYS = {
source: 'ft_source_lang',
target: 'ft_target_lang',
autoObserve: 'ft_auto_observe_',
position: 'ft_position',
disabled: 'ft_disabled',
siteDisabled: 'ft_site_disabled_',
wordSelection: 'ft_word_selection_global', // 全局划词翻译开关
};
// --------------------------
// Utils: page-context API access
// --------------------------
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
function hasTranslator() { return 'Translator' in pageWindow; }
function hasLanguageDetector() { return 'LanguageDetector' in pageWindow; }
const DEFAULTS = inferDefaultPair();
// DisplayNames for language labels
const displayNames = (() => {
try {
if ('Intl' in window && 'DisplayNames' in Intl) {
const locales = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || 'en-US'];
return new Intl.DisplayNames(locales, { type: 'language' });
}
} catch {}
return null;
})();
function langLabel(code) {
if (!displayNames) return code;
try {
const l = displayNames.of(code);
return l ? `${l} <${code}>` : code;
} catch {
return code;
}
}
function inferDefaultPair() {
const nav = navigator.language || 'en-US';
const isZh = /^zh\b/i.test(nav);
return {
source: hasLanguageDetector() ? 'auto' : (isZh ? 'zh-Hans' : 'en'),
target: 'zh-Hans',
autoObserve: false,
position: { top: 50, right: 12 },
};
}
function getStored(key, fallback) {
try { return GM_getValue ? GM_getValue(key, fallback) : fallback; } catch { return fallback; }
}
function setStored(key, val) {
try { GM_setValue && GM_setValue(key, val); } catch {}
}
// --------------------------
// Register menu commands
// --------------------------
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('重新启用 Chrome Translator', () => {
setStored(STORAGE_KEYS.disabled, false);
setStored(STORAGE_KEYS.siteDisabled + location.hostname, false);
location.reload();
});
GM_registerMenuCommand('清除本站禁用状态', () => {
setStored(STORAGE_KEYS.siteDisabled + location.hostname, false);
location.reload();
});
}
// --------------------------
// Check if disabled
// --------------------------
if (getStored(STORAGE_KEYS.disabled, false)) {
console.log('[ChromeTranslator] Permanently disabled');
return;
}
if (getStored(STORAGE_KEYS.siteDisabled + location.hostname, false)) {
console.log('[ChromeTranslator] Disabled for this site');
return;
}
// --------------------------
// State variables - 需要在buildUI之前定义
// --------------------------
let sourceLang = getStored(STORAGE_KEYS.source, DEFAULTS.source);
let targetLang = getStored(STORAGE_KEYS.target, DEFAULTS.target);
let autoObserve = !!getStored(STORAGE_KEYS.autoObserve + location.hostname, DEFAULTS.autoObserve);
let detectorInstance = null;
let translatorInstance = null;
let inProgress = false;
let observer = null;
let lastPairKey = '';
const MAX_CONCURRENCY = Math.min(4, Math.max(1, (navigator.hardwareConcurrency || 4) - 2));
let keepOriginal = true; // 默认保留原文
let isPageTranslated = false; // 跟踪页面翻译状态
let wordSelectionEnabled = !!getStored(STORAGE_KEYS.wordSelection, false); // 全局划词翻译开关
let selectionBubble = null; // 划词翻译悬浮框
let translationPopup = null; // 翻译结果弹窗
let currentAbortController = null; // 用于取消翻译的控制器
// --------------------------
// UI
// --------------------------
let ui;
try {
injectStyles();
ui = buildUI();
document.documentElement.appendChild(ui.container);
console.log('[ChromeTranslator] UI initialized successfully');
} catch (error) {
console.error('[ChromeTranslator] UI initialization failed:', error);
return;
}
// 检查页面是否已翻译
function checkPageTranslationStatus() {
const pairs = document.querySelectorAll('span.ft-pair');
const oldTranslated = document.querySelectorAll('[data-ft-original]');
const newTranslated = document.querySelectorAll('[data-ft-original-html]');
return pairs.length > 0 || oldTranslated.length > 0 || newTranslated.length > 0;
}
// init UI selections
populateSelect(ui.sourceSelect, buildSourceOptions());
populateSelect(ui.targetSelect, buildTargetOptions());
ui.sourceSelect.value = sourceLang;
ui.targetSelect.value = targetLang;
ui.observeCheckbox.checked = autoObserve;
// keepOriginal will be bound after UI is extended below
// bind events
ui.translateBtn.addEventListener('click', () => translatePage(false));
ui.observeCheckbox.addEventListener('change', (e) => {
autoObserve = e.target.checked;
setStored(STORAGE_KEYS.autoObserve + location.hostname, autoObserve);
setupObserver(autoObserve);
});
// 保留原文功能已默认启用
ui.sourceSelect.addEventListener('change', () => {
sourceLang = ui.sourceSelect.value;
setStored(STORAGE_KEYS.source, sourceLang);
});
ui.targetSelect.addEventListener('change', () => {
targetLang = ui.targetSelect.value;
setStored(STORAGE_KEYS.target, targetLang);
ui.container.updateFabTooltip(); // 更新提示词
});
// auto observe initially
setupObserver(autoObserve);
// 初始化保留原文功能(默认启用)
toggleKeepOriginal(keepOriginal);
// 初始化时检查页面翻译状态
isPageTranslated = checkPageTranslationStatus();
// 根据页面状态设置悬浮按钮样式
if (isPageTranslated) {
ui.fab.classList.add('ft-translated');
}
// 初始化划词翻译
if (wordSelectionEnabled) {
setupSelectionListeners();
}
// --------------------------
// Core: Translate workflow
// --------------------------
async function translatePage(isFromObserver) {
if (inProgress) return;
inProgress = true;
// 创建新的AbortController用于取消翻译
currentAbortController = new AbortController();
// 添加翻译中状态指示器
ui.fab.classList.add('ft-translating');
ui.fab.classList.remove('ft-translated');
setPanelBusy(true);
setButtonState('loading', '准备翻译...');
try {
const availabilityOk = await ensureAvailability();
if (!availabilityOk) return;
const realSource = await resolveRealSourceLanguage();
const pairKey = `${realSource}->${targetLang}`;
const instanceOk = await ensureTranslator(realSource, targetLang);
if (!instanceOk) return;
if (pairKey !== lastPairKey) {
// pair changed; helpful to re-run from scratch to avoid mixed language
lastPairKey = pairKey;
}
const nodes = collectTextElements(document.body);
const scanText = `扫描到 ${nodes.length} 个文本元素,开始并行翻译(并发 ${MAX_CONCURRENCY})`;
ui.progressText.textContent = scanText + '...';
setButtonState('loading', scanText);
setProgress(0, true);
const useWorker = await canUseWorkerTranslator(realSource, targetLang);
let done = 0;
const updateProgress = () => {
if (done % 20 === 0 || done === nodes.length) {
const percentage = nodes.length > 0 ? (done / nodes.length) * 100 : 0;
const progressText = `已翻译 ${done}/${nodes.length}`;
ui.progressText.textContent = progressText;
setButtonState('loading', progressText);
setProgress(percentage, true);
}
};
if (useWorker) {
const pool = await createWorkerPool(MAX_CONCURRENCY, realSource, targetLang);
try {
const tasks = nodes.map(({ element, original }) => async () => {
if (currentAbortController?.signal.aborted) return;
if (!original.trim()) { done++; updateProgress(); return; }
try {
const translated = await pool.translate(original.replace(/\n/g, '<br>'));
if (currentAbortController?.signal.aborted) return;
const pretty = (translated || '').replace(/<br>/g, '\n').trim();
applyTranslationToElement(element, original, pretty);
} catch {}
finally { done++; updateProgress(); }
});
await runWithConcurrency(tasks, MAX_CONCURRENCY);
} finally { pool.terminate(); }
}
else {
// Fallback: main-thread concurrency using a single translator instance
const limit = createLimiter(Math.max(2, Math.min(3, MAX_CONCURRENCY)));
await Promise.all(nodes.map(({ element, original }) => limit(async () => {
if (currentAbortController?.signal.aborted) return;
if (!original.trim()) { done++; updateProgress(); return; }
try {
const translated = await translateStreaming(`${original.replace(/\n/g, '<br>')}`);
if (currentAbortController?.signal.aborted) return;
const pretty = (translated || '').replace(/<br>/g, '\n').trim();
applyTranslationToElement(element, original, pretty);
} catch {}
finally { done++; updateProgress(); }
})));
}
// 检查翻译是否被取消
if (currentAbortController?.signal.aborted) {
const cancelText = '翻译已取消';
ui.progressText.textContent = cancelText;
ui.fab.classList.remove('ft-translating');
setButtonState('idle', cancelText);
setProgress(0, false);
return;
}
const completeText = `翻译完成:${done}/${nodes.length}`;
ui.progressText.textContent = completeText;
// 更新翻译状态
isPageTranslated = true;
// 移除翻译中状态,添加已翻译状态
ui.fab.classList.remove('ft-translating');
ui.fab.classList.add('ft-success', 'ft-translated');
ui.container.updateFabTooltip(); // 更新提示词
setButtonState('success', `翻译完成 ${done}/${nodes.length}`);
setProgress(100, false); // 隐藏进度条
setTimeout(() => {
ui.fab.classList.remove('ft-success');
// 保留ft-translated状态指示
}, 3000); // 3秒后移除成功状态
} catch (err) {
showError(err);
ui.fab.classList.remove('ft-translating');
setButtonState('error', '翻译失败');
setProgress(0, false); // 隐藏进度条
} finally {
// 确保移除翻译中状态
ui.fab.classList.remove('ft-translating');
setPanelBusy(false);
inProgress = false;
currentAbortController = null;
// ensure UI is still present after translation
setTimeout(() => {
if (!document.querySelector('.ft-ui')) {
console.warn('[ChromeTranslator] UI missing after translation, restoring...');
try {
document.documentElement.appendChild(ui.container);
} catch (e) {
console.error('[ChromeTranslator] Failed to restore UI after translation:', e);
}
}
}, 100);
}
}
function cancelTranslation() {
if (currentAbortController) {
currentAbortController.abort();
console.log('[ChromeTranslator] 翻译已取消');
const restoreText = '翻译已取消,正在还原...';
ui.progressText.textContent = restoreText;
setButtonState('loading', restoreText);
}
}
function restorePage() {
// 如果正在翻译,先取消翻译
if (inProgress && currentAbortController) {
cancelTranslation();
// 给一点时间让取消操作生效
setTimeout(() => {
performRestore();
}, 100);
return;
}
performRestore();
}
function performRestore() {
let restored = 0;
// 首先处理新的元素级翻译还原
const translatedElements = Array.from(document.querySelectorAll('[data-ft-original-html]'));
for (const element of translatedElements) {
const originalHTML = element.getAttribute('data-ft-original-html');
if (originalHTML) {
element.innerHTML = originalHTML;
element.removeAttribute('data-ft-original');
element.removeAttribute('data-ft-original-html');
element.removeAttribute('data-ft-original-text');
restored++;
}
}
// unwrap ft-pair wrappers (旧的文本节点级翻译)
const pairs = Array.from(document.querySelectorAll('span.ft-pair'));
for (const w of pairs) {
const original = w.getAttribute('data-ft-original-text') || w.querySelector('.ft-original')?.textContent || '';
const leading = w.getAttribute('data-ft-leading') || '';
const trailing = w.getAttribute('data-ft-trailing') || '';
const textNode = document.createTextNode(leading + original + trailing);
w.replaceWith(textNode);
restored++;
}
// compatibility: old style replacements
const iter = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
let node;
while ((node = iter.nextNode())) {
const parent = node.parentElement;
if (!parent) continue;
if (parent.closest('.ft-ui')) continue;
if (parent.hasAttribute('data-ft-original')) {
node.textContent = parent.getAttribute('data-ft-original-text') || node.textContent;
parent.removeAttribute('data-ft-original');
parent.removeAttribute('data-ft-original-text');
restored++;
}
}
const restoreCompleteText = `已还原 ${restored} 个元素`;
ui.progressText.textContent = restoreCompleteText;
// 更新翻译状态
isPageTranslated = false;
// 显示成功状态并移除翻译状态
ui.fab.classList.add('ft-success');
ui.fab.classList.remove('ft-translated');
ui.container.updateFabTooltip(); // 更新提示词
setButtonState('success', `已还原 ${restored} 个节点`);
setTimeout(() => {
ui.fab.classList.remove('ft-success');
}, 2000); // 2秒后移除成功状态
}
function setupObserver(enabled) {
if (observer) {
observer.disconnect();
observer = null;
}
if (!enabled) return;
observer = new MutationObserver((mutations) => {
// check if UI was accidentally removed and restore it
if (!document.querySelector('.ft-ui')) {
console.warn('[ChromeTranslator] UI was removed, restoring...');
try {
document.documentElement.appendChild(ui.container);
} catch (e) {
console.error('[ChromeTranslator] Failed to restore UI:', e);
}
}
// debounce: translate new nodes only
const added = [];
for (const m of mutations) {
m.addedNodes && added.push(...m.addedNodes);
}
const needTranslation = added.some(n => n.nodeType === 1 && !n.closest?.('.ft-ui'));
if (needTranslation) {
// schedule lightly
setTimeout(() => translatePage(true), 600);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// --------------------------
// Translator/Detector helpers
// --------------------------
async function ensureAvailability() {
if (!hasTranslator()) {
ui.statusText.textContent = '此浏览器不支持原生翻译(需 Chrome 138+)';
return false;
}
return true;
}
async function ensureDetector() {
if (!hasLanguageDetector() || sourceLang !== 'auto') return null;
if (detectorInstance) return detectorInstance;
setPanelBusy(true, '下载语言检测模型中...');
try {
detectorInstance = await pageWindow.LanguageDetector.create({
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
if (typeof e.loaded === 'number') {
ui.statusText.textContent = `检测模型下载 ${(e.loaded * 100).toFixed(2)}%`;
}
});
},
});
ui.statusText.textContent = '检测模型已就绪';
return detectorInstance;
} catch (e) {
ui.statusText.textContent = '检测模型加载失败';
return null;
} finally {
setPanelBusy(false);
}
}
async function resolveRealSourceLanguage() {
if (sourceLang !== 'auto') return sourceLang;
const det = await ensureDetector();
if (!det) return 'und';
// sample: title + a bit of body text
const sample = getSampleText(1500);
const list = await det.detect(sample).catch(() => []);
const lang = (list && list[0] && list[0].detectedLanguage) || 'und';
return lang === 'und' ? 'und' : lang;
}
async function ensureTranslator(src, tgt) {
setPanelBusy(true, '准备翻译模型...');
try {
// 检查当前实例是否匹配语言对且仍然有效
if (translatorInstance &&
translatorInstance.sourceLanguage === src &&
translatorInstance.targetLanguage === tgt) {
try {
// 验证实例是否仍然可用(通过检查配额)
await translatorInstance.measureInputUsage('test');
ui.statusText.textContent = '翻译模型就绪';
return true;
} catch (error) {
console.log('[ChromeTranslator] 现有翻译器实例无效,重新创建:', error.message);
// 实例无效,销毁并重新创建
try {
translatorInstance.destroy();
} catch (destroyError) {
console.warn('[ChromeTranslator] 销毁翻译器实例失败:', destroyError);
}
translatorInstance = null;
}
} else if (translatorInstance) {
// 语言对不匹配,销毁旧实例
console.log('[ChromeTranslator] 语言对改变,销毁旧翻译器实例');
try {
translatorInstance.destroy();
} catch (destroyError) {
console.warn('[ChromeTranslator] 销毁翻译器实例失败:', destroyError);
}
translatorInstance = null;
}
// 检查翻译器可用性
const availability = await pageWindow.Translator.availability({
sourceLanguage: src,
targetLanguage: tgt,
});
if (availability === 'unavailable') {
ui.statusText.textContent = `不支持 ${src} -> ${tgt} 翻译`;
return false;
}
ui.statusText.textContent = availability === 'available' ? '翻译模型已缓存,正在加载...' : '翻译模型下载中...';
// 创建新的翻译器实例
translatorInstance = await pageWindow.Translator.create({
sourceLanguage: src,
targetLanguage: tgt,
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
if (typeof e.loaded === 'number') {
ui.statusText.textContent = `翻译模型 ${(e.loaded * 100).toFixed(2)}%`;
}
});
},
});
ui.statusText.textContent = '翻译模型就绪';
return true;
} catch (e) {
console.error('[ChromeTranslator] 翻译器初始化失败:', e);
showError(e);
// 确保清理无效实例
if (translatorInstance) {
try {
translatorInstance.destroy();
} catch (destroyError) {
console.warn('[ChromeTranslator] 清理失败的翻译器实例时出错:', destroyError);
}
translatorInstance = null;
}
return false;
} finally {
setPanelBusy(false);
}
}
async function translateStreaming(text) {
const res = translatorInstance.translateStreaming(text);
const reader = res.getReader();
let out = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
out += value;
}
return out;
}
// --------------------------
// Concurrency helpers and Worker pool
// --------------------------
function createLimiter(limit) {
let active = 0;
const queue = [];
const next = () => {
if (active >= limit || queue.length === 0) return;
const { fn, resolve, reject } = queue.shift();
active++;
Promise.resolve()
.then(fn)
.then(resolve, reject)
.finally(() => { active--; next(); });
};
return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); });
}
async function runWithConcurrency(tasks, limit) {
const limiter = createLimiter(limit);
await Promise.all(tasks.map(task => limiter(task)));
}
async function canUseWorkerTranslator(src, tgt) {
try {
// 首先检测CSP是否允许创建Worker
if (!canCreateWorker()) {
console.log('[ChromeTranslator] Web Workers blocked by CSP, falling back to main thread');
return false;
}
const url = createWorkerURL();
const worker = new Worker(url);
URL.revokeObjectURL(url);
const ready = new Promise((resolve) => {
const timer = setTimeout(() => { resolve(false); worker.terminate(); }, 3000);
worker.onmessage = (e) => {
if (e.data && e.data.type === 'ready') { clearTimeout(timer); resolve(true); worker.terminate(); }
else if (e.data && e.data.type === 'error') { clearTimeout(timer); resolve(false); worker.terminate(); }
};
});
worker.postMessage({ type: 'init-check' });
return await ready;
} catch (e) {
console.log('[ChromeTranslator] Worker creation failed:', e.message);
return false;
}
}
function canCreateWorker() {
try {
// 尝试创建一个简单的Worker来检测CSP
const testWorkerCode = 'self.postMessage("test");';
const blob = new Blob([testWorkerCode], { type: 'application/javascript' });
const workerURL = URL.createObjectURL(blob);
const testWorker = new Worker(workerURL);
testWorker.terminate();
URL.revokeObjectURL(workerURL);
return true;
} catch (e) {
// 如果创建Worker失败,说明被CSP阻止
console.log('[ChromeTranslator] Worker creation blocked by CSP:', e.message);
return false;
}
}
function createWorkerURL() {
const code = `self.onmessage = async (e) => {
const data = e.data || {};
try {
if (data.type === 'init-check') {
if ('Translator' in self) postMessage({ type: 'ready' });
else postMessage({ type: 'error', message: 'Translator not in worker' });
return;
}
} catch (err) { postMessage({ type: 'error', message: String(err) }); }
};`;
const blob = new Blob([code], { type: 'application/javascript' });
return URL.createObjectURL(blob);
}
async function createWorkerPool(size, src, tgt) {
// 在创建Worker池之前检查CSP
if (!canCreateWorker()) {
throw new Error('Web Workers are blocked by Content Security Policy. Falling back to main thread.');
}
// Build full worker script with translator support
const code = `let translator = null;
self.onmessage = async (e) => {
const msg = e.data || {};
try {
if (msg.type === 'init') {
const availability = await self.Translator.availability({ sourceLanguage: msg.src, targetLanguage: msg.tgt });
if (availability === 'unavailable') { postMessage({ type: 'init-error', message: 'unavailable' }); return; }
translator = await self.Translator.create({ sourceLanguage: msg.src, targetLanguage: msg.tgt });
postMessage({ type: 'inited' });
return;
}
if (msg.type === 'translate') {
if (!translator) { postMessage({ type: 'translate-error', id: msg.id, message: 'no-translator' }); return; }
const res = translator.translateStreaming(msg.text);
const reader = res.getReader();
let out = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
out += value;
}
postMessage({ type: 'translated', id: msg.id, text: out });
return;
}
} catch (err) {
if (msg.type === 'translate') postMessage({ type: 'translate-error', id: msg.id, message: String(err) });
else postMessage({ type: 'init-error', message: String(err) });
}
};`;
let url, workers;
try {
url = URL.createObjectURL(new Blob([code], { type: 'application/javascript' }));
workers = Array.from({ length: size }, () => new Worker(url));
URL.revokeObjectURL(url);
} catch (e) {
if (url) URL.revokeObjectURL(url);
console.log('[ChromeTranslator] Failed to create worker pool:', e.message);
throw new Error('Failed to create worker pool due to CSP restrictions.');
}
await Promise.all(workers.map((w) => new Promise((resolve, reject) => {
const timer = setTimeout(() => { w.terminate(); reject(new Error('init timeout')); }, 10000);
w.onmessage = (e) => {
if (e.data && e.data.type === 'inited') { clearTimeout(timer); resolve(null); }
};
w.onerror = (e) => { clearTimeout(timer); reject(new Error('worker error')); };
w.postMessage({ type: 'init', src, tgt });
})).catch((e) => {
workers.forEach(w => w.terminate());
throw e;
}));
let nextId = 1;
const pending = new Map();
let idx = 0;
workers.forEach((w) => {
w.onmessage = (e) => {
const data = e.data || {};
if (data.type === 'translated') {
const entry = pending.get(data.id);
if (entry) { pending.delete(data.id); entry.resolve(data.text); }
} else if (data.type === 'translate-error') {
const entry = pending.get(data.id);
if (entry) { pending.delete(data.id); entry.reject(new Error(data.message || 'translate error')); }
}
};
});
function pick() { const w = workers[idx]; idx = (idx + 1) % workers.length; return w; }
return {
translate(text) {
const id = nextId++;
const worker = pick();
const p = new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
worker.postMessage({ type: 'translate', id, text });
return p;
},
terminate() { workers.forEach(w => w.terminate()); pending.forEach(p => p.reject(new Error('terminated'))); pending.clear(); },
};
}
// --------------------------
// DOM: text nodes
// --------------------------
function collectTextElements(root) {
const elements = [];
const processedElements = new Set();
// 获取所有包含文本的元素
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode(element) {
// skip UI panel - critical to prevent UI removal
if (element.closest('.ft-ui')) return NodeFilter.FILTER_REJECT;
// skip hidden
const style = getComputedStyle(element);
if (style && (style.display === 'none' || style.visibility === 'hidden')) return NodeFilter.FILTER_REJECT;
// skip code/script/etc
const tag = element.tagName.toLowerCase();
if (['script','style','noscript','textarea','input','code','pre','svg','math','kbd','samp'].includes(tag)) {
return NodeFilter.FILTER_REJECT;
}
// skip marked notranslate
if (element.closest('.notranslate,[translate="no"]')) return NodeFilter.FILTER_REJECT;
// already translated using legacy flag or ft-pair wrapper
if (element.hasAttribute('data-ft-original') || element.closest('.ft-pair')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
let element;
while ((element = walker.nextNode())) {
// 检查这个元素是否包含直接的文本内容(非空白)
const textContent = getElementTextContent(element);
if (!textContent.trim()) continue;
// 检查是否是文本容器(段落级元素)
if (isTextContainer(element) && !hasTextContainerAncestor(element, processedElements)) {
const original = textContent;
elements.push({
element: element,
original: original,
leading: '',
trailing: ''
});
processedElements.add(element);
}
}
return elements;
}
function getElementTextContent(element) {
// 获取元素的完整文本内容,保留内联元素的结构
let text = '';
for (const node of element.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
} else if (node.nodeType === Node.ELEMENT_NODE) {
// 对于内联元素,保留其文本内容
const tag = node.tagName.toLowerCase();
if (['a', 'span', 'strong', 'em', 'b', 'i', 'code', 'mark', 'small', 'sub', 'sup', 'u'].includes(tag)) {
text += node.textContent;
}
}
}
return text;
}
function isTextContainer(element) {
const tag = element.tagName.toLowerCase();
// 段落级元素
if (['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'th', 'blockquote', 'article', 'section'].includes(tag)) {
return true;
}
// 检查是否有文本内容且包含内联元素
const hasText = Array.from(element.childNodes).some(node =>
node.nodeType === Node.TEXT_NODE && node.textContent.trim()
);
const hasInlineElements = Array.from(element.childNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
['a', 'span', 'strong', 'em', 'b', 'i', 'code', 'mark', 'small', 'sub', 'sup', 'u'].includes(node.tagName.toLowerCase())
);
return hasText && hasInlineElements;
}
function hasTextContainerAncestor(element, processedElements) {
let parent = element.parentElement;
while (parent && parent !== document.body) {
if (processedElements.has(parent) && isTextContainer(parent)) {
return true;
}
parent = parent.parentElement;
}
return false;
}
function markTranslated(textNode, original) {
const el = textNode.parentElement;
if (!el) return;
if (!el.hasAttribute('data-ft-original')) {
el.setAttribute('data-ft-original', '1');
el.setAttribute('data-ft-original-text', original);
}
}
function getSampleText(limit) {
const parts = [document.title || ''];
const nodes = collectTextElements(document.body).slice(0, 200);
for (const it of nodes) {
parts.push(it.original);
if (parts.join('\n').length > limit) break;
}
return parts.join('\n').slice(0, limit);
}
// --------------------------
// UI building
// --------------------------
function buildSourceOptions() {
const options = [];
options.push({ value: 'auto', label: `自动检测` + (hasLanguageDetector() ? '' : '(不支持)'), disabled: !hasLanguageDetector() });
for (const l of LANGUAGES) options.push({ value: l, label: langLabel(l) });
return options;
}
function buildTargetOptions() {
return LANGUAGES.map(l => ({ value: l, label: langLabel(l) }));
}
function populateSelect(select, options) {
select.innerHTML = '';
for (const opt of options) {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
if (opt.disabled) o.disabled = true;
select.appendChild(o);
}
}
function setPanelBusy(busy, text) {
ui.container.classList.toggle('ft-busy', !!busy);
if (text) ui.statusText.textContent = text;
}
function showError(e) {
console.error('[ChromeTranslator] error:', e);
ui.statusText.textContent = `错误:${e?.message || e}`;
}
// 按钮状态管理
function setButtonState(state, text) {
const btn = ui.translateBtn;
if (!btn) return;
// 清除所有状态类
btn.classList.remove('success', 'error');
btn.disabled = false;
// 移除加载动画
const existingSpinner = btn.querySelector('.ft-loading-spinner');
if (existingSpinner) {
existingSpinner.remove();
}
switch (state) {
case 'loading':
btn.disabled = true;
btn.innerHTML = `<div class="ft-loading-spinner"></div><span>翻译中...</span>`;
// 进度信息显示在状态文本中,不在按钮上
if (ui.statusText) {
ui.statusText.textContent = text || '翻译中...';
}
break;
case 'success':
btn.classList.add('success');
btn.textContent = '翻译';
if (ui.statusText) {
ui.statusText.textContent = text || '翻译完成';
setTimeout(() => {
if (ui.statusText) {
ui.statusText.textContent = '翻译模型就绪';
}
}, 2000);
}
break;
case 'error':
btn.classList.add('error');
btn.textContent = '翻译';
if (ui.statusText) {
ui.statusText.textContent = text || '翻译失败';
setTimeout(() => {
if (ui.statusText) {
ui.statusText.textContent = '翻译模型就绪';
}
}, 3000);
}
break;
case 'idle':
default:
btn.textContent = '翻译';
if (ui.statusText && text) {
ui.statusText.textContent = text;
}
}
}
// 进度条管理
function setProgress(percentage, show = true) {
if (!ui.progressContainer || !ui.progressBar) return;
if (show) {
ui.progressContainer.classList.remove('hidden');
ui.progressBar.style.width = `${Math.max(0, Math.min(100, percentage))}%`;
} else {
ui.progressContainer.classList.add('hidden');
ui.progressBar.style.width = '0%';
}
}
function buildUI() {
const container = document.createElement('div');
container.className = 'ft-ui';
// 恢复位置
const savedPos = getStored(STORAGE_KEYS.position, DEFAULTS.position);
container.style.top = savedPos.top + '%';
container.style.right = savedPos.right + 'px';
// 主悬浮按钮
const fab = document.createElement('div');
fab.className = 'ft-fab';
fab.title = 'Chrome 翻译';
// 状态指示器圆圈
const statusIndicator = document.createElement('div');
statusIndicator.className = 'ft-status-indicator';
fab.appendChild(statusIndicator);
// 移除拖拽手柄,直接使用悬浮按钮拖拽
// 设置按钮(悬停显示)
const settingsBtn = document.createElement('div');
settingsBtn.className = 'ft-settings-btn';
settingsBtn.innerHTML = '⚙';
settingsBtn.title = '设置';
// 移除关闭按钮
// 设置面板(独立弹窗)
const settingsPanel = document.createElement('div');
settingsPanel.className = 'ft-settings-panel';
// 移除关闭选项面板
const panel = document.createElement('div');
panel.className = 'ft-panel';
const row1 = document.createElement('div');
row1.className = 'ft-row';
const sourceSelect = document.createElement('select');
sourceSelect.className = 'ft-select';
const arrow = document.createElement('span'); arrow.textContent = '→';
const targetSelect = document.createElement('select');
targetSelect.className = 'ft-select';
row1.append(sourceSelect, arrow, targetSelect);
const row2 = document.createElement('div');
row2.className = 'ft-row';
const translateBtn = document.createElement('button');
translateBtn.className = 'ft-btn';
translateBtn.textContent = '翻译整页';
const restoreBtn = document.createElement('button');
restoreBtn.className = 'ft-btn ghost';
restoreBtn.textContent = '还原';
row2.append(translateBtn, restoreBtn);
const row3 = document.createElement('label');
row3.className = 'ft-row ft-check';
const observeCheckbox = document.createElement('input');
observeCheckbox.type = 'checkbox';
const observeText = document.createElement('span');
observeText.textContent = '自动翻译新增内容';
row3.append(observeCheckbox, observeText);
const statusText = document.createElement('div');
statusText.className = 'ft-status';
statusText.textContent = hasTranslator() ? '就绪' : '此浏览器不支持原生翻译(需 Chrome 138+)';
const progressText = document.createElement('div');
progressText.className = 'ft-status ft-progress';
progressText.textContent = '';
// 构建设置面板内容 - 参考沉浸式翻译设计
settingsPanel.innerHTML = `
<div class="ft-settings-header">
<span>翻译设置</span>
<div class="ft-settings-close">×</div>
</div>
<div class="ft-lang-section">
<div class="ft-lang-row">
<select class="ft-lang-select ft-source-select">${row1.querySelector('select:first-of-type').innerHTML}</select>
<div class="ft-lang-arrow">→</div>
<select class="ft-lang-select ft-target-select">${row1.querySelector('select:last-of-type').innerHTML}</select>
</div>
</div>
<div class="ft-main-action">
<button class="ft-translate-btn">翻译</button>
</div>
<div class="ft-switches">
<div class="ft-switch-item">
<span class="ft-switch-label">自动翻译新增内容</span>
<div class="ft-switch ft-auto-switch" data-checked="false">
<input type="checkbox" style="display:none;">
</div>
</div>
<div class="ft-switch-item">
<span class="ft-switch-label">划词翻译(全局)</span>
<div class="ft-switch ft-word-switch" data-checked="false">
<input type="checkbox" style="display:none;">
</div>
</div>
<div class="ft-shortcut-tip">
<span class="ft-shortcut-text">💡 选中文本后按 <kbd>F2</kbd> 快速翻译</span>
</div>
</div>
<div class="ft-status-section">
<div class="ft-status-text">${statusText.textContent}</div>
<div class="ft-progress-container hidden">
<div class="ft-progress-bar" style="width:0%"></div>
</div>
</div>
`;
// 移除关闭选项面板
// 组装容器
container.append(fab, settingsBtn, panel, settingsPanel);
// 拖拽功能 - 长按拖拽
let isDragging = false;
let dragStarted = false;
let startY = 0;
let startTop = 0;
let longPressTimer = null;
let mouseMoved = false;
fab.addEventListener('mousedown', (e) => {
// 只响应左键
if (e.button !== 0) return;
startY = e.clientY;
startTop = parseFloat(container.style.top) || 50;
dragStarted = false;
mouseMoved = false;
// 长按500ms后才能拖拽
longPressTimer = setTimeout(() => {
if (!mouseMoved) { // 只有在没有鼠标移动的情况下才进入拖拽模式
isDragging = true;
fab.classList.add('ft-dragging'); // 添加拖拽状态类
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', onDragEnd);
}
}, 500);
// 监听鼠标移动,如果在长按期间移动则取消拖拽
const onMouseMove = (e) => {
const deltaY = Math.abs(e.clientY - startY);
if (deltaY > 5) { // 移动超过5px就算移动
mouseMoved = true;
clearTimeout(longPressTimer);
document.removeEventListener('mousemove', onMouseMove);
}
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', () => {
clearTimeout(longPressTimer);
document.removeEventListener('mousemove', onMouseMove);
}, { once: true });
e.preventDefault();
});
function onDrag(e) {
if (!isDragging) return;
const deltaY = e.clientY - startY;
const newTop = Math.max(5, Math.min(95, startTop + (deltaY / window.innerHeight) * 100));
container.style.top = newTop + '%';
}
function onDragEnd() {
clearTimeout(longPressTimer);
if (isDragging) {
isDragging = false;
dragStarted = true;
fab.classList.remove('ft-dragging'); // 移除拖拽状态类
const newPos = {
top: parseFloat(container.style.top) || 50,
right: parseFloat(container.style.right) || 12
};
setStored(STORAGE_KEYS.position, newPos);
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', onDragEnd);
// 延迟重置dragStarted状态,防止立即触发点击
setTimeout(() => {
dragStarted = false;
}, 100);
}
}
// 更新悬浮按钮的提示词
function updateFabTooltip() {
if (isPageTranslated) {
fab.title = '点击切换到原文';
} else {
const targetLangName = langLabel(targetLang);
fab.title = `点击翻译为${targetLangName}`;
}
}
// 将更新函数添加到返回对象中
container.updateFabTooltip = updateFabTooltip;
// 处理点击事件(区分拖拽和点击)
fab.addEventListener('click', (e) => {
// 如果刚完成拖拽,不触发点击
if (dragStarted || isDragging) {
return;
}
// 智能切换:翻译和还原
if (isPageTranslated || inProgress) {
restorePage();
} else {
translatePage(false);
}
});
// 初始化提示词
updateFabTooltip();
// 悬停显示控制按钮
let hoverTimer;
container.addEventListener('mouseenter', () => {
clearTimeout(hoverTimer);
container.classList.add('ft-hover');
});
container.addEventListener('mouseleave', () => {
hoverTimer = setTimeout(() => container.classList.remove('ft-hover'), 300);
});
// 设置按钮点击
settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
settingsPanel.classList.toggle('ft-show');
});
// 移除关闭按钮相关事件监听器
// 设置面板关闭
settingsPanel.querySelector('.ft-settings-close').addEventListener('click', () => {
settingsPanel.classList.remove('ft-show');
});
// 点击外部关闭面板
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) {
settingsPanel.classList.remove('ft-show');
}
});
// 自定义开关功能
const autoSwitch = settingsPanel.querySelector('.ft-auto-switch');
const wordSwitch = settingsPanel.querySelector('.ft-word-switch');
// 初始化自动翻译开关
const autoCheckbox = autoSwitch.querySelector('input[type="checkbox"]');
autoSwitch.setAttribute('data-checked', autoObserve);
autoCheckbox.checked = autoObserve;
autoSwitch.classList.toggle('active', autoObserve);
// 初始化划词翻译开关
const wordCheckbox = wordSwitch.querySelector('input[type="checkbox"]');
wordSwitch.setAttribute('data-checked', wordSelectionEnabled);
wordCheckbox.checked = wordSelectionEnabled;
wordSwitch.classList.toggle('active', wordSelectionEnabled);
// 自动翻译开关事件
autoSwitch.addEventListener('click', () => {
const isChecked = autoSwitch.getAttribute('data-checked') === 'true';
const newChecked = !isChecked;
autoSwitch.setAttribute('data-checked', newChecked);
autoCheckbox.checked = newChecked;
autoSwitch.classList.toggle('active', newChecked);
autoCheckbox.dispatchEvent(new Event('change'));
});
// 划词翻译开关事件
wordSwitch.addEventListener('click', () => {
const isChecked = wordSwitch.getAttribute('data-checked') === 'true';
const newChecked = !isChecked;
wordSwitch.setAttribute('data-checked', newChecked);
wordCheckbox.checked = newChecked;
wordSwitch.classList.toggle('active', newChecked);
// 更新全局状态
wordSelectionEnabled = newChecked;
setStored(STORAGE_KEYS.wordSelection, newChecked);
// 启用或禁用划词翻译监听器
if (newChecked) {
setupSelectionListeners();
} else {
removeSelectionListeners();
hideSelectionBubble();
hideTranslationPopup();
}
});
return {
container,
sourceSelect: settingsPanel.querySelector('.ft-source-select'),
targetSelect: settingsPanel.querySelector('.ft-target-select'),
translateBtn: settingsPanel.querySelector('.ft-translate-btn'),
restoreBtn: null, // 移除还原按钮,改为点击悬浮按钮切换
observeCheckbox: autoCheckbox,
statusText: settingsPanel.querySelector('.ft-status-text'),
progressText: progressText,
progressContainer: settingsPanel.querySelector('.ft-progress-container'),
progressBar: settingsPanel.querySelector('.ft-progress-bar'),
fab,
statusIndicator,
panel,
settingsPanel,
};
}
function injectStyles() {
const css = `
.ft-ui{ position:fixed !important; right:0 !important; transform:translateY(-50%) !important; z-index:2147483647 !important; font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Noto Sans,Ubuntu,Cantarell !important; color:#333 !important; pointer-events:auto !important; }
.ft-ui *{ box-sizing:border-box; }
/* 主悬浮按钮 - 紧贴侧边,保持黑色 */
.ft-fab{
width:36px; height:36px;
border-radius:18px 0 0 18px;
background:#333;
border:1px solid #555;
box-shadow:0 4px 12px rgba(0,0,0,.2);
cursor:pointer; display:flex; align-items:center; justify-content:center;
transition:all .3s ease; position:relative;
transform:translateX(0); /* 始终紧贴右侧 */
overflow:visible;
}
.ft-ui:hover .ft-fab{
transform:translateX(-2px); /* 悬停时稍微左移 */
background:#444;
}
/* 主图标 */
.ft-fab::before{
content:""; position:absolute; width:20px; height:20px;
background:#fff;
mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v2h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z' fill='currentColor'/%3E%3C/svg%3E") no-repeat center/contain;
transition:all .3s ease;
z-index:1;
}
/* 状态指示器 */
.ft-fab::after{
content:'';
position:absolute;
top:-2px;
right:-2px;
width:10px;
height:10px;
border-radius:50%;
border:2px solid #fff;
opacity:0;
transform:scale(0.5);
transition:all 0.3s ease;
z-index:2;
}
/* 翻译进行中状态 - 显示黄色小圆圈 */
.ft-fab.ft-translating::after{
background:#f59e0b;
opacity:1;
transform:scale(1);
}
/* 翻译状态 - 已翻译页面,图标保持黑色,只显示绿色小圆圈 */
.ft-fab.ft-translated::after{
background:#10b981;
opacity:1;
transform:scale(1);
}
/* 成功状态 - 临时动画,图标保持黑色 */
.ft-fab.ft-success{
transform:translateX(-2px) scale(1.05) !important;
}
.ft-fab.ft-success::after{
background:#10b981;
opacity:1;
transform:scale(1.2);
box-shadow:0 0 8px rgba(16,185,129,0.5);
}
/* 错误状态 - 图标保持黑色,只显示红色小圆圈 */
.ft-fab.ft-error::after{
background:#ef4444;
opacity:1;
transform:scale(1);
}
/* 悬浮按钮拖拽状态 */
.ft-fab:active{ cursor:ns-resize !important; }
.ft-fab.ft-dragging{
cursor:ns-resize !important;
transform:translateX(-4px) scale(1.05) !important;
box-shadow:0 8px 24px rgba(0,0,0,.4) !important;
}
/* 设置按钮 */
.ft-settings-btn{
position:absolute; right:8px; bottom:-30px;
width:24px; height:24px;
border-radius:12px;
background:rgba(255,255,255,.9);
border:1px solid rgba(0,0,0,.1);
display:flex; align-items:center; justify-content:center;
cursor:pointer; font-size:14px;
opacity:0; transition:all .2s ease;
box-shadow:0 2px 8px rgba(0,0,0,.1);
}
.ft-ui.ft-hover .ft-settings-btn{ opacity:1; }
/* 设置面板 - 美观重设计 */
.ft-settings-panel{
position:absolute;
top:-40px; right:40px;
width:280px;
background:#1e1e1e;
border:1px solid #333;
border-radius:16px;
box-shadow:-12px 0 40px rgba(0,0,0,.5), 0 12px 40px rgba(0,0,0,.3);
opacity:0; pointer-events:none;
transform:translateX(24px) scale(0.9);
transition:all .35s cubic-bezier(0.34,1.56,0.64,1);
z-index:10;
color:#fff;
overflow:hidden;
backdrop-filter:blur(20px);
}
.ft-settings-panel.ft-show{
opacity:1; pointer-events:auto;
transform:translateX(0) scale(1);
}
/* 标题栏 */
.ft-settings-header{
padding:18px 20px;
background:linear-gradient(135deg, #2d2d2d 0%, #1f1f1f 100%);
border-bottom:1px solid rgba(255,255,255,0.1);
display:flex; justify-content:space-between; align-items:center;
font-weight:600; font-size:15px;
color:#f5f5f5;
}
.ft-settings-close{
cursor:pointer;
font-size:20px;
color:#888;
width:24px; height:24px;
display:flex; align-items:center; justify-content:center;
border-radius:12px;
transition:all 0.2s ease;
}
.ft-settings-close:hover{
background:rgba(255,255,255,0.1);
color:#fff;
}
/* 语言选择区域 */
.ft-lang-section{
padding:16px;
background:rgba(255,255,255,0.02);
}
.ft-lang-row{
display:flex;
align-items:center;
gap:8px;
}
.ft-lang-select{
flex:1;
padding:8px 12px;
background:#2a2a2a;
border:1px solid #444;
border-radius:8px;
color:#fff;
font-size:12px;
cursor:pointer;
transition:all 0.2s ease;
outline:none;
min-width:0;
max-width:none;
}
.ft-lang-select:hover{
border-color:#555;
background:#333;
}
.ft-lang-select:focus{
border-color:#e91e63;
box-shadow:0 0 0 2px rgba(233,30,99,0.2);
}
.ft-lang-arrow{
color:#888;
font-size:14px;
font-weight:bold;
min-width:14px;
text-align:center;
flex-shrink:0;
}
/* 主要操作按钮 */
.ft-main-action{
padding:16px;
}
.ft-translate-btn{
width:100%;
padding:14px 24px;
background:linear-gradient(135deg, #e91e63 0%, #c2185b 100%);
border:none;
border-radius:12px;
color:#fff;
font-weight:600;
font-size:15px;
cursor:pointer;
transition:all 0.3s ease;
box-shadow:0 6px 20px rgba(233,30,99,0.25);
position:relative;
overflow:hidden;
display:flex;
align-items:center;
justify-content:center;
gap:8px;
}
.ft-translate-btn::before{
content:'';
position:absolute;
top:0; left:-100%;
width:100%; height:100%;
background:linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition:left 0.6s ease;
}
.ft-translate-btn:hover{
background:linear-gradient(135deg, #c2185b 0%, #ad1457 100%);
transform:translateY(-2px);
box-shadow:0 8px 25px rgba(233,30,99,0.35);
}
.ft-translate-btn:hover::before{
left:100%;
}
.ft-translate-btn:active{
transform:translateY(0);
}
.ft-translate-btn:disabled{
background:#666 !important;
cursor:not-allowed !important;
transform:none !important;
box-shadow:0 2px 8px rgba(0,0,0,0.2) !important;
}
/* 加载动画 */
.ft-loading-spinner{
width:16px;
height:16px;
border:2px solid rgba(255,255,255,0.3);
border-top:2px solid #fff;
border-radius:50%;
animation:ft-spin 1s linear infinite;
}
@keyframes ft-spin{
0%{ transform:rotate(0deg); }
100%{ transform:rotate(360deg); }
}
/* 成功/错误状态 */
.ft-translate-btn.success{
background:linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
}
.ft-translate-btn.error{
background:linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
}
/* 功能开关区域 */
.ft-switches{
padding:16px;
border-top:1px solid rgba(255,255,255,0.05);
}
.ft-switch-item{
display:flex;
justify-content:space-between;
align-items:center;
padding:12px 0;
}
.ft-switch-label{
font-size:14px;
color:#ddd;
font-weight:500;
}
.ft-switch{
position:relative;
width:48px;
height:24px;
background:#3a3a3a;
border-radius:12px;
cursor:pointer;
transition:all 0.3s ease;
border:1px solid #555;
}
.ft-switch.active{
background:linear-gradient(135deg, #e91e63 0%, #c2185b 100%);
border-color:#e91e63;
box-shadow:0 0 12px rgba(233,30,99,0.3);
}
.ft-switch::after{
content:'';
position:absolute;
top:2px;
left:2px;
width:18px;
height:18px;
background:#fff;
border-radius:50%;
transition:all 0.3s cubic-bezier(0.34,1.56,0.64,1);
box-shadow:0 2px 6px rgba(0,0,0,0.3);
}
.ft-switch.active::after{
transform:translateX(24px);
box-shadow:0 2px 8px rgba(0,0,0,0.4);
}
/* 快捷键提示 */
.ft-shortcut-tip{
padding:8px 0 4px 0;
margin-top:8px;
border-top:1px solid rgba(255,255,255,0.05);
}
.ft-shortcut-text{
font-size:11px;
color:#888;
display:flex;
align-items:center;
gap:4px;
}
.ft-shortcut-text kbd{
background:rgba(255,255,255,0.1);
border:1px solid rgba(255,255,255,0.2);
border-radius:4px;
padding:2px 6px;
font-size:10px;
font-family:monospace;
color:#fff;
box-shadow:0 1px 2px rgba(0,0,0,0.2);
}
/* 状态区域 */
.ft-status-section{
padding:12px 16px;
background:rgba(0,0,0,0.2);
border-top:1px solid rgba(255,255,255,0.05);
}
.ft-status-text{
font-size:12px;
color:#999;
text-align:center;
margin-bottom:8px;
}
/* 进度条 */
.ft-progress-container{
width:100%;
height:4px;
background:rgba(255,255,255,0.1);
border-radius:2px;
overflow:hidden;
position:relative;
}
.ft-progress-bar{
height:100%;
background:linear-gradient(90deg, #e91e63 0%, #c2185b 100%);
border-radius:2px;
transition:width 0.3s ease;
position:relative;
overflow:hidden;
}
.ft-progress-bar::after{
content:'';
position:absolute;
top:0;
left:-100%;
width:100%;
height:100%;
background:linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
animation:ft-progress-shine 1.5s infinite;
}
@keyframes ft-progress-shine{
0%{ left:-100%; }
100%{ left:100%; }
}
/* 隐藏进度条 */
.ft-progress-container.hidden{
opacity:0;
height:0;
margin:0;
transition:all 0.3s ease;
}
/* 移除关闭面板样式 */
/* 旧面板隐藏 */
.ft-panel{ display:none; }
.ft-panel{ position:absolute; right:50px; top:0; min-width:320px; max-width:420px; padding:12px; border-radius:14px; background:rgba(255,255,255,.95); border:1px solid rgba(0,0,0,.12); box-shadow:0 12px 32px rgba(0,0,0,.18); backdrop-filter:saturate(1.2) blur(8px); opacity:0; pointer-events:none; transform:translateX(10px); transition:all .15s ease; }
.ft-ui.open .ft-panel{ opacity:1; pointer-events:auto; transform:translateX(0); }
.ft-row{ display:flex; align-items:center; gap:6px; margin-bottom:10px; }
.ft-select{ flex:1; padding:5px 8px; border-radius:6px; border:1px solid #ddd; background:#fff; font-size:13px; }
.ft-btn{ padding:6px 12px; border-radius:6px; border:1px solid #3b82f6; background:#3b82f6; color:#fff; cursor:pointer; font-size:13px; transition:background .2s ease; }
.ft-btn:hover{ background:#2563eb; }
.ft-btn.ghost{ background:#fff; color:#3b82f6; border-color:#3b82f6; }
.ft-btn.ghost:hover{ background:#f1f5f9; }
.ft-status{ font-size:11px; color:#666; margin-top:4px; }
.ft-progress{ color:#444; font-size:11px; }
.ft-check{ gap:6px; user-select:none; align-items:center; margin-bottom:8px; }
.ft-check input{ margin:0; }
.ft-check label{ font-size:13px; }
.ft-busy .ft-btn{ opacity:.7; pointer-events:none; }
.ft-pair{ display:inline; }
.ft-pair .ft-original{ opacity:.6; margin-right:.35em; }
.ft-pair:not(.show-original) .ft-original{ display:none; }
@media (prefers-color-scheme: dark){
.ft-fab{ background:#222; border-color:#444; color:#eee; }
.ft-ui:hover .ft-fab{ background:#333; }
.ft-settings-panel{ background:rgba(24,24,24,.95); color:#ddd; }
.ft-settings-header{ border-color:rgba(255,255,255,.1); }
.ft-panel{ background:rgba(24,24,24,.95); border-color:rgba(255,255,255,.12); color:#ddd; }
.ft-select{ background:#1f2937; border-color:#374151; color:#e5e7eb; }
.ft-btn{ background:#3b82f6; color:#fff; border-color:#3b82f6; }
.ft-btn:hover{ background:#2563eb; }
.ft-btn.ghost{ background:#1f2937; color:#3b82f6; border-color:#3b82f6; }
.ft-btn.ghost:hover{ background:#374151; }
.ft-status{ color:#aaa; }
.ft-progress{ color:#ccc; }
}
/* fallback for browsers without mask support */
@supports not (mask: url()) {
.ft-fab::before{ display:none; }
.ft-fab::after{
content:"A文"; position:absolute;
font:bold 10px/1 sans-serif; color:#fff;
left:50%; top:50%; transform:translate(-50%,-50%);
transition:color .3s ease, text-shadow .3s ease;
z-index:1;
}
/* 状态指示器 - fallback模式 */
.ft-fab.ft-translated::after{
color:#fff;
text-shadow:0 0 4px rgba(255,255,255,0.5);
}
.ft-fab.ft-success::after{
color:#fff !important;
text-shadow:0 0 4px rgba(255,255,255,0.8);
}
.ft-fab.ft-error::after{
color:#fff;
text-shadow:0 0 4px rgba(255,255,255,0.5);
}
/* 为fallback模式添加状态指示点 */
.ft-fab.ft-translated::before,
.ft-fab.ft-error::before{
content:'';
display:block;
position:absolute;
top:-2px;
right:-2px;
width:8px;
height:8px;
border-radius:50%;
border:1px solid #fff;
z-index:2;
}
.ft-fab.ft-translated::before{
background:#10b981;
}
.ft-fab.ft-error::before{
background:#ef4444;
}
}
/* 划词翻译样式 */
.ft-selection-bubble{
position:absolute;
width:26px;
height:26px;
background:rgba(156, 163, 175, 0.9);
border:1px solid rgba(156, 163, 175, 0.3);
border-radius:50%;
box-shadow:0 3px 10px rgba(156, 163, 175, 0.3);
cursor:pointer;
display:flex;
align-items:center;
justify-content:center;
z-index:2147483646;
transition:all 0.2s ease;
font-size:12px;
color:#fff;
user-select:none;
opacity:0;
transform:scale(0.5);
animation:ft-bubble-appear 0.2s ease forwards;
}
@keyframes ft-bubble-appear{
to{
opacity:1;
transform:scale(1);
}
}
.ft-selection-bubble:hover{
transform:scale(1.1);
box-shadow:0 5px 15px rgba(156, 163, 175, 0.4);
}
.ft-selection-bubble::before{
content:"A";
font-weight:bold;
}
.ft-translation-popup{
position:absolute;
min-width:200px;
max-width:300px;
background:#1e1e1e;
border:1px solid #333;
border-radius:12px;
box-shadow:0 8px 32px rgba(0,0,0,.3);
color:#fff;
z-index:2147483646;
overflow:hidden;
opacity:0;
transform:scale(0.9);
transition:all 0.2s ease;
font-size:13px;
}
.ft-translation-popup.show{
opacity:1;
transform:scale(1);
}
.ft-translation-popup-header{
padding:8px 12px;
background:rgba(255,255,255,0.05);
border-bottom:1px solid rgba(255,255,255,0.1);
font-size:11px;
color:#999;
display:flex;
justify-content:space-between;
align-items:center;
}
.ft-translation-popup-actions{
display:flex;
gap:4px;
}
.ft-translation-popup-refresh,
.ft-translation-popup-close{
cursor:pointer;
width:16px;
height:16px;
display:flex;
align-items:center;
justify-content:center;
border-radius:8px;
transition:background 0.2s ease;
font-size:12px;
}
.ft-translation-popup-refresh:hover,
.ft-translation-popup-close:hover{
background:rgba(255,255,255,0.1);
}
.ft-translation-popup-refresh{
color:#3b82f6;
}
.ft-translation-popup-refresh:hover{
background:rgba(59,130,246,0.2);
}
.ft-translation-popup-content{
padding:12px;
line-height:1.4;
}
.ft-translation-original{
color:#999;
font-size:12px;
margin-bottom:8px;
word-break:break-word;
}
.ft-translation-result{
color:#fff;
word-break:break-word;
}
.ft-translation-loading{
display:flex;
align-items:center;
gap:8px;
color:#999;
}
.ft-translation-loading .ft-loading-spinner{
width:12px;
height:12px;
border:1px solid rgba(255,255,255,0.3);
border-top:1px solid #fff;
}
.ft-translation-error{
color:#ef4444;
line-height:1.4;
}
`;
try {
if (typeof GM_addStyle === 'function') GM_addStyle(css);
else {
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
} catch {}
}
function toggleKeepOriginal(on) {
const pairs = document.querySelectorAll('span.ft-pair');
pairs.forEach((w) => {
if (on) w.classList.add('show-original');
else w.classList.remove('show-original');
});
}
function applyTranslationToElement(element, original, translatedText) {
// 保存原始HTML内容
const originalHTML = element.innerHTML;
// 标记为已翻译
element.setAttribute('data-ft-original', '1');
element.setAttribute('data-ft-original-html', originalHTML);
element.setAttribute('data-ft-original-text', original);
// 如果保留原文,创建切换结构
if (keepOriginal) {
const wrapper = document.createElement('span');
wrapper.className = 'ft-pair show-original';
wrapper.setAttribute('data-ft-original-text', original);
const o = document.createElement('span');
o.className = 'ft-original';
o.innerHTML = originalHTML;
const t = document.createElement('span');
t.className = 'ft-translated';
t.textContent = translatedText;
wrapper.append(o, t);
element.innerHTML = '';
element.appendChild(wrapper);
} else {
// 直接替换文本内容
element.textContent = translatedText;
}
}
// 向后兼容的文本节点处理函数
function applyTranslationToNode(node, original, leading, trailing, translatedPretty) {
// Wrap the original text node with a pair container to support toggling
const wrapper = document.createElement('span');
wrapper.className = 'ft-pair';
if (keepOriginal) wrapper.classList.add('show-original');
wrapper.setAttribute('data-ft-original-text', original);
wrapper.setAttribute('data-ft-leading', leading);
wrapper.setAttribute('data-ft-trailing', trailing);
const o = document.createElement('span');
o.className = 'ft-original';
o.textContent = original.trim();
const t = document.createElement('span');
t.className = 'ft-translated';
t.textContent = leading + translatedPretty + trailing;
wrapper.append(o, t);
node.parentNode && node.parentNode.replaceChild(wrapper, node);
}
// --------------------------
// 划词翻译功能
// --------------------------
function setupSelectionListeners() {
document.addEventListener('mouseup', handleTextSelection);
document.addEventListener('touchend', handleTextSelection);
}
function removeSelectionListeners() {
document.removeEventListener('mouseup', handleTextSelection);
document.removeEventListener('touchend', handleTextSelection);
}
function handleTextSelection(e) {
// 延迟处理,确保选择已完成
setTimeout(() => {
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (selectedText && selectedText.length > 0 && selectedText.length < 500) {
// 排除在翻译UI内的选择
if (e.target.closest('.ft-ui, .ft-selection-bubble, .ft-translation-popup')) {
return;
}
// 获取鼠标/触摸位置
let mouseX, mouseY;
if (e.type === 'touchend' && e.changedTouches && e.changedTouches.length > 0) {
// 触摸事件
mouseX = e.changedTouches[0].clientX;
mouseY = e.changedTouches[0].clientY;
} else {
// 鼠标事件
mouseX = e.clientX || e.pageX;
mouseY = e.clientY || e.pageY;
}
showSelectionBubble(selectedText, { x: mouseX, y: mouseY });
} else {
hideSelectionBubble();
}
}, 100);
}
function showSelectionBubble(text, mousePos) {
hideSelectionBubble();
hideTranslationPopup();
selectionBubble = document.createElement('div');
selectionBubble.className = 'ft-selection-bubble';
// 使用鼠标位置,添加少量偏移避免遮挡光标
const offsetX = 10;
const offsetY = 10;
// 计算最终位置,确保不超出视窗边界
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const bubbleSize = 26; // 悬浮框尺寸(26px)
let left = mousePos.x + offsetX;
let top = mousePos.y + offsetY;
// 边界检查和调整
if (left + bubbleSize > viewportWidth) {
left = mousePos.x - bubbleSize - offsetX;
}
if (top + bubbleSize > viewportHeight) {
top = mousePos.y - bubbleSize - offsetY;
}
// 确保不小于0
left = Math.max(0, left);
top = Math.max(0, top);
selectionBubble.style.left = (left + window.scrollX) + 'px';
selectionBubble.style.top = (top + window.scrollY) + 'px';
selectionBubble.addEventListener('click', (e) => {
e.stopPropagation();
showTranslationPopup(text, selectionBubble);
});
document.body.appendChild(selectionBubble);
// 自动隐藏机制
setTimeout(() => {
if (selectionBubble && !selectionBubble.matches(':hover')) {
hideSelectionBubble();
}
}, 3000);
}
function hideSelectionBubble() {
if (selectionBubble) {
selectionBubble.remove();
selectionBubble = null;
}
}
function showTranslationPopupAtPosition(text, x, y) {
hideTranslationPopup();
translationPopup = document.createElement('div');
translationPopup.className = 'ft-translation-popup';
// 计算弹窗位置,确保不超出视窗边界
const popupWidth = 300; // 最大宽度
const popupHeight = 150; // 估计高度
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x + window.scrollX;
let top = y + window.scrollY + 10; // 在选中文本下方10px
// 边界检查
if (left + popupWidth > viewportWidth + window.scrollX) {
left = x + window.scrollX - popupWidth;
}
if (top + popupHeight > viewportHeight + window.scrollY) {
top = y + window.scrollY - popupHeight - 10; // 在选中文本上方
}
// 确保不超出左边界和上边界
left = Math.max(window.scrollX + 10, left);
top = Math.max(window.scrollY + 10, top);
translationPopup.style.left = left + 'px';
translationPopup.style.top = top + 'px';
translationPopup.innerHTML = `
<div class="ft-translation-popup-header">
<span>快捷翻译 (F2)</span>
<div class="ft-translation-popup-actions">
<div class="ft-translation-popup-refresh" title="重新翻译">↻</div>
<div class="ft-translation-popup-close" title="关闭">×</div>
</div>
</div>
<div class="ft-translation-popup-content">
<div class="ft-translation-original">${text}</div>
<div class="ft-translation-loading">
<div class="ft-loading-spinner"></div>
<span>翻译中...</span>
</div>
</div>
`;
document.body.appendChild(translationPopup);
// 添加关闭和刷新事件
translationPopup.querySelector('.ft-translation-popup-close').addEventListener('click', hideTranslationPopup);
translationPopup.querySelector('.ft-translation-popup-refresh').addEventListener('click', () => {
manualRefreshTranslation(text);
});
// 显示动画
setTimeout(() => {
translationPopup.classList.add('show');
}, 10);
// 开始翻译
retryTranslation(text, 0);
}
function showTranslationPopup(text, anchorElement) {
hideTranslationPopup();
translationPopup = document.createElement('div');
translationPopup.className = 'ft-translation-popup';
const rect = anchorElement.getBoundingClientRect();
translationPopup.style.left = (rect.left + window.scrollX) + 'px';
translationPopup.style.top = (rect.bottom + window.scrollY + 5) + 'px';
translationPopup.innerHTML = `
<div class="ft-translation-popup-header">
<span>划词翻译</span>
<div class="ft-translation-popup-actions">
<div class="ft-translation-popup-refresh" title="重新翻译">↻</div>
<div class="ft-translation-popup-close" title="关闭">×</div>
</div>
</div>
<div class="ft-translation-popup-content">
<div class="ft-translation-original">${text}</div>
<div class="ft-translation-loading">
<div class="ft-loading-spinner"></div>
<span>翻译中...</span>
</div>
</div>
`;
document.body.appendChild(translationPopup);
// 添加关闭和刷新事件
translationPopup.querySelector('.ft-translation-popup-close').addEventListener('click', hideTranslationPopup);
translationPopup.querySelector('.ft-translation-popup-refresh').addEventListener('click', () => {
manualRefreshTranslation(text); // 手动刷新,只执行一次翻译
});
// 显示动画
setTimeout(() => {
translationPopup.classList.add('show');
}, 10);
// 开始翻译
retryTranslation(text, 0);
}
function hideTranslationPopup() {
if (translationPopup) {
translationPopup.remove();
translationPopup = null;
}
}
async function manualRefreshTranslation(text) {
if (!translationPopup) return;
// 显示加载状态
const content = translationPopup.querySelector('.ft-translation-popup-content');
content.innerHTML = `
<div class="ft-translation-original">${text}</div>
<div class="ft-translation-loading">
<div class="ft-loading-spinner"></div>
<span>重新初始化翻译器...</span>
</div>
`;
try {
// 强制重新初始化翻译器
console.log('[ChromeTranslator] 手动刷新:强制重新初始化翻译器');
// 销毁现有翻译器和语言检测器实例
if (translatorInstance) {
try {
translatorInstance.destroy();
console.log('[ChromeTranslator] 已销毁现有翻译器实例');
} catch (destroyError) {
console.warn('[ChromeTranslator] 销毁翻译器实例失败:', destroyError);
}
translatorInstance = null;
}
if (detectorInstance) {
try {
detectorInstance.destroy();
console.log('[ChromeTranslator] 已销毁现有语言检测器实例');
} catch (destroyError) {
console.warn('[ChromeTranslator] 销毁语言检测器实例失败:', destroyError);
}
detectorInstance = null;
}
// 更新状态显示
if (translationPopup) {
const loadingSpan = content.querySelector('.ft-translation-loading span');
if (loadingSpan) loadingSpan.textContent = '重新翻译中...';
}
// 重新初始化并翻译
const result = await translateSelectedText(text);
// 翻译成功,更新UI
if (translationPopup && result) {
content.innerHTML = `
<div class="ft-translation-original">${text}</div>
<div class="ft-translation-result">${result}</div>
`;
}
} catch (error) {
console.error('[ChromeTranslator] 手动刷新翻译失败:', error);
// 手动刷新失败,显示错误但不自动重试
if (translationPopup) {
content.innerHTML = `
<div class="ft-translation-original">${text}</div>
<div class="ft-translation-error">
<div style="color:#ef4444; margin-bottom:8px;">翻译失败: ${error.message}</div>
<div style="font-size:11px; color:#999;">请点击刷新按钮重试</div>
</div>
`;
}
}
}
async function retryTranslation(text, retryCount = 0) {
const maxRetries = 3;
if (!translationPopup) return;
// 显示加载状态
const content = translationPopup.querySelector('.ft-translation-popup-content');
const isInitError = retryCount > 0; // 重试时检查是否需要重新初始化
content.innerHTML = `
<div class="ft-translation-original">${text}</div>
<div class="ft-translation-loading">
<div class="ft-loading-spinner"></div>
<span>${isInitError ? '重新初始化翻译器' : '翻译中'}${retryCount > 0 ? ` (重试 ${retryCount}/${maxRetries})` : ''}...</span>
</div>
`;
try {
// 如果是重试且上次失败可能是初始化问题,强制重新初始化
if (retryCount > 0) {
console.log(`[ChromeTranslator] 自动重试第${retryCount}次:重新初始化翻译器`);
// 销毁现有实例
if (translatorInstance) {
try {
translatorInstance.destroy();
console.log('[ChromeTranslator] 已销毁现有翻译器实例');
} catch (destroyError) {
console.warn('[ChromeTranslator] 销毁翻译器实例失败:', destroyError);
}
translatorInstance = null;
}
if (detectorInstance) {
try {
detectorInstance.destroy();
console.log('[ChromeTranslator] 已销毁现有语言检测器实例');
} catch (destroyError) {
console.warn('[ChromeTranslator] 销毁语言检测器实例失败:', destroyError);
}
detectorInstance = null;
}
// 更新状态显示
if (translationPopup) {
const loadingSpan = content.querySelector('.ft-translation-loading span');
if (loadingSpan) {
loadingSpan.textContent = `翻译中 (重试 ${retryCount}/${maxRetries})...`;
}
}
}
const result = await translateSelectedText(text);
// 翻译成功,更新UI
if (translationPopup && result) {
content.innerHTML = `
<div class="ft-translation-original">${text}</div>
<div class="ft-translation-result">${result}</div>
`;
}
} catch (error) {
console.error(`[ChromeTranslator] 划词翻译失败 (第${retryCount + 1}次):`, error);
if (retryCount < maxRetries) {
// 还有重试次数,1秒后重试
if (translationPopup) {
content.innerHTML = `
<div class="ft-translation-original">${text}</div>
<div class="ft-translation-loading">
<div class="ft-loading-spinner"></div>
<span>翻译失败,${1}秒后重试 (${retryCount + 1}/${maxRetries})...</span>
</div>
`;
}
setTimeout(() => {
retryTranslation(text, retryCount + 1);
}, 1000);
} else {
// 重试次数用完,显示最终错误
if (translationPopup) {
content.innerHTML = `
<div class="ft-translation-original">${text}</div>
<div class="ft-translation-error">
<div style="color:#ef4444; margin-bottom:8px;">翻译失败: ${error.message}</div>
<div style="font-size:11px; color:#999;">已重试 ${maxRetries} 次,请检查网络或稍后再试</div>
</div>
`;
}
}
}
}
async function translateSelectedText(text) {
if (!hasTranslator()) throw new Error('翻译器不可用');
// 确保翻译器可用
const availabilityOk = await ensureAvailability();
if (!availabilityOk) throw new Error('翻译器不可用');
const realSource = await resolveRealSourceLanguage();
const instanceOk = await ensureTranslator(realSource, targetLang);
if (!instanceOk) throw new Error('翻译器初始化失败');
// 执行翻译
const translated = await translateStreaming(text.replace(/\n/g, '<br>'));
const result = (translated || '').replace(/<br>/g, '\n').trim();
if (!result) throw new Error('翻译结果为空');
return result;
}
// 点击其他地方关闭划词翻译相关UI
document.addEventListener('click', (e) => {
if (!e.target.closest('.ft-selection-bubble, .ft-translation-popup')) {
hideSelectionBubble();
hideTranslationPopup();
}
});
// F2快捷键快速翻译选中文本
document.addEventListener('keydown', (e) => {
if (e.key === 'F2' && wordSelectionEnabled) {
e.preventDefault();
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (selectedText && selectedText.length > 0 && selectedText.length < 500) {
// 排除在翻译UI内的选择
const range = selection.getRangeAt(0);
const container = range.commonAncestorContainer;
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
if (element && element.closest('.ft-ui, .ft-selection-bubble, .ft-translation-popup')) {
return;
}
// 获取选中文本的位置,用于显示翻译弹窗
const rect = range.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// 直接显示翻译弹窗,跳过悬浮按钮
showTranslationPopupAtPosition(selectedText, centerX, centerY);
}
}
});
})();