Remote Emoji Picker for Linux.do

从远程 JSON 加载表情包并注入表情选择器到 Linux.do 论坛

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Remote Emoji Picker for Linux.do
// @namespace    https://linux.do/
// @version      1.0.1
// @description  从远程 JSON 加载表情包并注入表情选择器到 Linux.do 论坛
// @author       stevessr
// @match        https://linux.do/*
// @match        https://*.linux.do/*
// @icon         https://linux.do/uploads/default/optimized/3X/9/d/9dd49731091ce8656e94433a26a3ef76f9c0f8d9_2_32x32.png
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      *
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
  'use strict';

  // ============== 配置 ==============
  const CONFIG = {
    // 远程 JSON URL - 可以通过油猴菜单修改
    remoteUrl: GM_getValue('remoteUrl', 'https://video2gif-pages.pages.dev/assets/defaultEmojiGroups.json'),
    // 缓存有效期(毫秒)- 默认 1 小时
    cacheDuration: 60 * 60 * 1000,
    // 图片输出缩放比例
    imageScale: GM_getValue('imageScale', 30),
    // 输出格式:'markdown' 或 'html'
    outputFormat: GM_getValue('outputFormat', 'markdown'),
    // 是否显示搜索栏
    showSearchBar: true,
    // 是否启用悬浮预览
    enableHoverPreview: GM_getValue('enableHoverPreview', true),
    // 视图模式:'auto', 'desktop', 'mobile'
    viewMode: GM_getValue('viewMode', 'auto')
  };

  // ============== 移动端检测 ==============
  function isMobile() {
    const userAgent = navigator.userAgent;
    const mobileKeywords = ['Android', 'iPhone', 'iPad', 'iPod', 'Windows Phone'];
    return mobileKeywords.some(keyword => userAgent.includes(keyword));
  }

  function shouldUseMobileView() {
    if (CONFIG.viewMode === 'mobile') return true;
    if (CONFIG.viewMode === 'desktop') return false;
    return isMobile();
  }

  // ============== 注册油猴菜单 ==============
  GM_registerMenuCommand('设置远程 JSON URL', () => {
    const url = prompt('请输入远程 JSON URL:', CONFIG.remoteUrl);
    if (url !== null) {
      GM_setValue('remoteUrl', url);
      CONFIG.remoteUrl = url;
      localStorage.removeItem('emoji_remote_cache_timestamp');
      alert('URL 已设置,请刷新页面加载新配置');
    }
  });

  GM_registerMenuCommand('设置图片缩放比例', () => {
    const scale = prompt('请输入缩放比例 (1-100):', CONFIG.imageScale);
    if (scale !== null) {
      const num = parseInt(scale, 10);
      if (!isNaN(num) && num >= 1 && num <= 100) {
        GM_setValue('imageScale', num);
        CONFIG.imageScale = num;
        alert('缩放比例已设置为 ' + num + '%');
      }
    }
  });

  GM_registerMenuCommand('切换输出格式', () => {
    const newFormat = CONFIG.outputFormat === 'markdown' ? 'html' : 'markdown';
    GM_setValue('outputFormat', newFormat);
    CONFIG.outputFormat = newFormat;
    alert('输出格式已切换为:' + newFormat);
  });

  GM_registerMenuCommand('清除缓存', () => {
    localStorage.removeItem('emoji_remote_cache');
    localStorage.removeItem('emoji_remote_cache_timestamp');
    alert('缓存已清除,请刷新页面');
  });

  GM_registerMenuCommand('切换视图模式', () => {
    const modes = ['auto', 'desktop', 'mobile'];
    const modeLabels = { auto: '自动', desktop: '桌面', mobile: '移动' };
    const currentIndex = modes.indexOf(CONFIG.viewMode);
    const nextMode = modes[(currentIndex + 1) % modes.length];
    GM_setValue('viewMode', nextMode);
    CONFIG.viewMode = nextMode;
    alert('视图模式已切换为:' + modeLabels[nextMode] + (nextMode === 'auto' ? ' (当前检测:' + (isMobile() ? '移动' : '桌面') + ')' : ''));
  });

  // ============== 存储工具 ==============
  const CACHE_KEY = 'emoji_remote_cache';
  const CACHE_TIME_KEY = 'emoji_remote_cache_timestamp';

  function loadCache() {
    try {
      const data = localStorage.getItem(CACHE_KEY);
      return data ? JSON.parse(data) : null;
    } catch (e) {
      return null;
    }
  }

  function saveCache(data) {
    try {
      localStorage.setItem(CACHE_KEY, JSON.stringify(data));
      localStorage.setItem(CACHE_TIME_KEY, Date.now().toString());
    } catch (e) {
      console.warn('[Remote Emoji] 缓存保存失败:', e);
    }
  }

  function isCacheValid() {
    try {
      const timestamp = localStorage.getItem(CACHE_TIME_KEY);
      if (!timestamp) return false;
      return Date.now() - parseInt(timestamp, 10) < CONFIG.cacheDuration;
    } catch (e) {
      return false;
    }
  }

  // ============== 远程加载 ==============
  function fetchRemoteConfig(url) {
    return new Promise((resolve, reject) => {
      if (!url) {
        reject(new Error('未设置远程 URL'));
        return;
      }

      GM_xmlhttpRequest({
        method: 'GET',
        url: url,
        onload: function(response) {
          try {
            const data = JSON.parse(response.responseText);
            if (data.groups && Array.isArray(data.groups)) {
              resolve(data.groups);
            } else if (Array.isArray(data)) {
              resolve(data);
            } else {
              reject(new Error('无效的 JSON 格式'));
            }
          } catch (e) {
            reject(e);
          }
        },
        onerror: function(error) {
          reject(error);
        }
      });
    });
  }

  // ============== 表情数据管理 ==============
  let emojiGroups = [];

  async function loadEmojiGroups() {
    // 先尝试使用缓存
    if (isCacheValid()) {
      const cached = loadCache();
      if (cached && cached.length > 0) {
        emojiGroups = cached;
        console.log('[Remote Emoji] 使用缓存数据');
        // 后台刷新
        refreshInBackground();
        return;
      }
    }

    // 从远程加载
    if (!CONFIG.remoteUrl) {
      console.warn('[Remote Emoji] 未设置远程 URL,请通过油猴菜单设置');
      return;
    }

    try {
      console.log('[Remote Emoji] 从远程加载:', CONFIG.remoteUrl);
      const groups = await fetchRemoteConfig(CONFIG.remoteUrl);
      emojiGroups = groups;
      saveCache(groups);
      console.log('[Remote Emoji] 加载成功,共', groups.length, '个分组');
    } catch (e) {
      console.error('[Remote Emoji] 远程加载失败:', e);
      // 尝试使用过期缓存
      const cached = loadCache();
      if (cached) {
        emojiGroups = cached;
        console.log('[Remote Emoji] 使用过期缓存');
      }
    }
  }

  function refreshInBackground() {
    if (!CONFIG.remoteUrl) return;
    fetchRemoteConfig(CONFIG.remoteUrl).then(groups => {
      emojiGroups = groups;
      saveCache(groups);
      console.log('[Remote Emoji] 后台刷新完成');
    }).catch(() => {});
  }

  // ============== 样式注入 ==============
  const ANIMATION_DURATION = 200;

  function injectStyles() {
    if (document.getElementById('remote-emoji-picker-styles')) return;

    const css = `
      /* 悬浮预览 */
      .emoji-picker-hover-preview {
        position: fixed;
        pointer-events: none;
        display: none;
        z-index: 1000002;
        max-width: 320px;
        max-height: 320px;
        overflow: hidden;
        border-radius: 8px;
        box-shadow: 0 6px 20px rgba(0,0,0,0.32);
        background: var(--secondary, #fff);
        padding: 8px;
        border: 1px solid var(--primary-low, #ddd);
      }
      .emoji-picker-hover-preview img {
        display: block;
        max-width: 100%;
        max-height: 220px;
        object-fit: contain;
      }
      .emoji-picker-hover-preview .label {
        font-size: 12px;
        color: var(--primary, #333);
        margin-top: 8px;
        text-align: center;
        word-break: break-word;
      }

      /* 选择器容器 */
      .remote-emoji-picker {
        position: fixed;
        z-index: 999999;
        background: var(--secondary, #fff);
        border: 1px solid var(--primary-low, #ddd);
        border-radius: 8px;
        box-shadow: 0 4px 16px rgba(0,0,0,0.2);
        max-width: 400px;
        max-height: 450px;
        display: flex;
        flex-direction: column;
        overflow: hidden;
      }

      /* 进入动画 */
      .remote-emoji-picker.picker-enter {
        opacity: 0 !important;
        transform: scale(0.95) translateY(-8px) !important;
      }
      .remote-emoji-picker.picker-enter-active {
        opacity: 1 !important;
        transform: scale(1) translateY(0) !important;
        transition: opacity ${ANIMATION_DURATION}ms ease-out, transform ${ANIMATION_DURATION}ms ease-out !important;
      }

      /* 退出动画 */
      .remote-emoji-picker.picker-exit {
        opacity: 1 !important;
        transform: scale(1) translateY(0) !important;
      }
      .remote-emoji-picker.picker-exit-active {
        opacity: 0 !important;
        transform: scale(0.95) translateY(-8px) !important;
        transition: opacity ${ANIMATION_DURATION}ms ease-in, transform ${ANIMATION_DURATION}ms ease-in !important;
      }

      /* 搜索栏 */
      .remote-emoji-picker .search-bar {
        padding: 8px;
        border-bottom: 1px solid var(--primary-low, #eee);
        display: flex;
        gap: 8px;
      }
      .remote-emoji-picker .search-bar input {
        flex: 1;
        padding: 6px 10px;
        border: 1px solid var(--primary-low, #ddd);
        border-radius: 4px;
        font-size: 14px;
        background: var(--secondary, #fff);
        color: var(--primary, #333);
      }
      .remote-emoji-picker .search-bar .close-btn {
        background: none;
        border: none;
        font-size: 18px;
        cursor: pointer;
        color: var(--primary, #666);
        padding: 0 8px;
      }

      /* 分组导航 */
      .remote-emoji-picker .group-nav {
        display: flex;
        gap: 4px;
        padding: 6px 8px;
        border-bottom: 1px solid var(--primary-low, #eee);
        overflow-x: auto;
        flex-shrink: 0;
      }
      .remote-emoji-picker .group-nav button {
        background: none;
        border: none;
        padding: 4px 8px;
        cursor: pointer;
        border-radius: 4px;
        font-size: 16px;
        flex-shrink: 0;
      }
      .remote-emoji-picker .group-nav button:hover {
        background: var(--primary-very-low, #f0f0f0);
      }
      .remote-emoji-picker .group-nav button.active {
        background: var(--tertiary, #007bff);
        color: white;
      }
      .remote-emoji-picker .group-nav button img {
        width: 18px;
        height: 18px;
        object-fit: contain;
        vertical-align: middle;
      }

      /* 内容区 */
      .remote-emoji-picker .content {
        flex: 1;
        overflow-y: auto;
        padding: 8px;
      }

      /* 分组区块 */
      .remote-emoji-picker .group-section {
        margin-bottom: 16px;
      }
      .remote-emoji-picker .group-section h3 {
        font-size: 12px;
        color: var(--primary-medium, #888);
        margin: 0 0 8px 0;
        padding-bottom: 4px;
        border-bottom: 1px solid var(--primary-very-low, #eee);
      }
      .remote-emoji-picker .emoji-grid {
        display: grid;
        grid-template-columns: repeat(6, 1fr);
        gap: 4px;
      }
      .remote-emoji-picker .emoji-grid img {
        width: 32px;
        height: 32px;
        object-fit: contain;
        cursor: pointer;
        border-radius: 4px;
        transition: transform 0.1s, background 0.1s;
      }
      .remote-emoji-picker .emoji-grid img:hover {
        transform: scale(1.2);
        background: var(--primary-very-low, #f0f0f0);
      }

      /* 工具栏按钮 */
      .remote-emoji-toolbar-btn {
        background: none;
        border: none;
        padding: 4px 8px;
        cursor: pointer;
        font-size: 18px;
        border-radius: 4px;
      }
      .remote-emoji-toolbar-btn:hover {
        background: var(--primary-very-low, #f0f0f0);
      }

      /* ============== 移动端样式 ============== */
      /* 移动端遮罩 */
      .remote-emoji-backdrop {
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: rgba(0, 0, 0, 0.5);
        z-index: 999998;
      }
      .remote-emoji-backdrop.backdrop-enter {
        opacity: 0 !important;
      }
      .remote-emoji-backdrop.backdrop-enter-active {
        opacity: 1 !important;
        transition: opacity ${ANIMATION_DURATION}ms ease-out !important;
      }
      .remote-emoji-backdrop.backdrop-exit {
        opacity: 1 !important;
      }
      .remote-emoji-backdrop.backdrop-exit-active {
        opacity: 0 !important;
        transition: opacity ${ANIMATION_DURATION}ms ease-in !important;
      }

      /* 移动端模态框 */
      .remote-emoji-modal {
        position: fixed;
        left: 0;
        right: 0;
        bottom: 0;
        z-index: 999999;
        background: var(--secondary, #fff);
        border-radius: 16px 16px 0 0;
        box-shadow: 0 -4px 20px rgba(0,0,0,0.2);
        max-height: 70vh;
        display: flex;
        flex-direction: column;
        overflow: hidden;
      }
      .remote-emoji-modal.modal-enter {
        opacity: 0 !important;
        transform: translateY(100%) !important;
      }
      .remote-emoji-modal.modal-enter-active {
        opacity: 1 !important;
        transform: translateY(0) !important;
        transition: opacity ${ANIMATION_DURATION}ms ease-out, transform ${ANIMATION_DURATION}ms ease-out !important;
      }
      .remote-emoji-modal.modal-exit {
        opacity: 1 !important;
        transform: translateY(0) !important;
      }
      .remote-emoji-modal.modal-exit-active {
        opacity: 0 !important;
        transform: translateY(100%) !important;
        transition: opacity ${ANIMATION_DURATION}ms ease-in, transform ${ANIMATION_DURATION}ms ease-in !important;
      }

      /* 移动端头部 */
      .remote-emoji-modal .modal-header {
        display: flex;
        align-items: center;
        padding: 12px 16px;
        border-bottom: 1px solid var(--primary-low, #eee);
        gap: 12px;
      }
      .remote-emoji-modal .modal-header input {
        flex: 1;
        padding: 10px 14px;
        border: 1px solid var(--primary-low, #ddd);
        border-radius: 8px;
        font-size: 16px;
        background: var(--secondary, #fff);
        color: var(--primary, #333);
      }
      .remote-emoji-modal .modal-header .close-btn {
        background: none;
        border: none;
        font-size: 24px;
        cursor: pointer;
        color: var(--primary, #666);
        padding: 4px 8px;
      }

      /* 移动端分组导航 */
      .remote-emoji-modal .group-nav {
        display: flex;
        gap: 4px;
        padding: 8px 12px;
        border-bottom: 1px solid var(--primary-low, #eee);
        overflow-x: auto;
        flex-shrink: 0;
        -webkit-overflow-scrolling: touch;
      }
      .remote-emoji-modal .group-nav button {
        background: none;
        border: none;
        padding: 8px 12px;
        cursor: pointer;
        border-radius: 8px;
        font-size: 20px;
        flex-shrink: 0;
      }
      .remote-emoji-modal .group-nav button:hover,
      .remote-emoji-modal .group-nav button:active {
        background: var(--primary-very-low, #f0f0f0);
      }
      .remote-emoji-modal .group-nav button.active {
        background: var(--tertiary, #007bff);
        color: white;
      }
      .remote-emoji-modal .group-nav button img {
        width: 22px;
        height: 22px;
        object-fit: contain;
        vertical-align: middle;
      }

      /* 移动端内容区 */
      .remote-emoji-modal .content {
        flex: 1;
        overflow-y: auto;
        padding: 12px;
        -webkit-overflow-scrolling: touch;
      }

      /* 移动端分组区块 */
      .remote-emoji-modal .group-section {
        margin-bottom: 20px;
      }
      .remote-emoji-modal .group-section h3 {
        font-size: 14px;
        color: var(--primary-medium, #888);
        margin: 0 0 10px 0;
        padding-bottom: 6px;
        border-bottom: 1px solid var(--primary-very-low, #eee);
      }
      .remote-emoji-modal .emoji-grid {
        display: grid;
        grid-template-columns: repeat(5, 1fr);
        gap: 8px;
      }
      .remote-emoji-modal .emoji-grid img {
        width: 100%;
        aspect-ratio: 1;
        object-fit: contain;
        cursor: pointer;
        border-radius: 8px;
        padding: 4px;
        transition: background 0.1s;
      }
      .remote-emoji-modal .emoji-grid img:active {
        background: var(--primary-very-low, #f0f0f0);
      }
    `;

    const style = document.createElement('style');
    style.id = 'remote-emoji-picker-styles';
    style.textContent = css;
    document.head.appendChild(style);
  }

  // ============== 悬浮预览 ==============
  let hoverPreview = null;

  function ensureHoverPreview() {
    if (!hoverPreview) {
      hoverPreview = document.createElement('div');
      hoverPreview.className = 'emoji-picker-hover-preview';
      hoverPreview.innerHTML = '<img><div class="label"></div>';
      document.body.appendChild(hoverPreview);
    }
    return hoverPreview;
  }

  function bindHoverPreview(imgEl, emoji) {
    if (!CONFIG.enableHoverPreview) return;

    const preview = ensureHoverPreview();
    const previewImg = preview.querySelector('img');
    const previewLabel = preview.querySelector('.label');

    imgEl.addEventListener('mouseenter', (e) => {
      previewImg.src = emoji.url;
      previewLabel.textContent = emoji.name || '';
      preview.style.display = 'block';
      movePreview(e);
    });

    imgEl.addEventListener('mousemove', movePreview);

    imgEl.addEventListener('mouseleave', () => {
      preview.style.display = 'none';
    });

    function movePreview(e) {
      const pad = 12;
      const vw = window.innerWidth;
      const vh = window.innerHeight;
      const rect = preview.getBoundingClientRect();
      let left = e.clientX + pad;
      let top = e.clientY + pad;
      if (left + rect.width > vw) left = e.clientX - rect.width - pad;
      if (top + rect.height > vh) top = e.clientY - rect.height - pad;
      preview.style.left = left + 'px';
      preview.style.top = top + 'px';
    }
  }

  // ============== 插入表情 ==============
  function insertEmoji(emoji) {
    // 查找编辑器
    const selectors = [
      'textarea.d-editor-input',
      'textarea.ember-text-area',
      '.ProseMirror.d-editor-input',
      '[contenteditable="true"]'
    ];

    let editor = null;
    for (const sel of selectors) {
      editor = document.querySelector(sel);
      if (editor) break;
    }

    if (!editor) {
      console.error('[Remote Emoji] 找不到编辑器');
      return;
    }

    // 构建插入文本
    const width = emoji.width || 500;
    const height = emoji.height || 500;
    const scale = CONFIG.imageScale;

    let insertText = '';
    if (CONFIG.outputFormat === 'html') {
      const scaledWidth = Math.max(1, Math.round(width * (scale / 100)));
      const scaledHeight = Math.max(1, Math.round(height * (scale / 100)));
      insertText = `<img src="${emoji.url}" title=":${emoji.name}:" class="emoji" alt=":${emoji.name}:" loading="lazy" width="${scaledWidth}" height="${scaledHeight}"> `;
    } else {
      insertText = `![${emoji.name}|${width}x${height},${scale}%](${emoji.url}) `;
    }

    // 插入到 textarea
    if (editor.tagName === 'TEXTAREA') {
      const start = editor.selectionStart;
      const end = editor.selectionEnd;
      editor.value = editor.value.substring(0, start) + insertText + editor.value.substring(end);
      editor.selectionStart = editor.selectionEnd = start + insertText.length;
      editor.focus();
      editor.dispatchEvent(new Event('input', { bubbles: true }));
    }
    // 插入到 ProseMirror 或 contenteditable
    else {
      try {
        const dataTransfer = new DataTransfer();
        if (CONFIG.outputFormat === 'html') {
          dataTransfer.setData('text/html', insertText);
        } else {
          dataTransfer.setData('text/plain', insertText);
        }
        const pasteEvent = new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true });
        editor.dispatchEvent(pasteEvent);
      } catch (e) {
        // Fallback
        document.execCommand('insertText', false, insertText);
      }
    }
  }

  // ============== 表情选择器 ==============
  let currentPicker = null;
  let currentBackdrop = null;
  let isAnimating = false;

  // 关闭移动端模态框
  function closeMobilePicker(callback) {
    if (isAnimating) {
      if (callback) callback();
      return;
    }

    if (!currentPicker && !currentBackdrop) {
      if (callback) callback();
      return;
    }

    isAnimating = true;

    // 隐藏悬浮预览
    if (hoverPreview) {
      hoverPreview.style.display = 'none';
    }

    // 遮罩退出动画
    if (currentBackdrop) {
      currentBackdrop.classList.add('backdrop-exit');
      void currentBackdrop.offsetHeight;
      currentBackdrop.classList.remove('backdrop-exit');
      currentBackdrop.classList.add('backdrop-exit-active');
    }

    // 模态框退出动画
    if (currentPicker) {
      currentPicker.classList.add('modal-exit');
      void currentPicker.offsetHeight;
      currentPicker.classList.remove('modal-exit');
      currentPicker.classList.add('modal-exit-active');
    }

    setTimeout(() => {
      if (currentBackdrop) {
        currentBackdrop.remove();
        currentBackdrop = null;
      }
      if (currentPicker) {
        currentPicker.remove();
        currentPicker = null;
      }
      isAnimating = false;
      if (callback) callback();
    }, ANIMATION_DURATION);
  }

  // 关闭桌面端选择器
  function closeDesktopPicker(callback) {
    if (!currentPicker || isAnimating) {
      if (callback) callback();
      return;
    }

    isAnimating = true;

    // 隐藏悬浮预览
    if (hoverPreview) {
      hoverPreview.style.display = 'none';
    }

    // 添加退出动画
    currentPicker.classList.add('picker-exit');
    void currentPicker.offsetHeight;
    currentPicker.classList.remove('picker-exit');
    currentPicker.classList.add('picker-exit-active');

    setTimeout(() => {
      if (currentPicker) {
        currentPicker.remove();
        currentPicker = null;
      }
      isAnimating = false;
      if (callback) callback();
    }, ANIMATION_DURATION);
  }

  // 统一关闭函数
  function closePicker(callback) {
    if (currentBackdrop) {
      closeMobilePicker(callback);
    } else {
      closeDesktopPicker(callback);
    }
  }

  // 创建移动端选择器
  function createMobilePicker() {
    if (emojiGroups.length === 0) {
      alert('没有可用的表情数据,请先设置远程 URL');
      return null;
    }

    // 创建遮罩
    const backdrop = document.createElement('div');
    backdrop.className = 'remote-emoji-backdrop backdrop-enter';
    backdrop.onclick = () => closePicker();

    // 创建模态框
    const modal = document.createElement('div');
    modal.className = 'remote-emoji-modal modal-enter';

    // 头部(搜索栏 + 关闭按钮)
    const header = document.createElement('div');
    header.className = 'modal-header';

    const searchInput = document.createElement('input');
    searchInput.type = 'text';
    searchInput.placeholder = '搜索表情...';
    header.appendChild(searchInput);

    const closeBtn = document.createElement('button');
    closeBtn.className = 'close-btn';
    closeBtn.textContent = '✕';
    closeBtn.onclick = () => closePicker();
    header.appendChild(closeBtn);

    modal.appendChild(header);

    // 分组导航
    const groupNav = document.createElement('div');
    groupNav.className = 'group-nav';

    emojiGroups.forEach((group, index) => {
      if (!group.emojis || group.emojis.length === 0) return;

      const btn = document.createElement('button');
      btn.title = group.name;
      if (index === 0) btn.classList.add('active');

      const icon = group.icon;
      if (icon && (icon.startsWith('http') || icon.startsWith('data:'))) {
        const img = document.createElement('img');
        img.src = icon;
        img.alt = group.name;
        btn.appendChild(img);
      } else {
        btn.textContent = icon || '📁';
      }

      btn.onclick = () => {
        groupNav.querySelectorAll('button').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        const section = content.querySelector(`[data-group="${group.id}"]`);
        if (section) section.scrollIntoView({ behavior: 'smooth', block: 'start' });
      };

      groupNav.appendChild(btn);
    });

    modal.appendChild(groupNav);

    // 内容区
    const content = document.createElement('div');
    content.className = 'content';

    emojiGroups.forEach(group => {
      if (!group.emojis || group.emojis.length === 0) return;

      const section = document.createElement('div');
      section.className = 'group-section';
      section.dataset.group = group.id;

      const title = document.createElement('h3');
      title.textContent = group.name;
      section.appendChild(title);

      const grid = document.createElement('div');
      grid.className = 'emoji-grid';

      group.emojis.forEach(emoji => {
        if (!emoji.url || !emoji.name) return;

        const img = document.createElement('img');
        img.src = emoji.displayUrl || emoji.url;
        img.alt = emoji.name;
        img.title = emoji.name;
        img.loading = 'lazy';
        img.dataset.name = emoji.name.toLowerCase();

        img.onclick = () => {
          insertEmoji(emoji);
          closePicker();
        };

        grid.appendChild(img);
      });

      section.appendChild(grid);
      content.appendChild(section);
    });

    modal.appendChild(content);

    // 搜索功能
    searchInput.addEventListener('input', (e) => {
      const query = e.target.value.toLowerCase();
      content.querySelectorAll('.emoji-grid img').forEach(img => {
        const name = img.dataset.name || '';
        img.style.display = (query === '' || name.includes(query)) ? '' : 'none';
      });
      content.querySelectorAll('.group-section').forEach(section => {
        const visibleEmojis = section.querySelectorAll('.emoji-grid img:not([style*="display: none"])');
        section.style.display = visibleEmojis.length > 0 ? '' : 'none';
      });
    });

    return { backdrop, modal };
  }

  // 创建桌面端选择器
  function createDesktopPicker() {
    if (emojiGroups.length === 0) {
      alert('没有可用的表情数据,请先设置远程 URL');
      return null;
    }

    const picker = document.createElement('div');
    // 创建时带有进入动画初始类
    picker.className = 'remote-emoji-picker picker-enter';

    // 搜索栏
    const searchBar = document.createElement('div');
    searchBar.className = 'search-bar';

    const searchInput = document.createElement('input');
    searchInput.type = 'text';
    searchInput.placeholder = '搜索表情...';
    searchBar.appendChild(searchInput);

    const closeBtn = document.createElement('button');
    closeBtn.className = 'close-btn';
    closeBtn.textContent = '✕';
    closeBtn.onclick = () => closePicker();
    searchBar.appendChild(closeBtn);

    picker.appendChild(searchBar);

    // 分组导航
    const groupNav = document.createElement('div');
    groupNav.className = 'group-nav';

    emojiGroups.forEach((group, index) => {
      if (!group.emojis || group.emojis.length === 0) return;

      const btn = document.createElement('button');
      btn.title = group.name;
      if (index === 0) btn.classList.add('active');

      // 图标
      const icon = group.icon;
      if (icon && (icon.startsWith('http') || icon.startsWith('data:'))) {
        const img = document.createElement('img');
        img.src = icon;
        img.alt = group.name;
        btn.appendChild(img);
      } else {
        btn.textContent = icon || '📁';
      }

      btn.onclick = () => {
        groupNav.querySelectorAll('button').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        const section = content.querySelector(`[data-group="${group.id}"]`);
        if (section) section.scrollIntoView({ behavior: 'smooth', block: 'start' });
      };

      groupNav.appendChild(btn);
    });

    picker.appendChild(groupNav);

    // 内容区
    const content = document.createElement('div');
    content.className = 'content';

    emojiGroups.forEach(group => {
      if (!group.emojis || group.emojis.length === 0) return;

      const section = document.createElement('div');
      section.className = 'group-section';
      section.dataset.group = group.id;

      const title = document.createElement('h3');
      title.textContent = group.name;
      section.appendChild(title);

      const grid = document.createElement('div');
      grid.className = 'emoji-grid';

      group.emojis.forEach(emoji => {
        if (!emoji.url || !emoji.name) return;

        const img = document.createElement('img');
        img.src = emoji.displayUrl || emoji.url;
        img.alt = emoji.name;
        img.title = emoji.name;
        img.loading = 'lazy';
        img.dataset.name = emoji.name.toLowerCase();

        bindHoverPreview(img, emoji);

        img.onclick = () => {
          insertEmoji(emoji);
          closePicker();
        };

        grid.appendChild(img);
      });

      section.appendChild(grid);
      content.appendChild(section);
    });

    picker.appendChild(content);

    // 搜索功能
    searchInput.addEventListener('input', (e) => {
      const query = e.target.value.toLowerCase();
      content.querySelectorAll('.emoji-grid img').forEach(img => {
        const name = img.dataset.name || '';
        img.style.display = (query === '' || name.includes(query)) ? '' : 'none';
      });
      content.querySelectorAll('.group-section').forEach(section => {
        const visibleEmojis = section.querySelectorAll('.emoji-grid img:not([style*="display: none"])');
        section.style.display = visibleEmojis.length > 0 ? '' : 'none';
      });
    });

    return picker;
  }

  function showPicker(anchorEl) {
    if (isAnimating) return;

    // 如果已有 picker,先关闭再打开
    if (currentPicker || currentBackdrop) {
      closePicker(() => showPicker(anchorEl));
      return;
    }

    const useMobile = shouldUseMobileView();

    if (useMobile) {
      // 移动端模式
      const result = createMobilePicker();
      if (!result) return;

      currentBackdrop = result.backdrop;
      currentPicker = result.modal;

      document.body.appendChild(currentBackdrop);
      document.body.appendChild(currentPicker);

      // 触发进入动画
      requestAnimationFrame(() => {
        if (!currentBackdrop || !currentPicker) return;

        void currentBackdrop.offsetHeight;
        currentBackdrop.classList.remove('backdrop-enter');
        currentBackdrop.classList.add('backdrop-enter-active');

        void currentPicker.offsetHeight;
        currentPicker.classList.remove('modal-enter');
        currentPicker.classList.add('modal-enter-active');

        // 动画完成后清理类
        setTimeout(() => {
          if (currentBackdrop) {
            currentBackdrop.classList.remove('backdrop-enter-active');
          }
          if (currentPicker) {
            currentPicker.classList.remove('modal-enter-active');
          }
        }, ANIMATION_DURATION);
      });
    } else {
      // 桌面端模式
      currentPicker = createDesktopPicker();
      if (!currentPicker) return;

      document.body.appendChild(currentPicker);

      // 定位
      const rect = anchorEl.getBoundingClientRect();
      const margin = 8;
      const vw = window.innerWidth;
      const vh = window.innerHeight;

      let top = rect.bottom + margin;
      let left = rect.left;

      // 等待渲染后调整位置并触发进入动画
      requestAnimationFrame(() => {
        if (!currentPicker) return;

        const pickerRect = currentPicker.getBoundingClientRect();

        if (top + pickerRect.height > vh) {
          top = Math.max(margin, rect.top - pickerRect.height - margin);
        }
        if (left + pickerRect.width > vw) {
          left = Math.max(margin, vw - pickerRect.width - margin);
        }

        currentPicker.style.top = top + 'px';
        currentPicker.style.left = left + 'px';

        // 触发进入动画
        void currentPicker.offsetHeight;
        currentPicker.classList.remove('picker-enter');
        currentPicker.classList.add('picker-enter-active');

        // 动画完成后清理类
        setTimeout(() => {
          if (currentPicker) {
            currentPicker.classList.remove('picker-enter-active');
          }
        }, ANIMATION_DURATION);
      });

      // 点击外部关闭
      setTimeout(() => {
        const handler = (e) => {
          if (currentPicker && !currentPicker.contains(e.target) && e.target !== anchorEl && !isAnimating) {
            document.removeEventListener('click', handler);
            closePicker();
          }
        };
        document.addEventListener('click', handler);
      }, 100);
    }
  }

  // ============== 工具栏注入 ==============
  function findToolbars() {
    const selectors = [
      '.d-editor-button-bar',
      '.toolbar-visible',
      '.chat-composer__wrapper .chat-composer__inner-container'
    ];

    const toolbars = [];
    for (const sel of selectors) {
      document.querySelectorAll(sel).forEach(el => toolbars.push(el));
    }
    return toolbars;
  }

  function injectButton(toolbar) {
    if (toolbar.querySelector('.remote-emoji-toolbar-btn')) return;

    const btn = document.createElement('button');
    btn.className = 'btn no-text btn-icon remote-emoji-toolbar-btn';
    btn.title = '表情包';
    btn.type = 'button';
    btn.textContent = '🐱';

    btn.onclick = (e) => {
      e.stopPropagation();
      showPicker(btn);
    };

    toolbar.appendChild(btn);
  }

  function attemptInjection() {
    const toolbars = findToolbars();
    toolbars.forEach(toolbar => injectButton(toolbar));
    return toolbars.length;
  }

  // ============== 初始化 ==============
  async function init() {
    console.log('[Remote Emoji] 初始化...');

    injectStyles();
    await loadEmojiGroups();

    // 尝试注入
    let attempts = 0;
    const maxAttempts = 10;

    function tryInject() {
      attempts++;
      const count = attemptInjection();

      if (count > 0) {
        console.log('[Remote Emoji] 注入成功,工具栏数量:', count);
      } else if (attempts < maxAttempts) {
        setTimeout(tryInject, 1000);
      } else {
        console.log('[Remote Emoji] 未找到工具栏');
      }
    }

    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', tryInject);
    } else {
      tryInject();
    }

    // 定期检查新工具栏
    setInterval(attemptInjection, 30000);

    // 监听 DOM 变化
    const observer = new MutationObserver(() => {
      attemptInjection();
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  // 检测是否是 Discourse 站点
  function isDiscourseSite() {
    const metaTags = document.querySelectorAll('meta[name*="discourse"], meta[content*="discourse"]');
    if (metaTags.length > 0) return true;

    const generator = document.querySelector('meta[name="generator"]');
    if (generator && generator.content && generator.content.toLowerCase().includes('discourse')) return true;

    if (document.querySelector('#main-outlet, .ember-application, textarea.d-editor-input')) return true;

    return false;
  }

  if (isDiscourseSite()) {
    init();
  } else {
    console.log('[Remote Emoji] 非 Discourse 站点,跳过');
  }

})();