feat: 完善表情选择与评论富文本展示

发帖编辑器支持 OwO 贴纸;评论区复用文章列表、段落与代码块阅读态,并优化表情面板间距与悬停反馈。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 03:57:41 +08:00
parent 4d93e455f9
commit 76be8926f2
11 changed files with 492 additions and 164 deletions

View File

@@ -1,5 +1,6 @@
import {
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo,
type ReactNode, type Ref,
} from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import { TextSelection, NodeSelection } from '@tiptap/pm/state';
@@ -37,7 +38,10 @@ import { ReplyOnly } from './editor/ReplyOnlyExtension';
import { PointsOnly } from './editor/PointsOnlyExtension';
import { TabIndent } from './editor/TabIndentExtension';
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
import { ArticleSticker } from './editor/ArticleStickerExtension';
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
import StickerPicker from './emoji/StickerPicker';
import type { Sticker } from '../data/stickers';
import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph';
import { ArticleLinkDialog, type ArticleLinkConfirm } from './editor/ArticleLinkDialog';
import { ArticleImagePickerDialog } from './editor/ArticleImagePickerDialog';
@@ -84,6 +88,7 @@ interface ToolBtn {
align?: 'start' | 'center' | 'end';
active?: boolean;
className?: string;
buttonRef?: Ref<HTMLButtonElement>;
action: () => void;
}
@@ -119,9 +124,17 @@ function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
}
/** 判断编辑器内容是否为空 */
/** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */
function isEditorEmpty(editor: Editor): boolean {
return editor.state.doc.textContent.trim().length === 0;
if (editor.state.doc.textContent.trim().length > 0) return false;
let hasMedia = false;
editor.state.doc.descendants((node) => {
if (node.type.name === 'image' || node.type.name === 'sticker' || node.type.name === 'imageGroup') {
hasMedia = true;
return false;
}
});
return !hasMedia;
}
/**
@@ -177,10 +190,12 @@ function renderToolButtons(tools: ToolBtn[]) {
) : null}
<Tooltip content={t.title} hint={t.hint} align={t.align} side="bottom">
<button
ref={t.buttonRef}
type="button"
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
aria-pressed={t.active || undefined}
>
{t.icon}
</button>
@@ -213,7 +228,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const [tableDialogOpen, setTableDialogOpen] = useState(false);
const [tableTarget, setTableTarget] = useState<TableTarget>('rich');
const [tableEditing, setTableEditing] = useState(false);
const [showSticker, setShowSticker] = useState(false);
const markdownRef = useRef<HTMLTextAreaElement>(null);
const editorBoxRef = useRef<HTMLDivElement>(null);
const stickerBtnRef = useRef<HTMLButtonElement>(null);
const editor = useEditor({
extensions: [
@@ -246,6 +264,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
},
}),
ArticleImage.configure({ inline: false, allowBase64: false }),
ArticleSticker,
ImageGroup,
Placeholder.configure({
placeholder: ({ node }) => {
@@ -322,23 +341,42 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
placeCaretInFirstTextblock(editor);
}, [value, editor, mode]);
// 全屏时锁定页面滚动,Esc 退出
// 全屏时锁定页面滚动;Esc 先关表情面板,再退出全屏
useEffect(() => {
if (!fullscreen) return undefined;
if (!fullscreen && !showSticker) return undefined;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const prevOverflow = fullscreen ? document.body.style.overflow : null;
if (fullscreen) document.body.style.overflow = 'hidden';
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFullscreen(false);
if (e.key !== 'Escape') return;
if (showSticker) {
setShowSticker(false);
stickerBtnRef.current?.focus();
return;
}
if (fullscreen) setFullscreen(false);
};
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = prevOverflow;
if (prevOverflow !== null) document.body.style.overflow = prevOverflow;
window.removeEventListener('keydown', onKeyDown);
};
}, [fullscreen]);
}, [fullscreen, showSticker]);
// 点击编辑器外关闭贴纸面板
useEffect(() => {
if (!showSticker) return;
const onPointer = (e: MouseEvent) => {
if (editorBoxRef.current && !editorBoxRef.current.contains(e.target as Node)) {
setShowSticker(false);
stickerBtnRef.current?.focus();
}
};
document.addEventListener('mousedown', onPointer);
return () => document.removeEventListener('mousedown', onPointer);
}, [showSticker]);
useImperativeHandle(ref, () => ({
getHTML: () => {
@@ -523,6 +561,30 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
editor.chain().focus().extendMarkRange('link').unsetLink().run();
}, [editor]);
const insertSticker = useCallback((sticker: Sticker) => {
if (mode === 'markdown') {
const textarea = markdownRef.current;
if (!textarea) return;
const snippet = (sticker.type === 'text' && sticker.text)
? sticker.text
: (sticker.url ? `![${sticker.name || '表情'}](${sticker.url})` : '');
if (!snippet) return;
insertAtCursor(textarea, markdownSource, snippet, handleMarkdownChange);
setShowSticker(false);
return;
}
if (!editor) return;
if (sticker.type === 'text' && sticker.text) {
editor.chain().focus().insertContent(sticker.text).run();
} else if (sticker.url) {
editor.chain().focus().insertContent([
{ type: 'sticker', attrs: { src: sticker.url, alt: sticker.name } },
{ type: 'text', text: ' ' },
]).run();
}
setShowSticker(false);
}, [editor, mode, markdownSource, handleMarkdownChange]);
const openImagePicker = useCallback((target: ImagePickerTarget) => {
setImagePickerTarget(target);
setImagePickerOpen(true);
@@ -614,6 +676,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const html = sanitizeHtml(editor.getHTML());
lastValueRef.current = html;
setMarkdownSource(htmlToMarkdown(html));
setShowSticker(false);
setMode('markdown');
}, [editor]);
@@ -626,6 +689,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
editor.commands.setContent(html || '', { emitUpdate: false });
placeCaretInFirstTextblock(editor);
}
setShowSticker(false);
setMode('rich');
}, [editor, markdownSource, onChange]);
@@ -676,6 +740,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
active: groupActive,
action: wrapSelectedAsGroup,
},
{
icon: <span className="article-tool-btn__owo">OwO</span>,
title: '表情 OwO',
hint: '插入贴纸或颜文字',
active: showSticker,
className: 'article-tool-btn--owo',
buttonRef: stickerBtnRef,
action: () => setShowSticker(v => !v),
},
];
if (editor.isActive('table')) {
@@ -760,7 +833,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
}
return tools;
}, [editor, enableContentGates, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]);
}, [editor, enableContentGates, showSticker, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]);
const buildMarkdownTools = useCallback((): ToolBtn[] => {
const tools: ToolBtn[] = [
@@ -782,6 +855,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
hint: '上传、链接或从已上传中选择',
action: () => openImagePicker('markdown'),
},
{
icon: <span className="article-tool-btn__owo">OwO</span>,
title: '表情 OwO',
hint: '插入贴纸或颜文字',
active: showSticker,
className: 'article-tool-btn--owo',
buttonRef: stickerBtnRef,
action: () => setShowSticker(v => !v),
},
];
if (enableContentGates) {
tools.push(
@@ -809,7 +891,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
);
}
return tools;
}, [enableContentGates, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker]);
}, [enableContentGates, showSticker, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker]);
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
const words = mode === 'markdown'
@@ -817,12 +899,16 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
: (editor ? countWords(editor.getText()) : 0);
return (
<div className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}>
<div
ref={editorBoxRef}
className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}
>
<div className="article-editor-bar">
<div className="article-editor-tools">
{renderToolButtons(tools)}
</div>
</div>
{showSticker && <StickerPicker onSelect={insertSticker} />}
<div className="article-editor-body">
{mode === 'rich' ? (

View File

@@ -1,15 +1,19 @@
import { useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { renderCommentContent } from '../utils/content';
import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks';
import { userPath } from '../utils/userPath';
import { notify } from '@/lib/notify';
interface Props {
content: string;
}
/** 渲染评论正文(支持正文内 @ 高亮与点击跳转) */
/** 渲染评论正文(支持正文内 @ 高亮、代码块阅读态与点击跳转) */
export default function CommentContent({ content }: Props) {
const nav = useNavigate();
const html = useMemo(() => renderCommentContent(content), [content]);
const openMention = async (name: string) => {
try {
@@ -26,17 +30,29 @@ export default function CommentContent({ content }: Props) {
}
};
const onClick = useCallback(async (e: React.MouseEvent) => {
try {
if (await handleMdCodeBlockUiClick(e.target)) {
e.preventDefault();
return;
}
} catch {
notify.error('复制失败');
return;
}
const el = (e.target as HTMLElement).closest('.mention') as HTMLElement | null;
if (!el) return;
const name = el.getAttribute('data-name');
if (!name) return;
e.preventDefault();
void openMention(name);
}, [nav]);
return (
<div
className="floor-body"
onClick={(e) => {
const el = (e.target as HTMLElement).closest('.mention') as HTMLElement | null;
if (!el) return;
const name = el.getAttribute('data-name');
if (!name) return;
e.preventDefault();
void openMention(name);
}}
className="floor-body post-detail-content"
onClick={(e) => { void onClick(e); }}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const el = e.target as HTMLElement;
@@ -46,9 +62,7 @@ export default function CommentContent({ content }: Props) {
e.preventDefault();
void openMention(name);
}}
dangerouslySetInnerHTML={{
__html: renderCommentContent(content),
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useCallback, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { renderPostContentHtml } from '../utils/postContent';
import { handleMdCodeBlockUiClick } from '../utils/enhanceCodeBlocks';
import { loginPath, registerPath } from '../utils/authRedirect';
import { useForumLimits } from '../hooks/useForumLimits';
import { notify } from '@/lib/notify';
@@ -105,40 +106,12 @@ export default function PostContent({
}
return;
}
const foldBtn = target.closest<HTMLElement>('[data-code-fold]');
if (foldBtn) {
e.preventDefault();
const block = foldBtn.closest('.md-codeblock');
if (!block) return;
const collapsed = block.classList.toggle('md-codeblock--collapsed');
const lineCount = parseInt(block.getAttribute('data-line-count') || '0', 10)
|| block.querySelectorAll('.md-code-line').length
|| 1;
if (collapsed && lineCount <= 5) block.classList.add('md-codeblock--short');
else block.classList.remove('md-codeblock--short');
foldBtn.textContent = collapsed ? '展开' : '收起';
return;
}
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
if (copyBtn) {
e.preventDefault();
const block = copyBtn.closest('.md-codeblock');
const bodies = block?.querySelectorAll('.md-code-line__body');
const text = bodies && bodies.length
? [...bodies].map(el => el.textContent ?? '').join('\n')
: (block?.querySelector('pre')?.textContent ?? '');
try {
await navigator.clipboard.writeText(text);
const prev = copyBtn.textContent;
copyBtn.textContent = '已复制';
copyBtn.classList.add('is-copied');
window.setTimeout(() => {
copyBtn.textContent = prev || '复制';
copyBtn.classList.remove('is-copied');
}, 1600);
} catch {
notify.error('复制失败');
try {
if (await handleMdCodeBlockUiClick(target)) {
e.preventDefault();
}
} catch {
notify.error('复制失败');
}
}, [nav, openLightbox, onRequestReply, onUnlocked, postId, unlocking]);

View File

@@ -1,5 +1,6 @@
import Image from '@tiptap/extension-image';
import { mergeAttributes } from '@tiptap/core';
import { isStickerSrc } from './ArticleStickerExtension';
/** 单图展示形态(对齐 Notion / Medium 常见选项) */
export type ImageDisplay = 'default' | 'wide' | 'float-left' | 'float-right';
@@ -19,6 +20,19 @@ declare module '@tiptap/core' {
export const ArticleImage = Image.extend({
name: 'image',
parseHTML() {
return [
{
tag: this.options.allowBase64 ? 'img[src]' : 'img[src]:not([src^="data:"])',
getAttrs: (node) => {
if (typeof node === 'string') return false;
if (isStickerSrc(node.getAttribute('src'))) return false;
return null;
},
},
];
},
addAttributes() {
return {
...this.parent?.(),

View File

@@ -0,0 +1,54 @@
import { Node, mergeAttributes } from '@tiptap/core';
/** 贴纸资源路径:评论、私信、发帖共用 /stickers/ */
export function isStickerSrc(src: string | null | undefined): boolean {
return typeof src === 'string' && src.includes('/stickers/');
}
/**
* 行内表情贴纸。文章 Image 是 block,不能拿来插表情,否则会独自占一段。
* 解析 HTML 时优先于普通 img,避免贴纸被收成通栏大图。
*/
export const ArticleSticker = Node.create({
name: 'sticker',
group: 'inline',
inline: true,
atom: true,
selectable: true,
draggable: true,
priority: 60,
addAttributes() {
return {
src: { default: null },
alt: { default: '' },
};
},
parseHTML() {
return [
{
tag: 'img[src]',
getAttrs: (node) => {
if (typeof node === 'string') return false;
const src = node.getAttribute('src') || '';
if (!isStickerSrc(src)) return false;
return {
src,
alt: node.getAttribute('alt') || '',
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'img',
mergeAttributes(HTMLAttributes, {
class: 'article-sticker',
draggable: 'false',
}),
];
},
});

View File

@@ -36,17 +36,58 @@ export default function StickerPicker({ onSelect }: Props) {
}, [active]);
useEffect(() => {
const el = gridRef.current?.querySelectorAll<HTMLElement>('[role="option"]')[focusIndex];
el?.focus();
}, [focusIndex, stickers]);
if (loading || stickers.length === 0) return;
gridRef.current?.focus();
}, [loading, 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 === ' ') {
const items = gridRef.current?.querySelectorAll<HTMLElement>('[role="option"]');
if (!items?.length) return;
const moveTo = (next: number) => {
e.preventDefault();
const i = Math.max(0, Math.min(items.length - 1, next));
setFocusIndex(i);
items[i]?.focus();
};
if (e.key === 'ArrowRight') {
moveTo(focusIndex + 1);
return;
}
if (e.key === 'ArrowLeft') {
moveTo(focusIndex - 1);
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
// 颜文字宽度不固定,按视觉行列找下一格,避免按固定 8 列错位
e.preventDefault();
const cur = items[focusIndex];
if (!cur) return;
const cr = cur.getBoundingClientRect();
const cx = cr.left + cr.width / 2;
const cy = cr.top + cr.height / 2;
const dir = e.key === 'ArrowDown' ? 1 : -1;
let best = -1;
let bestScore = Infinity;
items.forEach((el, i) => {
if (i === focusIndex) return;
const r = el.getBoundingClientRect();
const dy = (r.top + r.height / 2) - cy;
if (dy * dir <= 6) return;
const score = Math.abs(dy) * 24 + Math.abs((r.left + r.width / 2) - cx);
if (score < bestScore) {
bestScore = score;
best = i;
}
});
if (best >= 0) {
setFocusIndex(best);
items[best]?.focus();
}
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const s = stickers[focusIndex];
if (s) onSelect(s);
@@ -73,6 +114,7 @@ export default function StickerPicker({ onSelect }: Props) {
ref={gridRef}
className="sticker-picker-grid"
role="listbox"
tabIndex={0}
aria-label={`${active}贴纸`}
aria-activedescendant={`${autoId}-opt-${focusIndex}`}
onKeyDown={onKeyDown}
@@ -82,33 +124,35 @@ export default function StickerPicker({ onSelect }: Props) {
) : 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>
))
stickers.map((s, i) => {
const isText = s.type === 'text' && !!s.text;
return (
<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={isText ? 'sticker-picker-item sticker-picker-item--text' : 'sticker-picker-item sticker-picker-item--image'}
onClick={() => onSelect(s)}
onFocus={() => setFocusIndex(i)}
>
{isText ? (
<span className="sticker-picker-text">{s.text}</span>
) : (
<img
src={s.url}
alt={s.name}
width={32}
height={32}
loading="lazy"
/>
)}
</button>
);
})
)}
</div>
</div>