Greasy Fork is available in English.
英语阅读三合一:1. 双指触屏快速开启翻译 2. 智能单词高亮 3. 点击查词 (精准触控版) 4. 沉浸式双语翻译 (智能缓存+多引擎点选切换+单指左滑操作) 5. 配置导出修复。
// ==UserScript== // @name EnLight // @namespace http://tampermonkey.net/ // @version 1.23 // @description 英语阅读三合一:1. 双指触屏快速开启翻译 2. 智能单词高亮 3. 点击查词 (精准触控版) 4. 沉浸式双语翻译 (智能缓存+多引擎点选切换+单指左滑操作) 5. 配置导出修复。 // @author HAL & Gemini // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getResourceText // @grant GM_download // @grant GM_setClipboard // @grant unsafeWindow // @connect translate.googleapis.com // @connect translate.google.com // @connect edge.microsoft.com // @connect api-edge.cognitive.microsofttranslator.com // @connect dict.youdao.com // @connect * // @run-at document-idle // @require https://unpkg.com/[email protected]/builds/compromise.min.js // @require https://cdn.jsdelivr.net/npm/sweetalert2@11 // @resource SwalCSS https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css // ==/UserScript== (function() { 'use strict'; // ========================================== // 0. 初始化 SweetAlert2 样式 & 配置管理 // ========================================== const swalCssText = GM_getResourceText("SwalCSS"); if (swalCssText) { GM_addStyle(swalCssText); GM_addStyle(`.swal2-container { z-index: 2147483647 !important; }`); } const Toast = Swal.mixin({ toast: true, position: 'top-end', showConfirmButton: false, timer: 3000, timerProgressBar: false, didOpen: (toast) => { toast.addEventListener('mouseenter', Swal.stopTimer); toast.addEventListener('mouseleave', Swal.resumeTimer); } }); function showToast(msg, icon = 'info') { Toast.fire({ icon: icon, title: msg }); } // 默认 API 地址 const DEFAULT_GOOGLE_API = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=zh-CN&dt=t&q="; const DEFAULT_MS_API = "https://api-edge.cognitive.microsofttranslator.com/translate?api-version=3.0&from=en&to=zh-Hans&textType=plain"; const DEFAULT_CONFIG = { urls: { red: '', yellow: '', blue: '', green: '', purple: '', exclude: '' }, listState: { red: true, yellow: true, blue: true, green: true, purple: true, exclude: true }, style: { fontSizeRatio: '100', lineHeight: '1.6', color: '#333333', marginTop: '6px', theme: 'card', learningMode: false }, behavior: { mode: 'blacklist', blacklist: [], whitelist: [] }, translation: { engine: 'google', // 'google' | 'microsoft' googleApi: DEFAULT_GOOGLE_API, microsoftApi: DEFAULT_MS_API } }; function getConfig() { let conf = GM_getValue('highlightConfig', DEFAULT_CONFIG); // 深度合并防止新字段丢失 if (!conf.style) conf.style = DEFAULT_CONFIG.style; if (!conf.behavior) conf.behavior = DEFAULT_CONFIG.behavior; if (!conf.listState) conf.listState = DEFAULT_CONFIG.listState; if (!conf.translation) conf.translation = DEFAULT_CONFIG.translation; // 补全 Microsoft API 字段 (如果是旧版本升级上来) if (!conf.translation.microsoftApi) conf.translation.microsoftApi = DEFAULT_MS_API; return conf; } function shouldRun() { const c = getConfig(); const currentUrl = window.location.href; const matchRule = (rule, url) => { const r = rule.trim(); if (!r) return false; if (r.includes('*')) { const escapeRegex = (str) => str.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); const pattern = "^" + r.split('*').map(escapeRegex).join('.*') + "$"; return new RegExp(pattern).test(url); } else { return url.includes(r); } }; const checkList = (list) => { if (!Array.isArray(list)) return false; return list.some(rule => matchRule(rule, currentUrl)); }; if (c.behavior.mode === 'whitelist') { return checkList(c.behavior.whitelist); } else { if (checkList(c.behavior.blacklist)) return false; return true; } } if (!shouldRun()) { GM_registerMenuCommand("⚙️ EnLight 设置 (当前已禁用)", openSettings); return; } // ========================================== // 1. 核心基础库 (IndexedDB & LazyLoad) // ========================================== let nlpReady = typeof window.nlp !== 'undefined'; let isNlpLoading = false; function ensureNlp() { if (typeof window.nlp !== 'undefined') { nlpReady = true; return Promise.resolve(); } if (isNlpLoading) return new Promise(resolve => { const check = setInterval(() => { if(nlpReady){ clearInterval(check); resolve(); } }, 100); }); isNlpLoading = true; return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://unpkg.com/[email protected]/builds/compromise.min.js'; script.onload = () => { nlpReady = true; isNlpLoading = false; resolve(); }; script.onerror = () => { isNlpLoading = false; reject(); }; document.head.appendChild(script); }); } const DB_NAME = 'EnLightDB'; const STORE_NAME = 'trans_cache'; const dbPromise = new Promise((resolve, reject) => { if (!window.indexedDB) { reject('IDB not supported'); return; } const request = indexedDB.open(DB_NAME, 1); request.onupgradeneeded = (e) => { e.target.result.createObjectStore(STORE_NAME); }; request.onsuccess = (e) => resolve(e.target.result); request.onerror = (e) => reject(e); }); const IDB = { async get(key) { try { const db = await dbPromise; return new Promise(resolve => { const tx = db.transaction(STORE_NAME, 'readonly'); const req = tx.objectStore(STORE_NAME).get(key); req.onsuccess = () => resolve(req.result); req.onerror = () => resolve(null); }); } catch(e) { return null; } }, async set(key, val) { try { const db = await dbPromise; return new Promise(resolve => { const tx = db.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).put(val, key); tx.oncomplete = () => resolve(); }); } catch(e) {} }, async clear() { try { const db = await dbPromise; return new Promise(resolve => { const tx = db.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).clear(); tx.oncomplete = () => resolve(); }); } catch(e) {} } }; // ========================================== // 2. 样式系统 // ========================================== const config = getConfig(); const THEMES = { card: `background-color: #f7f9fa; border-left: 3px solid #007AFF; padding: 6px 10px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);`, minimal: `background-color: transparent; border-left: none; padding: 2px 0; font-style: italic; color: #555;`, dashed: `background-color: #fff; border: 1px dashed #999; padding: 6px 10px; border-radius: 6px;`, underline: `background-color: transparent; border-bottom: 1px solid #ddd; padding: 2px 0 6px 0; margin-bottom: 8px;`, dark: `background-color: #2c2c2e; color: #e5e5e5 !important; border-left: 3px solid #FF9500; padding: 6px 10px; border-radius: 4px;` }; const PAGE_CSS = ` .wh-highlighted { font-weight: bold; border-radius: 3px; } .it-trans-block { all: initial; display: block; margin-top: ${config.style.marginTop}; margin-bottom: 8px; line-height: ${config.style.lineHeight}; color: ${config.style.color}; font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif; width: auto; box-sizing: border-box; word-wrap: break-word; overflow-wrap: break-word; transition: filter 0.3s ease; ${THEMES[config.style.theme] || THEMES.card} } .it-trans-blur { filter: blur(6px); user-select: none; cursor: pointer; } .it-trans-blur:hover { filter: blur(4px); } .it-from-cache { border-left-color: #34C759 !important; } @media (prefers-color-scheme: dark) { .it-trans-block { color: #ccc; } } body[data-bbc-live="true"] .it-trans-block { clear: both; margin-top: 6px; font-size: 0.95em; width: 100% !important; flex-basis: 100% !important; box-sizing: border-box !important; } body[data-bbc-live="true"] li { flex-wrap: wrap !important; } `; GM_addStyle(PAGE_CSS); const POPUP_CSS = ` :host { all: initial; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; z-index: 2147483640; } #custom-dict-popup { position: fixed; background: #fff; border: 1px solid #eee; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.12); padding: 15px; width: 290px; max-width: 85vw; max-height: 50vh; overflow-y: auto; font-size: 14px; line-height: 1.5; color: #333; opacity: 0; pointer-events: none; transition: opacity 0.2s ease, transform 0.2s ease; transform: translateY(5px); text-align: left; box-sizing: border-box; touch-action: manipulation; } #custom-dict-popup.active { opacity: 1; pointer-events: auto; transform: translateY(0); } #custom-dict-popup::-webkit-scrollbar { width: 4px; } #custom-dict-popup::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; } .g-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid #f5f5f5; } .g-word-row { display: flex; align-items: center; gap: 8px; flex: 1; } .g-word { font-size: 20px; font-weight: bold; color: #111; line-height: 1.2; word-break: break-all; } .g-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; } .g-phonetic { color: #666; font-size: 13px; font-family: "Lucida Sans Unicode", sans-serif; background: #f0f2f5; padding: 2px 6px; border-radius: 4px; } .g-tag { background: #e8f0fe; color: #1967d2; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: bold; display: inline-block; line-height: 1.4; } .g-collins-stars { display: inline-flex; color: #f1c40f; font-size: 14px; margin-left: 2px; align-items: center; letter-spacing: 1px; } .g-collins-stars .inactive { color: #eee; } .g-list { margin: 0; padding: 0; list-style: none; color: #444; font-size: 14px; line-height: 1.6; } .g-list li { margin-bottom: 6px; display: flex; align-items: baseline; } .g-bullet { color: #007AFF; margin-right: 8px; font-size: 16px; line-height: 1; font-weight: bold; } .g-msg { color: #999; font-size: 12px; font-style: italic; } .cdp-play-btn { cursor: pointer; color: #007AFF; background: #f0f8ff; border: none; padding: 6px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.2s; } .cdp-play-btn:active { background-color: #dbeafe; } .cdp-play-btn svg { width: 20px; height: 20px; } .cdp-play-btn.playing { color: #E91E63; animation: cdp-pulse 1s infinite; } @keyframes cdp-pulse { 0% { transform: scale(1); } 50% { transform: scale(1.1); } 100% { transform: scale(1); } } `; let popupRoot, popupEl; function createShadowPopup() { if (document.getElementById('wh-shadow-host')) return; const host = document.createElement('div'); host.id = 'wh-shadow-host'; host.style.cssText = 'position: fixed; top: 0; left: 0; width: 0; height: 0; pointer-events: none; z-index: 2147483640;'; document.body.appendChild(host); const shadow = host.attachShadow({mode: 'open'}); const style = document.createElement('style'); style.textContent = POPUP_CSS; shadow.appendChild(style); popupEl = document.createElement('div'); popupEl.id = 'custom-dict-popup'; shadow.appendChild(popupEl); popupRoot = shadow; } // ========================================== // 3. 高亮系统 // ========================================== const wordSets = { red: new Set(), yellow: new Set(), blue: new Set(), green: new Set(), purple: new Set(), exclude: new Set() }; const COLORS = { red: { color: '#FF3B30', label: '红色' }, yellow: { color: '#F5A623', label: '黄色' }, blue: { color: '#007AFF', label: '蓝色' }, green: { color: '#34C759', label: '绿色' }, purple: { color: '#AF52DE', label: '紫色' }, exclude: { color: '#666666', label: '排除列表' } }; function hashText(str) { let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash) + str.charCodeAt(i); hash |= 0; } return 'h' + hash; } async function loadWordLists() { const c = getConfig(); const promises = Object.keys(c.urls).map(key => { if (!c.urls[key]) return Promise.resolve(); return new Promise(resolve => { GM_xmlhttpRequest({ method: "GET", url: c.urls[key] + '?t=' + new Date().getTime(), onload: (res) => { if (res.status === 200) { wordSets[key] = new Set(res.responseText.split(/\r?\n/).map(w => w.trim().toLowerCase()).filter(Boolean)); } resolve(); }, onerror: resolve }); }); }); await Promise.all(promises); startHighlighterObserver(); setTimeout(autoCheckCacheOrHome, 1000); } function checkSet(word, lemma, colorKey) { const set = wordSets[colorKey]; return set && set.size > 0 && (set.has(word.toLowerCase()) || set.has(lemma)); } function getLemma(word) { if (!nlpReady || !window.nlp) return word.toLowerCase(); const lower = word.toLowerCase(); if (!window._lemmaCache) window._lemmaCache = new Map(); if (window._lemmaCache.has(lower)) return window._lemmaCache.get(lower); try { const doc = window.nlp(lower); let root = null; root = doc.verbs().toInfinitive().text(); if (!root) root = doc.nouns().toSingular().text(); if (!root) { doc.compute('root'); root = doc.text('root'); } const result = root ? root.toLowerCase() : lower; window._lemmaCache.set(lower, result); return result; } catch(e) { return lower; } } function processHighlightChunk(textNodes) { if (textNodes.length === 0) return; const c = getConfig(); const CHUNK_SIZE = 50; const chunk = textNodes.splice(0, CHUNK_SIZE); chunk.forEach(textNode => { const text = textNode.nodeValue; if (!text || !text.trim()) return; const parts = text.split(/([a-zA-Z]+(?:'[a-z]+)?)/g); if (parts.length < 2) return; const fragment = document.createDocumentFragment(); let hasReplacement = false; parts.forEach(part => { if (/^[a-zA-Z]/.test(part)) { const lower = part.toLowerCase(); const lemma = getLemma(part); let color = null; const isExcluded = c.listState.exclude && (wordSets.exclude.has(lower) || wordSets.exclude.has(lemma)); if (!isExcluded) { for (let k of ['red','yellow','blue','green','purple']) { if (c.listState[k] && checkSet(part, lemma, k)) { color = COLORS[k].color; break; } } } if (color) { const span = document.createElement('span'); span.className = 'wh-highlighted'; span.style.color = color; span.textContent = part; fragment.appendChild(span); hasReplacement = true; } else fragment.appendChild(document.createTextNode(part)); } else fragment.appendChild(document.createTextNode(part)); }); if (hasReplacement && textNode.parentNode) { textNode.parentNode.replaceChild(fragment, textNode); } }); if (textNodes.length > 0) { if (window.requestIdleCallback) window.requestIdleCallback(() => processHighlightChunk(textNodes)); else setTimeout(() => processHighlightChunk(textNodes), 10); } } function scanNode(element) { if (element.dataset.whProcessed || element.closest('.it-trans-block')) return; element.dataset.whProcessed = "true"; const ignoreTags = ['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'SELECT', 'CODE', 'PRE', 'SVG', 'NOSCRIPT', 'BUTTON', 'A']; if (ignoreTags.includes(element.tagName) || element.isContentEditable) return; if (element.classList.contains('bbc-live-fix')) return; const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false); const nodes = []; let node; while (node = walker.nextNode()) { if (node.parentElement && !ignoreTags.includes(node.parentElement.tagName) && !node.parentElement.classList.contains('wh-highlighted')) { nodes.push(node); } } if (nodes.length > 0) { ensureNlp().then(() => processHighlightChunk(nodes)); } } function startHighlighterObserver() { const isBBCLive = window.location.href.includes('/live/'); if (isBBCLive) { document.body.setAttribute('data-bbc-live', 'true'); } const selector = 'p, li, h1, h2, h3, h4, h5, h6, td, dd, dt, blockquote, div, span, em, strong'; const observer = new IntersectionObserver((entries, obs) => { entries.forEach(e => { if (e.isIntersecting) { scanNode(e.target); obs.unobserve(e.target); } }); }, { rootMargin: '200px' }); document.querySelectorAll(selector).forEach(el => observer.observe(el)); new MutationObserver(mutations => mutations.forEach(m => m.addedNodes.forEach(n => { if (n.nodeType === 1 && n.matches && n.matches(selector)) { observer.observe(n); if(isTranslationActive) scanAndTranslateSingle(n); } }))).observe(document.body, { childList: true, subtree: true }); } // ========================================== // 4. 沉浸式翻译 (多引擎支持) // ========================================== const translationQueue = []; let isTranslating = false; let isTranslationActive = false; let isOnlineFetchAllowed = false; let msToken = null; let msTokenTime = 0; const IGNORE_SELECTORS = [ 'nav', 'header', 'footer', '[role="contentinfo"]', 'time', 'figcaption', '[class*="menu"]', '[class*="nav"]', '[class*="header"]', '.navigation', '.breadcrumb', '.button', 'button', '.lx-c-session-header', '.lx-c-sticky-share', '[data-testid*="card-metadata"]', '[data-testid*="card-footer"]', '[class*="Metadata"]', '[class*="Byline"]', '[class*="Contributor"]', '[class*="Copyright"]', '[class*="ImageMessage"]' ]; function togglePageTranslation() { if (isTranslationActive && isOnlineFetchAllowed) { document.querySelectorAll('.it-trans-block').forEach(el => el.remove()); document.querySelectorAll('[data-it-translated]').forEach(el => el.removeAttribute('data-it-translated')); isTranslationActive = false; isOnlineFetchAllowed = false; showToast('已关闭翻译', 'info'); } else { enableTranslation(true); showToast('全页双语翻译已开启', 'success'); } } function enableTranslation(allowNetwork) { isTranslationActive = true; isOnlineFetchAllowed = allowNetwork; scanAndTranslate(); } function autoCheckCacheOrHome() { if(isTranslationActive) return; if (/^https?:\/\/(www\.)?bbc\.com\/?(\?.*)?$/.test(window.location.href)) { console.log("EnLight: BBC Homepage detected, enabling FULL online translation."); enableTranslation(true); return; } const sampleEl = document.querySelector('h1, article p, p'); if (sampleEl) { const text = sampleEl.innerText.trim(); if(text.length > 10) { const hash = hashText(text); IDB.get(hash).then(val => { if(val) { console.log("EnLight: Page translation found in cache, enabling CACHE-ONLY mode."); enableTranslation(false); } }); } } } function scanAndTranslate() { if (!isTranslationActive) return; const blocks = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, div'); blocks.forEach(block => scanAndTranslateSingle(block)); processTranslationQueue(); } function scanAndTranslateSingle(block, force = false) { if (!isTranslationActive && !force) return; if (block.matches(IGNORE_SELECTORS.join(',')) || block.closest(IGNORE_SELECTORS.join(','))) return; const isBBCLive = document.body.getAttribute('data-bbc-live') === 'true'; if (isBBCLive) { if (block.tagName === 'DIV' || block.tagName === 'SPAN') return; if (block.tagName === 'LI') { if (block.querySelector('p, h1, h2, h3, h4, h5, h6, div, ul, ol')) return; } } else { if (block.tagName === 'DIV') { if (block.querySelector('div, p, li, h1, h2, h3, h4, h5, h6')) return; } if (block.tagName === 'LI' && block.querySelector('p')) return; } if (block.hasAttribute('data-it-translated') || block.closest('.it-trans-block') || block.offsetHeight === 0) return; const text = block.innerText.trim(); if (block.tagName === 'DIV' && text.length < 50) return; if (text.length < 5) return; if (/^\d+\s*(hrs?|hours?|mins?|minutes?|secs?|seconds?|days?|weeks?)\s+ago/i.test(text)) return; if (text.includes('|') && text.length < 40) return; if (/^(Getty Images|Reuters|AFP|EPA|AP|Anadolu|BBC|Copyright)/i.test(text)) return; if (text.toLowerCase().includes(' via ') && text.length < 60) return; if (/^(By|Reporting by|Written by)\s+/i.test(text)) return; if (/(correspondent|Editor|Reporter)$/i.test(text) && text.length < 40) return; if (/^(Share|More|Menu|Home|Search)$/i.test(text)) return; if ((text.match(/[a-zA-Z]/g) || []).length / text.length < 0.3) return; block.setAttribute('data-it-translated', 'true'); translationQueue.push({ element: block, text: text, force: force }); if(force) processTranslationQueue(); } async function processTranslationQueue() { if (isTranslating || translationQueue.length === 0) return; const item = translationQueue.shift(); if (!document.body.contains(item.element)) { processTranslationQueue(); return; } const textHash = hashText(item.text); const cached = await IDB.get(textHash); if (cached) { renderTranslation(item.element, cached, true); processTranslationQueue(); return; } if (!isOnlineFetchAllowed && !item.force) { item.element.removeAttribute('data-it-translated'); processTranslationQueue(); return; } isTranslating = true; const loadingDiv = document.createElement('div'); loadingDiv.className = 'it-trans-block'; loadingDiv.style.opacity = '0.6'; loadingDiv.innerText = 'Translating...'; try { const computed = window.getComputedStyle(item.element); loadingDiv.style.fontSize = computed.fontSize; loadingDiv.style.marginLeft = computed.paddingLeft || computed.marginLeft; } catch(e){} item.element.after(loadingDiv); try { // 根据配置选择翻译引擎 const transResult = await dispatchTranslation(item.text); if (transResult) { loadingDiv.remove(); await IDB.set(textHash, transResult); renderTranslation(item.element, transResult, false); } else { loadingDiv.remove(); item.element.removeAttribute('data-it-translated'); } } catch (e) { console.error("Translation Error:", e); loadingDiv.innerText = 'Error'; item.element.removeAttribute('data-it-translated'); } setTimeout(() => { isTranslating = false; processTranslationQueue(); }, 800 + Math.random() * 500); } function renderTranslation(targetElement, translatedText, isCached) { if (!document.body.contains(targetElement)) return; if (targetElement.nextElementSibling && targetElement.nextElementSibling.classList.contains('it-trans-block')) return; const div = document.createElement('div'); div.className = 'it-trans-block'; if (isCached) div.classList.add('it-from-cache'); div.innerText = translatedText; try { let styleEl = targetElement; if (targetElement.children.length > 0) { const textChild = targetElement.querySelector('span, b, strong, em, i, font'); if (textChild && textChild.innerText.length > targetElement.innerText.length * 0.5) styleEl = textChild; else if (targetElement.firstElementChild) styleEl = targetElement.firstElementChild; } const computed = window.getComputedStyle(styleEl); const originalFontSize = parseFloat(computed.fontSize); const ratio = parseInt(config.style.fontSizeRatio) || 100; const rect = targetElement.getBoundingClientRect(); if (rect.width > 0 && rect.width < window.innerWidth * 0.95) div.style.maxWidth = `${rect.width}px`; div.style.marginLeft = window.getComputedStyle(targetElement).marginLeft; if (originalFontSize) div.style.fontSize = `${originalFontSize * (ratio / 100)}px`; if (computed.fontWeight) div.style.fontWeight = computed.fontWeight; if (computed.lineHeight) div.style.lineHeight = computed.lineHeight; if (computed.textAlign && computed.textAlign !== 'start') div.style.textAlign = computed.textAlign; } catch(e) {} if (config.style.learningMode) { div.classList.add('it-trans-blur'); div.onclick = (e) => { e.stopPropagation(); div.classList.toggle('it-trans-blur'); }; } targetElement.after(div); } // --- 翻译接口分发 --- async function dispatchTranslation(text) { const c = getConfig(); if (c.translation.engine === 'microsoft') { return await fetchMicrosoftTranslation(text); } else { return await fetchGoogleTranslation(text); } } async function fetchGoogleTranslation(text) { const c = getConfig(); const apiUrl = c.translation.googleApi || DEFAULT_GOOGLE_API; const cleanText = text.replace(/\n/g, ' '); // 简单处理:如果 URL 结尾没有 =,补上 const finalUrl = apiUrl.endsWith('=') ? `${apiUrl}${encodeURIComponent(cleanText)}` : `${apiUrl}&q=${encodeURIComponent(cleanText)}`; return new Promise(resolve => { GM_xmlhttpRequest({ method: "GET", url: finalUrl, onload: (res) => { try { const data = JSON.parse(res.responseText); let result = ''; if (data && data[0]) data[0].forEach(s => { if (s[0]) result += s[0]; }); resolve(result); } catch (e) { resolve(null); } }, onerror: () => resolve(null) }); }); } async function fetchMicrosoftTranslation(text) { const c = getConfig(); // 1. 获取 Token (如果过期) if (!msToken || Date.now() - msTokenTime > 10 * 60 * 1000) { try { msToken = await getEdgeToken(); msTokenTime = Date.now(); } catch (e) { console.error("Failed to get Edge Token", e); // 降级回 Google return fetchGoogleTranslation(text); } } // 2. 发送翻译请求 (使用配置的 URL) const msApiUrl = c.translation.microsoftApi || DEFAULT_MS_API; return new Promise(resolve => { GM_xmlhttpRequest({ method: "POST", url: msApiUrl, headers: { "Authorization": "Bearer " + msToken, "Content-Type": "application/json" }, data: JSON.stringify([{ "Text": text }]), onload: (res) => { try { const data = JSON.parse(res.responseText); if (data && data[0] && data[0].translations && data[0].translations[0]) { resolve(data[0].translations[0].text); } else { resolve(null); } } catch (e) { resolve(null); } }, onerror: () => resolve(null) }); }); } function getEdgeToken() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: "https://edge.microsoft.com/translate/auth", headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0" }, onload: (res) => { if (res.status === 200) resolve(res.responseText.trim()); else reject(res.statusText); }, onerror: (err) => reject(err) }); }); } // ========================================== // 5. 音频控制工具 // ========================================== function stopAllTTS() { const dictAudio = document.getElementById('enlight-youdao-audio'); if (dictAudio) { dictAudio.pause(); dictAudio.remove(); } if ('speechSynthesis' in window) { window.speechSynthesis.cancel(); } if (popupRoot) { popupRoot.querySelectorAll('.cdp-play-btn').forEach(b => b.classList.remove('playing')); } } // ========================================== // 6. 查词弹窗 (精准触控修复版) // ========================================== let touchStartX = 0; let touchStartY = 0; let isScrollAction = false; function initPopup() { createShadowPopup(); document.addEventListener('click', handleGlobalClick); window.addEventListener('scroll', () => { if (popupEl && popupEl.classList.contains('active')) closePopup(); }, { passive: true }); } function handleGlobalClick(e) { if (isScrollAction) return; // 忽略阴影宿主、设置弹窗等 if (e.target.id === 'wh-shadow-host' || e.composedPath().some(el => el.id === 'wh-shadow-host')) return; if (document.getElementById('wh-settings-modal') && document.getElementById('wh-settings-modal').contains(e.target)) return; if (e.target.closest('.it-trans-block')) { closePopup(); return; } if (e.target.closest('.swal2-container')) return; const clickResult = getWordAtPoint(e.clientX, e.clientY); if (clickResult) { e.stopPropagation(); e.preventDefault(); ensureNlp(); showPopup(clickResult.word, clickResult.rect); } else { if (popupEl && popupEl.classList.contains('active')) { closePopup(); } } } // 精准获取单词逻辑 function getWordAtPoint(x, y) { let range, textNode; if (document.caretRangeFromPoint) { range = document.caretRangeFromPoint(x, y); } else if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(x, y); range = document.createRange(); range.setStart(pos.offsetNode, pos.offset); range.collapse(true); } if (!range || !range.startContainer || range.startContainer.nodeType !== Node.TEXT_NODE) return null; textNode = range.startContainer; if (['SCRIPT','STYLE','TEXTAREA'].includes(textNode.parentNode.tagName)) return null; if (textNode.parentNode.closest('a, button, input')) return null; const text = textNode.nodeValue; let start = range.startOffset; let end = range.startOffset; while (start > 0 && /[a-zA-Z']/.test(text[start - 1])) start--; while (end < text.length && /[a-zA-Z']/.test(text[end])) end++; const word = text.substring(start, end).trim(); if (!word || !/[a-zA-Z]/.test(word) || word.length > 45) return null; const wordRange = document.createRange(); wordRange.setStart(textNode, start); wordRange.setEnd(textNode, end); const rects = wordRange.getClientRects(); let isClickInside = false; const HIT_TOLERANCE = 5; for (let i = 0; i < rects.length; i++) { const r = rects[i]; if (x >= r.left - HIT_TOLERANCE && x <= r.right + HIT_TOLERANCE && y >= r.top - HIT_TOLERANCE && y <= r.bottom + HIT_TOLERANCE) { isClickInside = true; break; } } if (!isClickInside) return null; return { word: word, rect: wordRange.getBoundingClientRect() }; } async function showPopup(word, rect) { if (!popupEl) return; popupEl.innerHTML = ` <div class="g-header"> <div class="g-word-row"><span class="g-word">${word}</span></div> <button class="cdp-play-btn" id="cdp-play-btn-init"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg> </button> </div> <div class="g-msg">Loading...</div> `; const initBtn = popupRoot.getElementById('cdp-play-btn-init'); if(initBtn) initBtn.onclick = (e) => { e.stopPropagation(); playAudioText(word, initBtn); }; playAudioText(word, initBtn); positionPopup(rect); popupEl.classList.add('active'); const dictCacheKey = 'dict_' + word.toLowerCase(); const cachedHtml = await IDB.get(dictCacheKey); if (cachedHtml) { popupEl.innerHTML = cachedHtml; const newBtn = popupRoot.getElementById('cdp-play-btn-final'); if(newBtn) newBtn.onclick = (e) => { e.stopPropagation(); playAudioText(word, newBtn); }; positionPopup(rect); } else { GM_xmlhttpRequest({ method: "GET", url: `https://dict.youdao.com/w/eng/${encodeURIComponent(word)}/`, headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" }, onload: function(res) { if (res.status === 200) { const html = parseYoudaoHtml(res.responseText, word); popupEl.innerHTML = html; IDB.set(dictCacheKey, html); const newBtn = popupRoot.getElementById('cdp-play-btn-final'); if(newBtn) newBtn.onclick = (e) => { e.stopPropagation(); playAudioText(word, newBtn); }; positionPopup(rect); } else { popupEl.innerHTML += `<div style="color:red;margin-top:5px;">Connection failed.</div>`; } }, onerror: function() { popupEl.innerHTML += `<div style="color:red;margin-top:5px;">Network error.</div>`; } }); } } function parseYoudaoHtml(html, originalWord) { const doc = new DOMParser().parseFromString(html, "text/html"); let phone = ""; const phoneEl = doc.querySelector('.baav .phonetic'); if (phoneEl) { const raw = phoneEl.textContent.replace(/[\[\]]/g, ""); phone = `[${raw}]`; } let tagsHtml = ""; const examEl = doc.querySelector('.baav .exam_type'); if (examEl) { const exams = examEl.textContent.trim().split(/\s+/); exams.forEach(t => { if(t) tagsHtml += `<span class="g-tag">${t}</span>`; }); } let starLevel = 0; let starEls = doc.querySelectorAll('[class*="star star"]'); starEls.forEach(el => { let match = el.className.match(/star(\d)/); if (match) { let lvl = parseInt(match[1]); if (lvl > starLevel) starLevel = lvl; } }); let starDisplay = ""; if (starLevel > 0) { let active = '★'.repeat(starLevel); let inactive = '★'.repeat(5 - starLevel); starDisplay = `<span class="g-collins-stars" title="Collins ${starLevel} Stars">${active}<span class="inactive">${inactive}</span></span>`; } let defs = []; const lis = doc.querySelectorAll('#phrsListTab .trans-container ul li'); lis.forEach(li => defs.push(li.textContent.trim())); if (defs.length === 0) { const web = doc.querySelectorAll('#tWebTrans .wt-container .title span'); if (web.length > 0) web.forEach(s => defs.push(s.textContent.trim())); } if (defs.length === 0) { const wordGroups = doc.querySelectorAll('.wordGroup .contentTitle'); wordGroups.forEach(el => defs.push(el.textContent.trim())); } const defsHtml = defs.length > 0 ? `<ul class="g-list">${defs.slice(0, 4).map(d => `<li><span class="g-bullet">•</span>${d}</li>`).join('')}</ul>` : `<div class="g-msg">No definitions found.</div>`; return ` <div class="g-header"> <div class="g-word-row"><span class="g-word">${originalWord}</span></div> <button class="cdp-play-btn" id="cdp-play-btn-final"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg> </button> </div> ${ (phone || tagsHtml || starDisplay) ? `<div class="g-meta">${phone ? `<span class="g-phonetic">${phone}</span>` : ''}${starDisplay}${tagsHtml}</div>` : '' } ${defsHtml} `; } function positionPopup(rect) { if (!popupEl) return; const popupWidth = 290; const gap = 12; const winW = window.innerWidth; const winH = window.innerHeight; let left = rect.left + (rect.width / 2) - (popupWidth / 2); if (left < 10) left = 10; else if (left + popupWidth > winW - 10) left = winW - popupWidth - 10; let top = rect.bottom + gap; const popupH = popupEl.offsetHeight || 150; if (top + popupH > winH - 10 && rect.top > popupH + 20) { top = rect.top - popupH - gap; } else { if (top + popupH > winH) top = winH - popupH - 10; } popupEl.style.top = `${top}px`; popupEl.style.left = `${left}px`; } function closePopup() { if (popupEl && popupEl.classList.contains('active')) { popupEl.classList.remove('active'); stopAllTTS(); } } // ========================================== // 7. SPA 兼容性 & 其他工具 // ========================================== const _historyWrap = function(type) { const orig = history[type]; return function() { const rv = orig.apply(this, arguments); const e = new Event(type); e.arguments = arguments; window.dispatchEvent(e); return rv; }; }; history.pushState = _historyWrap('pushState'); history.replaceState = _historyWrap('replaceState'); function reinit() { if (!shouldRun()) return; setTimeout(() => { if (isTranslationActive) scanAndTranslate(); startHighlighterObserver(); autoCheckCacheOrHome(); }, 1000); } window.addEventListener('popstate', reinit); window.addEventListener('pushState', reinit); window.addEventListener('replaceState', reinit); function playAudioText(text, btn) { if(!text) return; stopAllTTS(); // 停止其他 if(btn) btn.classList.add('playing'); const ttsUrl = `https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(text)}&type=2`; const audio = document.createElement('audio'); audio.id = 'enlight-youdao-audio'; audio.style.display = 'none'; audio.src = ttsUrl; audio.onended = () => { if(btn) btn.classList.remove('playing'); }; audio.onerror = (e) => { console.warn('Youdao Audio failed, switching to local.'); if(btn) btn.classList.remove('playing'); if ('speechSynthesis' in window) { const u = new SpeechSynthesisUtterance(text); u.lang = 'en-US'; window.speechSynthesis.speak(u); } }; document.body.appendChild(audio); audio.play().catch(error => { if(btn) btn.classList.remove('playing'); if ('speechSynthesis' in window) { const u = new SpeechSynthesisUtterance(text); u.lang = 'en-US'; window.speechSynthesis.speak(u); } }); } // ========================================== // 8. 设置界面 (UI 更新版:点选式翻译引擎) // ========================================== function openSettings() { if(document.getElementById('wh-settings-modal')) { document.getElementById('wh-settings-modal').style.display='flex'; return; } const c = getConfig(); const m = document.createElement('div'); m.id='wh-settings-modal'; m.style.cssText=`display:flex;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:2147483600;align-items:center;justify-content:center;font-family:sans-serif;`; let urlInputs = ''; ['red','yellow','blue','green','purple','exclude'].forEach(k => { const isEnabled = c.listState[k]; const color = COLORS[k].color; const dotStyle = `display:inline-block;width:12px;height:12px;border-radius:50%;margin-right:8px;cursor:pointer;border:2px solid ${color};background-color:${isEnabled?color:'transparent'};vertical-align:middle;transition:background 0.2s;`; urlInputs += `<div style="margin-bottom:12px"> <div style="margin-bottom:4px;display:flex;align-items:center;"> <span id="wh-dot-${k}" style="${dotStyle}" title="点击开启/关闭"></span> <label style="font-size:12px;font-weight:bold;color:${k==='exclude'?'#666':color}">${COLORS[k].label}</label> </div> <input type="text" id="wh-input-${k}" value="${c.urls[k]||''}" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;"> </div>`; }); // 翻译引擎部分 HTML 生成 let activeEngine = c.translation.engine; // 'google' or 'microsoft' const engines = [ { id: 'google', label: 'Google Translate', color: '#4285F4', inputValue: c.translation.googleApi || DEFAULT_GOOGLE_API, desc: 'API 地址 (支持反代)' }, { id: 'microsoft', label: 'Microsoft Translate (Edge)', color: '#00A4EF', inputValue: c.translation.microsoftApi || DEFAULT_MS_API, desc: 'API 地址 (通常无需修改)' } ]; let engineInputs = ''; engines.forEach(eng => { const isActive = activeEngine === eng.id; const dotStyle = `display:inline-block;width:12px;height:12px;border-radius:50%;margin-right:8px;cursor:pointer;border:2px solid ${eng.color};background-color:${isActive ? eng.color : 'transparent'};vertical-align:middle;transition:background 0.2s;`; engineInputs += `<div style="margin-bottom:12px"> <div style="margin-bottom:4px;display:flex;align-items:center;" class="wh-engine-selector" data-engine="${eng.id}"> <span id="wh-dot-engine-${eng.id}" style="${dotStyle}" title="点击选择"></span> <label style="font-size:12px;font-weight:bold;color:#333;cursor:pointer;">${eng.label}</label> </div> <input type="text" id="wh-input-engine-${eng.id}" value="${eng.inputValue}" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;font-size:12px;color:#555;" placeholder="${eng.desc}"> </div>`; }); const blacklistStr = c.behavior.blacklist.join('\n'); const whitelistStr = c.behavior.whitelist.join('\n'); m.innerHTML = ` <div style="background:white;width:90%;max-width:400px;max-height:80vh;border-radius:10px;padding:20px;display:flex;flex-direction:column;position:relative;box-sizing:border-box;"> <h3 style="margin-top:0;border-bottom:1px solid #eee;padding-bottom:10px;flex-shrink:0;">EnLight 设置</h3> <div style="overflow-y:auto;flex:1;padding-right:5px;margin-bottom:10px;overscroll-behavior:contain;"> <div style="font-size:14px;font-weight:bold;color:#007AFF;border-bottom:2px solid #f0f0f0;padding-bottom:5px;margin:15px 0 10px 0;">🌐 翻译服务</div> ${engineInputs} <div style="font-size:14px;font-weight:bold;color:#007AFF;border-bottom:2px solid #f0f0f0;padding-bottom:5px;margin:15px 0 10px 0;">🛡️ 运行模式</div> <div style="margin-bottom:15px;"> <select id="wh-behavior-mode" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;"> <option value="blacklist" ${c.behavior.mode==='blacklist'?'selected':''}>⚫ 黑名单模式</option> <option value="whitelist" ${c.behavior.mode==='whitelist'?'selected':''}>⚪ 白名单模式</option> </select> </div> <div style="margin-bottom:15px;"> <label style="display:block;font-size:13px;font-weight:bold;margin-bottom:5px;color:#444;">黑名单 (一行一个)</label> <textarea id="wh-behavior-blacklist" rows="3" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;resize:vertical;box-sizing:border-box;" placeholder="*.example.com/*">${blacklistStr}</textarea> </div> <div style="margin-bottom:15px;"> <label style="display:block;font-size:13px;font-weight:bold;margin-bottom:5px;color:#444;">白名单 (一行一个)</label> <textarea id="wh-behavior-whitelist" rows="3" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;resize:vertical;box-sizing:border-box;" placeholder="https://www.bbc.com/*">${whitelistStr}</textarea> </div> <div style="font-size:14px;font-weight:bold;color:#007AFF;border-bottom:2px solid #f0f0f0;padding-bottom:5px;margin:15px 0 10px 0;">🎨 外观</div> <div style="margin-bottom:10px;display:flex;align-items:center;gap:10px;font-size:13px;"> <input type="checkbox" id="wh-style-learning" ${c.style.learningMode ? 'checked' : ''}> <label for="wh-style-learning">🎓 学习模式 (译文默认模糊)</label> </div> <div style="margin-bottom:15px;"> <label style="display:block;font-size:13px;font-weight:bold;margin-bottom:5px;color:#444;">字体大小比例 (%)</label> <input type="number" id="wh-style-fontSizeRatio" value="${c.style.fontSizeRatio}" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;"> </div> <div style="margin-bottom:15px;"> <select id="wh-style-theme" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;"> <option value="card" ${c.style.theme==='card'?'selected':''}>卡片 (默认)</option> <option value="minimal" ${c.style.theme==='minimal'?'selected':''}>极简</option> <option value="dashed" ${c.style.theme==='dashed'?'selected':''}>虚线笔记</option> <option value="underline" ${c.style.theme==='underline'?'selected':''}>下划线</option> <option value="dark" ${c.style.theme==='dark'?'selected':''}>暗黑高亮</option> </select> </div> <div style="font-size:14px;font-weight:bold;color:#007AFF;border-bottom:2px solid #f0f0f0;padding-bottom:5px;margin:15px 0 10px 0;">📚 词库订阅</div> ${urlInputs} <div style="font-size:14px;font-weight:bold;color:#007AFF;border-bottom:2px solid #f0f0f0;padding-bottom:5px;margin:15px 0 10px 0;">⚙️ 数据管理</div> <div style="display:flex;gap:10px;"> <button id="wh-btn-export" style="flex:1;padding:8px;background:#eee;border:none;border-radius:4px;cursor:pointer;">📤 导出配置</button> <button id="wh-btn-import" style="flex:1;padding:8px;background:#eee;border:none;border-radius:4px;cursor:pointer;">📥 导入配置</button> <input type="file" id="wh-file-input" accept=".json" style="display:none"> </div> </div> <div style="flex-shrink:0;padding-top:15px;border-top:1px solid #eee;display:flex;gap:10px;background:white;padding-bottom: env(safe-area-inset-bottom);"> <button id="wh-btn-save" style="flex:2;padding:10px;background:#007AFF;color:white;border:none;border-radius:4px;cursor:pointer;font-weight:bold;">保存</button> <button id="wh-btn-close" style="flex:1;padding:10px;background:#ccc;color:white;border:none;border-radius:4px;cursor:pointer;">关闭</button> </div> </div>`; document.body.appendChild(m); document.getElementById('wh-btn-close').onclick=()=>m.style.display='none'; // 绑定翻译引擎点选逻辑 (互斥选择) const engineSelectors = m.querySelectorAll('.wh-engine-selector'); engineSelectors.forEach(sel => { sel.onclick = () => { const selectedId = sel.getAttribute('data-engine'); activeEngine = selectedId; // 更新当前选中的引擎变量 // 重绘 UI engines.forEach(eng => { const dot = document.getElementById(`wh-dot-engine-${eng.id}`); if (eng.id === selectedId) { dot.style.backgroundColor = eng.color; } else { dot.style.backgroundColor = 'transparent'; } }); }; }); // 绑定词库订阅点选逻辑 const tempListState = {...c.listState}; ['red','yellow','blue','green','purple','exclude'].forEach(k => { const dot = document.getElementById(`wh-dot-${k}`); dot.onclick = () => { tempListState[k] = !tempListState[k]; const color = COLORS[k].color; dot.style.backgroundColor = tempListState[k] ? color : 'transparent'; }; }); document.getElementById('wh-btn-save').onclick=()=>{ const n = getConfig(); ['red','yellow','blue','green','purple','exclude'].forEach(k=>n.urls[k]=document.getElementById(`wh-input-${k}`).value.trim()); n.style.fontSizeRatio = document.getElementById('wh-style-fontSizeRatio').value.trim() || '100'; n.style.theme = document.getElementById('wh-style-theme').value; n.style.learningMode = document.getElementById('wh-style-learning').checked; n.behavior.mode = document.getElementById('wh-behavior-mode').value; n.behavior.blacklist = document.getElementById('wh-behavior-blacklist').value.split('\n').filter(s=>s.trim()); n.behavior.whitelist = document.getElementById('wh-behavior-whitelist').value.split('\n').filter(s=>s.trim()); // 保存翻译设置 n.translation.engine = activeEngine; // 使用当前点选的 activeEngine n.translation.googleApi = document.getElementById('wh-input-engine-google').value.trim() || DEFAULT_GOOGLE_API; n.translation.microsoftApi = document.getElementById('wh-input-engine-microsoft').value.trim() || DEFAULT_MS_API; n.listState = tempListState; GM_setValue('highlightConfig',n); m.style.display='none'; Swal.fire({ title: '设置已保存', text: '页面即将刷新以应用更改', icon: 'success', timer: 1500, showConfirmButton: false }).then(() => location.reload()); }; // 导出功能 document.getElementById('wh-btn-export').onclick = () => { try { const curConf = getConfig(); // 同步当前UI的值到导出对象 curConf.translation.engine = activeEngine; curConf.translation.googleApi = document.getElementById('wh-input-engine-google').value.trim(); curConf.translation.microsoftApi = document.getElementById('wh-input-engine-microsoft').value.trim(); ['red','yellow','blue','green','purple','exclude'].forEach(k => { curConf.urls[k] = document.getElementById(`wh-input-${k}`).value.trim(); }); const jsonStr = JSON.stringify(curConf, null, 2); const fileName = `enlight_config_${new Date().toISOString().slice(0,10)}.json`; if (typeof GM_download === 'function') { const blob = new Blob([jsonStr], {type: "application/json"}); const url = URL.createObjectURL(blob); GM_download({ url: url, name: fileName, saveAs: true, onload: () => { showToast('配置已导出', 'success'); setTimeout(() => URL.revokeObjectURL(url), 1000); }, onerror: (err) => { if(typeof GM_setClipboard === 'function') { GM_setClipboard(jsonStr); Swal.fire('下载被拦截', '配置已复制到剪贴板!', 'warning'); } } }); } else { const blob = new Blob([jsonStr], {type: "application/json"}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100); showToast('配置已导出', 'success'); } } catch (e) { console.error(e); showToast('导出错误: ' + e.message, 'error'); } }; const fileInput = document.getElementById('wh-file-input'); document.getElementById('wh-btn-import').onclick = () => fileInput.click(); fileInput.onchange = (e) => { const file = e.target.files[0]; if(!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const parsed = JSON.parse(event.target.result); if(parsed.urls && parsed.style) { GM_setValue('highlightConfig', parsed); Swal.fire({ title: '导入成功', text: '页面即将刷新', icon: 'success', timer: 1000, showConfirmButton: false }).then(() => location.reload()); } else showToast('JSON 格式错误', 'error'); } catch(ex) { showToast('JSON 解析失败', 'error'); } }; reader.readAsText(file); }; } // 优化的手势系统 function initGesture() { let touchStartData = null; let singleTouchStart = null; document.addEventListener('touchstart', (e) => { if (e.touches.length === 2) { touchStartData = { time: Date.now(), x1: e.touches[0].clientX, y1: e.touches[0].clientY, x2: e.touches[1].clientX, y2: e.touches[1].clientY }; } else { touchStartData = null; } if (e.touches.length === 1) { isScrollAction = false; touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; singleTouchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY, target: e.target, time: Date.now() }; } }, { passive: true }); document.addEventListener('touchmove', (e) => { if (touchStartData) { const t1 = e.touches[0], t2 = e.touches[1]; if (t1 && (Math.abs(t1.clientX - touchStartData.x1) > 20 || Math.abs(t1.clientY - touchStartData.y1) > 20)) touchStartData = null; if (t2 && (Math.abs(t2.clientX - touchStartData.x2) > 20 || Math.abs(t2.clientY - touchStartData.y2) > 20)) touchStartData = null; } if (e.touches.length > 0) { const dx = Math.abs(e.touches[0].clientX - touchStartX); const dy = Math.abs(e.touches[0].clientY - touchStartY); if (dx > 10 || dy > 10) isScrollAction = true; } }, { passive: true }); document.addEventListener('touchend', (e) => { if (touchStartData && Date.now() - touchStartData.time < 500) { togglePageTranslation(); touchStartData = null; return; } if (singleTouchStart && e.changedTouches.length === 1) { const touchEnd = e.changedTouches[0]; const dx = touchEnd.clientX - singleTouchStart.x; const dy = touchEnd.clientY - singleTouchStart.y; const dt = Date.now() - singleTouchStart.time; if (dx < -80 && Math.abs(dy) < 40 && dt < 500) { const targetBlock = singleTouchStart.target.closest('p, h1, h2, h3, h4, h5, h6, li, blockquote'); if (targetBlock) { const nextEl = targetBlock.nextElementSibling; if (nextEl && nextEl.classList.contains('it-trans-block')) { nextEl.remove(); targetBlock.removeAttribute('data-it-translated'); showToast('已隐藏该段翻译', 'info'); } else { scanAndTranslateSingle(targetBlock, true); showToast('正在翻译该段落...', 'info'); } } } singleTouchStart = null; } }); } GM_registerMenuCommand("🎓 开启/关闭 学习模式", () => { const c = getConfig(); c.style.learningMode = !c.style.learningMode; GM_setValue('highlightConfig', c); showToast(`学习模式已${c.style.learningMode ? '开启' : '关闭'} (即将刷新)`, 'success'); setTimeout(() => location.reload(), 1000); }); GM_registerMenuCommand("⚙️ EnLight 设置", openSettings); GM_registerMenuCommand("🗑️ 清空翻译/词典缓存", () => { Swal.fire({ title: '确定清空缓存?', text: "这将删除所有已保存的翻译和查词记录。", icon: 'warning', showCancelButton: true, confirmButtonColor: '#3085d6', cancelButtonColor: '#d33', confirmButtonText: '是的,清空!', cancelButtonText: '取消' }).then((result) => { if (result.isConfirmed) { IDB.clear().then(() => { Swal.fire('已清空!', '缓存数据已成功删除。', 'success'); }); } }); }); initPopup(); initGesture(); loadWordLists(); })();