228 lines
7.6 KiB
JavaScript
228 lines
7.6 KiB
JavaScript
import React, { useState, useRef, useMemo } from 'react';
|
|
import CodeMirror from '@uiw/react-codemirror';
|
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
import { EditorView } from '@codemirror/view';
|
|
import MarkdownRenderer from './MarkdownRenderer';
|
|
import './MarkdownEditor.css';
|
|
|
|
const MarkdownEditor = ({
|
|
value,
|
|
onChange,
|
|
onImageUpload,
|
|
placeholder = '在这里编写内容...',
|
|
height = 400,
|
|
showImageUpload = true
|
|
}) => {
|
|
const editorRef = useRef(null);
|
|
const imageInputRef = useRef(null);
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
const [showHeadingMenu, setShowHeadingMenu] = useState(false);
|
|
|
|
// CodeMirror extensions
|
|
const editorExtensions = useMemo(() => [
|
|
markdown({ base: markdownLanguage }),
|
|
EditorView.lineWrapping,
|
|
EditorView.theme({
|
|
"&": {
|
|
fontSize: "14px",
|
|
border: "2px solid #e2e8f0",
|
|
borderRadius: "0 0 8px 8px",
|
|
borderTop: "none",
|
|
},
|
|
".cm-content": {
|
|
fontFamily: "'Monaco', 'Menlo', 'Consolas', monospace",
|
|
padding: "1rem",
|
|
minHeight: `${height}px`,
|
|
},
|
|
".cm-scroller": {
|
|
fontFamily: "'Monaco', 'Menlo', 'Consolas', monospace",
|
|
},
|
|
"&.cm-focused": {
|
|
outline: "none",
|
|
borderColor: "#667eea",
|
|
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.1)",
|
|
}
|
|
})
|
|
], [height]);
|
|
|
|
// Markdown 插入函数
|
|
const insertMarkdown = (before, after = '', placeholder = '') => {
|
|
if (!editorRef.current?.view) return;
|
|
|
|
const view = editorRef.current.view;
|
|
const selection = view.state.selection.main;
|
|
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
|
|
const text = selectedText || placeholder;
|
|
const newText = `${before}${text}${after}`;
|
|
|
|
view.dispatch({
|
|
changes: { from: selection.from, to: selection.to, insert: newText },
|
|
selection: { anchor: selection.from + before.length, head: selection.from + before.length + text.length }
|
|
});
|
|
view.focus();
|
|
};
|
|
|
|
// 工具栏操作
|
|
const toolbarActions = {
|
|
bold: () => insertMarkdown('**', '**', '粗体文字'),
|
|
italic: () => insertMarkdown('*', '*', '斜体文字'),
|
|
heading: (level) => {
|
|
setShowHeadingMenu(false);
|
|
insertMarkdown('#'.repeat(level) + ' ', '', '标题');
|
|
},
|
|
quote: () => insertMarkdown('> ', '', '引用内容'),
|
|
code: () => insertMarkdown('`', '`', '代码'),
|
|
codeBlock: () => insertMarkdown('```\n', '\n```', '代码块'),
|
|
link: () => insertMarkdown('[', '](url)', '链接文字'),
|
|
unorderedList: () => insertMarkdown('- ', '', '列表项'),
|
|
orderedList: () => insertMarkdown('1. ', '', '列表项'),
|
|
table: () => {
|
|
const tableTemplate = '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| 单元格 | 单元格 | 单元格 |\n| 单元格 | 单元格 | 单元格 |\n';
|
|
insertMarkdown(tableTemplate, '', '');
|
|
},
|
|
hr: () => insertMarkdown('\n---\n', '', ''),
|
|
image: () => imageInputRef.current?.click(),
|
|
};
|
|
|
|
// 图片上传处理
|
|
const handleImageSelect = async (event) => {
|
|
const file = event.target.files[0];
|
|
if (file && onImageUpload) {
|
|
const imageUrl = await onImageUpload(file);
|
|
if (imageUrl) {
|
|
insertMarkdown(``, '', '');
|
|
}
|
|
}
|
|
// Reset file input
|
|
if (imageInputRef.current) {
|
|
imageInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="markdown-editor-wrapper">
|
|
<div className="editor-toolbar">
|
|
<button type="button" className="toolbar-btn" onClick={toolbarActions.bold} title="粗体 (Ctrl+B)">
|
|
<strong>B</strong>
|
|
</button>
|
|
<button type="button" className="toolbar-btn" onClick={toolbarActions.italic} title="斜体 (Ctrl+I)">
|
|
<em>I</em>
|
|
</button>
|
|
|
|
{/* 多级标题下拉菜单 */}
|
|
<div className="toolbar-dropdown">
|
|
<button
|
|
type="button"
|
|
className="toolbar-btn"
|
|
onClick={() => setShowHeadingMenu(!showHeadingMenu)}
|
|
title="标题"
|
|
>
|
|
H ▾
|
|
</button>
|
|
{showHeadingMenu && (
|
|
<div className="dropdown-menu">
|
|
<button type="button" onClick={() => toolbarActions.heading(1)}>
|
|
<h1 style={{ fontSize: '1.5rem', margin: 0 }}>标题 1</h1>
|
|
</button>
|
|
<button type="button" onClick={() => toolbarActions.heading(2)}>
|
|
<h2 style={{ fontSize: '1.3rem', margin: 0 }}>标题 2</h2>
|
|
</button>
|
|
<button type="button" onClick={() => toolbarActions.heading(3)}>
|
|
<h3 style={{ fontSize: '1.1rem', margin: 0 }}>标题 3</h3>
|
|
</button>
|
|
<button type="button" onClick={() => toolbarActions.heading(4)}>
|
|
<h4 style={{ fontSize: '1rem', margin: 0 }}>标题 4</h4>
|
|
</button>
|
|
<button type="button" onClick={() => toolbarActions.heading(5)}>
|
|
<h5 style={{ fontSize: '0.9rem', margin: 0 }}>标题 5</h5>
|
|
</button>
|
|
<button type="button" onClick={() => toolbarActions.heading(6)}>
|
|
<h6 style={{ fontSize: '0.85rem', margin: 0 }}>标题 6</h6>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<span className="toolbar-divider"></span>
|
|
|
|
<button type="button" className="toolbar-btn" onClick={toolbarActions.quote} title="引用">
|
|
"
|
|
</button>
|
|
<button type="button" className="toolbar-btn" onClick={toolbarActions.code} title="代码">
|
|
{'<>'}
|
|
</button>
|
|
<button type="button" className="toolbar-btn" onClick={toolbarActions.link} title="链接">
|
|
🔗
|
|
</button>
|
|
<button type="button" className="toolbar-btn" onClick={toolbarActions.table} title="表格">
|
|
⊞
|
|
</button>
|
|
|
|
{showImageUpload && (
|
|
<button type="button" className="toolbar-btn" onClick={toolbarActions.image} title="上传图片">
|
|
⬆︎
|
|
</button>
|
|
)}
|
|
|
|
<span className="toolbar-divider"></span>
|
|
|
|
<button type="button" className="toolbar-btn" onClick={toolbarActions.unorderedList} title="无序列表">
|
|
•
|
|
</button>
|
|
<button type="button" className="toolbar-btn" onClick={toolbarActions.orderedList} title="有序列表">
|
|
1.
|
|
</button>
|
|
<button type="button" className="toolbar-btn" onClick={toolbarActions.hr} title="分隔线">
|
|
—
|
|
</button>
|
|
|
|
<span className="toolbar-divider"></span>
|
|
|
|
{/* 预览按钮 */}
|
|
<button
|
|
type="button"
|
|
className={`toolbar-btn ${showPreview ? 'active' : ''}`}
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
title={showPreview ? "编辑" : "预览"}
|
|
>
|
|
{showPreview ? '编辑' : '预览'}
|
|
</button>
|
|
</div>
|
|
|
|
{showPreview ? (
|
|
<MarkdownRenderer
|
|
content={value}
|
|
className="markdown-preview"
|
|
emptyMessage="*暂无内容*"
|
|
/>
|
|
) : (
|
|
<CodeMirror
|
|
ref={editorRef}
|
|
value={value}
|
|
onChange={onChange}
|
|
extensions={editorExtensions}
|
|
placeholder={placeholder}
|
|
basicSetup={{
|
|
lineNumbers: false,
|
|
foldGutter: false,
|
|
highlightActiveLineGutter: false,
|
|
highlightActiveLine: false,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showImageUpload && (
|
|
<input
|
|
ref={imageInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleImageSelect}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MarkdownEditor;
|