// ==UserScript== // @name Emoji Tooltip // @name:zh-CN Emoji 含义选中提示 // @namespace http://tampermonkey.net/ // @version 1.17 // @description:zh-CN 在网页中选中 Emoji 时,显示其含义、名称和分类。使用 Google Noto Emoji SVG 源,通过标准 IMG 标签加载。(支持移动端) // @description When an emoji is selected, display its meaning, name, and category. Uses Google Noto Emoji SVG source via standard IMG tags. // @author Kaesinol // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_openInTab // @connect cdn.jsdelivr.net // @connect raw.githubusercontent.com // @run-at document-start // @license MIT // @icon https://www.emojiall.com/images/60/google/1f609.png // @downloadURL none // ==/UserScript== (function () { 'use strict'; // ==================== // ⚙️ Configuration // ==================== const CONFIG = { BASE_URL: 'https://cdn.jsdelivr.net/npm/emojibase-data@17.0.0', // 修改为 Google Noto Emoji SVG 仓库 SVG_BASE_URL: 'https://raw.githubusercontent.com/googlefonts/noto-emoji/refs/heads/main/svg', CACHE_KEY: 'emoji_tooltip_data_v5', CACHE_VERSION: '1.15', // 版本更新 AUTO_HIDE_DELAY: 10000, MOUSE_MOVE_THRESHOLD: 300, GROUP_MAP: { 0: 'Smileys & Emotion', 1: 'People & Body', 2: 'Component', 3: 'Animals & Nature', 4: 'Food & Drink', 5: 'Travel & Places', 6: 'Activities', 7: 'Objects', 8: 'Symbols', 9: 'Flags' } }; // ==================== // 📦 State Variables // ==================== let emojiMap = new Map(); let tooltipElement; let hideTimer, autoHideTimer, debounceTimer; let isTooltipVisible = false; let lastMousePosition = { x: 0, y: 0 }; // ==================== // 🎨 Tooltip UI Logic // ==================== /** 初始化 Tooltip 元素并注入到 DOM */ function initTooltipElement() { tooltipElement = document.createElement('div'); tooltipElement.id = 'emoji-tooltip-container'; tooltipElement.style.cssText = ` position: fixed; background: #2b2b2b; color: #fff; padding: 10px 14px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.4); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.4; z-index: 2147483647; max-width: 320px; opacity: 0; transition: opacity 0.2s, transform 0.2s; display: none; transform: translateX(10px) translateY(5px); border: 1px solid #444; user-select: text; -webkit-user-select: text; `; if (document.body) { document.body.appendChild(tooltipElement); } else { new MutationObserver((mutations, observer) => { if (document.body) { document.body.appendChild(tooltipElement); observer.disconnect(); } }).observe(document.documentElement, { childList: true, subtree: true }); } } /** 显示 Tooltip */ function showTooltip(content, x, y) { clearTimeout(hideTimer); clearTimeout(autoHideTimer); tooltipElement.innerHTML = content; tooltipElement.style.display = 'block'; tooltipElement.style.opacity = '0'; tooltipElement.style.transform = 'translateX(10px) translateY(5px)'; void tooltipElement.offsetWidth; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const tooltipWidth = tooltipElement.clientWidth || 200; const tooltipHeight = tooltipElement.clientHeight || 80; let left = x + 15; let top = y + 15; // 智能定位 if (left + tooltipWidth > viewportWidth - 10) left = x - tooltipWidth - 15; if (top + tooltipHeight > viewportHeight - 10) top = y - tooltipHeight - 15; if (left < 10) left = 10; tooltipElement.style.left = `${left}px`; tooltipElement.style.top = `${top}px`; requestAnimationFrame(() => { tooltipElement.style.opacity = '1'; tooltipElement.style.transform = 'translateX(0) translateY(0)'; }); isTooltipVisible = true; autoHideTimer = setTimeout(hideTooltip, CONFIG.AUTO_HIDE_DELAY); } /** 隐藏 Tooltip */ function hideTooltip() { if (!isTooltipVisible) return; clearTimeout(hideTimer); clearTimeout(autoHideTimer); tooltipElement.style.opacity = '0'; tooltipElement.style.transform = 'translateX(10px) translateY(5px)'; hideTimer = setTimeout(() => { tooltipElement.style.display = 'none'; tooltipElement.onclick = null; tooltipElement.title = ''; tooltipElement.style.cursor = 'default'; isTooltipVisible = false; }, 200); } /** 显示加载状态 */ function showLoadingTooltip(x, y) { const content = `
✨ Loading Emoji Data...
`; tooltipElement.onclick = null; tooltipElement.style.cursor = 'default'; showTooltip(content, x, y); } // ==================== // 🧠 Event Handling // ==================== function handleSelection() { let selection; let rangeRect; try { selection = window.getSelection(); const selectionText = selection.toString().trim(); if (!selectionText || selectionText.length < 1 || selectionText.length > 15) { if (isTooltipVisible) hideTimer = setTimeout(hideTooltip, 2000); return; } if (selection.rangeCount > 0) { rangeRect = selection.getRangeAt(0).getBoundingClientRect(); } let emojiData = emojiMap.get(selectionText); let finalChar = selectionText; if (!emojiData) { const selectionWithVariant = selectionText + '\uFE0F'; emojiData = emojiMap.get(selectionWithVariant); if (emojiData) { finalChar = selectionWithVariant; } } if (rangeRect && (emojiData || emojiMap.size === 0)) { const x = rangeRect.left + (rangeRect.width / 2); const y = rangeRect.bottom; if (emojiData) { showEmojiTooltip(emojiData, finalChar, x, y); } else if (emojiMap.size === 0) { showLoadingTooltip(x, y); } } else { if (isTooltipVisible) hideTooltip(); } } catch (error) { console.warn('Selection Error:', error); hideTooltip(); } } function debouncedSelectionHandler() { clearTimeout(debounceTimer); debounceTimer = setTimeout(handleSelection, 300); } /** * 构建 Tooltip 内容并显示 */ function showEmojiTooltip(emojiData, emojiChar, x, y) { const name = emojiData.name.charAt(0).toUpperCase() + emojiData.name.slice(1); // --- 核心修改:构建 Noto Emoji SVG URL --- // 1. 获取 hexcode (例如: "0031-20E3" 或 "1F600") // 2. 转换为小写 // 3. 将连字符 '-' 替换为下划线 '_' // 4. 拼接 URL: emoji_u + code + .svg const notoFilename = `emoji_u${emojiData.hexcode.toLowerCase().replace(/-/g, '_')}.svg`; const svgUrl = `${CONFIG.SVG_BASE_URL}/${notoFilename}`; let lang = navigator.language.toLowerCase(); lang = lang.startsWith('zh') ? (/(tw|hk|mo|hant)/.test(lang) ? 'zh-hant' : 'zh-hans') : lang.slice(0, 2); const targetUrl = `https://www.emojiall.com/${lang}/emoji/${encodeURIComponent(emojiChar)}`; // 设置 Tooltip 容器属性 tooltipElement.title = `Unicode: U+${emojiData.hexcode}`; tooltipElement.style.cursor = 'pointer'; // 绑定点击跳转事件 tooltipElement.onclick = (e) => { const selection = window.getSelection(); const selectedText = selection.toString(); if (selectedText.length > 0) { if (tooltipElement.contains(selection.anchorNode)) { return; } } window.open(targetUrl, '_blank'); }; // --- 核心修改:使用传统 img 标签 --- // 直接设置 src,并在 img 标签上添加 onerror 处理加载失败的情况 const iconHtml = ` ${emojiChar}
${emojiChar}
`; const content = `
${iconHtml}
${name}
Group: ${emojiData.group}
`; showTooltip(content, x, y); lastMousePosition = { x, y }; } function handleMouseMove(e) { if (!isTooltipVisible) return; if (lastMousePosition.x === 0 && lastMousePosition.y === 0) return; const dx = Math.abs(e.clientX - lastMousePosition.x); const dy = Math.abs(e.clientY - lastMousePosition.y); if (dx > CONFIG.MOUSE_MOVE_THRESHOLD || dy > CONFIG.MOUSE_MOVE_THRESHOLD) { hideTooltip(); } } // ==================== // 💾 Data & Cache Logic // ==================== function processAndCacheData(data, langCode, origin) { try { emojiMap.clear(); data.forEach(item => { if (item.emoji && item.label && item.hexcode) { const info = { name: item.label, group: CONFIG.GROUP_MAP[item.group] || 'Other', hexcode: item.hexcode }; emojiMap.set(item.emoji, info); } if (Array.isArray(item.skins)) { item.skins.forEach(skin => { if (skin.emoji && skin.label && skin.hexcode) { emojiMap.set(skin.emoji, { name: skin.label, group: CONFIG.GROUP_MAP[skin.group || item.group] || 'Other', hexcode: skin.hexcode }); } }); } }); if (origin === 'network') { GM_setValue(CONFIG.CACHE_KEY, { version: CONFIG.CACHE_VERSION, lang: langCode, timestamp: Date.now(), data: data }); } console.log(`[Emoji Tooltip] ${emojiMap.size} emojis loaded (${langCode}) from ${origin}.`); } catch (e) { console.error('[Emoji Tooltip] Failed to process data', e); } } function fetchEmojiData(langCode, isFallback = false) { const url = `${CONFIG.BASE_URL}/${langCode}/data.json`; GM_xmlhttpRequest({ method: 'GET', url: url, onload: function (response) { if (response.status === 200) { processAndCacheData(JSON.parse(response.responseText), langCode, 'network'); } else if (!isFallback && langCode !== 'en') { fetchEmojiData('en', true); } }, onerror: function () { if (!isFallback && langCode !== 'en') fetchEmojiData('en', true); } }); } function loadEmojiData() { const browserLang = (navigator.language || 'en').split('-')[0]; const cached = GM_getValue(CONFIG.CACHE_KEY, null); if (cached && cached.version === CONFIG.CACHE_VERSION) { if (cached.lang === browserLang || cached.lang === 'en') { try { processAndCacheData(cached.data, cached.lang, 'cache'); return; } catch (e) { GM_setValue(CONFIG.CACHE_KEY, null); } } } fetchEmojiData(browserLang); } // ==================== // 启动程序 // ==================== function init() { initTooltipElement(); loadEmojiData(); document.addEventListener('mouseup', handleSelection, { passive: true }); document.addEventListener('touchend', handleSelection, { passive: true }); document.addEventListener('selectionchange', debouncedSelectionHandler, { passive: true }); document.addEventListener('mousemove', handleMouseMove, { passive: true }); window.addEventListener('scroll', hideTooltip, { passive: true }); window.addEventListener('blur', hideTooltip); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isTooltipVisible) hideTooltip(); }); } init(); })();