支持评论级联软删与后台回收站,并完善 Markdown 代码围栏嵌套。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,22 @@ export function isGuestComment(c: Comment): boolean {
|
||||
return !c.user_id || c.user_id === 0;
|
||||
}
|
||||
|
||||
/** 收集评论及其 reply_to 后代的 ID(含自身) */
|
||||
export function collectCommentSubtreeIds(comments: Comment[], rootId: number): Set<number> {
|
||||
const ids = new Set<number>([rootId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const c of comments) {
|
||||
if (!ids.has(c.id) && c.reply_to != null && ids.has(c.reply_to)) {
|
||||
ids.add(c.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** 构建嵌套评论树(优先 thread_parent_id,回退 reply_to) */
|
||||
export function buildCommentTree(comments: Comment[]): CommentNode[] {
|
||||
const map = new Map<number, CommentNode>();
|
||||
|
||||
@@ -40,9 +40,47 @@ function readDisplayOptions(pre: Element) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在换行处闭合并重开跨行 <span>,使每行 HTML 片段自包含。
|
||||
* hljs token 常跨多行,直接按 \\n 切开会破坏标签导致行号布局叠字。
|
||||
*/
|
||||
function balanceHighlightLines(highlightedHtml: string): string[] {
|
||||
const openTags: string[] = [];
|
||||
let balanced = '';
|
||||
const tokenRe = /(<span\b[^>]*>)|(<\/span>)|(\n)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = tokenRe.exec(highlightedHtml)) !== null) {
|
||||
balanced += highlightedHtml.slice(lastIndex, match.index);
|
||||
lastIndex = tokenRe.lastIndex;
|
||||
|
||||
if (match[3] !== undefined) {
|
||||
// 换行:先闭合当前栈,再于下一行重开
|
||||
for (let i = openTags.length - 1; i >= 0; i--) balanced += '</span>';
|
||||
balanced += '\n';
|
||||
for (const tag of openTags) balanced += tag;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match[2] !== undefined) {
|
||||
openTags.pop();
|
||||
balanced += match[2];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 开标签
|
||||
openTags.push(match[1]);
|
||||
balanced += match[1];
|
||||
}
|
||||
|
||||
balanced += highlightedHtml.slice(lastIndex);
|
||||
return balanced.split('\n');
|
||||
}
|
||||
|
||||
/** 为高亮后的 HTML 按行包一层,便于行号与折叠计数 */
|
||||
function wrapCodeLines(highlightedHtml: string, withLineNumbers: boolean): string {
|
||||
const lines = highlightedHtml.split('\n');
|
||||
const lines = balanceHighlightLines(highlightedHtml);
|
||||
return lines
|
||||
.map((line, i) => {
|
||||
const num = i + 1;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions';
|
||||
import { mapOutsideFences, wrapFencedCode } from './markdownFences';
|
||||
|
||||
const GATED_BLOCK_RE = /<(members-only|reply-only)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi;
|
||||
|
||||
@@ -80,7 +81,7 @@ function addTurndownContentRules(service: TurndownService): void {
|
||||
const collapsed = pre.getAttribute('data-collapsed') === 'true'
|
||||
|| wrap?.getAttribute('data-collapsed') === 'true';
|
||||
const info = formatFenceInfo({ language, lineNumbers, collapsed });
|
||||
return `\n\n\`\`\`${info}\n${text}\n\`\`\`\n\n`;
|
||||
return wrapFencedCode(info, text);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -284,10 +285,9 @@ function sanitizeContentHtml(html: string): string {
|
||||
|
||||
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */
|
||||
function preserveLeadingIndent(markdown: string): string {
|
||||
return markdown.split(/(```[\s\S]*?```)/g).map((part, index) => {
|
||||
if (index % 2 === 1) return part;
|
||||
return part.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length));
|
||||
}).join('');
|
||||
return mapOutsideFences(markdown, (outside) =>
|
||||
outside.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 将普通 Markdown 片段转为 HTML */
|
||||
|
||||
73
frontend/src/utils/markdownFences.ts
Normal file
73
frontend/src/utils/markdownFences.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/** CommonMark 围栏:开围栏行(最多 3 空格缩进 + 至少 3 个反引号) */
|
||||
const OPEN_FENCE_RE = /^ {0,3}(`{3,})([^`\n]*)$/;
|
||||
/** 闭围栏行:仅反引号与可选尾随空白 */
|
||||
const CLOSE_FENCE_RE = /^ {0,3}(`{3,})[ \t]*$/;
|
||||
|
||||
/** 正文中最长连续反引号数;外层围栏需至少 longest+1(且 ≥ 3) */
|
||||
export function fenceLengthForContent(text: string): number {
|
||||
let longest = 0;
|
||||
let run = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === '`') {
|
||||
run += 1;
|
||||
if (run > longest) longest = run;
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
return Math.max(3, longest + 1);
|
||||
}
|
||||
|
||||
/** 用足够长的围栏包裹代码正文(info 为语言/选项串,可为空) */
|
||||
export function wrapFencedCode(info: string, text: string): string {
|
||||
const len = fenceLengthForContent(text);
|
||||
const fence = '`'.repeat(len);
|
||||
const open = info ? `${fence}${info}` : fence;
|
||||
return `\n\n${open}\n${text}\n${fence}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按行识别围栏块;仅对围栏外文本调用 fn。
|
||||
* 闭合条件:行首闭围栏长度 ≥ 开围栏(CommonMark)。
|
||||
*/
|
||||
export function mapOutsideFences(markdown: string, fn: (outside: string) => string): string {
|
||||
const lines = markdown.split('\n');
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
let outsideBuf: string[] = [];
|
||||
|
||||
const flushOutside = () => {
|
||||
if (outsideBuf.length === 0) return;
|
||||
out.push(fn(outsideBuf.join('\n')));
|
||||
outsideBuf = [];
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const openMatch = lines[i].match(OPEN_FENCE_RE);
|
||||
if (!openMatch) {
|
||||
outsideBuf.push(lines[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushOutside();
|
||||
const openLen = openMatch[1].length;
|
||||
const fenceLines = [lines[i]];
|
||||
i += 1;
|
||||
|
||||
while (i < lines.length) {
|
||||
fenceLines.push(lines[i]);
|
||||
const closeMatch = lines[i].match(CLOSE_FENCE_RE);
|
||||
if (closeMatch && closeMatch[1].length >= openLen) {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
out.push(fenceLines.join('\n'));
|
||||
}
|
||||
|
||||
flushOutside();
|
||||
return out.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user