Emoji Tooltip

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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