Files
blog/themes/Ying/assets/js/modules/emoticon-recent.js
T
zqlit c837d72b1a feat(评论): 最近表情改为分组 tab + 提交后面板收起 + 小编徽章配色 + 走心评论 20 条
- emoticon-recent.js: 「最近使用」从面板顶部独立栏重构为分组切换条最左侧的
  同构 tab(data-index="recent"),无记录时整个 tab 隐藏;复用 Artalk 原生
  .atk-grp[data-type=image] 网格样式,零自定义布局。
- artalk.js:
  · 新建评论成功(res.ok)后,对当前激活的插件按钮派发 click(),收起仍展开的
    表情面板(Artalk 上游只 reset 输入内容、不关面板),内部 openedPlug 状态同步复位。
  · 徽章处理末尾把徽章文字镜像到 data-badge 属性,供 CSS 按名字配色。
- artalk.html: 新增 .atk-badge[data-badge="小编"] 玫红实底规则(含暗色模式),
  特异性高于博主规则,两者互不覆盖。
- featured-comments.html: 走心评论 limit 8 → 20(后端上限)。
- main.css: 移除旧 .atk-recent-emoticons 相关样式。
2026-10-05 17:34:18 +08:00

208 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
})();