英语阅读三合一:1. 双指触屏快速开启翻译 2. 智能单词高亮 3. 点击查词 (集成有道词典/考试标签/音标修复/本地缓存) 4. 沉浸式双语翻译 (BBC Live完美适配+彻底去重降噪)。
当前为
// ==UserScript== // @name EnLight // @namespace http://tampermonkey.net/ // @version 0.27 // @description 英语阅读三合一:1. 双指触屏快速开启翻译 2. 智能单词高亮 3. 点击查词 (集成有道词典/考试标签/音标修复/本地缓存) 4. 沉浸式双语翻译 (BBC Live完美适配+彻底去重降噪)。 // @author HAL // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant unsafeWindow // @connect translate.googleapis.com // @connect dict.youdao.com // @run-at document-idle // @require https://unpkg.com/[email protected]/builds/compromise.min.js // ==/UserScript== (function() { 'use strict'; // ========================================== // 0. 配置管理与黑白名单检测 // ========================================== 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: [] } }; 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; 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; /* Allow flexible width */ 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; } } /* BBC Live 专用补丁 */ 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); // --- 查词弹窗 CSS --- const POPUP_CSS = ` :host { all: initial; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; z-index: 2147483647; } #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: #f7bf34; color: #fff; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: bold; display: inline-block; line-height: 1.4; } .g-tag.collins { background: #ff9c38; } .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: 2147483647;'; 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(); } 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. 沉浸式翻译 (BBC Ultimate Clean & De-dup) // ========================================== const translationQueue = []; let isTranslating = false; let isTranslationActive = false; // 智能黑名单:这些元素内部绝对不翻译 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) { document.querySelectorAll('.it-trans-block').forEach(el => el.remove()); document.querySelectorAll('[data-it-translated]').forEach(el => el.removeAttribute('data-it-translated')); isTranslationActive = false; showToast('已关闭翻译'); } else { isTranslationActive = true; scanAndTranslate(); showToast('双语翻译已开启'); } } 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) { if (!isTranslationActive) return; // 1. 全局黑名单检测 if (block.matches(IGNORE_SELECTORS.join(',')) || block.closest(IGNORE_SELECTORS.join(','))) return; // 2. BBC Live 专项净化 (保持原样) const isBBCLive = document.body.getAttribute('data-bbc-live') === 'true'; if (isBBCLive) { if (block.tagName === 'DIV' || block.tagName === 'SPAN') return; if (block.tagName === 'LI') { // 如果LI包含块级元素,说明是容器,LI本身不翻译 if (block.querySelector('p, h1, h2, h3, h4, h5, h6, div, ul, ol')) return; } } else { // 3. 通用去重逻辑 (关键修复):防止父子DIV同时翻译 // 如果一个DIV内部包含任何子DIV,我们假设它是容器,不翻译它,而是等待递归扫描子DIV // 我们同时保留对 p, li, h* 的检测 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(); // 4. 暴力降噪 (文本特征过滤) // A. 短 DIV 熔断机制:如果标签是 DIV 且文本极短,大概率是元数据,跳过 if (block.tagName === 'DIV' && text.length < 50) return; // B. 兜底长度检查 if (text.length < 5) return; // C. 时间格式过滤 if (/^\d+\s*(hrs?|hours?|mins?|minutes?|secs?|seconds?|days?|weeks?)\s+ago/i.test(text)) return; // D. 导航/面包屑过滤 if (text.includes('|') && text.length < 40) return; // E. 版权/图片来源过滤 if (/^(Getty Images|Reuters|AFP|EPA|AP|Anadolu|BBC|Copyright)/i.test(text)) return; // 增加包含 "via" 的检测 (如 "Anadolu via Getty Images") if (text.toLowerCase().includes(' via ') && text.length < 60) return; // F. 记者/UI 词过滤 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 }); } 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; } 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 fetchGoogleTranslation(item.text); if (transResult) { loadingDiv.remove(); await IDB.set(textHash, transResult); renderTranslation(item.element, transResult, false); } else { loadingDiv.remove(); } } catch (e) { loadingDiv.innerText = 'Error'; } 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 { // ---------------------------------------------------- // 修复 v0.27:深度样式同步 (Deep Style Sync) // 解决:图片说明文字(Caption)的译文块太宽、字体太大的问题 // ---------------------------------------------------- let styleEl = targetElement; // 1. 尝试找到真正包含文本的子节点,以获取准确的字号 // 很多时候 caption 是 div > span 结构,外层 div 字体大,内层 span 字体小 if (targetElement.children.length > 0) { // 简单的启发式:如果有 span/b/i 等内联元素,优先取其样式 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; // 2. 布局同步:限制最大宽度,防止译文块撑满全屏导致突兀 // 获取原文的渲染宽度,如果它不是撑满全屏的(比如图片Caption),译文也应该受限 const rect = targetElement.getBoundingClientRect(); if (rect.width > 0 && rect.width < window.innerWidth * 0.95) { div.style.maxWidth = `${rect.width}px`; } // 3. 对齐同步:复制左侧边距和内边距,确保与原文左对齐一致 // 注意:我们只复制左侧,因为翻译块是块级元素 div.style.marginLeft = window.getComputedStyle(targetElement).marginLeft; div.style.paddingLeft = window.getComputedStyle(targetElement).paddingLeft; // 4. 字体样式应用 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 fetchGoogleTranslation(text) { const cleanText = text.replace(/\n/g, ' '); const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=zh-CN&dt=t&q=${encodeURIComponent(cleanText)}`; return new Promise(resolve => { GM_xmlhttpRequest({ method: "GET", url: url, 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) }); }); } // ========================================== // 5. 查词弹窗 // ========================================== 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 }); // --- 优化2: 移动端误触判定 (Tap vs Scroll) --- document.addEventListener('touchstart', (e) => { if (e.touches.length > 0) { touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; isScrollAction = false; // 重置状态 } }, { passive: true }); document.addEventListener('touchmove', (e) => { if (e.touches.length > 0) { const dx = Math.abs(e.touches[0].clientX - touchStartX); const dy = Math.abs(e.touches[0].clientY - touchStartY); // 优化: 将阈值从 10 提升到 20,避免手指轻微抖动被误判为滚动,导致无法点击空白处关闭 if (dx > 20 || dy > 20) { isScrollAction = true; } } }, { passive: true }); } function handleGlobalClick(e) { // 如果是滚动操作引起的点击(触摸结束时触发的click),直接忽略 if (isScrollAction) return; // Shadow DOM 内部点击忽略 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; // 优化:如果点击的是翻译块(Translation Block),直接关闭查词弹窗并返回。 // 翻译块不参与查词,应视为“空白处”处理。 if (e.target.closest('.it-trans-block')) { closePopup(); return; } const clickResult = getWordAtPoint(e.clientX, e.clientY); if (clickResult) { e.stopPropagation(); e.preventDefault(); ensureNlp(); showPopup(clickResult.word, clickResult.rect); } else { // 点击空白处或非单词区域,关闭弹窗 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','INPUT','TEXTAREA'].includes(textNode.parentNode.tagName)) return null; const text = textNode.nodeValue; let start = range.startOffset, 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++; let word = text.substring(start, end).trim(); if (!word || !/[a-zA-Z]/.test(word) || word.length > 45) return null; const rect = document.createRange(); rect.setStart(textNode, start); rect.setEnd(textNode, end); const r = rect.getBoundingClientRect(); return { word: word, rect: r }; } async function showPopup(word, rect) { if (!popupEl) return; // 初始显示 Loading 状态 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'); // --- 优化1: 查词缓存检测 --- 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/${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 rankEl = doc.querySelector('.via.rank'); if (rankEl) tagsHtml += `<span class="g-tag collins">${rankEl.textContent.trim()}</span>`; 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 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())); } 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) ? `<div class="g-meta">${phone ? `<span class="g-phonetic">${phone}</span>` : ''}${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'); const existingAudio = document.getElementById('enlight-youdao-audio'); if (existingAudio) { existingAudio.pause(); existingAudio.remove(); } } } // ========================================== // 6. 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(); }, 1000); } window.addEventListener('popstate', reinit); window.addEventListener('pushState', reinit); window.addEventListener('replaceState', reinit); function playAudioText(text, btn) { if(!text) return; const existingAudio = document.getElementById('enlight-youdao-audio'); if (existingAudio) { existingAudio.pause(); existingAudio.remove(); } if (popupRoot) { popupRoot.querySelectorAll('.cdp-play-btn').forEach(b => b.classList.remove('playing')); } 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) => { if(btn) btn.classList.remove('playing'); }; document.body.appendChild(audio); audio.play().catch(error => { if(btn) btn.classList.remove('playing'); }); } function showToast(msg, dur=2000) { let toast = document.getElementById('wh-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'wh-toast'; toast.style.cssText = "position:fixed;top:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.8);color:white;padding:8px 16px;border-radius:20px;font-size:14px;z-index:1000001;pointer-events:none;opacity:0;transition:opacity 0.3s;"; document.body.appendChild(toast); } toast.innerText = msg; toast.style.opacity = '1'; setTimeout(() => toast.style.opacity = '0', dur); } // ========================================== // 7. 设置界面 // ========================================== 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:1000000;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>`; }); const blacklistStr = c.behavior.blacklist.join('\n'); const whitelistStr = c.behavior.whitelist.join('\n'); // 修复:为 input/textarea/select 添加 box-sizing:border-box 以统一宽度 m.innerHTML = ` <div style="background:white;width:90%;max-width:400px;max-height:90vh;overflow-y:auto;border-radius:8px;padding:20px;display:flex;flex-direction:column;"> <h3 style="margin-top:0;border-bottom:1px solid #eee;padding-bottom:10px;">EnLight 设置</h3> <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:15px;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 style="margin-top:20px;display:flex;gap:10px;padding-top:15px;border-top:1px solid #eee;"> <button id="wh-btn-save" style="flex:2;padding:10px;background:#007AFF;color:white;border:none;border-radius:4px;cursor:pointer;">保存</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 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.listState = tempListState; GM_setValue('highlightConfig',n); m.style.display='none'; location.reload(); }; document.getElementById('wh-btn-export').onclick = () => { const curConf = getConfig(); curConf.behavior.mode = document.getElementById('wh-behavior-mode').value; curConf.behavior.blacklist = document.getElementById('wh-behavior-blacklist').value.split('\n').filter(s=>s.trim()); curConf.behavior.whitelist = document.getElementById('wh-behavior-whitelist').value.split('\n').filter(s=>s.trim()); curConf.listState = tempListState; const blob = new Blob([JSON.stringify(curConf, null, 2)], {type: "application/json"}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `enlight_config_${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast('配置已导出'); }; 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); showToast('配置导入成功,即将刷新'); setTimeout(() => location.reload(), 1000); } else showToast('JSON 格式错误'); } catch(ex) { showToast('JSON 解析失败'); } }; reader.readAsText(file); }; } function initGesture() { let touchStartData = 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; } }, { passive: true }); document.addEventListener('touchend', (e) => { if (!touchStartData) return; if (Date.now() - touchStartData.time > 500) { touchStartData = null; return; } togglePageTranslation(); touchStartData = null; }); document.addEventListener('touchmove', (e) => { if (!touchStartData) return; 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; }, { passive: true }); } // --- 优化3: 新增菜单项 --- GM_registerMenuCommand("🎓 开启/关闭 学习模式", () => { const c = getConfig(); c.style.learningMode = !c.style.learningMode; GM_setValue('highlightConfig', c); showToast(`学习模式已${c.style.learningMode ? '开启' : '关闭'} (即将刷新)`); setTimeout(() => location.reload(), 1000); }); GM_registerMenuCommand("⚙️ EnLight 设置", openSettings); GM_registerMenuCommand("🗑️ 清空翻译/词典缓存", () => IDB.clear().then(() => showToast('缓存已清空'))); initPopup(); initGesture(); loadWordLists(); })();