/** * emoticon-recent —— 表情面板「最近使用」分组 * * 场景:表情面板几十套图翻页找常用的太累,用过的排最前,一次点击直达。 * * 实现方式(零侵入,不碰 Artalk 源码,靠 DOM 契约): * - Artalk 表情面板的结构是固定的(见 Artalk.js emoticons 插件): * .atk-editor-plug-emoticons * ├── .atk-grp-wrap ← 分组内容区 * │ └── .atk-grp[data-index=i][data-type=image] * │ └── span.atk-item[title=key] > img[src alt=key] * └── .atk-grp-switcher ← 底部分组切换条 * └── span[data-index=i] (click → openGrp(i)) * - 这里在切换条**最前面**插一个「最近」tab,在内容区插一个同构的 .atk-grp 装最近用过的表情。 * 因为 .atk-item / [data-type=image] 的原生样式(63×63 网格、hover 底色)都靠类名命中, * 同构插入 = 排版自动和「抖音」等原生分组完全一致,一行自定义布局都不用写。 * - tab 的点击由我们接管(显示自己、隐藏其它、切 active)。反过来,Artalk 的 openGrp 是按 * data-index 数字匹配来显示/隐藏分组的 —— 我们的分组 data-index="recent" 永远匹配不上, * 所以用户点原生 tab 时它会被自动 display:none,两边互补,不会打架。 * - 点最近里的图 = 找到面板里原生的同一张(按 alt=key 匹配)然后 programmatic click(), * 完全复用 Artalk 自己的插入逻辑(插 :[key] 令牌),不重复实现插入。 * - 最近列表为空时整个 tab 不出现(没用过就没有「最近」,符合直觉)。 * - 面板 DOM 首次打开才创建,MutationObserver 兜底重注入。 */ (function () { if (window.__atkEmoticonRecentReady) return; // PJAX 重复执行防抖 window.__atkEmoticonRecentReady = true; var STORE_KEY = 'atk_recent_emoticons'; var MAX = 8; function load() { try { var arr = JSON.parse(localStorage.getItem(STORE_KEY) || '[]'); if (!Array.isArray(arr)) return []; return arr.filter(function (it) { return it && it.k && it.s; }); } catch (e) { return []; } } function save(list) { try { localStorage.setItem(STORE_KEY, JSON.stringify(list.slice(0, MAX))); } catch (e) {} } function record(key, src) { if (!key || !src) return; var list = load().filter(function (it) { return it.k !== key; }); list.unshift({ k: key, s: src }); save(list); } function itemImg(item) { return item && item.querySelector ? item.querySelector('img[alt]') : null; } // ── 记录:点的是面板里的原生表情(capture,先于 Artalk 绑在 .atk-item 上的 onclick)── // 监听 .atk-item 而不是 img:点在图与图之间的内边距上也算用过(原生 padding:5px)。 document.addEventListener('click', function (e) { var el = e.target; if (!el || !el.closest) return; if (!el.closest('.atk-editor-plug-emoticons')) return; if (el.closest('.atk-recent-grp')) return; // 最近分组自己的点击单独处理 var img = itemImg(el.closest('.atk-item')); if (!img) return; var key = img.getAttribute('alt'); if (key) { record(key, img.getAttribute('src') || ''); scheduleScan(); // 顺序变了,刷新最近分组 } }, true); // ── 在面板里找原生表情图(排除最近分组自己的),按 alt=key 匹配 ────────── function findNativeImg(panel, key) { var imgs = panel.querySelectorAll('.atk-grp-wrap > .atk-grp:not(.atk-recent-grp) img[alt]'); for (var i = 0; i < imgs.length; i++) { if (imgs[i].getAttribute('alt') === key) return imgs[i]; } return null; } function buildItems() { var frag = document.createDocumentFragment(); load().forEach(function (it) { var item = document.createElement('span'); item.className = 'atk-item'; item.title = it.k; var img = document.createElement('img'); img.src = it.s; img.alt = it.k; img.loading = 'lazy'; item.appendChild(img); item.onclick = function () { var panel = item.closest('.atk-editor-plug-emoticons'); var orig = panel ? findNativeImg(panel, it.k) : null; if (orig) { orig.click(); // 复用 Artalk 的插入逻辑(插 :[key] 令牌) record(it.k, it.s); // 再点一次最近里的图,顺序也要更新 scheduleScan(); } else { // 表情包配置变了,这张已经不存在 → 从最近记录里淘汰 save(load().filter(function (x) { return x.k !== it.k; })); scheduleScan(); } }; frag.appendChild(item); }); return frag; } // 最近分组当前展示的键序(用于和存储比对,判断要不要重建) function grpKeys(grp) { var imgs = grp.querySelectorAll('img[alt]'); var keys = []; for (var i = 0; i < imgs.length; i++) keys.push(imgs[i].getAttribute('alt')); return keys; } // ── 切到「最近」:自己接管显示。原生 openGrp 会在用户点回原生 tab 时 // 自动把我们的分组藏起来(按 data-index 数字匹配),所以这里不需要反向干预。── function showRecent(panel) { var switcher = panel.querySelector('.atk-grp-switcher'); var wrap = panel.querySelector('.atk-grp-wrap'); if (!switcher || !wrap) return; var kids = wrap.children; for (var i = 0; i < kids.length; i++) { kids[i].style.display = kids[i].classList.contains('atk-recent-grp') ? '' : 'none'; } var spans = switcher.querySelectorAll('span'); for (var j = 0; j < spans.length; j++) spans[j].classList.remove('active'); var tab = switcher.querySelector('.atk-recent-tab'); if (tab) tab.classList.add('active'); } function inject(panel) { if (!panel || !panel.querySelector) return; var switcher = panel.querySelector('.atk-grp-switcher'); var wrap = panel.querySelector('.atk-grp-wrap'); // 只有一组表情时 Artalk 不建切换条(没有 tab 可挂)→ 不注入 if (!switcher || !wrap) return; // 原生分组还没渲染出来时先不注入(找原生图会找不到),等下轮扫描 if (!wrap.querySelector('.atk-grp:not(.atk-recent-grp) img')) return; var list = load(); var tab = switcher.querySelector('.atk-recent-tab'); var grp = wrap.querySelector('.atk-recent-grp'); // 没有最近记录 → 整个 tab 不出现;若正停在「最近」上则切回第一组 if (!list.length) { if (tab) { var wasActive = tab.classList.contains('active'); switcher.removeChild(tab); if (grp && grp.parentNode) grp.parentNode.removeChild(grp); if (wasActive) { var first = switcher.querySelector('span[data-index="0"]'); if (first) first.click(); } } return; } if (!tab) { tab = document.createElement('span'); tab.className = 'atk-recent-tab'; tab.setAttribute('data-index', 'recent'); tab.textContent = '最近'; tab.onclick = function () { showRecent(panel); }; switcher.insertBefore(tab, switcher.firstChild); // 放在「抖音」左边 } if (!grp) { grp = document.createElement('div'); grp.className = 'atk-grp atk-recent-grp'; grp.setAttribute('data-index', 'recent'); grp.setAttribute('data-grp-name', '最近'); grp.setAttribute('data-type', 'image'); // 命中原生 63×63 网格样式 grp.style.display = 'none'; // 与原生分组一致:默认不显示 wrap.appendChild(grp); } // 内容比对:键序一致就跳过重建(避免无谓重建打断 hover) var have = grpKeys(grp); var same = have.length === list.length; if (same) { for (var i = 0; i < list.length; i++) { if (list[i].k !== have[i]) { same = false; break; } } } if (same) return; grp.innerHTML = ''; grp.appendChild(buildItems()); } var scanTimer = null; function scheduleScan() { if (scanTimer) return; scanTimer = setTimeout(function () { scanTimer = null; try { var panels = document.querySelectorAll('.atk-editor-plug-emoticons'); for (var i = 0; i < panels.length; i++) inject(panels[i]); } catch (e) {} }, 120); } try { new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true }); } catch (e) {} document.addEventListener('focusin', scheduleScan, true); scheduleScan(); })();