// ==UserScript== // @name Emoji Tooltip // @name:zh-CN Emoji 含义选中提示 // @namespace http://tampermonkey.net/ // @version 1.18 // @description:zh-CN 在网页中选中 Emoji 时,显示其含义、名称和分类。非国旗使用 Google Noto SVG,国旗回退至 Emojiall PNG 源。(支持移动端) // @description When an emoji is selected, display its meaning, name, and category. Uses Google Noto SVG, falls back to Emojiall PNG for flags. // @author Kaesinol // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_openInTab // @connect cdn.jsdelivr.net // @connect raw.githubusercontent.com // @connect www.emojiall.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', // SVG 源:Google Noto Emoji SVG_BASE_URL: 'https://raw.githubusercontent.com/googlefonts/noto-emoji/refs/heads/main/svg', // PNG 回退源:Emojiall PNG_BASE_URL: 'https://www.emojiall.com/images/60/google', CACHE_KEY: 'emoji_tooltip_data_v5', CACHE_VERSION: '1.18', // 版本更新 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); let imageUrl, imageType;   // --- 核心修改:判断是否为国旗 --- if (emojiData.group === 'Flags') { // 1. 国旗:使用 Emojiall 的 PNG 源 imageUrl = `${CONFIG.PNG_BASE_URL}/${emojiData.hexcode.toLowerCase()}.png`; imageType = 'png'; } else { // 2. 非国旗:使用 Noto Emoji 的 SVG 源 let hex = emojiData.hexcode.toLowerCase(); // Noto 文件名处理: 移除 fe0f 并将 - 换成 _ hex = hex.replace(/-?fe0f/g, ''); hex = hex.replace(/-/g, '_'); const notoFilename = `emoji_u${hex}.svg`; imageUrl = `${CONFIG.SVG_BASE_URL}/${notoFilename}`; imageType = 'svg'; }   // --- 跳转链接设置 (保持不变) --- 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'); };   // --- 图像 HTML --- 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(); })();