feat: 共用 MarkdownEditor 组件(左编辑右实时预览+防抖+工具栏+拖拽上传),博客写作/论坛发帖迁移

This commit is contained in:
2026-08-12 17:32:45 +08:00
parent bfbcf69e22
commit 6f9691c18e
4 changed files with 413 additions and 95 deletions
+212
View File
@@ -0,0 +1,212 @@
import React, { useEffect, useRef, useState } from 'react';
import { uploadFile } from '../api/upload.js';
import { showSnackbar } from '../lib/utils.js';
import MarkdownRenderer from './MarkdownRenderer.jsx';
/**
* 标准防抖:value 停止变化 debounceMs 后才更新返回值。
* useEffect 内建 setTimeout,卸载时 clearTimeout 清理 timer。
*/
function useDebounced(value, debounceMs) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), debounceMs);
return () => clearTimeout(timer);
}, [value, debounceMs]);
return debounced;
}
/**
* 共用 Markdown 编辑器(博客写作页 / 论坛发帖弹窗):
* 左侧编辑区(textarea)+ 右侧实时预览区,预览防抖刷新(默认 1200ms,停止输入才重渲染,
* 避免每敲一键都重渲染造成"抖动")。
*
* 工具栏:加粗 / 斜体 / 链接 / 代码块 / 上传图片或附件(复用 uploadFile → [image:]/[file:] 标签)。
* 附件标签在编辑区以明文显示,预览区由 MarkdownRendererDOMPurify 净化)渲染成 /uploads/ 链接。
*
* props:
* value / onChange — 受控内容
* placeholder — textarea 占位文案
* label — textarea 无障碍标签(默认 "Markdown 内容"
* compact — 紧凑模式(弹窗内使用,更小的字号/边距/最小高度)
* debounceMs — 预览防抖毫秒数,默认 1200
* className — 附加到根节点的 class
*/
export default function MarkdownEditor({
value = '',
onChange,
placeholder = '支持 Markdown 语法',
label = 'Markdown 内容',
compact = false,
debounceMs = 1200,
className = '',
}) {
const textareaRef = useRef(null);
const fileInputRef = useRef(null);
const [dragging, setDragging] = useState(false);
const [uploadStatus, setUploadStatus] = useState('');
// 右侧预览的防抖值:停止输入 debounceMs 后才刷新
const previewValue = useDebounced(value, debounceMs);
const pending = value !== previewValue;
/** 在光标处插入文本,并在重渲染后把光标移到插入内容末尾 */
const setValueAtCaret = (text) => {
const v = value || '';
const ta = textareaRef.current;
if (ta && typeof ta.selectionStart === 'number') {
const s = ta.selectionStart;
const e = ta.selectionEnd;
const next = v.slice(0, s) + text + v.slice(e);
const caret = s + text.length;
onChange(next);
requestAnimationFrame(() => {
if (ta) {
ta.focus();
ta.setSelectionRange(caret, caret);
}
});
} else {
onChange(v + text);
}
};
/** 工具栏包装:选中文本用 before/after 包裹(无选中时用 fallback 占位) */
const wrapSelection = (before, after, fallback) => {
const v = value || '';
const ta = textareaRef.current;
if (ta && typeof ta.selectionStart === 'number') {
const s = ta.selectionStart;
const e = ta.selectionEnd;
const inner = v.slice(s, e) || fallback || '';
const next = v.slice(0, s) + before + inner + after + v.slice(e);
const caret = s + before.length + inner.length + after.length;
onChange(next);
requestAnimationFrame(() => {
if (ta) {
ta.focus();
ta.setSelectionRange(caret, caret);
}
});
} else {
onChange(v + before + (fallback || '') + after);
}
};
/** 插入链接:光标定位到 url 位置方便直接输入 */
const insertLink = () => {
const v = value || '';
const ta = textareaRef.current;
if (ta && typeof ta.selectionStart === 'number') {
const s = ta.selectionStart;
const e = ta.selectionEnd;
const text = v.slice(s, e) || '链接文字';
const url = 'https://';
const next = v.slice(0, s) + `[${text}](${url})` + v.slice(e);
const caret = s + 1 + text.length + 2 + url.length; // 落在 (https:// 之后
onChange(next);
requestAnimationFrame(() => {
if (ta) {
ta.focus();
ta.setSelectionRange(caret, caret);
}
});
} else {
onChange(v + '[链接文字](https://)');
}
};
/** 上传附件/图片 → 插入 [image:]/[file:] 标签(复用 Write.jsx 的 uploadFile 模式) */
const doUpload = async (file) => {
try {
const data = await uploadFile(file);
setValueAtCaret('\n' + data.tag + '\n');
setUploadStatus('已插入: ' + data.tag);
} catch (e) {
showSnackbar(e.message);
}
};
const handleFileSelect = (e) => {
const f = e.target.files && e.target.files[0];
if (f) doUpload(f);
e.target.value = '';
};
const handleDrop = async (e) => {
e.preventDefault();
setDragging(false);
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (files.length === 0) { showSnackbar('请拖入图片文件'); return; }
for (const f of files) await doUpload(f);
};
return (
<div className={'md-editor' + (compact ? ' compact' : '') + (className ? ' ' + className : '')}>
{/* 工具栏 */}
<div className="md-editor-toolbar">
<button type="button" className="btn-icon" title="加粗" aria-label="加粗" onClick={() => wrapSelection('**', '**', '加粗文字')}>
<span className="material-icons">format_bold</span>
</button>
<button type="button" className="btn-icon" title="斜体" aria-label="斜体" onClick={() => wrapSelection('*', '*', '斜体文字')}>
<span className="material-icons">format_italic</span>
</button>
<button type="button" className="btn-icon" title="插入链接" aria-label="插入链接" onClick={insertLink}>
<span className="material-icons">link</span>
</button>
<button type="button" className="btn-icon" title="代码块" aria-label="插入代码块" onClick={() => wrapSelection('```\n', '\n```', '代码')}>
<span className="material-icons">code</span>
</button>
<button type="button" className="btn-icon" title="上传图片或附件" aria-label="上传图片或附件" onClick={() => fileInputRef.current && fileInputRef.current.click()}>
<span className="material-icons">upload</span>
</button>
{uploadStatus && <span className="md-editor-upload-status">{uploadStatus}</span>}
</div>
{/* 左编辑 / 右预览 */}
<div className="md-editor-panes">
<div
className={'md-editor-pane md-editor-input' + (dragging ? ' dragover' : '')}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false); }}
onDrop={handleDrop}
>
<div className="md-editor-pane-head">
<span className="material-icons" style={{ fontSize: 15 }}>edit_note</span> 内容
</div>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label={label}
spellCheck="false"
/>
{dragging && (
<div className="md-editor-drop-hint">
<span className="material-icons" style={{ fontSize: 20 }}>image</span> 松开以插入图片
</div>
)}
</div>
<div className="md-editor-pane md-editor-preview">
<div className="md-editor-pane-head">
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> 预览
<span className={'md-editor-sync' + (pending ? ' pending' : '')}>
{pending ? '待刷新…' : '已同步'}
</span>
</div>
<div className="md-editor-preview-body" role="region" aria-label="Markdown 实时预览">
{previewValue.trim() ? (
<MarkdownRenderer content={previewValue} useMarkdown />
) : (
<div className="md-editor-preview-empty">预览区 输入内容后稍候自动更新</div>
)}
</div>
</div>
</div>
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
</div>
);
}