feat: 新增贴纸系统,重构评论编辑器与内容处理

1. 新增多平台贴纸库:贴吧、微博、知乎、小红书、抖音、B站共500+贴纸,搭配颜文字贴纸
2. 实现贴纸选择器面板,支持分类浏览、键盘导航和懒加载
3. 重构评论内容渲染:替换旧的@提及高亮逻辑,新增HTML净化与双向兼容
4. 重构评论编辑器为Tiptap富文本版本,支持表情插入、格式编辑
5. 优化评论编辑体验,修复滚动位置保留问题,调整评论区UI样式
6. 移除旧版EmojiPicker工具类,新增贴纸数据管理模块
This commit is contained in:
2026-08-08 02:24:04 +08:00
parent 1550ccb693
commit 536707f812
203 changed files with 3313 additions and 336 deletions

View File

@@ -1,3 +1,6 @@
import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
/** 转义 HTML 并保留换行 */
function escapeWithBreaks(text: string): string {
return text
@@ -18,6 +21,54 @@ export function highlightMentions(text: string): string {
);
}
/** 判断内容是否为 HTML包含常见 HTML 标签) */
function isHtmlContent(text: string): boolean {
return /<(?:p|div|span|br|h[1-6]|ul|ol|li|pre|code|blockquote|a|img|table|strong|em|u|s)\b/i.test(text);
}
/** 在 HTML 文本节点中高亮 @ 提及DOM 遍历,避免破坏标签) */
function processMentionsInHtml(html: string): string {
const div = document.createElement('div');
div.innerHTML = html;
const walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
for (const textNode of textNodes) {
const text = textNode.textContent ?? '';
if (!/@[\w\u4e00-\u9fa5_-]/.test(text)) continue;
const frag = document.createDocumentFragment();
const parts = text.split(/(@[\w\u4e00-\u9fa5_-]+)/);
for (const part of parts) {
const m = part.match(/^@([\w\u4e00-\u9fa5_-]+)$/);
if (m) {
const span = document.createElement('span');
span.className = 'mention';
span.setAttribute('data-name', m[1]);
span.setAttribute('role', 'link');
span.setAttribute('tabindex', '0');
span.textContent = part;
frag.appendChild(span);
} else if (part) {
frag.appendChild(document.createTextNode(part));
}
}
textNode.parentNode?.replaceChild(frag, textNode);
}
return div.innerHTML;
}
/** 渲染评论内容HTML 净化 + @提及高亮,兼容旧版纯文本 */
export function renderCommentContent(content: string): string {
if (isHtmlContent(content)) {
const sanitized = DOMPurify.sanitize(content, POST_CONTENT_PURIFY_CONFIG) as string;
return processMentionsInHtml(sanitized);
}
return highlightMentions(content);
}
/** 相对时间:刚刚 / N分钟前 / N小时前 / N天前更早用具体日期 */
export function formatTime(iso: string) {
const d = new Date(iso);

View File

@@ -1,21 +0,0 @@
/** 评论表情列表OwO 面板) */
export const EMOJI_LIST = [
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
'🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥',
'😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜',
'😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '🙁',
'😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩',
'🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '🥴',
'😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇',
'🥳', '🥺', '🤠', '🤡', '🤥', '🤫', '🤭', '🧐', '🤓', '😈',
'👻', '💀', '☠️', '👽', '👾', '🤖', '🎃', '😺', '😸', '😹',
'😻', '😼', '😽', '🙀', '😿', '😾', '👋', '🤚', '🖐', '✋',
'🖖', '👌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
'👆', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏',
'🙌', '👐', '🤲', '🤝', '🙏', '💪', '🦾', '🦿', '🦵', '🦶',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
'❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️',
'✨', '⭐', '🌟', '💫', '🔥', '💥', '💯', '✅', '❌', '❓',
'❗', '💢', '💤', '💦', '🎉', '🎊', '🎁', '🏆', '🥇', '🥈',
];

View File

@@ -107,14 +107,18 @@ function extractGatedInnerHtml(el: Element, bodyClass: string, badgeClass: strin
.join('');
}
/** 判断 HTML 正文是否为空(忽略空段落等) */
/** 判断 HTML 正文是否为空(忽略空段落等,含图片/视频视为非空 */
export function isHtmlEmpty(html: string): boolean {
if (!html.trim()) return true;
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
'text/html',
);
return (doc.body.textContent ?? '').trim().length === 0;
// 有文本内容
if ((doc.body.textContent ?? '').trim().length > 0) return false;
// 有图片、视频等媒体节点
if (doc.body.querySelector('img, video, iframe')) return false;
return true;
}
/** 根据登录状态渲染帖子正文 HTML */