增加回复可见功能
This commit is contained in:
@@ -4,7 +4,7 @@ import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
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 = {
|
||||
headingStyle: 'atx' as const,
|
||||
@@ -212,27 +212,34 @@ function patchTurndownEscape(service: TurndownService): void {
|
||||
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', {
|
||||
filter: 'members-only',
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLElement;
|
||||
const parts: string[] = [];
|
||||
replacement: (_content, node) => gatedBlockToMarkdown('members-only', node as HTMLElement),
|
||||
});
|
||||
|
||||
el.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'));
|
||||
return `\n\n<members-only>\n\n${body}\n\n</members-only>\n\n`;
|
||||
},
|
||||
turndown.addRule('replyOnly', {
|
||||
filter: 'reply-only',
|
||||
replacement: (_content, node) => gatedBlockToMarkdown('reply-only', node as HTMLElement),
|
||||
});
|
||||
|
||||
marked.setOptions({
|
||||
@@ -293,7 +300,10 @@ function parseMarkdownFragment(markdown: string): string {
|
||||
function prepareHtmlForMarkdown(html: string): string {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -303,14 +313,22 @@ function prepareHtmlForMarkdown(html: string): string {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 规范化 members-only 标签边界,避免闭合标签与正文粘连 */
|
||||
function normalizeMembersOnlyMarkdown(markdown: string): string {
|
||||
/** 规范化门控标签边界,避免闭合标签与正文粘连 */
|
||||
function normalizeGatedMarkdown(markdown: string): string {
|
||||
return markdown
|
||||
.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 默认会输出两个及以上空格) */
|
||||
@@ -330,13 +348,13 @@ export function htmlToMarkdown(html: string): string {
|
||||
|
||||
/**
|
||||
* Markdown 源码转为编辑器 HTML。
|
||||
* 先提取 members-only 块再分别解析,避免闭合标签后同行文字被吞入区块。
|
||||
* 先提取门控块再分别解析,避免闭合标签后同行文字被吞入区块。
|
||||
*/
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
if (!markdown.trim()) return '';
|
||||
|
||||
const normalized = normalizeMembersOnlyMarkdown(markdown);
|
||||
const re = new RegExp(MEMBERS_ONLY_BLOCK_RE.source, 'gi');
|
||||
const normalized = normalizeGatedMarkdown(markdown);
|
||||
const re = new RegExp(GATED_BLOCK_RE.source, 'gi');
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null = re.exec(normalized);
|
||||
@@ -347,11 +365,13 @@ export function markdownToHtml(markdown: string): string {
|
||||
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()
|
||||
? splitParagraphBreaks(parseMarkdownFragment(innerMd))
|
||||
: '';
|
||||
result += `<members-only>${innerHtml}</members-only>`;
|
||||
result += `<${tag} data-gate="${gate}">${innerHtml}</${tag}>`;
|
||||
lastIndex = re.lastIndex;
|
||||
match = re.exec(normalized);
|
||||
}
|
||||
|
||||
@@ -82,9 +82,22 @@ export function insertMarkdownMembersOnly(
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
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 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import { enhanceHeadingAnchors } from './postHeadings';
|
||||
* 全局选择器仍会污染整页,故显式禁止。
|
||||
*/
|
||||
export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||
ADD_TAGS: ['members-only'],
|
||||
ADD_TAGS: ['members-only', 'reply-only'],
|
||||
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-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits',
|
||||
'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 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 {
|
||||
const lengthHint = charLength > 0
|
||||
@@ -47,6 +49,41 @@ function buildLockedGateHtml(charLength: number): string {
|
||||
</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 正文是否为空(忽略空段落等) */
|
||||
export function isHtmlEmpty(html: string): boolean {
|
||||
if (!html.trim()) return true;
|
||||
@@ -81,17 +118,38 @@ export function renderPostContentHtml(
|
||||
return;
|
||||
}
|
||||
|
||||
const innerHtml = el.querySelector('.post-members-only__body')?.innerHTML
|
||||
?? Array.from(el.childNodes)
|
||||
.filter(n => !(n instanceof Element && n.classList.contains('post-members-only__badge')))
|
||||
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
|
||||
.join('');
|
||||
const innerHtml = extractGatedInnerHtml(
|
||||
el,
|
||||
'post-members-only__body',
|
||||
'post-members-only__badge',
|
||||
);
|
||||
|
||||
// 已登录:降噪,不展示醒目 badge,仅保留结构容器
|
||||
el.className = 'post-members-only post-members-only--visible';
|
||||
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 => {
|
||||
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
|
||||
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
||||
@@ -169,7 +227,7 @@ function isBlankParagraph(el: Element): boolean {
|
||||
if (el.tagName !== 'P') return false;
|
||||
const text = (el.textContent || '').replace(/\u00a0/g, ' ').trim();
|
||||
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',
|
||||
);
|
||||
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 => {
|
||||
el.prepend(doc.createTextNode('\n'));
|
||||
el.append(doc.createTextNode('\n'));
|
||||
|
||||
Reference in New Issue
Block a user