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,15 +1,14 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Send } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { User, Comment } from '../api/types';
import EmojiPicker from './EmojiPicker';
import CommentEditor, { type CommentEditorHandle } from './CommentEditor';
import { commentNick } from '../utils/comment';
import { loginPath, registerPath } from '../utils/authRedirect';
import { cn } from '@/lib/utils';
import { isHtmlEmpty } from '../utils/postContent';
export interface CommentSubmitData {
content: string;
@@ -26,179 +25,32 @@ interface Props {
onCancelReply?: () => void;
}
type MentionUser = { id: number; username: string; nickname: string; avatar?: string };
/** 评论输入框:需登录后发表;支持 @ 用户补全 */
/** 评论输入框:需登录后发表;富文本编辑器 + 贴纸 + 隐私评论 */
export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
const [content, setContent] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const [showEmoji, setShowEmoji] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionStart, setMentionStart] = useState(-1);
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([]);
const [mentionIndex, setMentionIndex] = useState(0);
const [mentionLoading, setMentionLoading] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const owoRef = useRef<HTMLButtonElement>(null);
const mentionTimer = useRef<number | null>(null);
const editorRef = useRef<CommentEditorHandle>(null);
useEffect(() => {
if (inline && replyTo) {
textareaRef.current?.focus({ preventScroll: true });
editorRef.current?.focus();
}
}, [replyTo?.id, inline]);
useEffect(() => {
setContent('');
setShowEmoji(false);
setIsPrivate(false);
setMentionQuery(null);
setMentionUsers([]);
editorRef.current?.focus();
}, [submitCount]);
useEffect(() => {
if (!showEmoji) return;
const onPointer = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
setShowEmoji(false);
owoRef.current?.focus();
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowEmoji(false);
owoRef.current?.focus();
}
};
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [showEmoji]);
const closeMention = useCallback(() => {
setMentionQuery(null);
setMentionUsers([]);
setMentionIndex(0);
setMentionStart(-1);
}, []);
const scanMention = useCallback((text: string, caret: number) => {
const before = text.slice(0, caret);
const m = before.match(/@([\w\u4e00-\u9fa5_-]*)$/);
if (!m) {
closeMention();
return;
}
const start = caret - m[0].length;
// @ 前须为行首或空白,避免邮箱等误触
if (start > 0 && !/\s/.test(text[start - 1])) {
closeMention();
return;
}
setMentionStart(start);
setMentionQuery(m[1] ?? '');
}, [closeMention]);
useEffect(() => {
if (mentionQuery === null) return;
if (mentionQuery.length === 0) {
setMentionUsers([]);
setMentionLoading(false);
return;
}
if (mentionTimer.current) window.clearTimeout(mentionTimer.current);
mentionTimer.current = window.setTimeout(() => {
setMentionLoading(true);
api.searchUsers(mentionQuery, 8)
.then((r) => {
setMentionUsers(r.users || []);
setMentionIndex(0);
})
.catch(() => setMentionUsers([]))
.finally(() => setMentionLoading(false));
}, 200);
return () => {
if (mentionTimer.current) window.clearTimeout(mentionTimer.current);
};
}, [mentionQuery]);
const insertAtCaret = (insert: string, replaceFrom?: number, replaceTo?: number) => {
const el = textareaRef.current;
if (!el) {
setContent((prev) => prev + insert);
return;
}
const start = replaceFrom ?? el.selectionStart ?? content.length;
const end = replaceTo ?? el.selectionEnd ?? content.length;
const next = content.slice(0, start) + insert + content.slice(end);
setContent(next);
requestAnimationFrame(() => {
el.focus();
const pos = start + insert.length;
el.setSelectionRange(pos, pos);
});
};
const insertEmoji = (emoji: string) => {
insertAtCaret(emoji);
};
const pickMention = (u: MentionUser) => {
if (mentionStart < 0) return;
const el = textareaRef.current;
const caret = el?.selectionStart ?? content.length;
insertAtCaret(`@${u.username} `, mentionStart, caret);
closeMention();
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const next = e.target.value;
setContent(next);
scanMention(next, e.target.selectionStart ?? next.length);
};
const handleSubmit = () => {
if (!user) return;
const text = content.trim();
if (!text) {
if (isHtmlEmpty(content)) {
notify.warning('请先写点内容');
textareaRef.current?.focus();
editorRef.current?.focus();
return;
}
onSubmit({ content: text, isPrivate });
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (mentionQuery !== null && mentionUsers.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionIndex((i) => (i + 1) % mentionUsers.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionIndex((i) => (i - 1 + mentionUsers.length) % mentionUsers.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
pickMention(mentionUsers[mentionIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeMention();
return;
}
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
onSubmit({ content, isPrivate });
};
if (!user) {
@@ -218,11 +70,10 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
}
const avatarInitial = user.nickname?.[0] || '?';
const canSend = !!content.trim() && !submitting;
const showMentionPopup = mentionQuery !== null && mentionQuery.length > 0;
const canSend = !submitting && !isHtmlEmpty(content);
return (
<div className="comment-box" ref={boxRef}>
<div className="comment-box">
<div className="comment-box-avatar">
{user.avatar ? (
<img src={user.avatar} alt="" className="comment-box-avatar-img" loading="lazy" decoding="async" />
@@ -244,86 +95,31 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
)}
<div className={`comment-box-input-wrap ${isPrivate ? 'private-mode' : ''}`}>
{showMentionPopup && (
<div className="comment-mention-popup" role="listbox" aria-label="提及用户">
{mentionLoading && mentionUsers.length === 0 ? (
<div className="comment-mention-empty"></div>
) : mentionUsers.length === 0 ? (
<div className="comment-mention-empty"></div>
) : (
mentionUsers.map((u, i) => (
<button
key={u.id}
type="button"
role="option"
aria-selected={i === mentionIndex}
className={cn('comment-mention-item', i === mentionIndex && 'active')}
onMouseDown={(e) => {
e.preventDefault();
pickMention(u);
}}
>
<span className="comment-mention-avatar" aria-hidden>
{u.avatar
? <img src={u.avatar} alt="" />
: (u.nickname?.[0] || u.username[0] || '?')}
</span>
<span className="comment-mention-meta">
<span className="comment-mention-nick">{u.nickname || u.username}</span>
<span className="comment-mention-user">@{u.username}</span>
</span>
</button>
))
)}
</div>
)}
<textarea
ref={textareaRef}
className="comment-box-textarea"
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧,可用 @ 提及用户'}
<CommentEditor
ref={editorRef}
value={content}
onChange={handleChange}
onKeyDown={handleKeyDown}
onClick={(e) => scanMention(content, e.currentTarget.selectionStart ?? content.length)}
onKeyUp={(e) => {
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
scanMention(content, e.currentTarget.selectionStart ?? content.length);
}
}}
rows={3}
onChange={setContent}
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧,可用 @ 提及用户'}
/>
</div>
<div className="comment-box-toolbar">
<label className="comment-box-private" title="仅作者与管理员可见">
<Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
<span></span>
</label>
<span className="comment-box-private-hint"></span>
<button
type="button"
className="comment-box-send"
disabled={!canSend}
onClick={handleSubmit}
aria-label="发送评论"
title="发送Ctrl/⌘ + Enter"
title="发送"
>
<Send size={16} />
</button>
</div>
<div className="comment-box-toolbar">
<button
ref={owoRef}
type="button"
className={`comment-box-owo ${showEmoji ? 'active' : ''}`}
onClick={() => setShowEmoji((v) => !v)}
aria-label="插入表情"
aria-expanded={showEmoji}
aria-controls="comment-emoji-picker"
>
OwO
</button>
<label className="comment-box-private" title="仅作者与管理员可见">
<Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
<span></span>
</label>
<span className="comment-box-private-hint"></span>
</div>
{showEmoji && <EmojiPicker id="comment-emoji-picker" onSelect={insertEmoji} />}
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { highlightMentions } from '../utils/content';
import { renderCommentContent } from '../utils/content';
import { userPath } from '../utils/userPath';
interface Props {
@@ -47,7 +47,7 @@ export default function CommentContent({ content }: Props) {
void openMention(name);
}}
dangerouslySetInnerHTML={{
__html: highlightMentions(content),
__html: renderCommentContent(content),
}}
/>
);

View File

@@ -0,0 +1,293 @@
import {
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState,
} from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import DOMPurify from 'dompurify';
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Quote,
List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon,
} from 'lucide-react';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { api } from '../api/client';
import { notify } from '@/lib/notify';
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
import { ArticleImage } from './editor/ArticleImageExtension';
import { TabIndent } from './editor/TabIndentExtension';
import type { CodeBlockInsertOptions } from '../utils/codeBlockOptions';
import { Tooltip } from './ui/Tooltip';
import StickerPicker from './emoji/StickerPicker';
import type { Sticker } from '../data/stickers';
export interface CommentEditorHandle {
getHTML: () => string;
isEmpty: () => boolean;
focus: () => void;
}
interface Props {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}
function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
}
function isEditorEmpty(editor: Editor): boolean {
if (editor.state.doc.textContent.trim().length > 0) return false;
// 检查是否有图片节点(表情)
let hasImage = false;
editor.state.doc.descendants((node) => {
if (node.type.name === 'image') { hasImage = true; return false; }
});
return !hasImage;
}
/** 触发图片文件选择并上传 */
async function uploadImageFiles(): Promise<string[]> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.multiple = false;
input.onchange = async () => {
const files = [...(input.files ?? [])];
if (!files.length) { resolve([]); return; }
try {
const { url } = await api.uploadPostImage(files[0]);
resolve([url]);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
resolve([]);
}
};
input.click();
});
}
const CommentEditor = forwardRef<CommentEditorHandle, Props>(function CommentEditor(
{ value, onChange, placeholder = '说点什么吧…' },
ref,
) {
const isInternalUpdate = useRef(false);
const lastValueRef = useRef(value);
const [, setTick] = useState(0);
const [showSticker, setShowSticker] = useState(false);
const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false);
const [codeBlockEditing, setCodeBlockEditing] = useState(false);
const [codeBlockInitial, setCodeBlockInitial] = useState<CodeBlockInsertOptions | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
const stickerBtnRef = useRef<HTMLButtonElement>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3, 4] },
codeBlock: false,
link: false,
underline: false,
}),
ArticleCodeBlock,
Underline,
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
}),
ArticleImage.configure({ inline: true, allowBase64: true }),
Placeholder.configure({ placeholder }),
TabIndent,
],
content: sanitizeHtml(value) || '',
autofocus: false,
onUpdate: ({ editor: ed }) => {
const html = sanitizeHtml(ed.getHTML());
isInternalUpdate.current = true;
lastValueRef.current = html;
onChange(html);
},
onSelectionUpdate: () => setTick(t => t + 1),
onTransaction: () => setTick(t => t + 1),
editorProps: {
attributes: {
class: 'article-prosemirror post-detail-content',
spellcheck: 'false',
},
},
});
useEffect(() => {
if (!editor) return;
if (isInternalUpdate.current) {
isInternalUpdate.current = false;
return;
}
const next = sanitizeHtml(value);
if (next === lastValueRef.current) return;
lastValueRef.current = next;
editor.commands.setContent(next || '', { emitUpdate: false });
}, [value, editor]);
// 点击外部关闭贴纸面板
useEffect(() => {
if (!showSticker) return;
const onPointer = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
setShowSticker(false);
stickerBtnRef.current?.focus();
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowSticker(false);
stickerBtnRef.current?.focus();
}
};
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [showSticker]);
useImperativeHandle(ref, () => ({
getHTML: () => editor ? sanitizeHtml(editor.getHTML()) : value,
isEmpty: () => editor ? isEditorEmpty(editor) : !value.trim(),
focus: () => { editor?.commands.focus(); },
}), [editor, value]);
const insertSticker = useCallback((sticker: Sticker) => {
if (!editor) return;
if (sticker.type === 'text' && sticker.text) {
editor.chain().focus().insertContent(sticker.text).run();
} else if (sticker.url) {
// 插入内联图片 + 尾随空格,确保光标可定位到图片右侧(类似聊天 app
editor.chain().focus().insertContent([
{ type: 'image', attrs: { src: sticker.url, alt: sticker.name } },
{ type: 'text', text: ' ' },
]).run();
// 再次 focus 确保光标在空格之后
editor.chain().focus().run();
}
setShowSticker(false);
}, [editor]);
const setImage = useCallback(async () => {
if (!editor) return;
const urls = await uploadImageFiles();
if (!urls.length) return;
editor.chain().focus().setImage({ src: urls[0] }).run();
}, [editor]);
const openCodeBlockDialog = useCallback(() => {
if (!editor) return;
const isEditing = editor.isActive('codeBlock');
setCodeBlockEditing(isEditing);
if (isEditing) {
const attrs = editor.getAttributes('codeBlock');
setCodeBlockInitial({
language: (attrs.language as string) || '',
lineNumbers: Boolean(attrs.lineNumbers),
collapsed: Boolean(attrs.collapsed),
});
} else {
// 新建代码块默认折叠(评论空间有限)
setCodeBlockInitial({ language: '', lineNumbers: false, collapsed: true });
}
setCodeBlockDialogOpen(true);
}, [editor]);
const applyCodeBlock = useCallback((opts: CodeBlockInsertOptions) => {
if (!editor) return;
editor.chain().focus().setArticleCodeBlock({
language: opts.language || null,
lineNumbers: opts.lineNumbers,
collapsed: opts.collapsed,
}).run();
}, [editor]);
const setLink = useCallback(() => {
if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('链接地址', prev ?? 'https://');
if (url === null) return;
if (!url) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
if (!editor) {
return <div className="comment-editor"><div className="article-editor-bar" /><div className="article-editor-body" /></div>;
}
const tools: { icon: React.ReactNode; title: string; active?: boolean; action: () => void }[] = [
{ icon: <strong>H</strong>, title: '标题', active: editor.isActive('heading'), action: () => {
for (let l = 2; l <= 4; l++) {
if (editor.isActive('heading', { level: l })) {
if (l === 4) editor.chain().focus().setParagraph().run();
else editor.chain().focus().toggleHeading({ level: (l + 1) as 2 | 3 | 4 }).run();
return;
}
}
editor.chain().focus().toggleHeading({ level: 2 }).run();
}},
{ icon: <Bold size={15} />, title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
{ icon: <Italic size={15} />, title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
{ icon: <UnderlineIcon size={15} />, title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
{ icon: <Strikethrough size={15} />, title: '删除线', active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() },
{ icon: <Quote size={15} />, title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() },
{ icon: <List size={15} />, title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() },
{ icon: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
{ icon: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: openCodeBlockDialog },
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: setLink },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: setImage },
{ icon: <span className="article-tool-btn__owo">OwO</span>, title: '表情 OwO', active: showSticker, action: () => setShowSticker(v => !v) },
];
return (
<div className="comment-editor" ref={boxRef}>
<div className="article-editor-bar">
<div className="article-editor-tools">
{tools.map((t, i) => (
<Tooltip key={i} content={t.title} side="bottom">
<button
ref={i === tools.length - 1 ? stickerBtnRef : undefined}
type="button"
className={`article-tool-btn${t.active ? ' active' : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
aria-label={t.title}
>
{t.icon}
</button>
</Tooltip>
))}
</div>
</div>
<div className="article-editor-body">
<div className="article-editor-scroll">
<EditorContent editor={editor} className="article-editor-content" />
</div>
</div>
{showSticker && <StickerPicker onSelect={insertSticker} />}
<ArticleCodeBlockDialog
open={codeBlockDialogOpen}
onOpenChange={setCodeBlockDialogOpen}
initial={codeBlockInitial}
editing={codeBlockEditing}
onConfirm={applyCodeBlock}
/>
</div>
);
});
export default CommentEditor;

View File

@@ -7,6 +7,7 @@ import type { ReactNode } from 'react';
import type { Comment, ReportReason, User } from '../api/types';
import { api } from '../api/client';
import CommentContent from './CommentContent';
import CommentEditor from './CommentEditor';
import CommentRevisionDialog from './CommentRevisionDialog';
import {
AlertDialog,
@@ -46,6 +47,7 @@ import {
import { isTimeDiffSignificant } from '../utils/content';
import { REPORT_REASON_OPTIONS } from '../utils/report';
import { useForumLimits } from '../hooks/useForumLimits';
import { isHtmlEmpty } from '../utils/postContent';
import { Tooltip } from './ui/Tooltip';
import UserLink from './UserLink';
import { cn } from '@/lib/utils';
@@ -274,12 +276,10 @@ function CommentItem({
</div>
) : isEditing ? (
<div className="waline-comment-edit">
<textarea
className="waline-comment-edit-input"
<CommentEditor
value={editText}
onChange={e => setEditText(e.target.value)}
rows={3}
maxLength={limits.comment_max > 0 ? limits.comment_max : undefined}
onChange={setEditText}
placeholder="编辑评论…"
/>
<div className="waline-comment-edit-actions">
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelEdit} disabled={saving}>
@@ -289,7 +289,7 @@ function CommentItem({
type="button"
className="waline-comment-reply-btn"
onClick={handleSave}
disabled={saving || !editText.trim()}
disabled={saving || isHtmlEmpty(editText)}
>
{saving ? '保存中…' : '保存'}
</button>

View File

@@ -1,68 +0,0 @@
import { useEffect, useId, useRef, useState } from 'react';
import { EMOJI_LIST } from '../utils/emojis';
interface Props {
onSelect: (emoji: string) => void;
id?: string;
}
/** OwO 表情选择面板方向键浏览Enter 选中) */
export default function EmojiPicker({ onSelect, id }: Props) {
const autoId = useId();
const listId = id ?? autoId;
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
listRef.current?.querySelectorAll<HTMLElement>('[role="option"]')[active]?.focus();
}, [active]);
const onKeyDown = (e: React.KeyboardEvent) => {
const cols = 8;
let next = active;
if (e.key === 'ArrowRight') next = Math.min(EMOJI_LIST.length - 1, active + 1);
else if (e.key === 'ArrowLeft') next = Math.max(0, active - 1);
else if (e.key === 'ArrowDown') next = Math.min(EMOJI_LIST.length - 1, active + cols);
else if (e.key === 'ArrowUp') next = Math.max(0, active - cols);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = EMOJI_LIST.length - 1;
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(EMOJI_LIST[active]);
return;
} else {
return;
}
e.preventDefault();
setActive(next);
};
return (
<div
id={listId}
ref={listRef}
className="emoji-picker"
role="listbox"
aria-label="表情列表"
aria-activedescendant={`${listId}-opt-${active}`}
onKeyDown={onKeyDown}
>
{EMOJI_LIST.map((e, i) => (
<button
key={e}
id={`${listId}-opt-${i}`}
type="button"
role="option"
tabIndex={active === i ? 0 : -1}
aria-selected={active === i}
className={`emoji-picker-item${active === i ? ' emoji-picker-item--active' : ''}`}
aria-label={e}
onClick={() => onSelect(e)}
onFocus={() => setActive(i)}
>
{e}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { useState, useEffect, useId, useRef, useCallback } from 'react';
import {
STICKER_CATEGORIES,
loadStickersByCategory,
type Sticker,
type StickerCategory,
} from '../../data/stickers';
interface Props {
onSelect: (sticker: Sticker) => void;
}
/** 贴纸选择面板(分类 Tab + 懒加载 + 键盘导航 + 图片/纯文本混合渲染) */
export default function StickerPicker({ onSelect }: Props) {
const autoId = useId();
const [active, setActive] = useState<StickerCategory>('热门');
const [stickers, setStickers] = useState<Sticker[]>([]);
const [loading, setLoading] = useState(false);
const [focusIndex, setFocusIndex] = useState(0);
const gridRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setStickers([]);
loadStickersByCategory(active)
.then((list) => {
if (cancelled) return;
setStickers(list);
setFocusIndex(0);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [active]);
useEffect(() => {
const el = gridRef.current?.querySelectorAll<HTMLElement>('[role="option"]')[focusIndex];
el?.focus();
}, [focusIndex, stickers]);
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
const cols = 8;
if (e.key === 'ArrowRight') { e.preventDefault(); setFocusIndex((i) => Math.min(stickers.length - 1, i + 1)); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); setFocusIndex((i) => Math.max(0, i - 1)); }
else if (e.key === 'ArrowDown') { e.preventDefault(); setFocusIndex((i) => Math.min(stickers.length - 1, i + cols)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusIndex((i) => Math.max(0, i - cols)); }
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const s = stickers[focusIndex];
if (s) onSelect(s);
}
}, [stickers, focusIndex, onSelect]);
return (
<div className="sticker-picker" role="dialog" aria-label="贴纸选择">
<div className="sticker-picker-tabs" role="tablist">
{STICKER_CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
role="tab"
aria-selected={active === cat}
className={`sticker-picker-tab${active === cat ? ' active' : ''}`}
onClick={() => setActive(cat)}
>
{cat}
</button>
))}
</div>
<div
ref={gridRef}
className="sticker-picker-grid"
role="listbox"
aria-label={`${active}贴纸`}
aria-activedescendant={`${autoId}-opt-${focusIndex}`}
onKeyDown={onKeyDown}
>
{loading ? (
<div className="sticker-picker-loading"></div>
) : stickers.length === 0 ? (
<div className="sticker-picker-loading"></div>
) : (
stickers.map((s, i) => (
<button
key={s.id}
id={`${autoId}-opt-${i}`}
type="button"
role="option"
tabIndex={focusIndex === i ? 0 : -1}
aria-selected={focusIndex === i}
aria-label={s.name}
className="sticker-picker-item"
onClick={() => onSelect(s)}
onFocus={() => setFocusIndex(i)}
>
{s.type === 'text' && s.text ? (
<span className="sticker-picker-text">{s.text}</span>
) : (
<img
src={s.url}
alt={s.name}
width={32}
height={32}
style={{ width: 32, height: 32, objectFit: 'contain' }}
loading="lazy"
/>
)}
</button>
))
)}
</div>
</div>
);
}