feat: 新增贴纸系统,重构评论编辑器与内容处理
1. 新增多平台贴纸库:贴吧、微博、知乎、小红书、抖音、B站共500+贴纸,搭配颜文字贴纸 2. 实现贴纸选择器面板,支持分类浏览、键盘导航和懒加载 3. 重构评论内容渲染:替换旧的@提及高亮逻辑,新增HTML净化与双向兼容 4. 重构评论编辑器为Tiptap富文本版本,支持表情插入、格式编辑 5. 优化评论编辑体验,修复滚动位置保留问题,调整评论区UI样式 6. 移除旧版EmojiPicker工具类,新增贴纸数据管理模块
This commit is contained in:
@@ -70,8 +70,8 @@ const router = createBrowserRouter(
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/user/:id" element={<UserProfilePage />} />
|
||||
<Route path="/favorites" element={<FavoritesPage />} />
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
|
||||
</Route>
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
293
frontend/src/components/CommentEditor.tsx
Normal file
293
frontend/src/components/CommentEditor.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
116
frontend/src/components/emoji/StickerPicker.tsx
Normal file
116
frontend/src/components/emoji/StickerPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1594
frontend/src/data/stickers/emoji.json
Normal file
1594
frontend/src/data/stickers/emoji.json
Normal file
File diff suppressed because it is too large
Load Diff
57
frontend/src/data/stickers/emojiData.ts
Normal file
57
frontend/src/data/stickers/emojiData.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Sticker, StickerCategory } from './index';
|
||||
import emojiJson from './emoji.json';
|
||||
|
||||
/** 平台 → 分类名映射 */
|
||||
const PLATFORM_TO_CATEGORY: Record<string, StickerCategory> = {
|
||||
tieba: '贴吧',
|
||||
zhihu: '知乎',
|
||||
xiaohongshu: '小红书',
|
||||
douyin: '抖音',
|
||||
bilibili: 'B站',
|
||||
weibo: '微博',
|
||||
};
|
||||
|
||||
interface EmojiItem {
|
||||
url: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
/** 将 emoji.json 的 url (output/tieba/tb_01.avif) 转为本地路径 (/stickers/tieba/tb_01.avif) */
|
||||
function toLocalUrl(url: string): string {
|
||||
// output/tieba/tb_01.avif → /stickers/tieba/tb_01.avif
|
||||
return url.replace(/^output\//, '/stickers/');
|
||||
}
|
||||
|
||||
/** 缓存:平台 → Sticker[] */
|
||||
const cache = new Map<string, Sticker[]>();
|
||||
|
||||
/** 按平台获取贴纸列表 */
|
||||
export function getStickersByPlatform(platform: string): Sticker[] {
|
||||
if (cache.has(platform)) return cache.get(platform)!;
|
||||
|
||||
const category = PLATFORM_TO_CATEGORY[platform];
|
||||
if (!category) return [];
|
||||
|
||||
const entry = (emojiJson as Array<{ platform: string; emojis: EmojiItem[] }>)
|
||||
.find((p) => p.platform === platform);
|
||||
if (!entry) return [];
|
||||
|
||||
const stickers: Sticker[] = entry.emojis.map((e, i) => ({
|
||||
id: `${platform}-${i + 1}`,
|
||||
name: e.name,
|
||||
category,
|
||||
type: 'image' as const,
|
||||
url: toLocalUrl(e.url),
|
||||
aliases: e.keywords,
|
||||
}));
|
||||
|
||||
cache.set(platform, stickers);
|
||||
return stickers;
|
||||
}
|
||||
|
||||
/** 获取所有平台的贴纸(用于热门筛选) */
|
||||
export function getAllStickers(): Sticker[] {
|
||||
return Object.keys(PLATFORM_TO_CATEGORY).flatMap((p) => getStickersByPlatform(p));
|
||||
}
|
||||
24
frontend/src/data/stickers/hot.ts
Normal file
24
frontend/src/data/stickers/hot.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Sticker } from './index';
|
||||
import { getAllStickers } from './emojiData';
|
||||
import { KAOMOJI_STICKERS } from './kaomoji';
|
||||
|
||||
/** 热门贴纸 — 从各平台精选 */
|
||||
const HOT_KEYWORDS = [
|
||||
'笑哭', '开心', '大笑', '哈哈', '捂脸', '捂嘴', '飙泪笑',
|
||||
'酷', '怒', '哭', '大哭', '生气', '惊讶', '尴尬',
|
||||
'真棒', '赞', '666', '耶', '爱', '害羞', '思考',
|
||||
'滑稽', '吃瓜', '调皮', '发呆', '机智',
|
||||
];
|
||||
|
||||
/** 热门贴纸懒加载:合并所有平台后按关键词筛选 */
|
||||
export async function loadHotStickers(): Promise<Sticker[]> {
|
||||
const allEmoji = getAllStickers();
|
||||
const all = [...allEmoji, ...KAOMOJI_STICKERS];
|
||||
return all
|
||||
.filter((s) => {
|
||||
if (s.category === '颜文字') return true;
|
||||
return s.aliases?.some((a) => HOT_KEYWORDS.includes(a)) || HOT_KEYWORDS.includes(s.name);
|
||||
})
|
||||
.slice(0, 30)
|
||||
.map((s) => ({ ...s, category: '热门' as const }));
|
||||
}
|
||||
35
frontend/src/data/stickers/index.ts
Normal file
35
frontend/src/data/stickers/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/** 贴纸类型定义(URL 方式,非 sprite 裁切) */
|
||||
|
||||
export type StickerCategory = '热门' | '贴吧' | '知乎' | '小红书' | '抖音' | 'B站' | '微博' | '颜文字';
|
||||
|
||||
export type StickerType = 'image' | 'text';
|
||||
|
||||
export interface Sticker {
|
||||
id: string;
|
||||
name: string;
|
||||
category: StickerCategory;
|
||||
aliases?: string[];
|
||||
type: StickerType;
|
||||
/** image 类型:图片路径(相对于 public 目录) */
|
||||
url?: string;
|
||||
/** text 类型:颜文字字符串 */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export const STICKER_CATEGORIES: StickerCategory[] = ['热门', '贴吧', '知乎', '小红书', '抖音', 'B站', '微博', '颜文字'];
|
||||
|
||||
const CATEGORY_LOADERS: Record<StickerCategory, () => Promise<Sticker[]>> = {
|
||||
'热门': () => import('./hot').then(m => m.loadHotStickers()),
|
||||
'贴吧': () => import('./emojiData').then(m => m.getStickersByPlatform('tieba')),
|
||||
'知乎': () => import('./emojiData').then(m => m.getStickersByPlatform('zhihu')),
|
||||
'小红书': () => import('./emojiData').then(m => m.getStickersByPlatform('xiaohongshu')),
|
||||
'抖音': () => import('./emojiData').then(m => m.getStickersByPlatform('douyin')),
|
||||
'B站': () => import('./emojiData').then(m => m.getStickersByPlatform('bilibili')),
|
||||
'微博': () => import('./emojiData').then(m => m.getStickersByPlatform('weibo')),
|
||||
'颜文字': () => import('./kaomoji').then(m => m.KAOMOJI_STICKERS),
|
||||
};
|
||||
|
||||
/** 按分类动态加载贴纸数据 */
|
||||
export async function loadStickersByCategory(cat: StickerCategory): Promise<Sticker[]> {
|
||||
return CATEGORY_LOADERS[cat]();
|
||||
}
|
||||
32
frontend/src/data/stickers/kaomoji.ts
Normal file
32
frontend/src/data/stickers/kaomoji.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Sticker } from './index';
|
||||
|
||||
/** 颜文字贴纸 — 纯文本类型,选择器和编辑器中均作为纯文本显示 */
|
||||
|
||||
export const KAOMOJI_STICKERS: Sticker[] = [
|
||||
{ id: 'km-happy', name: '开心', category: '颜文字', type: 'text', text: '(ノ´∀`)ノ', aliases: ['哈哈', '开心'] },
|
||||
{ id: 'km-laugh', name: '大笑', category: '颜文字', type: 'text', text: '(≧∇≦)ノ', aliases: ['哈哈哈', '笑死'] },
|
||||
{ id: 'km-cry', name: '哭', category: '颜文字', type: 'text', text: '(´;ω;`)', aliases: ['呜呜', '伤心'] },
|
||||
{ id: 'km-angry', name: '生气', category: '颜文字', type: 'text', text: 'ヽ(`⌒´)ノ', aliases: ['怒', '气死'] },
|
||||
{ id: 'km-shrug', name: '无奈', category: '颜文字', type: 'text', text: '╮(°-°)╭', aliases: ['呵呵', '无语'] },
|
||||
{ id: 'km-determined', name: '加油', category: '颜文字', type: 'text', text: '(๑•̀ㅁ•́ฅ)', aliases: ['冲', '奥利给'] },
|
||||
{ id: 'km-sad', name: '难过', category: '颜文字', type: 'text', text: '( ´・ω・`)', aliases: ['失落', '低落'] },
|
||||
{ id: 'km-sparkle', name: '兴奋', category: '颜文字', type: 'text', text: '(ノ´ヮ`)ノ*: ・゚', aliases: ['太棒了', '耶'] },
|
||||
{ id: 'km-tear', name: '泪奔', category: '颜文字', type: 'text', text: '(╥﹏╥)', aliases: ['泪流', '呜呜'] },
|
||||
{ id: 'km-love', name: '喜欢', category: '颜文字', type: 'text', text: '(◕ᴗ◕✿)', aliases: ['爱', '心动'] },
|
||||
{ id: 'km-cool', name: '酷', category: '颜文字', type: 'text', text: '(⌐■_■)', aliases: ['帅', '墨镜'] },
|
||||
{ id: 'km-stare', name: '盯', category: '颜文字', type: 'text', text: 'ಠ_ಠ', aliases: ['凝视', '盯着看'] },
|
||||
{ id: 'km-tableflip', name: '掀桌', category: '颜文字', type: 'text', text: '(╯°□°)╯︵ ┻━┻', aliases: ['掀桌', '愤怒'] },
|
||||
{ id: 'km-bow', name: '拜托', category: '颜文字', type: 'text', text: '(人・ω・)💦', aliases: ['求求', '拜托了'] },
|
||||
{ id: 'km-proud', name: '得意', category: '颜文字', type: 'text', text: '( ̄▽ ̄)"', aliases: ['嘿嘿', '自满'] },
|
||||
{ id: 'km-sleep', name: '困', category: '颜文字', type: 'text', text: '(-ω-)Zzz', aliases: ['睡觉', '晚安'] },
|
||||
{ id: 'km-wave', name: '招手', category: '颜文字', type: 'text', text: '(´・ω・)ノ', aliases: ['你好', '拜拜'] },
|
||||
{ id: 'km-wink', name: '眨眼', category: '颜文字', type: 'text', text: '(◠‿◠)', aliases: ['抛媚眼', '嘿嘿'] },
|
||||
{ id: 'km-sorry', name: '抱歉', category: '颜文字', type: 'text', text: 'm(._.)m', aliases: ['对不起', '跪了'] },
|
||||
{ id: 'km-doubt', name: '疑惑', category: '颜文字', type: 'text', text: '(╬ Ò _ Ó)', aliases: ['什么', '???'] },
|
||||
{ id: 'km-hungry', name: '饿了', category: '颜文字', type: 'text', text: '(๑´ㅂ`๑)', aliases: ['想吃', '吃货'] },
|
||||
{ id: 'km-gameover', name: 'GG', category: '颜文字', type: 'text', text: '(╯︿╰﹀ )', aliases: ['GG', '完了'] },
|
||||
{ id: 'km-gift', name: '送花', category: '颜文字', type: 'text', text: '(✿◠‿◠)', aliases: ['送花', '谢谢'] },
|
||||
{ id: 'km-clap', name: '鼓掌', category: '颜文字', type: 'text', text: 'ヾ(´▽`;)ゝ', aliases: ['呱唧', '鼓掌'] },
|
||||
{ id: 'km-cheer', name: '加油', category: '颜文字', type: 'text', text: '\(^ω^\)', aliases: ['冲鸭', 'go'] },
|
||||
{ id: 'km-please', name: '拜托了', category: '颜文字', type: 'text', text: '( ´・ω・`)', aliases: ['嘤嘤', '求求了'] },
|
||||
];
|
||||
@@ -4447,7 +4447,7 @@ a.post-title:visited {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.post-detail-content img {
|
||||
.post-detail-content img:not([src*="/stickers/"]) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
@@ -5266,7 +5266,7 @@ a.post-title:visited {
|
||||
}
|
||||
|
||||
.comment-box-input-wrap:focus-within {
|
||||
border-color: rgb(var(--primary-6));
|
||||
border-color: var(--j13-green);
|
||||
}
|
||||
|
||||
.comment-box-input-wrap.private-mode {
|
||||
@@ -5294,9 +5294,7 @@ a.post-title:visited {
|
||||
.comment-box-textarea::placeholder { color: var(--color-text-4); }
|
||||
|
||||
.comment-box-send {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
margin-left: auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
@@ -5309,6 +5307,7 @@ a.post-title:visited {
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comment-box-send:hover:not(:disabled) { background: #1a9bd8; }
|
||||
@@ -5461,6 +5460,190 @@ a.post-title:visited {
|
||||
box-shadow: inset 0 0 0 2px var(--j13-green);
|
||||
}
|
||||
|
||||
/* 贴纸选择器 */
|
||||
.sticker-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--j13-bg-surface);
|
||||
max-height: 280px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.sticker-picker-tabs {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sticker-picker-tab {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.sticker-picker-tab:hover { color: var(--color-text-1); }
|
||||
|
||||
.sticker-picker-tab.active {
|
||||
color: var(--j13-green);
|
||||
border-bottom-color: var(--j13-green);
|
||||
}
|
||||
|
||||
.sticker-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sticker-picker-item {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background 0.1s, transform 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sticker-picker-item:hover {
|
||||
background: var(--color-fill-2);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.sticker-picker-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--j13-green);
|
||||
}
|
||||
|
||||
.sticker-picker-loading {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--color-text-4);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 评论富文本编辑器(精简 Tiptap,复用 .article-editor-* 类) */
|
||||
.comment-editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-editor .article-editor-bar {
|
||||
padding: 4px 8px;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--j13-border-light);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.comment-editor .article-editor-tools { gap: 1px; flex-wrap: nowrap; }
|
||||
|
||||
.comment-editor .article-tool-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.comment-editor .article-tool-btn strong { font-size: 11px; }
|
||||
|
||||
/* OwO 文本按钮:与 SVG 图标视觉对齐 */
|
||||
.article-tool-btn__owo {
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
transform: translateY(-0.5px);
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.comment-editor .article-editor-body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.comment-editor .article-editor-scroll {
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.comment-editor .article-editor-content .tiptap,
|
||||
.comment-editor .article-prosemirror {
|
||||
min-height: 80px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 评论正文中的贴纸 img */
|
||||
.floor-body img[src*="/stickers/"],
|
||||
.comment-body img[src*="/stickers/"] {
|
||||
display: inline-block !important;
|
||||
vertical-align: middle;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
max-width: 28px;
|
||||
margin: 0 1px !important;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
box-shadow: none !important;
|
||||
object-fit: contain;
|
||||
clear: none !important;
|
||||
}
|
||||
|
||||
/* 编辑器内贴纸 img 尺寸约束 */
|
||||
.comment-editor .article-prosemirror img[src*="/stickers/"],
|
||||
.comment-editor .article-editor-content img[src*="/stickers/"] {
|
||||
display: inline-block !important;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
max-width: 28px;
|
||||
vertical-align: middle;
|
||||
margin: 0 1px !important;
|
||||
border-radius: 4px;
|
||||
box-shadow: none !important;
|
||||
object-fit: contain;
|
||||
clear: none !important;
|
||||
}
|
||||
|
||||
/* 选择器中颜文字纯文本样式 */
|
||||
.sticker-picker-text {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
font-family: 'Segoe UI Emoji', 'Apple Color Emoji', 'Noto Sans Mono', monospace;
|
||||
color: var(--color-text-1);
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* 移动端:贴纸选择器 4 列 */
|
||||
@media (max-width: 640px) {
|
||||
.sticker-picker { max-height: 240px; }
|
||||
.sticker-picker-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.sticker-picker-tab { padding: 6px 10px; font-size: 12px; }
|
||||
.comment-editor .article-tool-btn { width: 30px; height: 30px; }
|
||||
}
|
||||
|
||||
/* Waline 嵌套评论列表 — 与正文共用 .page-wrap 滚动 */
|
||||
|
||||
.waline-comment {
|
||||
@@ -5704,6 +5887,20 @@ a.waline-comment-author:hover {
|
||||
|
||||
.waline-comment-edit {
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--j13-border-light);
|
||||
border-radius: 10px;
|
||||
background: var(--j13-bg-block);
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.waline-comment-edit:focus-within {
|
||||
border-color: var(--j13-green);
|
||||
}
|
||||
|
||||
/* 覆盖 ProseMirror 默认黑色 outline */
|
||||
.comment-editor .article-prosemirror,
|
||||
.comment-editor .ProseMirror {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.waline-comment-edit-input {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/** 评论表情列表(OwO 面板) */
|
||||
export const EMOJI_LIST = [
|
||||
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
||||
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
|
||||
'🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥',
|
||||
'😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜',
|
||||
'😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '🙁',
|
||||
'😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩',
|
||||
'🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '🥴',
|
||||
'😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇',
|
||||
'🥳', '🥺', '🤠', '🤡', '🤥', '🤫', '🤭', '🧐', '🤓', '😈',
|
||||
'👻', '💀', '☠️', '👽', '👾', '🤖', '🎃', '😺', '😸', '😹',
|
||||
'😻', '😼', '😽', '🙀', '😿', '😾', '👋', '🤚', '🖐', '✋',
|
||||
'🖖', '👌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
|
||||
'👆', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏',
|
||||
'🙌', '👐', '🤲', '🤝', '🙏', '💪', '🦾', '🦿', '🦵', '🦶',
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
|
||||
'❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️',
|
||||
'✨', '⭐', '🌟', '💫', '🔥', '💥', '💯', '✅', '❌', '❓',
|
||||
'❗', '💢', '💤', '💦', '🎉', '🎊', '🎁', '🏆', '🥇', '🥈',
|
||||
];
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user