Emoji Tooltip

When an emoji is selected, display its meaning, name, and category. Supports mobile platforms.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();