Greasy Fork

Greasy Fork is available in English.

Neopets Smileys

Adds a table of smileys and symbols to Neoboards, NeoMail and Guild Boards, featuring options to set favorites and add custom symbols.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Neopets Smileys
// @author       Amanda Bynes
// @namespace    Amanda Bynes @clraik
// @version      1.2.2
// @description  Adds a table of smileys and symbols to Neoboards, NeoMail and Guild Boards, featuring options to set favorites and add custom symbols.
// @match        https://www.neopets.com/neoboards/topic.phtml*
// @match        https://www.neopets.com/neoboards/create_topic.phtml*
// @match        https://www.neopets.com/guilds/guild_board.phtml*
// @match        https://www.neopets.com/neomessages.phtml?type=send*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @connect      www.sunnyneo.com
// @connect      sunnyneo.com
// @connect      images.neopets.com
// ==/UserScript==

(function () {
  'use strict';

  const CFG = {
    SUNNYNEO_URL: 'https://www.sunnyneo.com/avatars/smileys.php',
    CACHE_KEY: 'NB_SMILIES_HELPER_CACHE_V3',
    CACHE_MAX_AGE_HOURS: 24 * 14,

    // favorites + symbols storage
    FAVORITES_KEY: 'NB_SMILIES_HELPER_FAVORITES_V1',
    SYMBOLS_KEY: 'NB_SMILIES_HELPER_SYMBOLS_V1',
    DEFAULT_SYMBOLS: ['♥', '♡'],

    // virtual categories
    CAT_ALL: 'All',
    CAT_FAVORITES: 'Favorites',
    CAT_SYMBOLS: 'Symbols',

    CELL_GAP_PX: 5,
    HEADER_PADDING_PX: 4,

    DEFAULT_CATEGORY: 'All',
    INSERT_WRAPS_WITH_SPACES: false,

    NB_TEXTAREA_SELECTOR: 'textarea[name="message"]',
    NB_TITLE_SELECTOR: 'input[name="topic_title"]',

    GUILD_TEXTAREA_SELECTOR: 'textarea[name="message_text"]',
    GUILD_TITLE_SELECTOR: 'input[name="message_title"]',

    NEOMAIL_SUBJECT_SELECTOR: 'input[name="subject"]',
    NEOMAIL_PRESET_SELECTOR: 'select[name="message_type"]',
    NEOMAIL_IFRAME_ID: 'message_body',

    CATEGORY_PRIORITY: { DEFAULT_FIRST: 'Default', LAST_TWO: ['Altador Cup', 'Seasonal'] },

    DROPDOWN_ORDER: [
      'Altador Cup',
      'Battledome',
      'Default',
      'Items',
      'Miscellaneous',
      'Neopets',
      'Neopians',
      'PetPet/Pets',
      'Seasonal',
    ],
  };

  GM_addStyle(`
  #nbSmileyHelper{
    --nbsh-gap: ${CFG.CELL_GAP_PX}px;
    --nbsh-header-pad: ${CFG.HEADER_PADDING_PX}px;
    box-sizing: border-box;
  }
  #nbSmileyHelper, #nbSmileyHelper *{ box-sizing: border-box; }

  #nbSmileyHelper{
    margin: 8px 0 6px 0;
    width: 100%;
    max-width: 100%;
    display: block;
    clear: both;
    border: 1px solid #cfcfcf;
    background: #f6f6f6;
    border-radius: 0px;
    overflow: hidden;
    box-shadow: 0 1px 0 rgba(0,0,0,.06);
    font-family: verdana;
  }

  #nbSmileyHelper.nbsh-square{
    border-radius: 0 !important;
    box-shadow: none !important;
  }

  #nbSmileyHeader{
    display:flex;
    align-items:center;
    gap:6px;
    padding: var(--nbsh-header-pad) calc(var(--nbsh-header-pad) + 1px);
    background:#eeeeee;
    border-bottom:1px solid #d9d9d9;
    flex-wrap: nowrap;
    overflow: hidden;
  }

  #nbSmileyTitle{
    font-family: Verdana;
    font-weight: 700;
    font-size: 9px;
    line-height: 1;
    white-space: nowrap;
    margin-right: 2px;
    flex: 0 0 auto;
    opacity: .9;
  }

  #nbSmileyHeader .nbsh-control{
    display:flex;
    align-items:center;
    gap:4px;
    min-width: 0;
    flex: 0 0 auto;
  }
  #nbSmileyHeader .nbsh-control.search{
    flex: 1 1 auto;
    min-width: 120px;
  }

  #nbSmileyHeader input,
  #nbSmileyHeader select{
    height:15px;
    padding:0px 3px;
    font-family: Verdana, Arial, sans-serif;
    font-size:10px;
    text-
    line-height: 15px;
    border:1px solid #cfcfcf;
    border-radius: 0;
    background:#fff;
    vertical-align: middle;
  }

  #nbSmileySearch{ width: 100%; }

  #nbSmileyCategory{
    width: max-content;
    inline-size: max-content;
    min-width: 0 !important;
    max-width: none !important;
  }
  @supports not (width: max-content){
    #nbSmileyCategory{ width: auto; }
  }

  #nbSmileyBody{
    padding: 6px;
    background:#f6f6f6;
    width: 100% !important;
  }

  /* NeoMail only: slight right breathing room */
  #nbSmileyHelper.nbsh-neomail #nbSmileyBody{ padding-right: 10px; }

  #nbSmileyGrid{
    width: 100% !important;
    display: grid;
    grid-template-columns: repeat(auto-fill, 30px);
    justify-content: start;
    grid-auto-rows: 30px;
    align-content: start;
    gap: 4px;
    padding: 2px;
    min-height: 34px;
    max-height: 100px;
    overflow-y: auto;
    overflow-x: hidden;
  }

  .nbSmileyCell{
    display:flex;
    align-items:center;
    justify-content:center;
    padding: 4px;
    border-radius: 0;
    background:#ffffff;
    border:1px solid #e6e6e6;
    cursor:pointer;
    user-select:none;
  }
  .nbSmileyCell:hover{ border-color:#c9c9c9; }
  .nbSmileyCell img{ display:block; image-rendering:auto; }

  @media (max-width: 720px){
    #nbSmileyHeader{ gap:4px; }
    #nbSmileyTitle{ display:none; }
  }
  `);

  function nowMs() { return Date.now(); }
  function alpha(a, b) { return String(a).localeCompare(String(b), undefined, { sensitivity: 'base' }); }

  function normalizeCode(code) {
    if (!code) return '';
    return code.trim().replace(/\s+/g, ' ');
  }

  function normalizeSymbol(sym) {
    if (!sym) return '';
    return String(sym).replace(/\s+/g, ' ').trim();
  }

  function buildInsertText(codeOrSymbol) {
    const c = normalizeCode(codeOrSymbol);
    if (!c) return '';
    return CFG.INSERT_WRAPS_WITH_SPACES ? ` ${c} ` : c;
  }

  function catKey(s) {
    return String(s || '')
      .toLowerCase()
      .replace(/\(pet\)\s*/g, '')
      .replace(/\./g, '')
      .replace(/\s+/g, ' ')
      .trim();
  }

  function buildCategoryOrder(categories) {
    const def = CFG.CATEGORY_PRIORITY.DEFAULT_FIRST;
    const lastTwo = CFG.CATEGORY_PRIORITY.LAST_TWO;

    const set = new Set(categories);
    set.delete(CFG.CAT_ALL);

    const ordered = [];
    if (set.has(def)) { ordered.push(def); set.delete(def); }

    const tail = [];
    for (const t of lastTwo) { if (set.has(t)) { tail.push(t); set.delete(t); } }

    const mid = Array.from(set).sort(alpha);
    ordered.push(...mid, ...tail);

    const orderIndex = Object.create(null);
    ordered.forEach((c, i) => { orderIndex[c] = i; });

    return { ordered, orderIndex };
  }

  function getCache() {
    const raw = GM_getValue(CFG.CACHE_KEY, null);
    if (!raw) return null;
    try {
      const parsed = JSON.parse(raw);
      if (!parsed || !parsed.ts || !Array.isArray(parsed.items) || !Array.isArray(parsed.categories)) return null;
      const ageHrs = (nowMs() - parsed.ts) / 36e5;
      if (ageHrs > CFG.CACHE_MAX_AGE_HOURS) return null;
      return parsed;
    } catch { return null; }
  }

  function setCache(items, categories, catOrder) {
    GM_setValue(CFG.CACHE_KEY, JSON.stringify({ ts: nowMs(), items, categories, catOrder }));
  }

  // favorites + symbols persistence
  function loadFavorites() {
    const raw = GM_getValue(CFG.FAVORITES_KEY, null);
    if (!raw) return [];
    try {
      const arr = JSON.parse(raw);
      return Array.isArray(arr) ? arr.filter(Boolean) : [];
    } catch { return []; }
  }

  function saveFavorites(arr) {
    const clean = Array.from(new Set((arr || []).filter(Boolean)));
    GM_setValue(CFG.FAVORITES_KEY, JSON.stringify(clean));
    return clean;
  }

  function loadSymbols() {
    const raw = GM_getValue(CFG.SYMBOLS_KEY, null);
    if (!raw) return CFG.DEFAULT_SYMBOLS.slice();
    try {
      const arr = JSON.parse(raw);
      if (!Array.isArray(arr)) return CFG.DEFAULT_SYMBOLS.slice();
      const clean = arr.map(normalizeSymbol).filter(Boolean);
      return clean.length ? clean : CFG.DEFAULT_SYMBOLS.slice();
    } catch {
      return CFG.DEFAULT_SYMBOLS.slice();
    }
  }

  function saveSymbols(arr) {
    const clean = Array.from(new Set((arr || []).map(normalizeSymbol).filter(Boolean)));
    GM_setValue(CFG.SYMBOLS_KEY, JSON.stringify(clean));
    return clean;
  }

  function idForItem(it) {
    if (!it) return '';
    if (it.kind === 'symbol') return `sym:${it.symbol}`;
    return `code:${it.code}`;
  }

  function fetchHTML(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        onload: (resp) => resolve(resp.responseText),
        onerror: (e) => reject(e),
        ontimeout: (e) => reject(e),
      });
    });
  }

  function parseSunnyNeoSmileys(html) {
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const tables = Array.from(doc.querySelectorAll('table.norm.center'));
    const items = [];
    const cats = new Set();

    for (const t of tables) {
      const th = t.querySelector('th');
      const category = (th ? th.textContent : 'Other').trim() || 'Other';

      const rows = Array.from(t.querySelectorAll('tr')).slice(1);
      for (const r of rows) {
        const img = r.querySelector('img');
        const tds = r.querySelectorAll('td');
        if (!img || !tds || tds.length < 2) continue;

        const code = normalizeCode(tds[1].textContent || '');
        if (!code || code.toLowerCase() === '-removed-') continue;

        const src = img.getAttribute('src') || '';
        const absSrc = src.startsWith('http') ? src : (src.startsWith('//') ? `https:${src}` : src);

        items.push({ kind: 'smiley', category, code, img: absSrc });
        cats.add(category);
      }
    }

    const seen = new Set();
    const deduped = [];
    for (const it of items) {
      if (seen.has(it.code)) continue;
      seen.add(it.code);
      deduped.push(it);
    }

    const baseCats = Array.from(cats);
    const { ordered, orderIndex } = buildCategoryOrder(baseCats);
    const categories = [CFG.CAT_ALL, ...ordered];

    return { items: deduped, categories, catOrder: { ordered, orderIndex } };
  }

  async function loadSmileyData() {
    const cached = getCache();
    if (cached) return cached;

    const html = await fetchHTML(CFG.SUNNYNEO_URL);
    const parsed = parseSunnyNeoSmileys(html);
    setCache(parsed.items, parsed.categories, parsed.catOrder);

    return { ts: nowMs(), items: parsed.items, categories: parsed.categories, catOrder: parsed.catOrder };
  }

  function insertAtCursor(field, text) {
    if (!field) return;

    const start = field.selectionStart ?? field.value.length;
    const end = field.selectionEnd ?? field.value.length;

    field.value = field.value.slice(0, start) + text + field.value.slice(end);

    const newPos = start + text.length;
    try { field.setSelectionRange(newPos, newPos); } catch {}

    field.focus();

    try {
      if (field.tagName === 'TEXTAREA') {
        const f = field.form;
        if (f && typeof window.textCounter === 'function' && f.remLen) {
          const max = (window.NeoboardPens && window.NeoboardPens.maxPostLength)
            ? window.NeoboardPens.maxPostLength
            : (field.maxLength || 500);
          window.textCounter(field, f.remLen, max);
        }
      }
    } catch {}
  }

  // ---------- NeoMail Advanced iframe caret preservation (Chrome-safe) ----------
  function getNeomailIframe() {
    return document.getElementById(CFG.NEOMAIL_IFRAME_ID) || document.querySelector(`iframe[name="${CFG.NEOMAIL_IFRAME_ID}"]`);
  }

  function installNeomailCaretTracker(iframe, state) {
    if (!iframe || iframe._nbshCaretInstalled) return;
    iframe._nbshCaretInstalled = true;

    const tryBind = () => {
      let doc;
      try { doc = iframe.contentDocument || iframe.contentWindow?.document; } catch { return false; }
      if (!doc || !doc.body) return false;

      const save = () => {
        try {
          const sel = doc.getSelection?.();
          if (sel && sel.rangeCount) iframe._nbshSavedRange = sel.getRangeAt(0).cloneRange();
        } catch {}
      };

      const markFocused = () => { state.lastFocused = iframe; };

      doc.addEventListener('keyup', () => { markFocused(); save(); }, true);
      doc.addEventListener('mouseup', () => { markFocused(); save(); }, true);
      doc.addEventListener('selectionchange', () => { markFocused(); save(); }, true);
      doc.addEventListener('focus', () => { markFocused(); save(); }, true);
      doc.body.addEventListener('input', () => { markFocused(); save(); }, true);
      doc.addEventListener('mousedown', markFocused, true);
      doc.addEventListener('click', markFocused, true);

      save();
      return true;
    };

    let tries = 0;
    const timer = setInterval(() => {
      tries++;
      if (tryBind() || tries > 60) clearInterval(timer);
    }, 100);
  }

  function restoreNeomailCaret(iframe) {
    if (!iframe) return false;

    let doc;
    try { doc = iframe.contentDocument || iframe.contentWindow?.document; } catch { return false; }
    if (!doc || !doc.body) return false;

    try { iframe.contentWindow?.focus(); doc.body.focus(); } catch {}

    try {
      const sel = doc.getSelection?.();
      if (!sel) return false;

      sel.removeAllRanges();

      if (iframe._nbshSavedRange) {
        sel.addRange(iframe._nbshSavedRange);
        return true;
      }

      const r = doc.createRange();
      r.selectNodeContents(doc.body);
      r.collapse(false);
      sel.addRange(r);
      return true;
    } catch { return false; }
  }

  function insertIntoNeomailRTE(iframe, text) {
    if (!iframe) return false;

    restoreNeomailCaret(iframe);

    let doc;
    try { doc = iframe.contentDocument || iframe.contentWindow?.document; } catch { return false; }
    if (!doc || !doc.body) return false;

    try {
      const sel = doc.getSelection?.();
      if (!sel || !sel.rangeCount) return false;

      const range = sel.getRangeAt(0);
      range.deleteContents();

      const node = doc.createTextNode(text);
      range.insertNode(node);

      range.setStartAfter(node);
      range.setEndAfter(node);
      sel.removeAllRanges();
      sel.addRange(range);

      iframe._nbshSavedRange = range.cloneRange();

      try { if (typeof window.updateRTE === 'function') window.updateRTE(iframe.id); } catch {}
      return true;
    } catch { return false; }
  }
  // ---------------------------------------------------------------------------

  function alreadyInjected() { return !!document.getElementById('nbSmileyHelper'); }

  function pageType() {
    const p = location.pathname;
    const q = location.search || '';
    if (p.includes('/neoboards/')) return 'neoboards';
    if (p.includes('/guilds/guild_board.phtml')) return 'guild';
    if (p.includes('/neomessages.phtml') && q.includes('type=send')) return 'neomail';
    return 'other';
  }

  // NeoMail: align panel LEFT edge to the real editor/inputs (not the TD edge)
  function alignPanelToReference(panel, refEl) {
    if (!panel || !refEl) return;
    try {
      const r = refEl.getBoundingClientRect();
      const parent = panel.parentElement || refEl.parentElement;
      if (!parent) return;
      const pr = parent.getBoundingClientRect();

      const left = Math.round(r.left - pr.left);
      const width = Math.round(r.width);

      if (width > 0) panel.style.width = `${width}px`;
      panel.style.marginLeft = `${Math.max(0, left)}px`;
      panel.style.marginRight = '0px';
      panel.style.maxWidth = 'none';
    } catch {}
  }

  function getNeomailWidthReference() {
    const iframe = getNeomailIframe();
    if (iframe) return iframe;
    const plain = document.getElementById(CFG.NEOMAIL_IFRAME_ID);
    if (plain && plain.tagName === 'TEXTAREA') return plain;
    const subj = document.querySelector(CFG.NEOMAIL_SUBJECT_SELECTOR);
    return subj || null;
  }

  function resolveTargetField(state, type) {
    const last = state.lastFocused;
    if (last && document.contains(last)) return last;

    if (type === 'neomail') {
      const iframe = state.neomailIframe || getNeomailIframe();
      if (iframe && document.contains(iframe)) return iframe;

      const plain = document.getElementById(CFG.NEOMAIL_IFRAME_ID);
      if (plain && plain.tagName === 'TEXTAREA') return plain;

      const subj = document.querySelector(CFG.NEOMAIL_SUBJECT_SELECTOR);
      if (subj) return subj;
    }

    if (type === 'guild') {
      return document.querySelector(CFG.GUILD_TEXTAREA_SELECTOR)
        || document.querySelector(CFG.GUILD_TITLE_SELECTOR)
        || null;
    }

    return document.querySelector(CFG.NB_TEXTAREA_SELECTOR)
      || document.querySelector(CFG.NB_TITLE_SELECTOR)
      || null;
  }

  function findAnchorInfo(type) {
    if (type === 'neoboards') {
      const container = document.querySelector('.topicReplyContainer') || document.querySelector('#boardCreateTopic');
      if (!container) return null;

      const remainder = container.querySelector('.topicReplyRemainder, .topicCreateRemainder');
      const inputWrap = container.querySelector('.topicReplyInput, .topicCreateInput');
      if (remainder && inputWrap) {
        const ta = inputWrap.querySelector(CFG.NB_TEXTAREA_SELECTOR) || container.querySelector(CFG.NB_TEXTAREA_SELECTOR);
        return { anchor: remainder, mode: 'before', textarea: ta || null };
      }

      const ta2 = container.querySelector(CFG.NB_TEXTAREA_SELECTOR);
      if (ta2) return { anchor: ta2, mode: 'after', textarea: ta2 };
      return null;
    }

    if (type === 'guild') {
      // IMPORTANT: Insert inside the SAME LEFT-ALIGNED cell as Subject/Message fields.
      // This keeps it lined up and avoids being centered by the toolbar row.
      const titleInput = document.querySelector(CFG.GUILD_TITLE_SELECTOR);
      if (titleInput) {
        const fieldsTable = titleInput.closest('table') || titleInput;
        return { anchor: fieldsTable, mode: 'before' }; // below toolbar (next row), aligned with fields
      }

      // Fallback only if we can't find the subject/message area for some reason
      const boldImg = document.querySelector('img[src*="postFormatting/bold.gif"]');
      const toolbarTable = boldImg ? boldImg.closest('table') : null;
      if (toolbarTable) return { anchor: toolbarTable, mode: 'after' };

      return null;
    }

    if (type === 'neomail') {
      const preset = document.querySelector(CFG.NEOMAIL_PRESET_SELECTOR);
      if (preset) return { anchor: preset, mode: 'after' };

      const subj = document.querySelector(CFG.NEOMAIL_SUBJECT_SELECTOR);
      if (subj) return { anchor: subj, mode: 'after' };
      return null;
    }

    return null;
  }

  // context menu (right-click)
  function ensureContextMenu() {
    let menu = document.getElementById('nbshCtxMenu');
    if (menu) return menu;

    menu = document.createElement('div');
    menu.id = 'nbshCtxMenu';
    menu.style.position = 'fixed';
    menu.style.zIndex = '999999';
    menu.style.display = 'none';
    menu.style.background = '#fff';
    menu.style.border = '1px solid #cfcfcf';
    menu.style.boxShadow = '0 1px 0 rgba(0,0,0,.06)';
    menu.style.fontFamily = 'Verdana, Arial, sans-serif';
    menu.style.fontSize = '11px';
    menu.style.color = '#000';
    menu.style.padding = '2px 0';
    menu.style.minWidth = '160px';

    document.body.appendChild(menu);

    const hide = () => { menu.style.display = 'none'; menu.innerHTML = ''; };

    document.addEventListener('click', hide, true);
    document.addEventListener('scroll', hide, true);
    window.addEventListener('blur', hide, true);
    document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); }, true);

    menu._nbshHide = hide;
    return menu;
  }

  function showContextMenu(x, y, entries) {
    const menu = ensureContextMenu();
    menu.innerHTML = '';

    for (const ent of entries) {
      if (!ent) continue;
      if (ent.type === 'sep') {
        const hr = document.createElement('div');
        hr.style.borderTop = '1px solid #e6e6e6';
        hr.style.margin = '2px 0';
        menu.appendChild(hr);
        continue;
      }

      const item = document.createElement('div');
      item.textContent = ent.label;
      item.style.padding = '4px 8px';
      item.style.cursor = 'pointer';
      item.addEventListener('mouseenter', () => { item.style.background = '#f6f6f6'; });
      item.addEventListener('mouseleave', () => { item.style.background = '#fff'; });

      item.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
      item.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        try { ent.onClick && ent.onClick(); } finally {
          if (menu._nbshHide) menu._nbshHide();
        }
      });

      menu.appendChild(item);
    }

    const pad = 6;
    menu.style.left = `${Math.max(pad, Math.min(x, window.innerWidth - pad))}px`;
    menu.style.top = `${Math.max(pad, Math.min(y, window.innerHeight - pad))}px`;
    menu.style.display = 'block';

    const r = menu.getBoundingClientRect();
    const nx = Math.min(r.left, window.innerWidth - r.width - pad);
    const ny = Math.min(r.top, window.innerHeight - r.height - pad);
    menu.style.left = `${Math.max(pad, nx)}px`;
    menu.style.top = `${Math.max(pad, ny)}px`;
  }

  function rebuildFavIndex(state) {
    const idx = Object.create(null);
    state.favorites.forEach((id, i) => { idx[id] = i; });
    state.favIndex = idx;
    state.favSet = new Set(state.favorites);
  }

  function toggleFavorite(state, item) {
    const id = idForItem(item);
    if (!id) return;

    const cur = state.favorites.slice();
    const i = cur.indexOf(id);
    if (i >= 0) cur.splice(i, 1);
    else cur.unshift(id);

    state.favorites = saveFavorites(cur);
    rebuildFavIndex(state);
    renderGrid(state);
  }

  function removeSymbol(state, symRaw) {
    const sym = normalizeSymbol(symRaw);
    if (!sym) return;

    state.symbols = saveSymbols(state.symbols.filter(s => normalizeSymbol(s) !== sym));

    // remove from favorites if present
    const favId = `sym:${sym}`;
    if (state.favSet.has(favId)) {
      state.favorites = saveFavorites(state.favorites.filter(x => x !== favId));
      rebuildFavIndex(state);
    }

    renderGrid(state);
  }

  function populateCategories(state) {
    const { category } = state.els;
    const catsRaw = (state.data && state.data.categories) ? state.data.categories : [CFG.CAT_ALL];

    const map = new Map();
    for (const c of catsRaw) {
      if (c === CFG.CAT_ALL) continue;
      map.set(catKey(c), c);
    }

    category.innerHTML = '';

    // All
    const optAll = document.createElement('option');
    optAll.value = CFG.CAT_ALL;
    optAll.textContent = 'Category: All';
    category.appendChild(optAll);

    // Favorites
    const optFav = document.createElement('option');
    optFav.value = CFG.CAT_FAVORITES;
    optFav.textContent = 'Favorites';
    category.appendChild(optFav);

    // Normal sunnyneo categories in preferred order
    for (const label of CFG.DROPDOWN_ORDER) {
      let actual =
        map.get(catKey(label)) ||
        (label === 'PetPet/Pets' ? map.get(catKey('(Pet) Petpets')) : null) ||
        (label === 'Miscellaneous' ? (map.get(catKey('Misc.')) || map.get(catKey('Misc'))) : null);

      if (!actual) continue;

      const opt = document.createElement('option');
      opt.value = actual;

      if (label === 'PetPet/Pets') opt.textContent = 'PetPet/Pets';
      else if (label === 'Miscellaneous') opt.textContent = 'Miscellaneous';
      else opt.textContent = label;

      category.appendChild(opt);
    }

    // Symbols
    const optSym = document.createElement('option');
    optSym.value = CFG.CAT_SYMBOLS;
    optSym.textContent = 'Symbols';
    category.appendChild(optSym);

    category.value = CFG.CAT_ALL;
    state.filters.category = CFG.CAT_ALL;
  }

  // FIXED: All allows BOTH smileys + symbols
  function matchesFilter(item, filters, state) {
    if (!item) return false;

    const q = filters.q || '';

    // Category filtering
    if (filters.category && filters.category !== CFG.CAT_ALL) {
      if (filters.category === CFG.CAT_FAVORITES) {
        const id = idForItem(item);
        if (!state.favSet.has(id)) return false;
      } else if (filters.category === CFG.CAT_SYMBOLS) {
        if (item.kind !== 'symbol') return false;
      } else {
        // normal sunnyneo category
        if (item.kind !== 'smiley') return false;
        if (item.category !== filters.category) return false;
      }
    }
    // else: Category = All → allow BOTH smileys + symbols

    // Search filtering
    if (q) {
      const hay = item.kind === 'symbol'
        ? `${item.symbol} ${CFG.CAT_SYMBOLS}`.toLowerCase()
        : `${item.code} ${item.category}`.toLowerCase();
      if (!hay.includes(q)) return false;
    }

    return true;
  }

  // FIXED: favorites first everywhere; symbols included in All; symbols grouped last (unless favorited)
  function sortItemsForDisplay(state, arr) {
    const filters = state.filters;
    const orderIndex = state.data?.catOrder?.orderIndex || {};
    const favIndex = state.favIndex || Object.create(null);

    const byFav = (a, b) => {
      const ai = favIndex[idForItem(a)];
      const bi = favIndex[idForItem(b)];
      const aFav = (ai !== undefined);
      const bFav = (bi !== undefined);
      if (aFav && bFav) return ai - bi;
      if (aFav && !bFav) return -1;
      if (!aFav && bFav) return 1;
      return 0;
    };

    const labelFor = (it) => (it.kind === 'symbol' ? it.symbol : it.code);

    const catIdx = (it) => {
      // Put Symbols group after normal categories in All view (unless favorited, which always goes first)
      if (it.kind === 'symbol') return 9998;
      return (orderIndex[it.category] ?? 9997);
    };

    if (filters.category === CFG.CAT_SYMBOLS) {
      return arr.slice().sort((a, b) => alpha(a.symbol, b.symbol));
    }

    if (filters.category === CFG.CAT_FAVORITES) {
      return arr.slice().sort((a, b) => {
        const d = byFav(a, b);
        if (d) return d;
        return alpha(labelFor(a), labelFor(b));
      });
    }

    if (filters.category === CFG.CAT_ALL) {
      return arr.slice().sort((a, b) => {
        const dFav = byFav(a, b);
        if (dFav) return dFav;

        const ai = catIdx(a);
        const bi = catIdx(b);
        if (ai !== bi) return ai - bi;

        return alpha(labelFor(a), labelFor(b));
      });
    }

    // Specific sunnyneo category: favorites first within the filtered set, then alpha
    return arr.slice().sort((a, b) => {
      const dFav = byFav(a, b);
      if (dFav) return dFav;
      return alpha(a.code, b.code);
    });
  }

  function renderGrid(state) {
    const { grid } = state.els;
    const { items } = state.data || { items: [] };
    const filters = state.filters;

    grid.innerHTML = '';

    const symbolItems = state.symbols.map(sym => ({
      kind: 'symbol',
      category: CFG.CAT_SYMBOLS,
      symbol: sym
    }));

    // FIXED: All + normal categories include symbols in pool
    let pool = [];
    if (filters.category === CFG.CAT_SYMBOLS) pool = symbolItems;
    else if (filters.category === CFG.CAT_FAVORITES) pool = items.concat(symbolItems);
    else pool = items.concat(symbolItems);

    const filtered = pool.filter(it => matchesFilter(it, filters, state));
    const sorted = sortItemsForDisplay(state, filtered);

    const attachCellBehavior = (cell, item) => {
      cell.addEventListener('mousedown', (e) => { e.preventDefault(); });

      cell.addEventListener('contextmenu', (e) => {
        e.preventDefault();
        e.stopPropagation();

        const id = idForItem(item);
        const isFav = state.favSet.has(id);

        const entries = [
          {
            label: isFav ? 'Remove Favorite' : 'Set Favorite',
            onClick: () => toggleFavorite(state, item),
          },
        ];

        if (item.kind === 'symbol') {
          entries.push({ type: 'sep' });
          entries.push({
            label: 'Delete Symbol',
            onClick: () => removeSymbol(state, item.symbol),
          });
        }

        showContextMenu(e.clientX, e.clientY, entries);
      });

      cell.addEventListener('click', () => {
        const field = resolveTargetField(state, state.pageType);
        if (!field) return;

        const text = (item.kind === 'symbol')
          ? buildInsertText(item.symbol)
          : buildInsertText(item.code);

        if (state.pageType === 'neomail') {
          const iframe = state.neomailIframe || getNeomailIframe();
          const plain = document.getElementById(CFG.NEOMAIL_IFRAME_ID);

          if (field === iframe && iframe && iframe.tagName === 'IFRAME') {
            insertIntoNeomailRTE(iframe, text);
            return;
          }
          if (plain && plain.tagName === 'TEXTAREA') {
            insertAtCursor(plain, text);
            return;
          }
          if (field && (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA')) {
            insertAtCursor(field, text);
          }
          return;
        }

        insertAtCursor(field, text);
      });
    };

    for (const it of sorted) {
      const cell = document.createElement('div');
      cell.className = 'nbSmileyCell';
      cell.title = (it.kind === 'symbol') ? it.symbol : it.code;

      if (it.kind === 'symbol') {
        const span = document.createElement('span');
        span.textContent = it.symbol;
        cell.appendChild(span);
      } else {
        const img = document.createElement('img');
        img.src = it.img;
        img.alt = it.code;
        cell.appendChild(img);
      }

      attachCellBehavior(cell, it);
      grid.appendChild(cell);
    }

    // Symbols: add one blank "+" slot
    if (filters.category === CFG.CAT_SYMBOLS) {
      const addCell = document.createElement('div');
      addCell.className = 'nbSmileyCell';
      addCell.title = 'Add Symbol';

      const span = document.createElement('span');
      span.textContent = '+';
      addCell.appendChild(span);

      addCell.addEventListener('mousedown', (e) => { e.preventDefault(); });

      addCell.addEventListener('contextmenu', (e) => {
        e.preventDefault();
        e.stopPropagation();
      });

      addCell.addEventListener('click', () => {
        const raw = window.prompt('Paste a symbol to add:', '');
        const sym = normalizeSymbol(raw);
        if (!sym) return;

        const next = state.symbols.slice();
        if (!next.includes(sym)) next.push(sym);

        state.symbols = saveSymbols(next);
        renderGrid(state);
      });

      grid.appendChild(addCell);
    }

    if (!sorted.length && filters.category !== CFG.CAT_SYMBOLS) {
      const empty = document.createElement('div');
      empty.style.padding = '8px';
      empty.style.fontSize = '12px';
      empty.style.color = '#666';
      empty.textContent = 'No smilies match your filters.';
      grid.appendChild(empty);
    }

    if (!sorted.length && filters.category === CFG.CAT_SYMBOLS && !state.symbols.length) {
      const empty = document.createElement('div');
      empty.style.padding = '8px';
      empty.style.fontSize = '12px';
      empty.style.color = '#666';
      empty.textContent = 'No symbols yet. Click + to add one.';
      grid.appendChild(empty);
    }
  }

  function injectPanel(anchorInfo, data, type) {
    const { anchor, mode, textarea } = anchorInfo;
    if (!anchor || alreadyInjected()) return;

    const panel = document.createElement('div');
    panel.id = 'nbSmileyHelper';

    if (type === 'guild' || type === 'neomail') panel.classList.add('nbsh-square');
    if (type === 'neomail') panel.classList.add('nbsh-neomail');

    const header = document.createElement('div');
    header.id = 'nbSmileyHeader';

    const title = document.createElement('div');
    title.id = 'nbSmileyTitle';
    title.textContent = 'Smileys';

    const searchWrap = document.createElement('div');
    searchWrap.className = 'nbsh-control search';
    const search = document.createElement('input');
    search.id = 'nbSmileySearch';
    search.type = 'text';
    search.placeholder = 'Search';
    searchWrap.appendChild(search);

    const categoryWrap = document.createElement('div');
    categoryWrap.className = 'nbsh-control';
    const category = document.createElement('select');
    category.id = 'nbSmileyCategory';
    categoryWrap.appendChild(category);

    header.appendChild(title);
    header.appendChild(searchWrap);
    header.appendChild(categoryWrap);

    const body = document.createElement('div');
    body.id = 'nbSmileyBody';

    const grid = document.createElement('div');
    grid.id = 'nbSmileyGrid';

    body.appendChild(grid);
    panel.appendChild(header);
    panel.appendChild(body);

    if (mode === 'before') anchor.insertAdjacentElement('beforebegin', panel);
    else if (mode === 'after') anchor.insertAdjacentElement('afterend', panel);
    else anchor.appendChild(panel);

    const favorites = loadFavorites();
    const symbols = loadSymbols();

    const state = {
      panel,
      data,
      els: { search, category, grid },
      filters: { q: '', category: CFG.DEFAULT_CATEGORY },
      lastFocused: null,
      pageType: type,
      neomailIframe: null,

      favorites,
      favSet: new Set(),
      favIndex: Object.create(null),
      symbols,
    };
    rebuildFavIndex(state);

    if (type === 'neoboards') {
      const titleInput = document.querySelector(CFG.NB_TITLE_SELECTOR);
      const messageBox = textarea || document.querySelector(CFG.NB_TEXTAREA_SELECTOR);
      state.lastFocused = messageBox || titleInput || null;

      if (titleInput) {
        titleInput.addEventListener('focus', () => { state.lastFocused = titleInput; });
        titleInput.addEventListener('click', () => { state.lastFocused = titleInput; });
      }
      if (messageBox) {
        messageBox.addEventListener('focus', () => { state.lastFocused = messageBox; });
        messageBox.addEventListener('click', () => { state.lastFocused = messageBox; });
      }
    }

    if (type === 'guild') {
      const titleInput = document.querySelector(CFG.GUILD_TITLE_SELECTOR);
      const messageBox = document.querySelector(CFG.GUILD_TEXTAREA_SELECTOR);
      state.lastFocused = messageBox || titleInput || null;

      if (titleInput) {
        titleInput.addEventListener('focus', () => { state.lastFocused = titleInput; });
        titleInput.addEventListener('click', () => { state.lastFocused = titleInput; });
      }
      if (messageBox) {
        messageBox.addEventListener('focus', () => { state.lastFocused = messageBox; });
        messageBox.addEventListener('click', () => { state.lastFocused = messageBox; });
      }

      try {
        const wStr = (messageBox && messageBox.style && messageBox.style.width) ? messageBox.style.width
          : (titleInput && titleInput.style && titleInput.style.width) ? titleInput.style.width
            : '';
        const w = parseInt(String(wStr).replace('px', ''), 10);
        if (w) panel.style.width = `${w}px`;
      } catch {}
    }

    if (type === 'neomail') {
      const subj = document.querySelector(CFG.NEOMAIL_SUBJECT_SELECTOR);
      if (subj) {
        subj.addEventListener('focus', () => { state.lastFocused = subj; });
        subj.addEventListener('click', () => { state.lastFocused = subj; });
      }

      const iframe = getNeomailIframe();
      const plain = document.getElementById(CFG.NEOMAIL_IFRAME_ID);

      if (iframe && iframe.tagName === 'IFRAME') {
        state.neomailIframe = iframe;
        state.lastFocused = iframe;
        installNeomailCaretTracker(iframe, state);
        iframe.addEventListener('mousedown', () => { state.lastFocused = iframe; });
        iframe.addEventListener('click', () => { state.lastFocused = iframe; });
      } else if (plain && plain.tagName === 'TEXTAREA') {
        state.lastFocused = plain;
        plain.addEventListener('focus', () => { state.lastFocused = plain; });
        plain.addEventListener('click', () => { state.lastFocused = plain; });
      }

      const ref = getNeomailWidthReference();
      if (ref) alignPanelToReference(panel, ref);
    }

    panel._nbState = state;

    populateCategories(state);
    renderGrid(state);

    search.addEventListener('input', () => {
      state.filters.q = (search.value || '').trim().toLowerCase();
      renderGrid(state);
    });

    category.addEventListener('change', () => {
      state.filters.category = category.value;
      renderGrid(state);
    });
  }

  let booted = false;

  async function boot() {
    if (booted) return;
    booted = true;

    const type = pageType();
    if (type === 'other') return;

    let data;
    try { data = await loadSmileyData(); } catch { return; }

    const anchorInfo = findAnchorInfo(type);
    if (anchorInfo) injectPanel(anchorInfo, data, type);

    const mo = new MutationObserver(() => {
      const existing = document.getElementById('nbSmileyHelper');
      const t = pageType();
      if (t === 'other') return;

      if (!existing) {
        const ai = findAnchorInfo(t);
        if (ai) injectPanel(ai, data, t);
        return;
      }

      if (existing && existing._nbState) {
        const state = existing._nbState;

        if (state.pageType === 'neomail') {
          const iframe = getNeomailIframe();
          if (iframe && iframe.tagName === 'IFRAME') {
            state.neomailIframe = iframe;
            installNeomailCaretTracker(iframe, state);
          }
          const ref = getNeomailWidthReference();
          if (ref) alignPanelToReference(existing, ref);
        }
      }
    });

    mo.observe(document.body, { childList: true, subtree: true });
  }

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