imetting_frontend/src/components/MarkdownEditor.jsx

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(`![${file.name}](${imageUrl})`, '', '');
}
}
// 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;