Greasy Fork is available in English.
在网页中选中 Emoji 时,显示其含义、名称和分类。支持移动端平台。
// ==UserScript==
// @name Emoji Tooltip
// @name:zh-CN Emoji 含义选中提示
// @namespace http://tampermonkey.net/
// @version 1.38
// @description:zh-CN 在网页中选中 Emoji 时,显示其含义、名称和分类。支持移动端平台。
// @description When an emoji is selected, display its meaning, name, and category. Supports mobile platforms.
// @icon https://www.emojiall.com/images/60/google/1f609.png
// @author Kaesinol
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect cdn.jsdelivr.net
// @connect raw.githubusercontent.com
// @connect www.emojiall.com
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const CONFIG = {
BASE_URL: "https://cdn.jsdelivr.net/npm/emojibase-data@latest",
SVG_BASE_URL:
"https://raw.githubusercontent.com/googlefonts/noto-emoji/refs/heads/main/svg",
PNG_BASE_URL: "https://www.emojiall.com/images/60/google",
CACHE_KEY: "emoji_tooltip_data_v5",
IMAGE_CACHE_KEY_PREFIX: "emoji_img_",
CACHE_VERSION: "1.24",
AUTO_HIDE_DELAY: 15000,
MAX_EMOJIS: 10,
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",
},
};
let emojiMap = new Map();
let tooltipElement, scrollBox;
let autoHideTimer;
let isTooltipVisible = false;
let lastInteractionCoords = { x: 0, y: 0 };
let currentSessionId = 0;
function arrayBufferToBase64(buffer) {
let binary = "";
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++)
binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
// ====================
// 🎨 UI 构造 (Trusted Types Safe)
// ====================
function initTooltipElement() {
if (document.getElementById("emoji-tooltip-container")) return;
tooltipElement = document.createElement("div");
tooltipElement.id = "emoji-tooltip-container";
tooltipElement.style.cssText = `
position: fixed; background: #2b2b2b; color: #fff; padding: 10px;
border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
font-family: -apple-system, sans-serif; font-size: 14px; z-index: 2147483647;
max-width: 85vw; width: auto; min-width: 180px; opacity: 0;
transition: opacity 0.15s; display: none; border: 1px solid #444;
pointer-events: auto; user-select: text; -webkit-user-select: text;
`;
const style = document.createElement("style");
style.textContent = `
#emoji-list-scroll-box::-webkit-scrollbar { width: 5px; }
#emoji-list-scroll-box::-webkit-scrollbar-thumb { background: #666; border-radius: 3px; }
.emoji-row:hover { background: rgba(255,255,255,0.1); }
.emoji-row:active { background: rgba(255,255,255,0.2); }
`;
document.head.appendChild(style);
scrollBox = document.createElement("div");
scrollBox.id = "emoji-list-scroll-box";
scrollBox.style.cssText =
"max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding-right: 4px;";
tooltipElement.appendChild(scrollBox);
(document.body || document.documentElement).appendChild(tooltipElement);
}
function showTooltip(x, y) {
clearTimeout(autoHideTimer);
tooltipElement.style.display = "block";
void tooltipElement.offsetWidth;
const vW = window.innerWidth,
vH = window.innerHeight;
const tW = tooltipElement.offsetWidth,
tH = tooltipElement.offsetHeight;
let left = x + 10,
top = y + 15;
if (left + tW > vW - 10) left = vW - tW - 10;
if (top + tH > vH - 10) top = y - tH - 15;
tooltipElement.style.left = `${Math.max(10, left)}px`;
tooltipElement.style.top = `${Math.max(10, top)}px`;
tooltipElement.style.opacity = "1";
isTooltipVisible = true;
autoHideTimer = setTimeout(hideTooltip, CONFIG.AUTO_HIDE_DELAY);
}
function hideTooltip() {
if (!isTooltipVisible) return;
tooltipElement.style.opacity = "0";
setTimeout(() => {
if (tooltipElement.style.opacity === "0") {
tooltipElement.style.display = "none";
isTooltipVisible = false;
currentSessionId++;
}
}, 150);
}
function detectLang() {
const raw = navigator.language ?? "en";
const locale = new Intl.Locale(raw);
// 中文处理
if (locale.language === "zh") {
// 优先使用 script 判断(最准确)
if (locale.script === "Hant") return "zh-hant";
if (locale.script === "Hans") return "zh-hans";
// 没有 script 时根据地区推断
const region = locale.region?.toUpperCase();
if (["TW", "HK", "MO"].includes(region)) {
return "zh-hant";
}
return "zh-hans";
}
// 其他语言返回标准两位语言码
return locale.language || "en";
}
// ====================
// 🧠 渲染逻辑 (找回 Unicode 支持)
// ====================
function renderEmojiList(emojiStates, x, y) {
while (scrollBox.firstChild) scrollBox.removeChild(scrollBox.firstChild);
emojiStates.forEach((item) => {
const row = document.createElement("div");
row.className = "emoji-row";
// 显示 Unicode 数值
row.title = row.title = [...item.char]
.map((c) => "U+" + c.codePointAt(0).toString(16).toUpperCase())
.join(" ");
row.style.cssText =
"display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 8px; border-radius: 8px; transition: background 0.2s;";
const iconWrap = document.createElement("div");
iconWrap.style.cssText =
"width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; pointer-events: none;";
if (item.status === "loading") {
iconWrap.textContent = "⏳";
} else if (item.status === "error") {
iconWrap.textContent = item.char;
iconWrap.style.fontSize = "20px";
} else {
const img = document.createElement("img");
img.src = item.dataUri;
img.style.width = "32px";
img.style.height = "32px";
iconWrap.appendChild(img);
}
const infoWrap = document.createElement("div");
infoWrap.style.cssText =
"overflow: hidden; flex-grow: 1; pointer-events: none;";
const nameEl = document.createElement("div");
nameEl.style.cssText =
"font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #fff;";
nameEl.textContent = item.data.name;
const groupEl = document.createElement("div");
groupEl.style.cssText = "font-size: 11px; color: #bbb;";
groupEl.textContent = item.data.group;
infoWrap.appendChild(nameEl);
infoWrap.appendChild(groupEl);
row.appendChild(iconWrap);
row.appendChild(infoWrap);
row.onclick = (e) => {
const selection = window.getSelection();
if (
selection.toString().length > 0 &&
tooltipElement.contains(selection.anchorNode)
)
return;
e.stopPropagation();
const lang = detectLang();
window.open(
`https://www.emojiall.com/${lang}/emoji/${encodeURIComponent(item.char)}`,
"_blank",
);
};
scrollBox.appendChild(row);
});
showTooltip(x, y);
}
function processEmojiSelections(matchedEmojis, x, y) {
currentSessionId++;
const sessionId = currentSessionId;
let emojiStates = matchedEmojis.map((e) => ({
char: e.char,
data: e.data,
status: "loading",
dataUri: null,
}));
renderEmojiList(emojiStates, x, y);
emojiStates.forEach((item, index) => {
const cacheKey = CONFIG.IMAGE_CACHE_KEY_PREFIX + item.data.hexcode;
const cached = GM_getValue(cacheKey);
if (cached) {
updateItem(index, cached);
} else {
const isFlag = item.data.group === "Flags";
const url = isFlag
? `${CONFIG.PNG_BASE_URL}/${item.data.hexcode.toLowerCase()}.png`
: `${CONFIG.SVG_BASE_URL}/emoji_u${item.data.hexcode.toLowerCase().replace(/-fe0f/g, "").replace(/-/g, "_")}.svg`;
GM_xmlhttpRequest({
method: "GET",
url,
responseType: "arraybuffer",
onload: (res) => {
if (res.status === 200) {
const uri = `data:image/${isFlag ? "png" : "svg+xml"};base64,${arrayBufferToBase64(res.response)}`;
GM_setValue(cacheKey, uri);
updateItem(index, uri);
} else updateItem(index, null, "error");
},
onerror: () => updateItem(index, null, "error"),
});
}
});
function updateItem(index, uri, status = "loaded") {
if (sessionId !== currentSessionId) return;
emojiStates[index].dataUri = uri;
emojiStates[index].status = status;
renderEmojiList(emojiStates, x, y);
}
}
// ====================
// 🔍 选区逻辑与滚动修复
// ====================
function handleSelection(event) {
if (tooltipElement && tooltipElement.contains(event.target)) return;
const selection = window.getSelection();
const text = selection.toString().trim();
if (!text) {
if (isTooltipVisible) hideTooltip();
return;
}
const segmenter = new Intl.Segmenter(undefined, {
granularity: "grapheme",
});
const segments = Array.from(segmenter.segment(text)).map((s) => s.segment);
let matched = [];
for (const seg of segments) {
let data =
emojiMap.get(seg) ||
emojiMap.get(seg.replace("\uFE0E", "\uFE0F")) ||
emojiMap.get(seg + "\uFE0F");
if (data && !matched.find((e) => e.char === seg)) {
matched.push({ char: seg, data });
}
}
if (matched.length > 0) {
let x = lastInteractionCoords.x,
y = lastInteractionCoords.y;
if (selection.rangeCount > 0) {
const rect = selection.getRangeAt(0).getBoundingClientRect();
if (rect.width > 0) {
x = rect.left + rect.width / 2;
y = rect.bottom;
}
}
processEmojiSelections(matched.slice(0, CONFIG.MAX_EMOJIS), x, y);
} else if (isTooltipVisible) hideTooltip();
}
function init() {
initTooltipElement();
const cached = GM_getValue(CONFIG.CACHE_KEY);
if (cached && cached.version === CONFIG.CACHE_VERSION) {
processAndCacheData(cached.data);
} else {
const lang = (navigator.language || "en").split("-")[0];
GM_xmlhttpRequest({
method: "GET",
url: `${CONFIG.BASE_URL}/${lang}/data.json`,
onload: (res) => {
if (res.status === 200) {
const data = JSON.parse(res.responseText);
GM_setValue(CONFIG.CACHE_KEY, {
version: CONFIG.CACHE_VERSION,
lang,
data,
});
processAndCacheData(data);
}
},
});
}
function processAndCacheData(data) {
emojiMap.clear();
data.forEach((item) => {
const info = {
name: item.label,
group: CONFIG.GROUP_MAP[item.group] || "Other",
hexcode: item.hexcode,
};
emojiMap.set(item.emoji, info);
if (item.skins)
item.skins.forEach((s) =>
emojiMap.set(s.emoji, {
...info,
name: s.label,
hexcode: s.hexcode,
}),
);
});
}
const updateCoords = (e) => {
// 尝试从 changedTouches 获取 (兼容 touchend)
// 回退到 e (兼容鼠标事件 mousedown/mouseup)
const touch = (e.changedTouches && e.changedTouches[0]) || e;
if (touch && typeof touch.clientX !== "undefined") {
lastInteractionCoords = { x: touch.clientX, y: touch.clientY };
}
};
const hideHandler = (e) => {
if (isTooltipVisible && !tooltipElement.contains(e.target)) hideTooltip();
};
document.addEventListener("mousedown", hideHandler, { passive: true });
document.addEventListener(
"mouseup",
(e) => {
updateCoords(e);
setTimeout(() => handleSelection(e), 50);
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
updateCoords(e);
setTimeout(() => handleSelection(e), 50);
},
{ passive: true },
);
document.addEventListener(
"selectionchange",
(e) => {
updateCoords(e);
setTimeout(() => handleSelection(e), 50);
},
{ passive: true },
);
// ====================
// 关键修复:排除内部滚动导致消失
// ====================
window.addEventListener(
"scroll",
(e) => {
if (isTooltipVisible) {
// 如果滚动目标在 Tooltip 内部,则不做任何操作
if (tooltipElement.contains(e.target)) return;
hideTooltip();
}
},
{ capture: true, passive: true },
);
window.addEventListener("blur", hideTooltip);
}
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", init);
else init();
})();