Emoji Tooltip

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
})();