您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Chrome 浏览器原生翻译功能的沉浸式翻译脚本,支持整页翻译、保留原文对照和自动翻译新增内容
// ==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); } } }); })();