Greasy Fork

来自缓存

Greasy Fork is available in English.

Emoji 含义选中提示

在网页中选中 Emoji 时,显示其含义、名称和分类。支持移动端平台。

您需要先安装一款用户脚本管理器扩展,例如 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();
})();