// ==UserScript== // @name 划词翻译 // @namespace http://tampermonkey.net/ // @version 1.1 // @description 完美适配移动端:只能在单词上方,绝不遮挡源文本。智能边缘偏移,箭头精准跟随,iOS风格毛玻璃UI。 // @author Hal // @license MIT // @match *://*/* // @connect dict.youdao.com // @grant GM_xmlhttpRequest // @grant GM_addStyle // @run-at document-end // @downloadURL https://update.greasyfork.icu/scripts/558911/%E5%88%92%E8%AF%8D%E7%BF%BB%E8%AF%91.user.js // @updateURL https://update.greasyfork.icu/scripts/558911/%E5%88%92%E8%AF%8D%E7%BF%BB%E8%AF%91.meta.js // ==/UserScript== (function () { 'use strict'; // ================= 配置区域 ================= const CONFIG = { autoAudio: true, // 点击后自动发音 zIndex: 2147483647, // 最高层级 arrowSize: 8, // 箭头大小 gap: 10, // 弹窗距离单词的间距 }; // ================= 内存缓存 ================= const wordCache = {}; // ================= 样式注入 (CSS) ================= const css = ` :root { --gt-bg: rgba(255, 255, 255, 0.96); --gt-backdrop: blur(12px); --gt-shadow: 0 8px 30px rgba(0,0,0,0.12), 0 0 1px rgba(0,0,0,0.1); --gt-radius: 12px; --gt-text: #1d1d1f; --gt-sub: #86868b; --gt-accent: #007aff; --gt-star: #ffcc00; } /* 容器:只负责定位,不负责显隐,显隐由 opacity 控制以避免重排闪烁 */ #gt-wrapper { position: absolute; z-index: ${CONFIG.zIndex}; width: max-content; max-width: 88vw; /* 移动端限制宽度 */ min-width: 160px; opacity: 0; pointer-events: none; /* 隐藏时不阻挡交互 */ transition: opacity 0.2s cubic-bezier(0.25, 0.8, 0.25, 1); will-change: transform, opacity; filter: drop-shadow(0 4px 12px rgba(0,0,0,0.15)); } #gt-wrapper.gt-visible { opacity: 1; pointer-events: auto; } /* 内容卡片 */ #gt-container { background: var(--gt-bg); backdrop-filter: var(--gt-backdrop); -webkit-backdrop-filter: var(--gt-backdrop); border-radius: var(--gt-radius); padding: 12px 14px; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Roboto, Helvetica, sans-serif; font-size: 14px; line-height: 1.5; color: var(--gt-text); border: 1px solid rgba(0,0,0,0.05); position: relative; } /* 动态箭头 - 绝对定位相对于 wrapper */ #gt-arrow { position: absolute; width: 0; height: 0; border-left: ${CONFIG.arrowSize}px solid transparent; border-right: ${CONFIG.arrowSize}px solid transparent; border-top: ${CONFIG.arrowSize}px solid var(--gt-bg); /* 默认向下指 */ bottom: -${CONFIG.arrowSize}px; /* 位于 wrapper 底部外侧 */ left: 50%; transform: translateX(-50%); z-index: 2; } /* 翻转模式:当上方完全没空间时,不得不显示在下方 (极罕见) */ #gt-wrapper.gt-flipped #gt-arrow { top: -${CONFIG.arrowSize}px; bottom: auto; border-top: none; border-bottom: ${CONFIG.arrowSize}px solid var(--gt-bg); } /* 第一行:单词 + 星级 */ .gt-row-1 { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; } .gt-word { font-weight: 700; font-size: 17px; color: #000; margin-right: 8px; } .gt-stars { font-size: 12px; color: #e5e5e5; letter-spacing: -1px; white-space: nowrap; } .gt-star-on { color: var(--gt-star); } /* 第二行:音标 + 喇叭 */ .gt-row-2 { display: flex; align-items: center; margin-bottom: 8px; color: var(--gt-sub); font-size: 13px; font-family: "Lucida Sans Unicode", monospace; } .gt-speaker { margin-left: 8px; color: var(--gt-accent); cursor: pointer; display: flex; align-items: center; padding: 4px; border-radius: 50%; background: rgba(0,122,255,0.05); transition: background 0.1s; } .gt-speaker:active { background: rgba(0,122,255,0.2); } /* 第三行:释义 */ .gt-row-3 ul { margin: 0; padding: 0; list-style: none; border-top: 1px solid rgba(0,0,0,0.05); padding-top: 8px; } .gt-row-3 li { margin-bottom: 5px; display: flex; align-items: baseline; } .gt-pos { color: #888; font-size: 11px; background: #f2f2f7; padding: 1px 6px; border-radius: 4px; margin-right: 6px; flex-shrink: 0; font-weight: 600; } .gt-def { color: #333; line-height: 1.4; text-align: left; word-break: break-word; } .gt-loading { color: #999; font-size: 13px; font-style: italic; display: flex; align-items: center; gap: 6px; } /* PC 端限制最大宽度 */ @media screen and (min-width: 481px) { #gt-wrapper { max-width: 320px; } } `; GM_addStyle(css); // ================= 全局变量 ================= let wrapper = null; let arrow = null; let contentBox = null; let currentAudio = null; let activeRangeRect = null; // 保存当前选中单词的几何位置 // ================= 初始化 UI ================= function initUI() { if (document.getElementById('gt-wrapper')) return; wrapper = document.createElement('div'); wrapper.id = 'gt-wrapper'; wrapper.innerHTML = `
`; document.body.appendChild(wrapper); contentBox = document.getElementById('gt-container'); arrow = document.getElementById('gt-arrow'); // 阻止点击内部关闭 wrapper.addEventListener('click', (e) => e.stopPropagation()); } // ================= 核心逻辑:获取单词范围 ================= // 返回 Range 对象,包含精确的几何位置 function getRangeAtPoint(x, y) { if (!document.caretRangeFromPoint) return null; const range = document.caretRangeFromPoint(x, y); if (!range || !range.startContainer || range.startContainer.nodeType !== Node.TEXT_NODE) return null; const textNode = range.startContainer; const offset = range.startOffset; const text = textNode.nodeValue; // 核心正则:匹配单词边界 const beforeMatch = text.substring(0, offset).match(/[a-zA-Z-']+$/); const afterMatch = text.substring(offset).match(/^[a-zA-Z-']+/); if (beforeMatch && afterMatch) { // 重新设置 Range 范围以包裹整个单词 range.setStart(textNode, offset - beforeMatch[0].length); range.setEnd(textNode, offset + afterMatch[0].length); return { range: range, word: beforeMatch[0] + afterMatch[0] }; } else if (beforeMatch) { range.setStart(textNode, offset - beforeMatch[0].length); range.setEnd(textNode, offset); return { range: range, word: beforeMatch[0] }; } else if (afterMatch) { range.setStart(textNode, offset); range.setEnd(textNode, offset + afterMatch[0].length); return { range: range, word: afterMatch[0] }; } return null; } // ================= 音频播放 ================= function playAudio(word) { if (currentAudio) { currentAudio.pause(); currentAudio = null; } const url = `https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(word)}&type=2`; currentAudio = new Audio(url); currentAudio.play().catch(() => {}); } // ================= 渲染星星 ================= function renderStars(count) { let html = ''; for (let i = 1; i <= 5; i++) { html += `★`; } return html; } // ================= 显示与数据请求 ================= function showPopup(wordObj, x, y) { if (!wrapper) initUI(); const word = wordObj.word.trim(); // 获取单词的精确几何矩形 (Bounding Client Rect) // 这是不遮挡的关键:我们基于这个矩形定位,而不是基于鼠标点击点 activeRangeRect = wordObj.range.getBoundingClientRect(); // 1. 优先显示缓存 if (wordCache[word]) { renderContent(word, wordCache[word]); if (CONFIG.autoAudio) playAudio(word); } else { contentBox.innerHTML = `