Greasy Fork is available in English.
在网页中选中 Emoji 时,显示其含义、名称和分类。使用 Google Noto Emoji SVG 源,通过标准 IMG 标签加载。
当前为
// ==UserScript==
// @name Emoji Tooltip
// @name:zh-CN Emoji 含义选中提示
// @namespace http://tampermonkey.net/
// @version 1.16
// @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
// ==/UserScript==
(function () {
'use strict';
// ====================
// ⚙️ Configuration
// ====================
const CONFIG = {
BASE_URL: 'https://cdn.jsdelivr.net/npm/[email protected]',
// 修改为 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 = `
<div style="display: flex; align-items: center; gap: 12px; pointer-events: none;">
<div style="width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-size: 16px;">
⏳
</div>
<div>
<div style="font-weight: 600; font-size: 14px; color: #f9e67d;">
✨ Loading Emoji Data...
</div>
</div>
</div>
`;
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 = `
<img src="${svgUrl}"
alt="${emojiChar}"
style="width: 32px; height: 32px; vertical-align: middle; object-fit: contain;"
onerror="this.onerror=null; this.style.display='none'; this.nextElementSibling.style.display='block';"
>
<div style="display:none; font-size: 24px; line-height: 32px;">${emojiChar}</div>
`;
const content = `
<div style="display: flex; align-items: center; gap: 12px; pointer-events: none;">
<div style="width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;">
${iconHtml}
</div>
<div>
<div style="font-weight: 600; line-height: 1.3; font-size: 14px; pointer-events: auto;">${name}</div>
<div style="color: #bbb; font-size: 12px; margin-top: 3px; pointer-events: auto;">
Group: ${emojiData.group}
</div>
</div>
</div>
`;
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();
})();