Files
zzzjy eb00b23de8 Initial commit: 译泡 PopLingo v1.0.0
AI 划词翻译 Chrome 扩展(Manifest V3):
- 支持 DeepSeek/OpenAI/Kimi/智谱/通义/Gemini/Claude/Ollama 等 10 种服务商
- 流式输出、左右分栏对照、原文一键展开收起
- 划词图标/自动翻译/Alt+T 快捷键/右键菜单四种触发方式
2026-09-17 00:43:32 +08:00

528 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = `
<button class="ait-fab" title="译泡翻译(Alt+T)" aria-label="译泡翻译">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none">
<path d="M4 5h10M9 3v2c0 4.5-2.2 8.4-5 10.5M6.5 9.5c1.5 3 3.8 5.2 6.5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 20.5l4-9 4 9M14.4 17.3h5.2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>译</span>
</button>
<div class="ait-panel" hidden>
<div class="ait-header">
<div class="ait-lang">
<span class="ait-src">自动检测</span>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none"><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span class="ait-dst">简体中文</span>
</div>
<span class="ait-model"></span>
<div class="ait-actions">
<button class="ait-btn ait-src-toggle ait-active" title="收起原文">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none">
<path d="M4 6h16M4 10h16M4 14h9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<button class="ait-btn ait-copy" title="复制译文">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<button class="ait-btn ait-retry" title="重新翻译">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="ait-btn ait-close" title="关闭 (Esc)">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<div class="ait-body">
<div class="ait-source" data-label="原文"></div>
<div class="ait-result" data-label="译文">
<span class="ait-spinner"></span>
<span class="ait-loading-text">正在翻译…</span>
</div>
</div>
<div class="ait-resizer"></div>
</div>
`;
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 = '<span class="ait-spinner"></span><span class="ait-loading-text">正在翻译…</span>';
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; }
}
`;
})();