Files
rainblogweb/frontend/src/components/MarkdownRenderer.jsx
T

273 lines
13 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useMemo } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { escapeHtml } from '../lib/utils.js';
import { getToken } from '../api/client.js';
import LockBlock from './LockBlock.jsx';
/**
* 锁块附件鉴权地址:后端 /api/upload/locked 按 (ref_type, ref_id, block, file) + token 校验,
* 只允许取「该帖该块」已解锁的附件。缺 refType/refId/token 返回 null → 调用方回退原直链。
*/
function lockedAttachmentUrl(refType, refId, blockIndex, filename, token) {
if (!refType || refId == null || !token) return null;
return `/api/upload/locked?ref_type=${encodeURIComponent(refType)}&ref_id=${refId}&block=${encodeURIComponent(blockIndex)}&file=${encodeURIComponent(filename)}&token=${encodeURIComponent(token)}`;
}
/** [image:文件名] → 图片标签;lockedUrl 存在则 src 走鉴权接口,否则 /uploads/ 直链 */
function imageTag(filename, lockedUrl) {
if (!filename) return '';
const src = lockedUrl || `/uploads/${encodeURIComponent(filename)}`;
return `<img src="${src}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
}
/** [file:文件名] → 下载链接;lockedUrl 存在则 href 走鉴权接口(保留 target=_blank 与样式),否则原下载逻辑 */
function fileTag(filename, lockedUrl) {
if (!filename) return '';
const token = getToken() || '';
const href = lockedUrl || `/api/upload/download/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}`;
return `<a href="${href}" target="_blank" class="file-link" style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--md-ref-surface-container);border-radius:8px;margin:4px 0;text-decoration:none;color:var(--md-ref-primary);font-size:14px">
<span class="material-icons" style="font-size:18px">attachment</span> ${escapeHtml(filename)}</a>`;
}
/**
* 剥离内联 [lock:] 成对标签,保留块内内容(只去标签不去内容)。
* 保留导出供外部/预览使用;renderContent 不再调用——组件层已用切段方案接管(保留锁块容器)。
*/
export function stripLockTags(content) {
return String(content).replace(/\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\]/g, '$1');
}
/** 从整块标签提取 lock 类型参数:'login' / 'reply' / 'password'(无参数返回 '' */
function extractLockType(block) {
const m = String(block).match(/^\[lock(?::([^\]]*))?\]/);
const param = (m && m[1]) || '';
return param.split(':')[0] || '';
}
/**
* 按内联 [lock:] 成对标签切段(split 带捕获组):
* 交替产出 [文本, 整块(含标签), 块内内容, 文本, …](i%3:0=文本 / 1=整块 / 2=块内内容)
*/
const INLINE_LOCK_RE = /(\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\])/g;
/** 统计文本段内内联 [lock:] 成对标签数量(全文锁块索引游标推进用) */
function countInlineLocks(text) {
const m = String(text).match(INLINE_LOCK_RE);
return m ? m.length : 0;
}
/**
* 渲染一段文本:文本段走 renderContent;内联 [lock:] 段渲染「已解锁内容」容器。
* 已解锁块(admin/作者/登录或评论后可见)后端在 content 里原样保留 [lock:...]...[/lock] 标签,
* 这里按标签切成锁块容器(同款背景+边框 + 🔓 已解锁头部),内容递归 MarkdownRenderer 渲染。
* rest.inlineStartIdx:本段起始内联块在全文中的索引(buildNodes 用锁块游标给出),
* 用于查 locks 数组拿该块的附件 token(块内 [image:]/[file:] 走鉴权接口)。
*/
function renderSegments(text, useMarkdown, rest) {
const { locks, refType, refId, blockCtx: parentCtx } = rest;
const parts = String(text).split(INLINE_LOCK_RE);
const nodes = [];
let textIdx = 0;
let lockIdx = 0;
let tagIdx = rest.inlineStartIdx || 0; // 内联块在全文中的索引游标
for (let i = 0; i < parts.length; i += 1) {
const p = parts[i];
if (i % 3 === 1) {
// 整块(含标签),紧随其后 i+1 是对应块内内容
const inner = parts[i + 1] || '';
const type = extractLockType(p);
const idx = tagIdx++;
// 内联块附件上下文:已处锁块内(parentCtx)时沿用父块 token(嵌套块文件同属父块);
// 顶层则按本段索引查 locks 数组拿该块的 token(无 token 保持直链向后兼容)
let childCtx = null;
if (parentCtx) {
childCtx = parentCtx;
} else {
const meta = (Array.isArray(locks) && locks.find((l) => l.index === idx)) || {};
if (meta.token) childCtx = { index: idx, token: meta.token };
}
nodes.push(
<div key={'reveal' + lockIdx++} className="lock-reveal">
<div className="lock-placeholder lock-revealed">
<div className="lock-revealed-head">
<span className="material-icons" style={{ fontSize: 16 }} aria-hidden="true">lock_open</span>
已解锁内容
{type ? <span className="lock-revealed-type">{type}</span> : null}
</div>
<div className="lock-revealed-body">
<MarkdownRenderer
content={inner}
useMarkdown={useMarkdown}
locks={locks}
unlocked={rest.unlocked}
lockContent={rest.lockContent}
onUnlock={rest.onUnlock}
onGoComment={rest.onGoComment}
refType={refType}
refId={refId}
blockCtx={childCtx}
/>
</div>
</div>
</div>
);
i += 1; // 跳过内容段(已作为 inner 渲染)
} else if (p) {
// 文本段:仅当外层处于已解锁锁块内(parentCtx)时附件才改走鉴权接口
const attach = parentCtx ? { refType, refId, blockIndex: parentCtx.index, token: parentCtx.token || '' } : null;
nodes.push(<RawBlock key={'text' + textIdx++} html={renderContent(p, useMarkdown, attach)} />);
}
}
return nodes;
}
/**
* 内容渲染:useMarkdown=false 走纯文本(转义 + 标签替换 + <br>);
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
* attach(可选)= { refType, refId, blockIndex, token }:处于已解锁锁块内时的附件鉴权上下文;
* 缺省则 [image:]/[file:] 照旧 /uploads/ 直链与下载逻辑。本函数只处理单段文本,lock 分段在组件层完成。
*/
export function renderContent(content, useMarkdown, attach) {
if (content == null) return '';
// 锁块附件地址生成器:无 attach 上下文或无 token 时返回 null → 走原直链/下载逻辑
const lockedUrl = (filename) => (attach
? lockedAttachmentUrl(attach.refType, attach.refId, attach.blockIndex, filename, attach.token)
: null);
if (!useMarkdown) {
let html = escapeHtml(content);
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f, lockedUrl(f)));
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f, lockedUrl(f)));
html = html.replace(/\n/g, '<br>');
return DOMPurify.sanitize(html);
}
// Markdown 模式:先抽取自定义标签,避免被 marked 转义
const images = [];
const files = [];
let html = content.replace(/\[image:([^\]]+)\]/g, (m, f) => { images.push(f); return `\x00IMG${images.length - 1}\x00`; });
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => { files.push(f); return `\x00FILE${files.length - 1}\x00`; });
html = marked.parse(html, { breaks: true, gfm: true });
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)], lockedUrl(images[parseInt(i)])));
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)], lockedUrl(files[parseInt(i)])));
html = DOMPurify.sanitize(html);
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
// 不在渲染后 DOM 上赋 id——React 重渲可能替换 DOM 导致 id 丢失)
let h2Index = 0;
html = html.replace(/<h2(?![^>]*\bid=)/gi, () => `<h2 id="toc-${h2Index++}"`);
return html;
}
/** 单段文本块:dangerouslySetInnerHTML 包装(renderContent 已 DOMPurify 净化) */
function RawBlock({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
/**
* 构建渲染节点(两层分段):
* 1. @@LOCK<n>@@ 占位分段(详情页 locks 非空)——lock 段:已解锁+有内容 → 递归渲染(.lock-reveal 淡入),
* 否则 LockBlock 锁定卡片;文本段 → 再按内联 [lock:] 切段(已解锁容器 / 文本)。
* 文本段内联块索引用全文游标(nextIdx)衔接,保证与后端 parseLocks 的 index 一致。
* 2. locks 为空(含 previewMode 编辑预览):整段按内联 [lock:] 切段,作者视角同样显示已解锁容器
*/
function buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }) {
if (content == null) return null;
const str = String(content);
const hasLocks = Array.isArray(locks) && locks.length > 0;
if (hasLocks) {
// 服务端已把未解锁块替换为 @@LOCK<n>@@split 交替产出 [文本, 索引, 文本, 索引…]
const parts = str.split(/@@LOCK(\d+)@@/);
const nodes = [];
let nextIdx = 0; // 全文锁块索引游标:内联块与 @@LOCK 占位按文档顺序递增
for (let i = 0; i < parts.length; i += 1) {
const p = parts[i];
if (i % 2 === 1) {
const n = parseInt(p, 10);
nextIdx = n + 1; // 占位符自带索引,同步游标
const meta = locks.find((l) => l.index === n) || {};
const type = meta.type || 'password';
const lockEntry = unlocked && unlocked.has(n) && lockContent && lockContent.get(n);
if (lockEntry) {
// lockContent 兼容纯字符串或 { content, token }token 优先取 locks 数组项(后端按块签发)
const raw = typeof lockEntry === 'object' ? lockEntry.content : lockEntry;
const entryToken = typeof lockEntry === 'object' && lockEntry.token ? lockEntry.token : '';
const token = (meta.token || entryToken) || '';
nodes.push(
<div key={'lock' + n} className="lock-reveal">
<MarkdownRenderer
content={raw}
useMarkdown={useMarkdown}
locks={locks}
unlocked={unlocked}
lockContent={lockContent}
onUnlock={onUnlock}
onGoComment={onGoComment}
refType={refType}
refId={refId}
blockCtx={{ index: n, token }}
/>
</div>
);
} else {
nodes.push(<LockBlock key={'lock' + n} index={n} type={type} onUnlock={onUnlock} onGoComment={onGoComment} />);
}
} else if (p) {
// 文本段:可能仍含内联 [lock:](已解锁块)→ 切段渲染已解锁容器
nodes.push(...renderSegments(p, useMarkdown, {
locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx,
inlineStartIdx: nextIdx,
}));
nextIdx += countInlineLocks(p);
}
}
return nodes;
}
// locks 为空:整段按内联 [lock:] 切段(previewMode 作者预览同样显示「已解锁内容」容器)
return renderSegments(str, useMarkdown, {
locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx,
inlineStartIdx: 0,
});
}
/**
* Markdown 渲染器(前台统一入口):
* props:
* content / useMarkdown — 同原有
* locks — [{index, type}](新后端带 {index, type, token})详情接口返回的锁定块元信息
* unlocked — Set<number> 已解锁索引(login/reply 由服务端判定后内容直接给原文;
* password 类解锁后内容存 lockContent
* lockContent — Map<number, string | {content, token}> password 块解锁拿到的 markdown(仅内存态)
* onUnlock — async (index, password?) → 解锁;失败 throw
* onGoComment — reply 块「去评论」滚动回调
* previewMode — 编辑器作者预览:锁定标签直接展开
* refType — 'blog' | 'forum'(可选):提供后锁块内 [image:]/[file:] 附件改走鉴权接口
* refId — 详情 id(可选,配合 refType
* blockCtx — {index, token}(可选):当前渲染上下文处于已解锁锁块内,附件用该块的 token 鉴权
*/
export default function MarkdownRenderer({
content = '',
useMarkdown = true,
locks,
unlocked,
lockContent,
onUnlock,
onGoComment,
previewMode = false,
refType,
refId,
blockCtx,
}) {
const nodes = useMemo(
() => buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }),
[content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx]
);
return <div className="md-body">{nodes}</div>;
}