feat: 共用 MarkdownEditor 组件(左编辑右实时预览+防抖+工具栏+拖拽上传),博客写作/论坛发帖迁移
This commit is contained in:
@@ -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:] 标签)。
|
||||
* 附件标签在编辑区以明文显示,预览区由 MarkdownRenderer(DOMPurify 净化)渲染成 /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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user