/* global chrome */ (() => { if (window.__AI_TRANSLATE_LOADED__) return; window.__AI_TRANSLATE_LOADED__ = true; const MAX_LEN = 5000; const $ = (sel, root = document) => root.querySelector(sel); let settings = { enabled: true, triggerMode: 'icon', targetLang: '简体中文', sourceLang: '自动检测', fontSize: 16, preset: '', model: '' }; let host = null; // shadow 宿主 let shadow = null; let ui = {}; // 缓存元素 let currentPort = null; let currentText = ''; let autoTimer = null; let lastMouseEvent = null; let dragState = null; let sourcePinned = false; // 用户手动固定原文显示时,不再自动收起 // ---------------- 初始化 ---------------- chrome.storage.sync.get(settings).then(s => { settings = { ...settings, ...s }; }); chrome.storage.onChanged.addListener(changes => { for (const k of Object.keys(changes)) { if (k in settings) settings[k] = changes[k].newValue; } applyFontSize(); }); // ---------------- 工具 ---------------- function getSelectionText() { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return ''; const text = sel.toString().replace(/\u00a0/g, ' ').trim(); return text; } function selectionRect() { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return null; const rect = sel.getRangeAt(0).getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return null; return rect; } function isOurElement(node) { return host && (node === host || (node instanceof Node && host.contains(node))); } function clampPanel(pos, w, h) { const vw = window.innerWidth, vh = window.innerHeight, m = 10; let { x, y } = pos; x = Math.max(m, Math.min(x, vw - w - m)); y = Math.max(m, Math.min(y, vh - h - m)); return { x, y }; } // ---------------- UI 构建 ---------------- function ensureUI() { if (host) return; host = document.createElement('div'); host.id = '__ai-translate-host-' + Math.random().toString(36).slice(2, 8); host.style.cssText = 'all:initial;position:fixed;top:0;left:0;width:0;height:0;z-index:2147483646;'; shadow = host.attachShadow({ mode: 'closed' }); const style = document.createElement('style'); style.textContent = cssText(); shadow.appendChild(style); const wrap = document.createElement('div'); wrap.className = 'ait-wrap'; wrap.innerHTML = ` `; shadow.appendChild(wrap); ui = { wrap, fab: $('.ait-fab', wrap), panel: $('.ait-panel', wrap), src: $('.ait-src', wrap), dst: $('.ait-dst', wrap), model: $('.ait-model', wrap), source: $('.ait-source', wrap), result: $('.ait-result', wrap), srcToggle: $('.ait-src-toggle', wrap), copy: $('.ait-copy', wrap), retry: $('.ait-retry', wrap), close: $('.ait-close', wrap), header: $('.ait-header', wrap), resizer: $('.ait-resizer', wrap) }; ui.fab.addEventListener('mousedown', e => e.stopPropagation()); ui.fab.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); hideFab(); translateSelection(); }); ui.copy.addEventListener('click', async () => { const text = ui.result.innerText; try { await navigator.clipboard.writeText(text); toast('已复制'); } catch { toast('复制失败', true); } }); ui.retry.addEventListener('click', () => translate(currentText, true)); ui.close.addEventListener('click', hidePanel); // 展开 / 收起原文 ui.srcToggle.addEventListener('click', () => { sourcePinned = true; setSourceVisible(ui.source.hidden); }); // 拖拽 ui.header.addEventListener('pointerdown', e => { if (e.target.closest('.ait-btn')) return; const rect = ui.panel.getBoundingClientRect(); dragState = { sx: e.clientX, sy: e.clientY, px: rect.left, py: rect.top }; ui.header.setPointerCapture(e.pointerId); }); ui.header.addEventListener('pointermove', e => { if (!dragState) return; const x = dragState.px + e.clientX - dragState.sx; const y = dragState.py + e.clientY - dragState.sy; setPanelPos(x, y); }); ui.header.addEventListener('pointerup', () => { dragState = null; }); // 右下角调整大小 ui.resizer.addEventListener('pointerdown', e => { e.preventDefault(); const startX = e.clientX, startY = e.clientY; const rect = ui.panel.getBoundingClientRect(); const move = ev => { ui.panel.style.width = Math.max(260, rect.width + ev.clientX - startX) + 'px'; ui.panel.style.maxHeight = Math.min(Math.max(160, rect.height + ev.clientY - startY), window.innerHeight - 20) + 'px'; }; const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); }); document.documentElement.appendChild(host); applyFontSize(); } function setPanelPos(x, y) { const rect = ui.panel.getBoundingClientRect(); const p = clampPanel({ x, y }, rect.width, rect.height); ui.panel.style.left = p.x + 'px'; ui.panel.style.top = p.y + 'px'; } function applyFontSize() { if (!ui.result) return; ui.result.style.fontSize = (settings.fontSize || 16) + 'px'; if (ui.source) ui.source.style.fontSize = Math.max(13, (settings.fontSize || 16) - 1) + 'px'; } function setSourceVisible(visible) { if (!ui.source) return; ui.source.hidden = !visible; ui.srcToggle.classList.toggle('ait-active', visible); ui.srcToggle.title = visible ? '收起原文' : '显示原文'; if (visible) ui.source.scrollTop = 0; } // 译文较长时自动收起原文;用户点过按钮则保持展开 function autoCollapseSource(resultLen) { if (resultLen > 80 && !sourcePinned) setSourceVisible(false); } function showFab() { ensureUI(); const rect = selectionRect(); if (!rect) return hideFab(); const x = Math.min(rect.right + 6, window.innerWidth - 48); let y = rect.bottom + 6; if (y > window.innerHeight - 40) y = rect.top - 34; ui.fab.style.left = Math.max(6, x) + 'px'; ui.fab.style.top = Math.max(6, y) + 'px'; ui.fab.classList.add('ait-show'); } function hideFab() { if (ui.fab) ui.fab.classList.remove('ait-show'); } // ---------------- 翻译 ---------------- function translateSelection() { const text = getSelectionText(); if (!text) { toast('请先选择要翻译的文本', true); return; } translate(text); } function translate(text, isRetry = false) { ensureUI(); currentText = text; if (!settings.enabled) { toast('插件已停用,请在弹窗中开启', true); return; } if (!text || text.length > MAX_LEN) { toast('文本过长或为空', true); return; } // 打断上一次请求 if (currentPort) { try { currentPort.disconnect(); } catch {} currentPort = null; } ui.panel.hidden = false; ui.panel.classList.add('ait-visible'); ui.src.textContent = settings.sourceLang || '自动检测'; ui.dst.textContent = settings.targetLang; ui.model.textContent = settings.model ? String(settings.model).split('/').pop() : ''; sourcePinned = false; ui.source.textContent = text; // 完整原文,展开时内部滚动查看 setSourceVisible(true); ui.result.className = 'ait-result ait-loading'; ui.result.innerHTML = '正在翻译…'; hideFab(); // 定位:优先选区附近,重试时保持原位 if (!isRetry) { const rect = selectionRect() || (lastMouseEvent ? { left: lastMouseEvent.clientX, bottom: lastMouseEvent.clientY, width: 0 } : null); if (rect) { const pw = Math.min(680, Math.max(480, Math.round(window.innerWidth * 0.5))); let x = rect.left + rect.width / 2 - pw / 2; let y = rect.bottom + 10; if (y > window.innerHeight * 0.65) y = rect.top - 10 - 280; const pos = clampPanel({ x, y }, pw, 240); ui.panel.style.width = pw + 'px'; ui.panel.style.left = pos.x + 'px'; ui.panel.style.top = pos.y + 'px'; } } const port = chrome.runtime.connect({ name: 'translate' }); currentPort = port; port.onMessage.addListener(msg => { if (msg.type === 'start') { ui.result.className = 'ait-result'; ui.result.textContent = ''; } else if (msg.type === 'delta') { if (ui.result.classList.contains('ait-loading')) { ui.result.className = 'ait-result'; ui.result.textContent = ''; } ui.result.textContent += msg.text; // 智能显示源文本 autoCollapseSource(ui.result.textContent.length); ui.result.scrollTop = ui.result.scrollHeight; } else if (msg.type === 'done') { ui.result.textContent = msg.full; autoCollapseSource(ui.result.textContent.length); ui.result.classList.add('ait-done'); currentPort = null; try { port.disconnect(); } catch {} } else if (msg.type === 'error') { ui.result.className = 'ait-result ait-error'; ui.result.textContent = '⚠ ' + (msg.error || '未知错误'); currentPort = null; try { port.disconnect(); } catch {} } }); port.onDisconnect.addListener(() => { if (currentPort === port) { if (ui.result.classList.contains('ait-loading')) { ui.result.className = 'ait-result ait-error'; ui.result.textContent = '⚠ 连接中断,请重试'; } currentPort = null; } }); port.postMessage({ type: 'translate', text }); } function hidePanel() { if (!ui.panel) return; ui.panel.classList.remove('ait-visible'); ui.panel.hidden = true; if (currentPort) { try { currentPort.disconnect(); } catch {} currentPort = null; } } function toast(message, isError = false) { ensureUI(); const t = document.createElement('div'); t.className = 'ait-toast' + (isError ? ' ait-toast-error' : ''); t.textContent = message; ui.wrap.appendChild(t); requestAnimationFrame(() => t.classList.add('ait-show')); setTimeout(() => { t.classList.remove('ait-show'); setTimeout(() => t.remove(), 300); }, 1800); } // ---------------- 事件 ---------------- document.addEventListener('mouseup', e => { lastMouseEvent = e; if (isOurElement(e.target)) return; if (!settings.enabled || settings.triggerMode === 'shortcut') return; // 等待选区稳定 setTimeout(() => { const text = getSelectionText(); if (text && text.length >= 2 && text.length <= MAX_LEN) { if (settings.triggerMode === 'auto') { clearTimeout(autoTimer); autoTimer = setTimeout(() => translate(getSelectionText()), 450); } else { showFab(); } } else { hideFab(); } }, 10); }, true); document.addEventListener('mousedown', e => { if (isOurElement(e.target)) return; hideFab(); }, true); // 键盘选区(Shift+方向键) document.addEventListener('keyup', e => { if (e.shiftKey || e.key === 'Shift') { if (!settings.enabled || settings.triggerMode === 'icon') { const text = getSelectionText(); if (text && text.length >= 2) setTimeout(showFab, 50); } } }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && ui.panel && !ui.panel.hidden) hidePanel(); }, true); // 滚动/ resize 时隐藏图标 window.addEventListener('scroll', () => hideFab(), { passive: true, capture: true }); // 来自 background 的消息(快捷键 / 右键菜单) chrome.runtime.onMessage.addListener(msg => { if (msg.type === 'translate-selection') translateSelection(); else if (msg.type === 'translate-text' && msg.text) translate(msg.text); }); // ---------------- 样式 ---------------- function cssText() { return CONTENT_CSS; } const CONTENT_CSS = ` :host { all: initial; } * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; } .ait-fab { all: unset; position: fixed; left: -100px; top: -100px; display: inline-flex; visibility: hidden; pointer-events: none; align-items: center; gap: 3px; cursor: pointer; padding: 4px 10px 4px 8px; border-radius: 999px; background: linear-gradient(135deg, #4f46e5, #8b5cf6); color: #fff; font-size: 12px; font-weight: 600; line-height: 1; box-shadow: 0 4px 14px rgba(79, 70, 229, .45); opacity: 0; transform: translateY(4px) scale(.9); transition: opacity .15s ease, transform .15s ease; -webkit-user-select: none; user-select: none; z-index: 2147483647; } .ait-fab.ait-show { visibility: visible; pointer-events: auto; opacity: 1; transform: translateY(0) scale(1); } .ait-fab:hover { filter: brightness(1.1); } .ait-fab:active { transform: scale(.95); } .ait-panel { position: fixed; width: 560px; max-width: calc(100vw - 24px); max-height: min(60vh, 420px); display: flex; flex-direction: column; background: #ffffff; color: #1f2937; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; box-shadow: 0 12px 40px rgba(0,0,0,.18), 0 2px 8px rgba(0,0,0,.08); opacity: 0; transform: translateY(6px) scale(.98); transition: opacity .16s ease, transform .16s ease; z-index: 2147483647; overflow: hidden; font-size: 14px; } .ait-panel[hidden] { display: none; } .ait-panel.ait-visible { opacity: 1; transform: translateY(0) scale(1); } .ait-header { display: flex; align-items: center; gap: 8px; padding: 8px 10px 8px 14px; cursor: grab; background: linear-gradient(135deg, rgba(79,70,229,.08), rgba(139,92,246,.08)); border-bottom: 1px solid rgba(0,0,0,.06); } .ait-header:active { cursor: grabbing; } .ait-lang { display: flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; color: #4f46e5; } .ait-lang svg { opacity: .7; } .ait-model { font-size: 10.5px; color: #9ca3af; background: rgba(0,0,0,.05); padding: 2px 7px; border-radius: 99px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ait-actions { margin-left: auto; display: flex; gap: 2px; } .ait-btn { all: unset; cursor: pointer; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 7px; color: #6b7280; } .ait-btn:hover { background: rgba(0,0,0,.07); color: #374151; } .ait-btn.ait-active { background: rgba(79,70,229,.12); color: #4f46e5; } .ait-btn.ait-active:hover { background: rgba(79,70,229,.18); color: #4338ca; } .ait-body { padding: 14px 16px 16px; overflow: hidden; flex: 1; min-height: 80px; display: flex; flex-direction: row; align-items: stretch; } .ait-source { flex: 1 1 50%; min-height: 0; min-width: 0; font-size: 15px; color: #6b7280; line-height: 1.75; padding-right: 16px; margin-right: 16px; border-right: 1px solid rgba(0,0,0,.09); overflow-y: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; } .ait-source[hidden] { display: none; } .ait-result { flex: 1 1 50%; min-height: 0; min-width: 0; font-size: 16px; line-height: 1.8; color: #111827; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; overflow-y: auto; } .ait-result.ait-loading { display: block; color: #6b7280; } .ait-result.ait-error { color: #dc2626; font-size: 13.5px; line-height: 1.6; } .ait-done { animation: ait-fadein .25s ease; } /* 左右分栏的小标签(原文 / 译文),滚动时吸顶 */ .ait-source::before, .ait-result::before { content: attr(data-label); position: sticky; top: 0; z-index: 1; display: block; background: #ffffff; font-size: 11px; font-weight: 700; letter-spacing: .08em; color: #9ca3af; padding-bottom: 6px; margin-bottom: 8px; } .ait-result::before { color: #4f46e5; } .ait-spinner { display: inline-block; vertical-align: -2px; width: 14px; height: 14px; margin-right: 8px; border-radius: 50%; border: 2px solid rgba(79,70,229,.25); border-top-color: #4f46e5; animation: ait-spin .7s linear infinite; } .ait-resizer { position: absolute; right: 2px; bottom: 2px; width: 14px; height: 14px; cursor: nwse-resize; opacity: .35; } .ait-resizer::after { content: ''; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border-right: 2px solid #9ca3af; border-bottom: 2px solid #9ca3af; border-bottom-right-radius: 2px; } .ait-toast { position: fixed; left: 50%; bottom: 40px; transform: translate(-50%, 8px); background: #111827; color: #fff; font-size: 13px; padding: 8px 16px; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.25); opacity: 0; transition: all .25s ease; z-index: 2147483647; pointer-events: none; } .ait-toast.ait-show { opacity: 1; transform: translate(-50%, 0); } .ait-toast-error { background: #dc2626; } @keyframes ait-spin { to { transform: rotate(360deg); } } @keyframes ait-fadein { from { opacity: .4; } to { opacity: 1; } } @media (prefers-color-scheme: dark) { .ait-panel { background: #1e1e24; color: #d1d5db; border-color: rgba(255,255,255,.1); box-shadow: 0 12px 40px rgba(0,0,0,.5); } .ait-header { background: linear-gradient(135deg, rgba(79,70,229,.16), rgba(139,92,246,.16)); border-bottom-color: rgba(255,255,255,.08); } .ait-model { background: rgba(255,255,255,.08); color: #9ca3af; } .ait-btn { color: #9ca3af; } .ait-btn:hover { background: rgba(255,255,255,.1); color: #e5e7eb; } .ait-btn.ait-active { background: rgba(129,140,248,.2); color: #a5b4fc; } .ait-btn.ait-active:hover { background: rgba(129,140,248,.28); color: #c7d2fe; } .ait-source { color: #9ca3af; border-right-color: rgba(255,255,255,.09); } .ait-result { color: #f3f4f6; } .ait-source::before, .ait-result::before { background: #1e1e24; } } `; })();