// ==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 // @downloadURL https://update.greasyfork.icu/scripts/568187/Neopets%20Smileys.user.js // @updateURL https://update.greasyfork.icu/scripts/568187/Neopets%20Smileys.meta.js // ==/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(); })();