2026-01-19 11:03:08 +00:00
|
|
|
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';
|
2026-03-26 06:55:12 +00:00
|
|
|
import {
|
|
|
|
|
Space, Button, Tooltip, Card,
|
|
|
|
|
Divider, Dropdown, Typography
|
|
|
|
|
} from 'antd';
|
|
|
|
|
import {
|
|
|
|
|
BoldOutlined, ItalicOutlined, FontSizeOutlined,
|
|
|
|
|
MessageOutlined, CodeOutlined, LinkOutlined,
|
|
|
|
|
TableOutlined, PictureOutlined, OrderedListOutlined,
|
|
|
|
|
UnorderedListOutlined, LineOutlined, EyeOutlined,
|
|
|
|
|
EditOutlined
|
|
|
|
|
} from '@ant-design/icons';
|
2026-01-19 11:03:08 +00:00
|
|
|
import MarkdownRenderer from './MarkdownRenderer';
|
2026-03-26 06:55:12 +00:00
|
|
|
|
|
|
|
|
const { Text } = Typography;
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
const MarkdownEditor = ({
|
|
|
|
|
value,
|
|
|
|
|
onChange,
|
|
|
|
|
onImageUpload,
|
|
|
|
|
placeholder = '在这里编写内容...',
|
|
|
|
|
height = 400,
|
|
|
|
|
showImageUpload = true
|
|
|
|
|
}) => {
|
|
|
|
|
const editorRef = useRef(null);
|
|
|
|
|
const imageInputRef = useRef(null);
|
|
|
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
|
|
|
|
|
|
|
|
const editorExtensions = useMemo(() => [
|
|
|
|
|
markdown({ base: markdownLanguage }),
|
|
|
|
|
EditorView.lineWrapping,
|
|
|
|
|
EditorView.theme({
|
|
|
|
|
"&": {
|
|
|
|
|
fontSize: "14px",
|
2026-03-26 06:55:12 +00:00
|
|
|
border: "1px solid #d9d9d9",
|
2026-01-19 11:03:08 +00:00
|
|
|
borderRadius: "0 0 8px 8px",
|
|
|
|
|
borderTop: "none",
|
|
|
|
|
},
|
|
|
|
|
".cm-content": {
|
2026-03-26 06:55:12 +00:00
|
|
|
fontFamily: "var(--ant-font-family-code), monospace",
|
|
|
|
|
padding: "16px",
|
2026-01-19 11:03:08 +00:00
|
|
|
minHeight: `${height}px`,
|
|
|
|
|
},
|
|
|
|
|
"&.cm-focused": {
|
|
|
|
|
outline: "none",
|
2026-03-26 06:55:12 +00:00
|
|
|
borderColor: "#1677ff",
|
|
|
|
|
boxShadow: "0 0 0 2px rgba(22, 119, 255, 0.1)",
|
2026-01-19 11:03:08 +00:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
], [height]);
|
|
|
|
|
|
|
|
|
|
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 = {
|
2026-03-26 06:55:12 +00:00
|
|
|
bold: () => insertMarkdown('**', '**', '粗体'),
|
|
|
|
|
italic: () => insertMarkdown('*', '*', '斜体'),
|
|
|
|
|
heading: (level) => insertMarkdown('#'.repeat(level) + ' ', '', '标题'),
|
|
|
|
|
quote: () => insertMarkdown('> ', '', '引用'),
|
2026-01-19 11:03:08 +00:00
|
|
|
code: () => insertMarkdown('`', '`', '代码'),
|
2026-03-26 06:55:12 +00:00
|
|
|
link: () => insertMarkdown('[', '](url)', '链接'),
|
2026-01-19 11:03:08 +00:00
|
|
|
unorderedList: () => insertMarkdown('- ', '', '列表项'),
|
|
|
|
|
orderedList: () => insertMarkdown('1. ', '', '列表项'),
|
2026-03-26 06:55:12 +00:00
|
|
|
table: () => insertMarkdown('\n| 列1 | 列2 |\n| --- | --- |\n| 单元格 | 单元格 |\n', '', ''),
|
2026-01-19 11:03:08 +00:00
|
|
|
hr: () => insertMarkdown('\n---\n', '', ''),
|
|
|
|
|
image: () => imageInputRef.current?.click(),
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
const headingMenu = {
|
|
|
|
|
items: [1, 2, 3, 4, 5, 6].map(level => ({
|
|
|
|
|
key: level,
|
|
|
|
|
label: `标题 ${level}`,
|
|
|
|
|
onClick: () => toolbarActions.heading(level)
|
|
|
|
|
}))
|
2026-01-19 11:03:08 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-26 06:55:12 +00:00
|
|
|
<div className="markdown-editor-modern">
|
|
|
|
|
<Card
|
|
|
|
|
size="small"
|
|
|
|
|
bodyStyle={{ padding: '4px 8px', background: '#f5f5f5', borderBottom: '1px solid #d9d9d9', borderRadius: '8px 8px 0 0' }}
|
|
|
|
|
bordered={false}
|
|
|
|
|
>
|
|
|
|
|
<Space split={<Divider type="vertical" />} size={4}>
|
|
|
|
|
<Space size={2}>
|
|
|
|
|
<Tooltip title="粗体"><Button type="text" size="small" icon={<BoldOutlined />} onClick={toolbarActions.bold} /></Tooltip>
|
|
|
|
|
<Tooltip title="斜体"><Button type="text" size="small" icon={<ItalicOutlined />} onClick={toolbarActions.italic} /></Tooltip>
|
|
|
|
|
<Dropdown menu={headingMenu} placement="bottomLeft">
|
|
|
|
|
<Button type="text" size="small" icon={<FontSizeOutlined />} />
|
|
|
|
|
</Dropdown>
|
|
|
|
|
</Space>
|
|
|
|
|
|
|
|
|
|
<Space size={2}>
|
|
|
|
|
<Tooltip title="引用"><Button type="text" size="small" icon={<MessageOutlined />} onClick={toolbarActions.quote} /></Tooltip>
|
|
|
|
|
<Tooltip title="代码"><Button type="text" size="small" icon={<CodeOutlined />} onClick={toolbarActions.code} /></Tooltip>
|
|
|
|
|
<Tooltip title="链接"><Button type="text" size="small" icon={<LinkOutlined />} onClick={toolbarActions.link} /></Tooltip>
|
|
|
|
|
<Tooltip title="表格"><Button type="text" size="small" icon={<TableOutlined />} onClick={toolbarActions.table} /></Tooltip>
|
|
|
|
|
{showImageUpload && (
|
|
|
|
|
<Tooltip title="图片"><Button type="text" size="small" icon={<PictureOutlined />} onClick={toolbarActions.image} /></Tooltip>
|
|
|
|
|
)}
|
|
|
|
|
</Space>
|
|
|
|
|
|
|
|
|
|
<Space size={2}>
|
|
|
|
|
<Tooltip title="无序列表"><Button type="text" size="small" icon={<UnorderedListOutlined />} onClick={toolbarActions.unorderedList} /></Tooltip>
|
|
|
|
|
<Tooltip title="有序列表"><Button type="text" size="small" icon={<OrderedListOutlined />} onClick={toolbarActions.orderedList} /></Tooltip>
|
|
|
|
|
<Tooltip title="分隔线"><Button type="text" size="small" icon={<LineOutlined />} onClick={toolbarActions.hr} /></Tooltip>
|
|
|
|
|
</Space>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
type={showPreview ? "primary" : "text"}
|
|
|
|
|
size="small"
|
|
|
|
|
icon={showPreview ? <EditOutlined /> : <EyeOutlined />}
|
|
|
|
|
onClick={() => setShowPreview(!showPreview)}
|
2026-01-19 11:03:08 +00:00
|
|
|
>
|
2026-03-26 06:55:12 +00:00
|
|
|
{showPreview ? "编辑" : "预览"}
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
|
|
|
|
</Card>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
{showPreview ? (
|
2026-03-26 06:55:12 +00:00
|
|
|
<Card bordered bodyStyle={{ padding: 16, minHeight: height, overflowY: 'auto' }} style={{ borderRadius: '0 0 8px 8px' }}>
|
|
|
|
|
<MarkdownRenderer content={value} />
|
|
|
|
|
</Card>
|
2026-01-19 11:03:08 +00:00
|
|
|
) : (
|
|
|
|
|
<CodeMirror
|
|
|
|
|
ref={editorRef}
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={onChange}
|
|
|
|
|
extensions={editorExtensions}
|
|
|
|
|
placeholder={placeholder}
|
2026-03-26 06:55:12 +00:00
|
|
|
basicSetup={{ lineNumbers: false, foldGutter: false }}
|
2026-01-19 11:03:08 +00:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-26 06:55:12 +00:00
|
|
|
<input ref={imageInputRef} type="file" accept="image/*" onChange={(e) => {
|
|
|
|
|
const file = e.target.files[0];
|
|
|
|
|
if (file && onImageUpload) {
|
|
|
|
|
onImageUpload(file).then(url => url && insertMarkdown(``, '', ''));
|
|
|
|
|
}
|
|
|
|
|
e.target.value = '';
|
|
|
|
|
}} style={{ display: 'none' }} />
|
2026-01-19 11:03:08 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default MarkdownEditor;
|