增加回复可见功能
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
FileCode, PenLine, Maximize2, Minimize2,
|
||||
Columns2, PanelLeft, PanelRight, StretchHorizontal,
|
||||
Table as TableIcon, BetweenHorizonalStart, BetweenVerticalStart, Rows3, Columns3,
|
||||
MessageSquareLock,
|
||||
} from 'lucide-react';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||
@@ -25,12 +26,14 @@ import {
|
||||
prefixMarkdownLines,
|
||||
cycleMarkdownHeading,
|
||||
insertMarkdownMembersOnly,
|
||||
insertMarkdownReplyOnly,
|
||||
insertMarkdownLink,
|
||||
} from '../utils/markdownFormat';
|
||||
import { countWords } from '../utils/text';
|
||||
import { api } from '../api/client';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { MembersOnly } from './editor/MembersOnlyExtension';
|
||||
import { ReplyOnly } from './editor/ReplyOnlyExtension';
|
||||
import { TabIndent } from './editor/TabIndentExtension';
|
||||
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
|
||||
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
|
||||
@@ -76,6 +79,7 @@ interface ToolBtn {
|
||||
}
|
||||
|
||||
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
|
||||
const REPLY_ONLY_PLACEHOLDER = '在此输入回复后可见的内容…';
|
||||
|
||||
/** 按选项生成 Markdown 侧插入片段(围栏 meta,便于手写) */
|
||||
function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代码'): string {
|
||||
@@ -257,11 +261,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
|
||||
return MEMBERS_ONLY_PLACEHOLDER;
|
||||
}
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'replyOnly') {
|
||||
return REPLY_ONLY_PLACEHOLDER;
|
||||
}
|
||||
return placeholder;
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
MembersOnly,
|
||||
ReplyOnly,
|
||||
TabIndent,
|
||||
],
|
||||
content: sanitizeHtml(value) || '',
|
||||
@@ -517,6 +525,22 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
editor.chain().focus().insertMembersOnly().run();
|
||||
}, [editor]);
|
||||
|
||||
const wrapReplyOnly = useCallback(() => {
|
||||
if (!editor) return;
|
||||
|
||||
if (editor.isActive('replyOnly')) {
|
||||
editor.chain().focus().exitReplyOnly().run();
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (!empty && from !== to) {
|
||||
editor.chain().focus().wrapReplyOnly().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().insertReplyOnly().run();
|
||||
}, [editor]);
|
||||
|
||||
const switchToMarkdown = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const html = sanitizeHtml(editor.getHTML());
|
||||
@@ -653,17 +677,27 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
);
|
||||
}
|
||||
|
||||
tools.push({
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
});
|
||||
tools.push(
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
},
|
||||
{
|
||||
icon: <MessageSquareLock size={15} />,
|
||||
title: '回复可见',
|
||||
hint: '读者回复后才可见;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('replyOnly'),
|
||||
className: 'article-tool-btn--reply',
|
||||
action: wrapReplyOnly,
|
||||
},
|
||||
);
|
||||
|
||||
return tools;
|
||||
}, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||
}, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||
|
||||
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
||||
@@ -686,6 +720,13 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
className: 'article-tool-btn--members',
|
||||
action: withMarkdown(insertMarkdownMembersOnly),
|
||||
},
|
||||
{
|
||||
icon: <MessageSquareLock size={15} />,
|
||||
title: '回复可见',
|
||||
hint: '插入 <reply-only> 区块',
|
||||
className: 'article-tool-btn--reply',
|
||||
action: withMarkdown(insertMarkdownReplyOnly),
|
||||
},
|
||||
], [withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]);
|
||||
|
||||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||||
|
||||
@@ -13,14 +13,17 @@ interface Props {
|
||||
className?: string;
|
||||
/** 正文标题树变化时回调(用于侧栏目录) */
|
||||
onHeadingsChange?: (headings: PostHeading[]) => void;
|
||||
/** 点击「回复可见」门控的「去回复」 */
|
||||
onRequestReply?: () => void;
|
||||
}
|
||||
|
||||
/** 帖子正文渲染(含会员专属区块、代码块美化、图片灯箱) */
|
||||
/** 帖子正文渲染(含会员专属 / 回复可见区块、代码块美化、图片灯箱) */
|
||||
export default function PostContent({
|
||||
html,
|
||||
isLoggedIn,
|
||||
className = 'post-detail-content',
|
||||
onHeadingsChange,
|
||||
onRequestReply,
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const { limits } = useForumLimits();
|
||||
@@ -50,6 +53,11 @@ export default function PostContent({
|
||||
|
||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-reply-scroll]')) {
|
||||
e.preventDefault();
|
||||
onRequestReply?.();
|
||||
return;
|
||||
}
|
||||
if (target.closest('[data-members-login]')) {
|
||||
e.preventDefault();
|
||||
nav(loginPath());
|
||||
@@ -116,7 +124,7 @@ export default function PostContent({
|
||||
notify.error('复制失败');
|
||||
}
|
||||
}
|
||||
}, [nav, openLightbox]);
|
||||
}, [nav, openLightbox, onRequestReply]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
|
||||
@@ -130,7 +130,8 @@ export const MembersOnly = Node.create({
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['members-only', mergeAttributes(HTMLAttributes), 0];
|
||||
// data-gate:消毒白名单要求自定义标签带允许属性,否则会被剥壳
|
||||
return ['members-only', mergeAttributes({ 'data-gate': 'login' }, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
|
||||
265
frontend/src/components/editor/ReplyOnlyExtension.tsx
Normal file
265
frontend/src/components/editor/ReplyOnlyExtension.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { MessageSquareLock, Trash2 } from 'lucide-react';
|
||||
|
||||
/** 查找光标所在的回复可见节点深度 */
|
||||
function findReplyOnlyDepth($pos: {
|
||||
depth: number;
|
||||
node: (d: number) => { type: { name: string }; nodeSize: number };
|
||||
before: (d: number) => number;
|
||||
start: (d: number) => number;
|
||||
}): number {
|
||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||
if ($pos.node(d).type.name === 'replyOnly') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 回复可见区块是否无实质文字 */
|
||||
function isReplyOnlyEmpty(node: ProseMirrorNode): boolean {
|
||||
return node.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
/** 编辑态「回复可见」区块视图 */
|
||||
function ReplyOnlyView({ selected, editor, node, getPos }: NodeViewProps) {
|
||||
const empty = isReplyOnlyEmpty(node);
|
||||
|
||||
const deleteThisBlock = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().removeReplyOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const handleUnwrap = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().unwrapReplyOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (isReplyOnlyEmpty(node)) {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
} else if (dispatch) {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={`post-reply-only post-reply-only--visible editor-reply-only${selected ? ' editor-reply-only--selected' : ''}${empty ? ' editor-reply-only--empty' : ''}`}
|
||||
>
|
||||
<div className="post-reply-only__badge" contentEditable={false}>
|
||||
<span className="post-reply-only__badge-icon" aria-hidden="true">
|
||||
<MessageSquareLock size={12} />
|
||||
</span>
|
||||
<span>回复可见</span>
|
||||
<div className="post-reply-only__badge-actions">
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
className="post-reply-only__unwrap-btn"
|
||||
title="取消回复可见包裹,保留正文"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleUnwrap}
|
||||
>
|
||||
取消包裹
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="post-reply-only__remove-btn"
|
||||
title={empty ? '删除空的回复可见区块' : '删除整个回复可见区块'}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={deleteThisBlock}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NodeViewContent className="post-reply-only__body" data-placeholder="此处内容需回复后可见…" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
replyOnly: {
|
||||
insertReplyOnly: () => ReturnType;
|
||||
wrapReplyOnly: () => ReturnType;
|
||||
exitReplyOnly: () => ReturnType;
|
||||
unwrapReplyOnly: () => ReturnType;
|
||||
removeReplyOnly: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** TipTap 自定义节点:回复后可见内容区块 */
|
||||
export const ReplyOnly = Node.create({
|
||||
name: 'replyOnly',
|
||||
group: 'block',
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'reply-only' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
// data-gate:消毒白名单要求自定义标签带允许属性,否则会被剥壳
|
||||
return ['reply-only', mergeAttributes({ 'data-gate': 'reply' }, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ReplyOnlyView);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
if (!isReplyOnlyEmpty(node)) {
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
const start = $from.start(depth);
|
||||
if ($from.pos !== start) return false;
|
||||
return editor.commands.unwrapReplyOnly();
|
||||
}
|
||||
|
||||
return editor.commands.removeReplyOnly();
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
if (!isReplyOnlyEmpty(node)) return false;
|
||||
|
||||
return editor.commands.removeReplyOnly();
|
||||
},
|
||||
Enter: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const parent = $from.parent;
|
||||
const atBlockEnd = $from.parentOffset === parent.content.size;
|
||||
const isEmptyBlock = parent.textContent.trim().length === 0;
|
||||
if (!atBlockEnd || !isEmptyBlock) return false;
|
||||
|
||||
const replyNode = $from.node(depth);
|
||||
if (isReplyOnlyEmpty(replyNode) && replyNode.childCount <= 1) {
|
||||
return editor.commands.removeReplyOnly();
|
||||
}
|
||||
|
||||
return editor.commands.exitReplyOnly();
|
||||
},
|
||||
'Mod-Enter': ({ editor }) => {
|
||||
if (!editor.isActive('replyOnly')) return false;
|
||||
return editor.commands.exitReplyOnly();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertReplyOnly: () => ({ chain }) => chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
content: [{ type: 'paragraph' }],
|
||||
})
|
||||
.run(),
|
||||
|
||||
wrapReplyOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { from, to, empty } = state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
const slice = state.doc.slice(from, to);
|
||||
if (!slice.content.size) return false;
|
||||
|
||||
const node = state.schema.nodes.replyOnly.create(null, slice.content);
|
||||
if (dispatch) {
|
||||
tr.replaceRangeWith(from, to, node);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
exitReplyOnly: () => ({ state, chain }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
const end = pos + node.nodeSize;
|
||||
|
||||
return chain()
|
||||
.insertContentAt(end, { type: 'paragraph' })
|
||||
.setTextSelection(end + 1)
|
||||
.run();
|
||||
},
|
||||
|
||||
unwrapReplyOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
|
||||
if (isReplyOnlyEmpty(node)) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
} else {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
removeReplyOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findReplyOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user