增加回复可见功能
This commit is contained in:
@@ -107,6 +107,8 @@ export interface PostDetailResponse {
|
|||||||
comment_count: number;
|
comment_count: number;
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
favorited: boolean;
|
favorited: boolean;
|
||||||
|
/** 当前用户是否已在本帖发表过评论(含审核中) */
|
||||||
|
has_replied?: boolean;
|
||||||
can_edit?: boolean;
|
can_edit?: boolean;
|
||||||
edit_block_reason?: string;
|
edit_block_reason?: string;
|
||||||
is_edited?: boolean;
|
is_edited?: boolean;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
FileCode, PenLine, Maximize2, Minimize2,
|
FileCode, PenLine, Maximize2, Minimize2,
|
||||||
Columns2, PanelLeft, PanelRight, StretchHorizontal,
|
Columns2, PanelLeft, PanelRight, StretchHorizontal,
|
||||||
Table as TableIcon, BetweenHorizonalStart, BetweenVerticalStart, Rows3, Columns3,
|
Table as TableIcon, BetweenHorizonalStart, BetweenVerticalStart, Rows3, Columns3,
|
||||||
|
MessageSquareLock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||||
@@ -25,12 +26,14 @@ import {
|
|||||||
prefixMarkdownLines,
|
prefixMarkdownLines,
|
||||||
cycleMarkdownHeading,
|
cycleMarkdownHeading,
|
||||||
insertMarkdownMembersOnly,
|
insertMarkdownMembersOnly,
|
||||||
|
insertMarkdownReplyOnly,
|
||||||
insertMarkdownLink,
|
insertMarkdownLink,
|
||||||
} from '../utils/markdownFormat';
|
} from '../utils/markdownFormat';
|
||||||
import { countWords } from '../utils/text';
|
import { countWords } from '../utils/text';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { MembersOnly } from './editor/MembersOnlyExtension';
|
import { MembersOnly } from './editor/MembersOnlyExtension';
|
||||||
|
import { ReplyOnly } from './editor/ReplyOnlyExtension';
|
||||||
import { TabIndent } from './editor/TabIndentExtension';
|
import { TabIndent } from './editor/TabIndentExtension';
|
||||||
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
|
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
|
||||||
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
|
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
|
||||||
@@ -76,6 +79,7 @@ interface ToolBtn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
|
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
|
||||||
|
const REPLY_ONLY_PLACEHOLDER = '在此输入回复后可见的内容…';
|
||||||
|
|
||||||
/** 按选项生成 Markdown 侧插入片段(围栏 meta,便于手写) */
|
/** 按选项生成 Markdown 侧插入片段(围栏 meta,便于手写) */
|
||||||
function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代码'): string {
|
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') {
|
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
|
||||||
return MEMBERS_ONLY_PLACEHOLDER;
|
return MEMBERS_ONLY_PLACEHOLDER;
|
||||||
}
|
}
|
||||||
|
if (node.type.name === 'paragraph' && node.parent?.type.name === 'replyOnly') {
|
||||||
|
return REPLY_ONLY_PLACEHOLDER;
|
||||||
|
}
|
||||||
return placeholder;
|
return placeholder;
|
||||||
},
|
},
|
||||||
includeChildren: true,
|
includeChildren: true,
|
||||||
}),
|
}),
|
||||||
MembersOnly,
|
MembersOnly,
|
||||||
|
ReplyOnly,
|
||||||
TabIndent,
|
TabIndent,
|
||||||
],
|
],
|
||||||
content: sanitizeHtml(value) || '',
|
content: sanitizeHtml(value) || '',
|
||||||
@@ -517,6 +525,22 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
editor.chain().focus().insertMembersOnly().run();
|
editor.chain().focus().insertMembersOnly().run();
|
||||||
}, [editor]);
|
}, [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(() => {
|
const switchToMarkdown = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const html = sanitizeHtml(editor.getHTML());
|
const html = sanitizeHtml(editor.getHTML());
|
||||||
@@ -653,17 +677,27 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
tools.push({
|
tools.push(
|
||||||
icon: <LockKeyhole size={15} />,
|
{
|
||||||
title: '登录可见',
|
icon: <LockKeyhole size={15} />,
|
||||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
title: '登录可见',
|
||||||
active: editor.isActive('membersOnly'),
|
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||||
className: 'article-tool-btn--members',
|
active: editor.isActive('membersOnly'),
|
||||||
action: wrapMembersOnly,
|
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;
|
return tools;
|
||||||
}, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
|
}, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||||
|
|
||||||
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
||||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
{ 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',
|
className: 'article-tool-btn--members',
|
||||||
action: withMarkdown(insertMarkdownMembersOnly),
|
action: withMarkdown(insertMarkdownMembersOnly),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <MessageSquareLock size={15} />,
|
||||||
|
title: '回复可见',
|
||||||
|
hint: '插入 <reply-only> 区块',
|
||||||
|
className: 'article-tool-btn--reply',
|
||||||
|
action: withMarkdown(insertMarkdownReplyOnly),
|
||||||
|
},
|
||||||
], [withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]);
|
], [withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]);
|
||||||
|
|
||||||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** 正文标题树变化时回调(用于侧栏目录) */
|
/** 正文标题树变化时回调(用于侧栏目录) */
|
||||||
onHeadingsChange?: (headings: PostHeading[]) => void;
|
onHeadingsChange?: (headings: PostHeading[]) => void;
|
||||||
|
/** 点击「回复可见」门控的「去回复」 */
|
||||||
|
onRequestReply?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 帖子正文渲染(含会员专属区块、代码块美化、图片灯箱) */
|
/** 帖子正文渲染(含会员专属 / 回复可见区块、代码块美化、图片灯箱) */
|
||||||
export default function PostContent({
|
export default function PostContent({
|
||||||
html,
|
html,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
className = 'post-detail-content',
|
className = 'post-detail-content',
|
||||||
onHeadingsChange,
|
onHeadingsChange,
|
||||||
|
onRequestReply,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
@@ -50,6 +53,11 @@ export default function PostContent({
|
|||||||
|
|
||||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-reply-scroll]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
onRequestReply?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (target.closest('[data-members-login]')) {
|
if (target.closest('[data-members-login]')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
nav(loginPath());
|
nav(loginPath());
|
||||||
@@ -116,7 +124,7 @@ export default function PostContent({
|
|||||||
notify.error('复制失败');
|
notify.error('复制失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [nav, openLightbox]);
|
}, [nav, openLightbox, onRequestReply]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ export const MembersOnly = Node.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['members-only', mergeAttributes(HTMLAttributes), 0];
|
// data-gate:消毒白名单要求自定义标签带允许属性,否则会被剥壳
|
||||||
|
return ['members-only', mergeAttributes({ 'data-gate': 'login' }, HTMLAttributes), 0];
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -207,6 +207,25 @@ export default function PostDetailPage() {
|
|||||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||||
}, [postId, user]);
|
}, [postId, user]);
|
||||||
|
|
||||||
|
/** 发评后重拉正文,解锁「回复可见」区块(跳过浏览计数) */
|
||||||
|
const reloadPostContent = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const detail = await api.post(postId, { skipView: true });
|
||||||
|
setPost(detail.post);
|
||||||
|
} catch {
|
||||||
|
// 评论已成功,正文刷新失败不阻断
|
||||||
|
}
|
||||||
|
}, [postId]);
|
||||||
|
|
||||||
|
const scrollToCommentBox = useCallback(() => {
|
||||||
|
const target = commentBoxRef.current || commentSectionRef.current;
|
||||||
|
target?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const ta = commentBoxRef.current?.querySelector('textarea');
|
||||||
|
ta?.focus({ preventScroll: true });
|
||||||
|
}, 320);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const jumpToFloor = useCallback((floor: number) => {
|
const jumpToFloor = useCallback((floor: number) => {
|
||||||
const el = document.getElementById(`floor-${floor}`);
|
const el = document.getElementById(`floor-${floor}`);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -338,7 +357,7 @@ export default function PostDetailPage() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setSubmitCount(c => c + 1);
|
setSubmitCount(c => c + 1);
|
||||||
notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功'));
|
notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功'));
|
||||||
await reloadComments();
|
await Promise.all([reloadComments(), reloadPostContent()]);
|
||||||
setTimeout(() => jumpToFloor(r.floor), 100);
|
setTimeout(() => jumpToFloor(r.floor), 100);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
notify.error(e instanceof Error ? e.message : '评论失败');
|
notify.error(e instanceof Error ? e.message : '评论失败');
|
||||||
@@ -641,6 +660,7 @@ export default function PostDetailPage() {
|
|||||||
html={post.content || ''}
|
html={post.content || ''}
|
||||||
isLoggedIn={!!user}
|
isLoggedIn={!!user}
|
||||||
onHeadingsChange={handleHeadingsChange}
|
onHeadingsChange={handleHeadingsChange}
|
||||||
|
onRequestReply={scrollToCommentBox}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="post-detail-actions">
|
<div className="post-detail-actions">
|
||||||
|
|||||||
@@ -4017,6 +4017,169 @@ a.post-title:visited {
|
|||||||
color: var(--j13-green-hover);
|
color: var(--j13-green-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-editor-tools .article-tool-btn--reply {
|
||||||
|
color: #c27803;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-editor-tools .article-tool-btn--reply:hover,
|
||||||
|
.article-editor-tools .article-tool-btn--reply.active {
|
||||||
|
background: rgba(194, 120, 3, 0.1);
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .article-editor-tools .article-tool-btn--reply {
|
||||||
|
color: #e8b84a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .article-editor-tools .article-tool-btn--reply:hover,
|
||||||
|
.dark .article-editor-tools .article-tool-btn--reply.active {
|
||||||
|
background: rgba(232, 184, 74, 0.12);
|
||||||
|
color: #f0c85a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 回复可见内容区块(阅读态) */
|
||||||
|
.post-detail-content reply-only,
|
||||||
|
.post-detail-content .post-reply-only {
|
||||||
|
display: block;
|
||||||
|
margin: 16px 0;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only--visible {
|
||||||
|
border: none;
|
||||||
|
border-left: 3px solid rgba(194, 120, 3, 0.5);
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 2px 0 2px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only--locked {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid rgba(194, 120, 3, 0.28);
|
||||||
|
border-left: 3px solid rgba(194, 120, 3, 0.65);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--j13-bg-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__locked-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__gate {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__gate-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(194, 120, 3, 0.12);
|
||||||
|
color: #c27803;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__gate-text {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__gate-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__gate-desc {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__gate-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__gate-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #c27803;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__gate-btn:hover {
|
||||||
|
background: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__gate-link {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #c27803;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-content .post-reply-only__gate-link:hover {
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .post-detail-content .post-reply-only--visible {
|
||||||
|
border-left-color: rgba(232, 184, 74, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .post-detail-content .post-reply-only--locked {
|
||||||
|
border-color: rgba(232, 184, 74, 0.3);
|
||||||
|
border-left-color: rgba(232, 184, 74, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .post-detail-content .post-reply-only__gate-icon {
|
||||||
|
background: rgba(232, 184, 74, 0.14);
|
||||||
|
color: #e8b84a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .post-detail-content .post-reply-only__gate-btn {
|
||||||
|
background: #c27803;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .post-detail-content .post-reply-only__gate-link {
|
||||||
|
color: #e8b84a;
|
||||||
|
}
|
||||||
|
|
||||||
.post-members-only__unwrap-btn {
|
.post-members-only__unwrap-btn {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -7418,6 +7581,118 @@ button.profile-stat:hover strong {
|
|||||||
box-shadow: inset 0 0 0 1px rgba(24, 160, 88, 0.28);
|
box-shadow: inset 0 0 0 1px rgba(24, 160, 88, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 编辑态:回复可见区块 */
|
||||||
|
.editor-reply-only.post-reply-only--visible {
|
||||||
|
border: none;
|
||||||
|
border-left: 3px solid rgba(194, 120, 3, 0.55);
|
||||||
|
background: rgba(194, 120, 3, 0.05);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only .post-reply-only__badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #c27803;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only .post-reply-only__badge-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only .post-reply-only__badge-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only .post-reply-only__unwrap-btn {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only .post-reply-only__unwrap-btn:hover {
|
||||||
|
color: #c27803;
|
||||||
|
background: rgba(194, 120, 3, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only .post-reply-only__remove-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only .post-reply-only__remove-btn:hover {
|
||||||
|
color: #c0392b;
|
||||||
|
background: rgba(192, 57, 43, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only--empty .post-reply-only__remove-btn {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only .post-reply-only__body {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
min-height: 48px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only .post-reply-only__body p.is-empty::before,
|
||||||
|
.editor-reply-only .post-reply-only__body[data-placeholder]:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--color-text-4);
|
||||||
|
font-style: italic;
|
||||||
|
pointer-events: none;
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .editor-reply-only.post-reply-only--visible {
|
||||||
|
border-left-color: rgba(232, 184, 74, 0.55);
|
||||||
|
background: rgba(232, 184, 74, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .editor-reply-only .post-reply-only__badge {
|
||||||
|
color: #e8b84a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-reply-only--selected {
|
||||||
|
background: rgba(194, 120, 3, 0.08);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(194, 120, 3, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.article-editor-status {
|
.article-editor-status {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import DOMPurify from 'dompurify';
|
|||||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||||
import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions';
|
import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions';
|
||||||
|
|
||||||
const MEMBERS_ONLY_BLOCK_RE = /<members-only>([\s\S]*?)<\/members-only>/gi;
|
const GATED_BLOCK_RE = /<(members-only|reply-only)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi;
|
||||||
|
|
||||||
const TURNDOWN_OPTIONS = {
|
const TURNDOWN_OPTIONS = {
|
||||||
headingStyle: 'atx' as const,
|
headingStyle: 'atx' as const,
|
||||||
@@ -212,27 +212,34 @@ function patchTurndownEscape(service: TurndownService): void {
|
|||||||
service.escape = (str: string) => original(str).replace(/(\d+)\\(\.)/g, '$1$2');
|
service.escape = (str: string) => original(str).replace(/(\d+)\\(\.)/g, '$1$2');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录可见区块转为 Markdown:逐子节点转换,保留首行缩进 */
|
/** 将门控区块(登录可见 / 回复可见)转为 Markdown 标签 */
|
||||||
|
function gatedBlockToMarkdown(tag: 'members-only' | 'reply-only', node: HTMLElement): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
node.childNodes.forEach(child => {
|
||||||
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = (child.textContent ?? '').trim();
|
||||||
|
if (text) parts.push(nbspToSpaces(text));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (child instanceof HTMLElement) {
|
||||||
|
parts.push(nbspToSpaces(contentTurndown.turndown(child.outerHTML).trim()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = trimBlockBoundaryLines(parts.join('\n\n'));
|
||||||
|
const gate = tag === 'reply-only' ? 'reply' : 'login';
|
||||||
|
return `\n\n<${tag} data-gate="${gate}">\n\n${body}\n\n</${tag}>\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
turndown.addRule('membersOnly', {
|
turndown.addRule('membersOnly', {
|
||||||
filter: 'members-only',
|
filter: 'members-only',
|
||||||
replacement: (_content, node) => {
|
replacement: (_content, node) => gatedBlockToMarkdown('members-only', node as HTMLElement),
|
||||||
const el = node as HTMLElement;
|
});
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
el.childNodes.forEach(child => {
|
turndown.addRule('replyOnly', {
|
||||||
if (child.nodeType === Node.TEXT_NODE) {
|
filter: 'reply-only',
|
||||||
const text = (child.textContent ?? '').trim();
|
replacement: (_content, node) => gatedBlockToMarkdown('reply-only', node as HTMLElement),
|
||||||
if (text) parts.push(nbspToSpaces(text));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (child instanceof HTMLElement) {
|
|
||||||
parts.push(nbspToSpaces(contentTurndown.turndown(child.outerHTML).trim()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = trimBlockBoundaryLines(parts.join('\n\n'));
|
|
||||||
return `\n\n<members-only>\n\n${body}\n\n</members-only>\n\n`;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
@@ -293,7 +300,10 @@ function parseMarkdownFragment(markdown: string): string {
|
|||||||
function prepareHtmlForMarkdown(html: string): string {
|
function prepareHtmlForMarkdown(html: string): string {
|
||||||
const doc = new DOMParser().parseFromString(sanitizeContentHtml(html), 'text/html');
|
const doc = new DOMParser().parseFromString(sanitizeContentHtml(html), 'text/html');
|
||||||
|
|
||||||
doc.querySelectorAll('.post-members-only__badge, .post-members-only__exit-btn, .post-members-only__unwrap-btn').forEach(el => {
|
doc.querySelectorAll([
|
||||||
|
'.post-members-only__badge', '.post-members-only__exit-btn', '.post-members-only__unwrap-btn',
|
||||||
|
'.post-reply-only__badge', '.post-reply-only__exit-btn', '.post-reply-only__unwrap-btn',
|
||||||
|
].join(', ')).forEach(el => {
|
||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -303,14 +313,22 @@ function prepareHtmlForMarkdown(html: string): string {
|
|||||||
el.innerHTML = splitParagraphBreaks(raw);
|
el.innerHTML = splitParagraphBreaks(raw);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('reply-only').forEach(el => {
|
||||||
|
const body = el.querySelector('.post-reply-only__body');
|
||||||
|
const raw = body ? body.innerHTML : el.innerHTML;
|
||||||
|
el.innerHTML = splitParagraphBreaks(raw);
|
||||||
|
});
|
||||||
|
|
||||||
return doc.body.innerHTML;
|
return doc.body.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 规范化 members-only 标签边界,避免闭合标签与正文粘连 */
|
/** 规范化门控标签边界,避免闭合标签与正文粘连 */
|
||||||
function normalizeMembersOnlyMarkdown(markdown: string): string {
|
function normalizeGatedMarkdown(markdown: string): string {
|
||||||
return markdown
|
return markdown
|
||||||
.replace(/<\/members-only>(?=[^\s\n])/g, '</members-only>\n\n')
|
.replace(/<\/members-only>(?=[^\s\n])/g, '</members-only>\n\n')
|
||||||
.replace(/<members-only>\s*<\/members-only>/g, '<members-only>\n\n</members-only>');
|
.replace(/<members-only(?:\s[^>]*)?>\s*<\/members-only>/g, '<members-only data-gate="login">\n\n</members-only>')
|
||||||
|
.replace(/<\/reply-only>(?=[^\s\n])/g, '</reply-only>\n\n')
|
||||||
|
.replace(/<reply-only(?:\s[^>]*)?>\s*<\/reply-only>/g, '<reply-only data-gate="reply">\n\n</reply-only>');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 列表标记后统一为单个空格(Turndown 默认会输出两个及以上空格) */
|
/** 列表标记后统一为单个空格(Turndown 默认会输出两个及以上空格) */
|
||||||
@@ -330,13 +348,13 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown 源码转为编辑器 HTML。
|
* Markdown 源码转为编辑器 HTML。
|
||||||
* 先提取 members-only 块再分别解析,避免闭合标签后同行文字被吞入区块。
|
* 先提取门控块再分别解析,避免闭合标签后同行文字被吞入区块。
|
||||||
*/
|
*/
|
||||||
export function markdownToHtml(markdown: string): string {
|
export function markdownToHtml(markdown: string): string {
|
||||||
if (!markdown.trim()) return '';
|
if (!markdown.trim()) return '';
|
||||||
|
|
||||||
const normalized = normalizeMembersOnlyMarkdown(markdown);
|
const normalized = normalizeGatedMarkdown(markdown);
|
||||||
const re = new RegExp(MEMBERS_ONLY_BLOCK_RE.source, 'gi');
|
const re = new RegExp(GATED_BLOCK_RE.source, 'gi');
|
||||||
let result = '';
|
let result = '';
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null = re.exec(normalized);
|
let match: RegExpExecArray | null = re.exec(normalized);
|
||||||
@@ -347,11 +365,13 @@ export function markdownToHtml(markdown: string): string {
|
|||||||
result += parseMarkdownFragment(before);
|
result += parseMarkdownFragment(before);
|
||||||
}
|
}
|
||||||
|
|
||||||
const innerMd = trimBlockBoundaryLines(match[1]);
|
const tag = match[1];
|
||||||
|
const gate = tag === 'reply-only' ? 'reply' : 'login';
|
||||||
|
const innerMd = trimBlockBoundaryLines(match[2]);
|
||||||
const innerHtml = innerMd.trim()
|
const innerHtml = innerMd.trim()
|
||||||
? splitParagraphBreaks(parseMarkdownFragment(innerMd))
|
? splitParagraphBreaks(parseMarkdownFragment(innerMd))
|
||||||
: '';
|
: '';
|
||||||
result += `<members-only>${innerHtml}</members-only>`;
|
result += `<${tag} data-gate="${gate}">${innerHtml}</${tag}>`;
|
||||||
lastIndex = re.lastIndex;
|
lastIndex = re.lastIndex;
|
||||||
match = re.exec(normalized);
|
match = re.exec(normalized);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,9 +82,22 @@ export function insertMarkdownMembersOnly(
|
|||||||
onChange: ChangeHandler,
|
onChange: ChangeHandler,
|
||||||
) {
|
) {
|
||||||
const { selectionStart, selectionEnd } = textarea;
|
const { selectionStart, selectionEnd } = textarea;
|
||||||
const snippet = '\n\n<members-only>\n\n\n</members-only>\n\n';
|
const snippet = '\n\n<members-only data-gate="login">\n\n\n</members-only>\n\n';
|
||||||
const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd);
|
const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd);
|
||||||
const cursor = selectionStart + '\n\n<members-only>\n\n'.length;
|
const cursor = selectionStart + '\n\n<members-only data-gate="login">\n\n'.length;
|
||||||
|
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插入回复可见区块模板 */
|
||||||
|
export function insertMarkdownReplyOnly(
|
||||||
|
textarea: HTMLTextAreaElement,
|
||||||
|
value: string,
|
||||||
|
onChange: ChangeHandler,
|
||||||
|
) {
|
||||||
|
const { selectionStart, selectionEnd } = textarea;
|
||||||
|
const snippet = '\n\n<reply-only data-gate="reply">\n\n\n</reply-only>\n\n';
|
||||||
|
const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd);
|
||||||
|
const cursor = selectionStart + '\n\n<reply-only data-gate="reply">\n\n'.length;
|
||||||
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { enhanceHeadingAnchors } from './postHeadings';
|
|||||||
* 全局选择器仍会污染整页,故显式禁止。
|
* 全局选择器仍会污染整页,故显式禁止。
|
||||||
*/
|
*/
|
||||||
export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||||
ADD_TAGS: ['members-only'],
|
ADD_TAGS: ['members-only', 'reply-only'],
|
||||||
ADD_ATTR: [
|
ADD_ATTR: [
|
||||||
'data-locked', 'data-length', 'target', 'rel',
|
'data-locked', 'data-length', 'data-gate', 'target', 'rel',
|
||||||
'data-code-copy', 'data-code-fold', 'data-lang', 'data-full',
|
'data-code-copy', 'data-code-fold', 'data-lang', 'data-full',
|
||||||
'data-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits',
|
'data-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits',
|
||||||
'data-image-group', 'data-layout', 'data-display',
|
'data-image-group', 'data-layout', 'data-display',
|
||||||
@@ -25,6 +25,8 @@ export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
|||||||
|
|
||||||
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
|
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
|
||||||
|
|
||||||
|
const REPLY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 15v4a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h10"/><path d="M20 7V3"/><path d="M22 5h-4"/></svg>`;
|
||||||
|
|
||||||
/** 游客看到的锁定区块:流内嵌条 + 登录引导(精简高度) */
|
/** 游客看到的锁定区块:流内嵌条 + 登录引导(精简高度) */
|
||||||
function buildLockedGateHtml(charLength: number): string {
|
function buildLockedGateHtml(charLength: number): string {
|
||||||
const lengthHint = charLength > 0
|
const lengthHint = charLength > 0
|
||||||
@@ -47,6 +49,41 @@ function buildLockedGateHtml(charLength: number): string {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 回复可见锁定门控:游客引导登录,已登录引导去评论 */
|
||||||
|
function buildReplyLockedGateHtml(charLength: number, isLoggedIn: boolean): string {
|
||||||
|
const lengthHint = charLength > 0
|
||||||
|
? `约 ${charLength} 字`
|
||||||
|
: '隐藏内容';
|
||||||
|
|
||||||
|
const actions = isLoggedIn
|
||||||
|
? `<button type="button" class="post-reply-only__gate-btn" data-reply-scroll>去回复</button>`
|
||||||
|
: `<button type="button" class="post-reply-only__gate-btn" data-members-login>登录后回复</button>
|
||||||
|
<button type="button" class="post-reply-only__gate-link" data-members-register>免费注册</button>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="post-reply-only__locked-wrap">
|
||||||
|
<div class="post-reply-only__gate">
|
||||||
|
<span class="post-reply-only__gate-icon" aria-hidden="true">${REPLY_ICON_SVG}</span>
|
||||||
|
<div class="post-reply-only__gate-text">
|
||||||
|
<p class="post-reply-only__gate-title">回复后可见(${lengthHint})</p>
|
||||||
|
<p class="post-reply-only__gate-desc">作者将此段设为回复本帖后可读</p>
|
||||||
|
</div>
|
||||||
|
<div class="post-reply-only__gate-actions">
|
||||||
|
${actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取门控区块正文 HTML(去掉编辑态 badge) */
|
||||||
|
function extractGatedInnerHtml(el: Element, bodyClass: string, badgeClass: string): string {
|
||||||
|
return el.querySelector(`.${bodyClass}`)?.innerHTML
|
||||||
|
?? Array.from(el.childNodes)
|
||||||
|
.filter(n => !(n instanceof Element && n.classList.contains(badgeClass)))
|
||||||
|
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
/** 判断 HTML 正文是否为空(忽略空段落等) */
|
/** 判断 HTML 正文是否为空(忽略空段落等) */
|
||||||
export function isHtmlEmpty(html: string): boolean {
|
export function isHtmlEmpty(html: string): boolean {
|
||||||
if (!html.trim()) return true;
|
if (!html.trim()) return true;
|
||||||
@@ -81,17 +118,38 @@ export function renderPostContentHtml(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const innerHtml = el.querySelector('.post-members-only__body')?.innerHTML
|
const innerHtml = extractGatedInnerHtml(
|
||||||
?? Array.from(el.childNodes)
|
el,
|
||||||
.filter(n => !(n instanceof Element && n.classList.contains('post-members-only__badge')))
|
'post-members-only__body',
|
||||||
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
|
'post-members-only__badge',
|
||||||
.join('');
|
);
|
||||||
|
|
||||||
// 已登录:降噪,不展示醒目 badge,仅保留结构容器
|
// 已登录:降噪,不展示醒目 badge,仅保留结构容器
|
||||||
el.className = 'post-members-only post-members-only--visible';
|
el.className = 'post-members-only post-members-only--visible';
|
||||||
el.innerHTML = `<div class="post-members-only__body">${innerHtml}</div>`;
|
el.innerHTML = `<div class="post-members-only__body">${innerHtml}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('reply-only').forEach(el => {
|
||||||
|
// 是否解锁由服务端 redact(data-locked)决定
|
||||||
|
const locked = el.getAttribute('data-locked') === 'true';
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0;
|
||||||
|
el.className = 'post-reply-only post-reply-only--locked';
|
||||||
|
el.innerHTML = buildReplyLockedGateHtml(charLength, isLoggedIn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerHtml = extractGatedInnerHtml(
|
||||||
|
el,
|
||||||
|
'post-reply-only__body',
|
||||||
|
'post-reply-only__badge',
|
||||||
|
);
|
||||||
|
|
||||||
|
el.className = 'post-reply-only post-reply-only--visible';
|
||||||
|
el.innerHTML = `<div class="post-reply-only__body">${innerHtml}</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
doc.querySelectorAll('img').forEach(img => {
|
doc.querySelectorAll('img').forEach(img => {
|
||||||
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
|
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
|
||||||
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
||||||
@@ -169,7 +227,7 @@ function isBlankParagraph(el: Element): boolean {
|
|||||||
if (el.tagName !== 'P') return false;
|
if (el.tagName !== 'P') return false;
|
||||||
const text = (el.textContent || '').replace(/\u00a0/g, ' ').trim();
|
const text = (el.textContent || '').replace(/\u00a0/g, ' ').trim();
|
||||||
if (text.length > 0) return false;
|
if (text.length > 0) return false;
|
||||||
return !el.querySelector('img, video, iframe, table, pre, blockquote, members-only');
|
return !el.querySelector('img, video, iframe, table, pre, blockquote, members-only, reply-only');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function htmlToDiffText(html: string): string {
|
|||||||
'text/html',
|
'text/html',
|
||||||
);
|
);
|
||||||
doc.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
|
doc.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
|
||||||
const blocks = doc.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, blockquote, pre, members-only');
|
const blocks = doc.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, blockquote, pre, members-only, reply-only');
|
||||||
blocks.forEach(el => {
|
blocks.forEach(el => {
|
||||||
el.prepend(doc.createTextNode('\n'));
|
el.prepend(doc.createTextNode('\n'));
|
||||||
el.append(doc.createTextNode('\n'));
|
el.append(doc.createTextNode('\n'));
|
||||||
|
|||||||
@@ -875,8 +875,13 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// 出口再消毒:兼容库内历史脏 HTML(如 <style>),避免旧帖污染整页
|
// 出口再消毒:兼容库内历史脏 HTML(如 <style>),避免旧帖污染整页
|
||||||
post.Content = service.SanitizePostHTML(post.Content)
|
post.Content = service.SanitizePostHTML(post.Content)
|
||||||
|
hasReplied := uid > 0 && h.Comment.HasUserReplied(uint(id), uid)
|
||||||
if uid == 0 {
|
if uid == 0 {
|
||||||
post.Content = service.RedactMembersOnlyHTML(post.Content)
|
post.Content = service.RedactMembersOnlyHTML(post.Content)
|
||||||
|
post.Content = service.RedactReplyOnlyHTML(post.Content)
|
||||||
|
} else if !isAdmin && post.UserID != uid && !hasReplied {
|
||||||
|
// 作者与管理员始终可见;其他用户需已回复
|
||||||
|
post.Content = service.RedactReplyOnlyHTML(post.Content)
|
||||||
}
|
}
|
||||||
comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c))
|
comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c))
|
||||||
canEdit := h.Post.CanUserEdit(post, uid, isAdmin)
|
canEdit := h.Post.CanUserEdit(post, uid, isAdmin)
|
||||||
@@ -886,13 +891,14 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
isEdited := post.UpdatedAt.Sub(post.CreatedAt) > time.Minute
|
isEdited := post.UpdatedAt.Sub(post.CreatedAt) > time.Minute
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"post": post,
|
"post": post,
|
||||||
"comment_count": len(comments),
|
"comment_count": len(comments),
|
||||||
"liked": h.Post.IsLiked(uid, uint(id)),
|
"liked": h.Post.IsLiked(uid, uint(id)),
|
||||||
"favorited": h.Post.IsFavorited(uid, uint(id)),
|
"favorited": h.Post.IsFavorited(uid, uint(id)),
|
||||||
"can_edit": canEdit,
|
"has_replied": hasReplied,
|
||||||
"edit_block_reason": editReason,
|
"can_edit": canEdit,
|
||||||
"is_edited": isEdited,
|
"edit_block_reason": editReason,
|
||||||
|
"is_edited": isEdited,
|
||||||
"post_edit_window_hours": h.Settings.PostEditWindowHours(),
|
"post_edit_window_hours": h.Settings.PostEditWindowHours(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.S
|
|||||||
|
|
||||||
func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model.Post) *embed_static.SPAPageMeta {
|
func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model.Post) *embed_static.SPAPageMeta {
|
||||||
permalink := h.Settings.Permalink()
|
permalink := h.Settings.Permalink()
|
||||||
content := service.RedactMembersOnlyHTML(post.Content)
|
content := service.RedactGatedPostHTML(post.Content)
|
||||||
plain := post.ContentPlain
|
plain := post.ContentPlain
|
||||||
if plain == "" {
|
if plain == "" {
|
||||||
plain = service.StripHTMLForSearch(content)
|
plain = service.StripHTMLForSearch(content)
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (h *Handlers) botHomeHTML(meta *embed_static.SPAPageMeta, brand service.Sit
|
|||||||
|
|
||||||
func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *model.Post) string {
|
func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *model.Post) string {
|
||||||
meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords)
|
meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords)
|
||||||
content := service.SanitizePostHTML(service.RedactMembersOnlyHTML(post.Content))
|
content := service.SanitizePostHTML(service.RedactGatedPostHTML(post.Content))
|
||||||
author := service.DisplayName(&post.User)
|
author := service.DisplayName(&post.User)
|
||||||
var body strings.Builder
|
var body strings.Builder
|
||||||
body.WriteString("<article>")
|
body.WriteString("<article>")
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ func NewCommentService(filter *SensitiveFilter, settings *ForumSettingsService)
|
|||||||
return &CommentService{filter: filter, settings: settings}
|
return &CommentService{filter: filter, settings: settings}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasUserReplied 用户是否已在该帖发表过有效评论(已发布或审核中,不含被拒)
|
||||||
|
func (s *CommentService) HasUserReplied(postID, userID uint) bool {
|
||||||
|
if postID == 0 || userID == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
err := model.DB.Model(&model.Comment{}).
|
||||||
|
Where("post_id = ? AND user_id = ? AND status IN ?", postID, userID,
|
||||||
|
[]string{model.ContentStatusPublished, model.ContentStatusPending}).
|
||||||
|
Limit(1).
|
||||||
|
Count(&count).Error
|
||||||
|
return err == nil && count > 0
|
||||||
|
}
|
||||||
|
|
||||||
type CommentCreateInput struct {
|
type CommentCreateInput struct {
|
||||||
UserID uint
|
UserID uint
|
||||||
PostID uint
|
PostID uint
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
membersOnlyBlockRe = regexp.MustCompile(`(?is)<members-only\b[^>]*>([\s\S]*?)</members-only>`)
|
membersOnlyBlockRe = regexp.MustCompile(`(?is)<members-only\b[^>]*>([\s\S]*?)</members-only>`)
|
||||||
|
replyOnlyBlockRe = regexp.MustCompile(`(?is)<reply-only\b[^>]*>([\s\S]*?)</reply-only>`)
|
||||||
// style/script 内文本不能进搜索/摘要,否则会出现 "* {color:red}" 之类噪声
|
// style/script 内文本不能进搜索/摘要,否则会出现 "* {color:red}" 之类噪声
|
||||||
styleOrScriptRe = regexp.MustCompile(`(?is)<(style|script)\b[^>]*>[\s\S]*?</(style|script)>`)
|
styleOrScriptRe = regexp.MustCompile(`(?is)<(style|script)\b[^>]*>[\s\S]*?</(style|script)>`)
|
||||||
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||||
@@ -16,21 +17,39 @@ var (
|
|||||||
|
|
||||||
// RedactMembersOnlyHTML 未登录时移除会员专属区块内的正文,保留长度提示供前端展示
|
// RedactMembersOnlyHTML 未登录时移除会员专属区块内的正文,保留长度提示供前端展示
|
||||||
func RedactMembersOnlyHTML(html string) string {
|
func RedactMembersOnlyHTML(html string) string {
|
||||||
|
return redactGatedBlocks(html, membersOnlyBlockRe, "members-only")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactReplyOnlyHTML 未回复时移除「回复可见」区块内的正文,保留长度提示供前端展示
|
||||||
|
func RedactReplyOnlyHTML(html string) string {
|
||||||
|
return redactGatedBlocks(html, replyOnlyBlockRe, "reply-only")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactGatedPostHTML 搜索/SEO 等场景:同时遮盖登录可见与回复可见正文
|
||||||
|
func RedactGatedPostHTML(html string) string {
|
||||||
|
return RedactReplyOnlyHTML(RedactMembersOnlyHTML(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactGatedBlocks(html string, re *regexp.Regexp, tag string) string {
|
||||||
if html == "" {
|
if html == "" {
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
return membersOnlyBlockRe.ReplaceAllStringFunc(html, func(full string) string {
|
return re.ReplaceAllStringFunc(html, func(full string) string {
|
||||||
m := membersOnlyBlockRe.FindStringSubmatch(full)
|
m := re.FindStringSubmatch(full)
|
||||||
inner := ""
|
inner := ""
|
||||||
if len(m) > 1 {
|
if len(m) > 1 {
|
||||||
inner = m[1]
|
inner = m[1]
|
||||||
}
|
}
|
||||||
length := membersContentLength(inner)
|
length := gatedContentLength(inner)
|
||||||
return `<members-only data-locked="true" data-length="` + strconv.Itoa(length) + `"></members-only>`
|
gate := "login"
|
||||||
|
if tag == "reply-only" {
|
||||||
|
gate = "reply"
|
||||||
|
}
|
||||||
|
return `<` + tag + ` data-gate="` + gate + `" data-locked="true" data-length="` + strconv.Itoa(length) + `"></` + tag + `>`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func membersContentLength(html string) int {
|
func gatedContentLength(html string) int {
|
||||||
text := strings.TrimSpace(htmlTagRe.ReplaceAllString(html, ""))
|
text := strings.TrimSpace(htmlTagRe.ReplaceAllString(html, ""))
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
28
service/content_test.go
Normal file
28
service/content_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRedactReplyOnlyHTML(t *testing.T) {
|
||||||
|
in := `<p>公开</p><reply-only><p>秘密答案</p></reply-only>`
|
||||||
|
out := RedactReplyOnlyHTML(in)
|
||||||
|
if strings.Contains(out, "秘密答案") {
|
||||||
|
t.Fatalf("不应保留回复可见正文,得到: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `data-locked="true"`) || !strings.Contains(out, "reply-only") {
|
||||||
|
t.Fatalf("应保留锁定壳,得到: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "公开") {
|
||||||
|
t.Fatalf("不应误删公开段落,得到: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactGatedPostHTML(t *testing.T) {
|
||||||
|
in := `<members-only><p>登录密</p></members-only><reply-only><p>回复密</p></reply-only>`
|
||||||
|
out := RedactGatedPostHTML(in)
|
||||||
|
if strings.Contains(out, "登录密") || strings.Contains(out, "回复密") {
|
||||||
|
t.Fatalf("门控正文应被遮盖,得到: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -359,7 +359,7 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Content: content,
|
Content: content,
|
||||||
ContentPlain: StripHTMLForSearch(content),
|
ContentPlain: StripHTMLForSearch(RedactGatedPostHTML(content)),
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
PostType: postType,
|
PostType: postType,
|
||||||
QuestionResolved: false,
|
QuestionResolved: false,
|
||||||
@@ -420,7 +420,7 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
|
|||||||
"board_id": nextBoardID,
|
"board_id": nextBoardID,
|
||||||
"title": title,
|
"title": title,
|
||||||
"content": content,
|
"content": content,
|
||||||
"content_plain": StripHTMLForSearch(content),
|
"content_plain": StripHTMLForSearch(RedactGatedPostHTML(content)),
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"post_type": nextType,
|
"post_type": nextType,
|
||||||
"question_resolved": nextResolved,
|
"question_resolved": nextResolved,
|
||||||
|
|||||||
@@ -17,16 +17,16 @@ func postContentHTMLPolicy() *bluemonday.Policy {
|
|||||||
p := bluemonday.UGCPolicy()
|
p := bluemonday.UGCPolicy()
|
||||||
|
|
||||||
// TipTap / Markdown 转换会用到的结构
|
// TipTap / Markdown 转换会用到的结构
|
||||||
p.AllowElements("div", "span", "u", "s", "center", "members-only")
|
p.AllowElements("div", "span", "u", "s", "center", "members-only", "reply-only")
|
||||||
p.AllowAttrs("class").OnElements(
|
p.AllowAttrs("class").OnElements(
|
||||||
"p", "div", "span", "pre", "code", "img", "a",
|
"p", "div", "span", "pre", "code", "img", "a",
|
||||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
"blockquote", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
|
"blockquote", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
|
||||||
"members-only",
|
"members-only", "reply-only",
|
||||||
)
|
)
|
||||||
p.AllowAttrs("colspan", "rowspan").OnElements("th", "td")
|
p.AllowAttrs("colspan", "rowspan").OnElements("th", "td")
|
||||||
p.AllowAttrs(
|
p.AllowAttrs(
|
||||||
"data-locked", "data-length",
|
"data-locked", "data-length", "data-gate",
|
||||||
"data-code-copy", "data-code-fold", "data-lang", "data-full",
|
"data-code-copy", "data-code-fold", "data-lang", "data-full",
|
||||||
"data-code-style", "data-line-numbers", "data-collapsed",
|
"data-code-style", "data-line-numbers", "data-collapsed",
|
||||||
"data-line-count", "data-lineno-digits",
|
"data-line-count", "data-lineno-digits",
|
||||||
|
|||||||
@@ -34,11 +34,12 @@ func TestSanitizePostHTML_StripsInlineStyleAndScript(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizePostHTML_KeepsMembersOnlyAndImageGroup(t *testing.T) {
|
func TestSanitizePostHTML_KeepsMembersOnlyAndImageGroup(t *testing.T) {
|
||||||
in := `<members-only data-locked="false"><p>密</p></members-only>` +
|
in := `<members-only data-gate="login"><p>密</p></members-only>` +
|
||||||
|
`<reply-only data-gate="reply"><p>回复可见</p></reply-only>` +
|
||||||
`<div data-image-group data-layout="cols-2" class="image-group"><img src="/uploads/posts/a.jpg" alt="x"></div>` +
|
`<div data-image-group data-layout="cols-2" class="image-group"><img src="/uploads/posts/a.jpg" alt="x"></div>` +
|
||||||
`<p data-clear-float class="article-clear-float">清浮动</p>`
|
`<p data-clear-float class="article-clear-float">清浮动</p>`
|
||||||
out := SanitizePostHTML(in)
|
out := SanitizePostHTML(in)
|
||||||
for _, want := range []string{"members-only", "data-image-group", "data-layout", "data-clear-float", "清浮动"} {
|
for _, want := range []string{"members-only", "reply-only", "data-gate", "回复可见", "data-image-group", "data-layout", "data-clear-float", "清浮动"} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Fatalf("缺少 %q,得到: %q", want, out)
|
t.Fatalf("缺少 %q,得到: %q", want, out)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user