// ==UserScript== // @name Emoji Tooltip // @name:zh-CN Emoji 含义选中提示 // @namespace http://tampermonkey.net/ // @version 1.12 // @description:zh-CN 在网页中选中 Emoji 时,强制以 PNG 格式图片显示其高清图标及名称和分类,通过 GM_xmlhttpRequest 绕过 CSP 限制。 // @description When an emoji is selected on a webpage, it forces the display of its high-resolution icon, name, and category as a PNG image, bypassing CSP using GM_xmlhttpRequest. // @author Kaesinol // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @connect cdn.jsdelivr.net // @connect www.emojiall.com // @connect *emojiall.com // @run-at document-start // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; // ==================== // ⚙️ Configuration // ==================== const CONFIG = { BASE_URL: 'https://cdn.jsdelivr.net/npm/emojibase-data@17.0.0', PNG_BASE_URL: 'https://www.emojiall.com/images/60/google', CACHE_KEY: 'emoji_tooltip_data_v5', CACHE_VERSION: '1.12', // 版本更新 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; let isTooltipVisible = false; let lastMousePosition = { x: 0, y: 0 }; // ==================== // 🎨 Tooltip UI Logic // ==================== /** 辅助函数:将 ArrayBuffer 转换为 Base64 字符串 */ function arrayBufferToBase64(buffer) { let binary = ''; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } /** 初始化 Tooltip 元素并注入到 DOM */ function initTooltipElement() { tooltipElement = document.createElement('div'); 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); pointer-events: none; border: 1px solid #444; `; 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; // 使用 clientWidth/Height 获取实际渲染尺寸 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; 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'; isTooltipVisible = false; }, 200); } /** 显示数据加载中的提示Tooltip */ function showLoadingTooltip(x, y) { const content = `
✨ Loading Emoji Data...
(Please wait a moment)
`; showTooltip(content, x, y); lastMousePosition = { x, y }; } // ==================== // 🧠 Event Handling // ==================== /** 鼠标松开(选中)事件处理 */ function handleSelection(e) { let selection; try { selection = window.getSelection().toString().trim(); } catch (error) { hideTooltip(); return; } if (!selection || selection.length < 1 || selection.length > 15) { if (isTooltipVisible) hideTimer = setTimeout(hideTooltip, 2000); return; } const emojiData = emojiMap.get(selection); if (emojiData) { // 1. 数据已加载且匹配:显示 Emoji 详情 (现在是异步加载图片) showEmojiTooltip(emojiData, selection, e.clientX, e.clientY); } else if (emojiMap.size === 0) { // 2. 数据未加载(Map为空):显示加载提示 showLoadingTooltip(e.clientX, e.clientY); } else { // 3. 数据已加载但未匹配:隐藏 Tooltip if (isTooltipVisible) hideTooltip(); } } /** * 构建 Tooltip 内容并显示 * 关键:使用 GM_xmlhttpRequest 异步加载图片并转换为 data:URI 绕过 CSP */ function showEmojiTooltip(emojiData, emojiChar, x, y) { const name = emojiData.name.charAt(0).toUpperCase() + emojiData.name.slice(1); const pngUrl = `${CONFIG.PNG_BASE_URL}/${emojiData.hexcode.toLowerCase()}.png`; // 1. 先显示一个带有加载提示的 Tooltip let initialContent = `
🖼️
${name}
Loading image via GM_xhr...
`; showTooltip(initialContent, x, y); lastMousePosition = { x, y }; // 2. 使用 GM_xmlhttpRequest 异步加载图片 GM_xmlhttpRequest({ method: 'GET', url: pngUrl, responseType: 'arraybuffer', // 关键:以二进制数组形式获取数据 onload: function (response) { if (response.status === 200) { // 将 ArrayBuffer 转换为 Base64 字符串 const base64String = arrayBufferToBase64(response.response); const dataUri = `data:image/png;base64,${base64String}`; // 3. 图片加载成功后,检查 Tooltip 是否仍然可见且位置相同 // 如果用户鼠标已经移开,则不再更新 Tooltip if (isTooltipVisible && lastMousePosition.x === x && lastMousePosition.y === y) { const iconHtml = `${emojiChar}`; const finalContent = `
${iconHtml}
${name}
Group: ${emojiData.group}
`; // 重新显示最终内容 showTooltip(finalContent, x, y); } } else { console.error(`[Emoji Tooltip] Failed to load image from ${pngUrl}. Status: ${response.status}`); // 如果加载失败,显示文本回退 const textFallbackContent = `
${emojiChar}
${name}
ERROR: Image Blocked/Failed
`; showTooltip(textFallbackContent, x, y); } }, onerror: function (error) { console.error(`[Emoji Tooltip] Network error during image fetch:`, error); // 同样进行错误回退 const textFallbackContent = `
${emojiChar}
${name}
ERROR: Network Failed
`; showTooltip(textFallbackContent, x, y); } }); } /** 鼠标大幅度移动事件处理 (用于自动隐藏) */ function handleMouseMove(e) { if (!isTooltipVisible) 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(); lastMousePosition = { x: e.clientX, y: e.clientY }; } } // ==================== // 💾 Data & Cache Logic // ==================== function processAndCacheData(data, langCode, origin) { try { // ... (数据处理逻辑保持不变) emojiMap.clear(); data.forEach(item => { if (item.emoji && item.label && item.hexcode) { emojiMap.set(item.emoji, { name: item.label, group: CONFIG.GROUP_MAP[item.group] || 'Other', hexcode: item.hexcode }); } 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') { console.warn(`[Emoji Tooltip] Failed to load ${langCode}. Falling back to English (en).`); fetchEmojiData('en', true); } }, onerror: function (error) { console.error(`[Emoji Tooltip] Network error loading ${langCode}:`, error); 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) { console.error('[Emoji Tooltip] Cached data corrupted, forcing network reload.'); GM_setValue(CONFIG.CACHE_KEY, null); } } } fetchEmojiData(browserLang); } // ==================== // 启动程序 // ==================== function init() { initTooltipElement(); loadEmojiData(); document.addEventListener('mouseup', handleSelection, { 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(); })();