Initial commit: 译泡 PopLingo v1.0.0
AI 划词翻译 Chrome 扩展(Manifest V3): - 支持 DeepSeek/OpenAI/Kimi/智谱/通义/Gemini/Claude/Ollama 等 10 种服务商 - 流式输出、左右分栏对照、原文一键展开收起 - 划词图标/自动翻译/Alt+T 快捷键/右键菜单四种触发方式
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
/* 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; }
|
||||
}
|
||||
`;
|
||||
})();
|
||||
Reference in New Issue
Block a user