diff --git a/frontend/data/i18n/en.json b/frontend/data/i18n/en.json index d018825..46bf27f 100644 --- a/frontend/data/i18n/en.json +++ b/frontend/data/i18n/en.json @@ -126,5 +126,45 @@ "passwordMismatch": "Passwords do not match", "passwordTooShort": "Password must be at least 8 characters long" } + }, + "editor": { + "heading1": "Heading 1", + "heading2": "Heading 2", + "heading3": "Heading 3", + "bold": "Bold", + "italic": "Italic", + "orderedList": "Ordered List", + "unorderedList": "Unordered List", + "quote": "Quote", + "link": "Link", + "image": "Image", + "inlineCode": "Inline Code", + "codeBlock": "Code Block", + "table": "Table", + "togglePreview": "Toggle Preview", + "toggleFullscreen": "Toggle Fullscreen", + "selectLanguage": "Select Language", + "plainText": "Plain Text", + "insertTable": "Insert Table", + "addRowAbove": "Add Row Above", + "addRowBelow": "Add Row Below", + "addColumnLeft": "Add Column Left", + "addColumnRight": "Add Column Right", + "deleteRow": "Delete Row", + "deleteColumn": "Delete Column", + "deleteTable": "Delete Table", + "dragAndDrop": "Drop images here", + "dropToUpload": "Drop files here to upload", + "uploading": "Uploading...", + "uploadProgress": "Upload progress: {{progress}}%", + "uploadError": "Failed to upload image: {{error}}", + "uploadSuccess": "Image uploaded successfully", + "codeBlockShortcut": "Ctrl+Shift+K", + "boldShortcut": "Ctrl+B", + "italicShortcut": "Ctrl+I", + "linkShortcut": "Ctrl+K", + "heading1Shortcut": "Shift+1", + "heading2Shortcut": "Shift+2", + "heading3Shortcut": "Shift+3" } } diff --git a/frontend/data/i18n/zh-Hans.json b/frontend/data/i18n/zh-Hans.json index b5ff115..50c6432 100644 --- a/frontend/data/i18n/zh-Hans.json +++ b/frontend/data/i18n/zh-Hans.json @@ -126,5 +126,45 @@ "passwordMismatch": "两次输入的密码不一致", "passwordTooShort": "密码长度不能少于8个字符" } + }, + "editor": { + "heading1": "一级标题", + "heading2": "二级标题", + "heading3": "三级标题", + "bold": "粗体", + "italic": "斜体", + "orderedList": "有序列表", + "unorderedList": "无序列表", + "quote": "引用", + "link": "链接", + "image": "图片", + "inlineCode": "行内代码", + "codeBlock": "代码块", + "table": "表格", + "togglePreview": "切换预览", + "toggleFullscreen": "切换全屏", + "selectLanguage": "选择语言", + "plainText": "纯文本", + "insertTable": "插入表格", + "addRowAbove": "在上方插入行", + "addRowBelow": "在下方插入行", + "addColumnLeft": "在左侧插入列", + "addColumnRight": "在右侧插入列", + "deleteRow": "删除行", + "deleteColumn": "删除列", + "deleteTable": "删除表格", + "dragAndDrop": "拖放图片到此处", + "dropToUpload": "拖放文件到此处上传", + "uploading": "上传中...", + "uploadProgress": "上传进度:{{progress}}%", + "uploadError": "图片上传失败:{{error}}", + "uploadSuccess": "图片上传成功", + "codeBlockShortcut": "Ctrl+Shift+K", + "boldShortcut": "Ctrl+B", + "italicShortcut": "Ctrl+I", + "linkShortcut": "Ctrl+K", + "heading1Shortcut": "Shift+1", + "heading2Shortcut": "Shift+2", + "heading3Shortcut": "Shift+3" } } diff --git a/frontend/data/i18n/zh-Hant.json b/frontend/data/i18n/zh-Hant.json index 14d776d..34b35a4 100644 --- a/frontend/data/i18n/zh-Hant.json +++ b/frontend/data/i18n/zh-Hant.json @@ -112,5 +112,45 @@ "passwordMismatch": "兩次輸入的密碼不一致", "passwordTooShort": "密碼長度不能少於8個字符" } + }, + "editor": { + "heading1": "一級標題", + "heading2": "二級標題", + "heading3": "三級標題", + "bold": "粗體", + "italic": "斜體", + "orderedList": "有序列表", + "unorderedList": "無序列表", + "quote": "引用", + "link": "連結", + "image": "圖片", + "inlineCode": "行內程式碼", + "codeBlock": "程式碼區塊", + "table": "表格", + "togglePreview": "切換預覽", + "toggleFullscreen": "切換全螢幕", + "selectLanguage": "選擇語言", + "plainText": "純文字", + "insertTable": "插入表格", + "addRowAbove": "在上方插入列", + "addRowBelow": "在下方插入列", + "addColumnLeft": "在左側插入欄", + "addColumnRight": "在右側插入欄", + "deleteRow": "刪除列", + "deleteColumn": "刪除欄", + "deleteTable": "刪除表格", + "dragAndDrop": "拖放圖片到此處", + "dropToUpload": "拖放檔案到此處上傳", + "uploading": "上傳中...", + "uploadProgress": "上傳進度:{{progress}}%", + "uploadError": "圖片上傳失敗:{{error}}", + "uploadSuccess": "圖片上傳成功", + "codeBlockShortcut": "Ctrl+Shift+K", + "boldShortcut": "Ctrl+B", + "italicShortcut": "Ctrl+I", + "linkShortcut": "Ctrl+K", + "heading1Shortcut": "Shift+1", + "heading2Shortcut": "Shift+2", + "heading3Shortcut": "Shift+3" } } diff --git a/frontend/package.json b/frontend/package.json index 013b1f8..195deb2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,10 +12,14 @@ "dependencies": { "@headlessui/react": "^2.2.0", "@tss-rocks/api": "workspace:*", + "@types/axios": "^0.14.4", "@types/classnames": "^2.3.4", + "@types/highlight.js": "^10.1.0", "@types/markdown-it": "^14.1.2", + "axios": "^1.7.9", "classnames": "^2.5.1", "dayjs": "^1.11.13", + "highlight.js": "^11.11.1", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.4", "lucide-react": "^0.474.0", @@ -24,7 +28,13 @@ "react-dom": "^19.0.0", "react-i18next": "^15.4.0", "react-icons": "^5.4.0", - "react-router-dom": "^7.1.5" + "react-markdown": "^10.0.0", + "react-router-dom": "^7.1.5", + "react-toastify": "^11.0.3", + "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^9.9.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9fa788f..db0ad14 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,16 +4,21 @@ import { AuthProvider } from './contexts/AuthContext'; import { UserProvider } from './contexts/UserContext'; import router from './router'; import LoadingSpinner from './components/LoadingSpinner'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; function App() { return ( - }> - - - - - - + <> + }> + + + + + + + + ); } diff --git a/frontend/src/components/admin/MarkdownEditor.tsx b/frontend/src/components/admin/MarkdownEditor.tsx new file mode 100644 index 0000000..0980c14 --- /dev/null +++ b/frontend/src/components/admin/MarkdownEditor.tsx @@ -0,0 +1,958 @@ +import { FC, useState, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize from 'rehype-sanitize'; +import rehypeHighlight from 'rehype-highlight'; +import 'highlight.js/styles/github-dark.css'; +import classNames from 'classnames'; +import axios from 'axios'; +import { useToast } from '@/hooks/useToast'; +import { + RiH1, + RiH2, + RiH3, + RiBold, + RiItalic, + RiListOrdered, + RiListUnordered, + RiLink, + RiImage2Line, + RiCodeLine, + RiCodeBoxLine, + RiTableLine, + RiDoubleQuotesL, + RiEyeLine, + RiEyeOffLine, + RiFullscreenLine, +} from 'react-icons/ri'; + +interface MarkdownEditorProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +type CodeBlockProps = { + inline?: boolean; + className?: string; + children: React.ReactNode; +} & React.HTMLAttributes; + +const MarkdownEditor: FC = ({ + value, + onChange, + placeholder, +}) => { + const { t } = useTranslation(); + const { showToast } = useToast(); + const textareaRef = useRef(null); + const tableMenuRef = useRef(null); + const langSelectorRef = useRef(null); + const [showTableMenu, setShowTableMenu] = useState(false); + const [showLangSelector, setShowLangSelector] = useState(false); + const [isPreview, setIsPreview] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isDraggingOver, setIsDraggingOver] = useState(false); + + // 检查当前行是否为空 + const isCurrentLineEmpty = (textarea: HTMLTextAreaElement): boolean => { + const text = textarea.value; + const lines = text.split('\n'); + const currentLine = getCurrentLine(textarea); + return !lines[currentLine].trim(); + }; + + // 获取当前行号 + const getCurrentLine = (textarea: HTMLTextAreaElement): number => { + const text = textarea.value.substring(0, textarea.selectionStart); + return text.split('\n').length - 1; + }; + + // 在当前位置插入文本 + const insertText = (textarea: HTMLTextAreaElement, text: string) => { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const before = textarea.value.substring(0, start); + const after = textarea.value.substring(end); + + const needNewLine = !isCurrentLineEmpty(textarea) && + (text.startsWith('#') || text.startsWith('>')); + + const newText = needNewLine ? `\n${text}` : text; + const newValue = before + newText + after; + + onChange(newValue); + + textarea.value = newValue; + const newCursorPos = start + newText.length; + textarea.selectionStart = newCursorPos; + textarea.selectionEnd = newCursorPos; + textarea.focus(); + }; + + const insertTable = (rows: number, cols: number) => { + if (!textareaRef.current) return; + + const headers = Array(cols).fill('header').join(' | '); + const separators = Array(cols).fill('---').join(' | '); + const cells = Array(cols).fill('content').join(' | '); + const rows_content = Array(rows).fill(cells).join('\n| '); + + insertText( + textareaRef.current, + `\n| ${headers} |\n| ${separators} |\n| ${rows_content} |\n\n` + ); + setShowTableMenu(false); + }; + + const findTableAtCursor = (): { table: string; start: number; end: number; rows: string[][]; alignments: string[] } | null => { + if (!textareaRef.current) return null; + + const text = textareaRef.current.value; + const cursorPos = textareaRef.current.selectionStart; + + const tableRegex = /\|[^\n]+\|[\s]*\n\|[- |:]+\|[\s]*\n(\|[^\n]+\|[\s]*\n?)+/g; + let match; + while ((match = tableRegex.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + if (cursorPos >= start && cursorPos <= end) { + const rows = match[0] + .trim() + .split('\n') + .map(row => + row + .trim() + .replace(/^\||\|$/g, '') + .split('|') + .map(cell => cell.trim()) + ); + + const alignments = rows[1].map(cell => { + if (cell.startsWith(':') && cell.endsWith(':')) return 'center'; + if (cell.endsWith(':')) return 'right'; + return 'left'; + }); + + return { + table: match[0], + start, + end, + rows: [rows[0], ...rows.slice(2)], + alignments, + }; + } + } + return null; + }; + + const generateTableText = (rows: string[][], alignments: string[]): string => { + const colWidths = rows[0].map((_, colIndex) => + Math.max(...rows.map(row => row[colIndex]?.length || 0)) + ); + + const separator = alignments.map((align, i) => { + const width = Math.max(3, colWidths[i]); + switch (align) { + case 'center': + return ':' + '-'.repeat(width - 2) + ':'; + case 'right': + return '-'.repeat(width - 1) + ':'; + default: + return '-'.repeat(width); + } + }); + + const tableRows = [ + rows[0], + separator, + ...rows.slice(1) + ].map(row => + '| ' + row.map((cell, i) => cell.padEnd(colWidths[i])).join(' | ') + ' |' + ); + + return tableRows.join('\n') + '\n'; + }; + + const updateTable = (pos: { table: string; start: number; end: number; rows: string[][]; alignments: string[] }, newRows?: string[][]) => { + if (!textareaRef.current) return; + + const text = textareaRef.current.value; + const newTable = generateTableText(newRows || pos.rows, pos.alignments); + const newText = text.substring(0, pos.start) + newTable + text.substring(pos.end); + onChange(newText); + }; + + const tableActions = [ + { + label: t('editor.insertTable'), + action: () => insertTable(2, 2), + }, + { + label: t('editor.addRowAbove'), + action: () => { + const tablePos = findTableAtCursor(); + if (!tablePos) return; + + const cursorPos = textareaRef.current?.selectionStart || 0; + let rowIndex = 0; + let currentPos = tablePos.start; + + for (let i = 0; i < tablePos.rows.length; i++) { + const rowText = generateTableText([tablePos.rows[i]], tablePos.alignments); + if (cursorPos <= currentPos + rowText.length) { + rowIndex = i; + break; + } + currentPos += rowText.length; + } + + const newRows = [...tablePos.rows]; + newRows.splice(rowIndex, 0, Array(tablePos.rows[0].length).fill('')); + updateTable(tablePos, newRows); + }, + }, + { + label: t('editor.addRowBelow'), + action: () => { + const tablePos = findTableAtCursor(); + if (!tablePos) return; + + const cursorPos = textareaRef.current?.selectionStart || 0; + let rowIndex = 0; + let currentPos = tablePos.start; + + for (let i = 0; i < tablePos.rows.length; i++) { + const rowText = generateTableText([tablePos.rows[i]], tablePos.alignments); + if (cursorPos <= currentPos + rowText.length) { + rowIndex = i; + break; + } + currentPos += rowText.length; + } + + const newRows = [...tablePos.rows]; + newRows.splice(rowIndex + 1, 0, Array(tablePos.rows[0].length).fill('')); + updateTable(tablePos, newRows); + }, + }, + { + label: t('editor.addColumnLeft'), + action: () => { + const tablePos = findTableAtCursor(); + if (!tablePos) return; + + const cursorPos = textareaRef.current?.selectionStart || 0; + const line = textareaRef.current?.value + .substring(tablePos.start, cursorPos) + .split('\n') + .pop() || ''; + const colIndex = (line.match(/\|/g) || []).length - 1; + + const newRows = tablePos.rows.map(row => { + const newRow = [...row]; + newRow.splice(colIndex, 0, ''); + return newRow; + }); + + const newAlignments = [...tablePos.alignments]; + newAlignments.splice(colIndex, 0, 'left'); + + tablePos.alignments = newAlignments; + updateTable(tablePos, newRows); + }, + }, + { + label: t('editor.addColumnRight'), + action: () => { + const tablePos = findTableAtCursor(); + if (!tablePos) return; + + const cursorPos = textareaRef.current?.selectionStart || 0; + const line = textareaRef.current?.value + .substring(tablePos.start, cursorPos) + .split('\n') + .pop() || ''; + const colIndex = (line.match(/\|/g) || []).length - 1; + + const newRows = tablePos.rows.map(row => { + const newRow = [...row]; + newRow.splice(colIndex + 1, 0, ''); + return newRow; + }); + + const newAlignments = [...tablePos.alignments]; + newAlignments.splice(colIndex + 1, 0, 'left'); + + tablePos.alignments = newAlignments; + updateTable(tablePos, newRows); + }, + }, + { + label: t('editor.deleteRow'), + action: () => { + const tablePos = findTableAtCursor(); + if (!tablePos) return; + + const cursorPos = textareaRef.current?.selectionStart || 0; + let rowIndex = 0; + let currentPos = tablePos.start; + + for (let i = 0; i < tablePos.rows.length; i++) { + const rowText = generateTableText([tablePos.rows[i]], tablePos.alignments); + if (cursorPos <= currentPos + rowText.length) { + rowIndex = i; + break; + } + currentPos += rowText.length; + } + + const newRows = [...tablePos.rows]; + newRows.splice(rowIndex, 1); + updateTable(tablePos, newRows); + }, + }, + { + label: t('editor.deleteColumn'), + action: () => { + const tablePos = findTableAtCursor(); + if (!tablePos) return; + + const cursorPos = textareaRef.current?.selectionStart || 0; + const line = textareaRef.current?.value + .substring(tablePos.start, cursorPos) + .split('\n') + .pop() || ''; + const colIndex = (line.match(/\|/g) || []).length - 1; + + const newRows = tablePos.rows.map(row => { + const newRow = [...row]; + newRow.splice(colIndex, 1); + return newRow; + }); + + const newAlignments = [...tablePos.alignments]; + newAlignments.splice(colIndex, 1); + + tablePos.alignments = newAlignments; + updateTable(tablePos, newRows); + }, + }, + { + label: t('editor.deleteTable'), + action: () => { + const tablePos = findTableAtCursor(); + if (!tablePos) return; + + const text = textareaRef.current?.value || ''; + const newText = text.substring(0, tablePos.start) + text.substring(tablePos.end); + onChange(newText); + }, + }, + ]; + + const toolbarButtons = [ + { + icon: RiH1, + label: t('editor.heading1'), + shortcut: t('editor.heading1Shortcut'), + action: (textarea: HTMLTextAreaElement) => { + insertText(textarea, '# '); + }, + }, + { + icon: RiH2, + label: t('editor.heading2'), + shortcut: t('editor.heading2Shortcut'), + action: (textarea: HTMLTextAreaElement) => { + insertText(textarea, '## '); + }, + }, + { + icon: RiH3, + label: t('editor.heading3'), + shortcut: t('editor.heading3Shortcut'), + action: (textarea: HTMLTextAreaElement) => { + insertText(textarea, '### '); + }, + }, + { + icon: RiBold, + label: t('editor.bold'), + shortcut: t('editor.boldShortcut'), + action: (textarea: HTMLTextAreaElement) => { + const text = textarea.value.substring( + textarea.selectionStart, + textarea.selectionEnd + ); + insertText(textarea, `**${text || t('editor.bold')}**`); + }, + }, + { + icon: RiItalic, + label: t('editor.italic'), + shortcut: t('editor.italicShortcut'), + action: (textarea: HTMLTextAreaElement) => { + const text = textarea.value.substring( + textarea.selectionStart, + textarea.selectionEnd + ); + insertText(textarea, `_${text || t('editor.italic')}_`); + }, + }, + { + icon: RiListUnordered, + label: t('editor.unorderedList'), + action: (textarea: HTMLTextAreaElement) => { + insertText(textarea, '- '); + }, + }, + { + icon: RiListOrdered, + label: t('editor.orderedList'), + action: (textarea: HTMLTextAreaElement) => { + insertText(textarea, '1. '); + }, + }, + { + icon: RiDoubleQuotesL, + label: t('editor.quote'), + action: (textarea: HTMLTextAreaElement) => { + insertText(textarea, '> '); + }, + }, + { + icon: RiLink, + label: t('editor.link'), + shortcut: t('editor.linkShortcut'), + action: (textarea: HTMLTextAreaElement) => { + const text = textarea.value.substring( + textarea.selectionStart, + textarea.selectionEnd + ); + insertText(textarea, `[${text || t('editor.link')}](url)`); + }, + }, + { + icon: RiImage2Line, + label: t('editor.image'), + action: () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + handleFileUpload(file); + } + }; + input.click(); + }, + }, + { + icon: RiCodeLine, + label: t('editor.inlineCode'), + action: (textarea: HTMLTextAreaElement) => { + const text = textarea.value.substring( + textarea.selectionStart, + textarea.selectionEnd + ); + insertText(textarea, `\`${text || t('editor.inlineCode')}\``); + }, + }, + ]; + + const commonLanguages = [ + { value: 'javascript', label: 'JavaScript' }, + { value: 'typescript', label: 'TypeScript' }, + { value: 'jsx', label: 'JSX' }, + { value: 'tsx', label: 'TSX' }, + { value: 'css', label: 'CSS' }, + { value: 'html', label: 'HTML' }, + { value: 'json', label: 'JSON' }, + { value: 'markdown', label: 'Markdown' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + { value: 'c', label: 'C' }, + { value: 'cpp', label: 'C++' }, + { value: 'csharp', label: 'C#' }, + { value: 'go', label: 'Go' }, + { value: 'rust', label: 'Rust' }, + { value: 'php', label: 'PHP' }, + { value: 'ruby', label: 'Ruby' }, + { value: 'swift', label: 'Swift' }, + { value: 'kotlin', label: 'Kotlin' }, + { value: 'sql', label: 'SQL' }, + { value: 'shell', label: 'Shell' }, + { value: 'yaml', label: 'YAML' }, + { value: 'xml', label: 'XML' }, + ]; + + const insertCodeBlock = (language?: string) => { + if (!textareaRef.current) return; + const lang = language || ''; + insertText(textareaRef.current, `\n\`\`\`${lang}\n\n\`\`\`\n`); + const cursorPos = textareaRef.current.selectionStart - 4; + textareaRef.current.setSelectionRange(cursorPos, cursorPos); + setShowLangSelector(false); + }; + + // 处理文件上传 + const handleFileUpload = async (file: File) => { + if (!file.type.startsWith('image/')) { + showToast('error', t('editor.uploadError', { error: 'Not an image file' })); + return; + } + + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await axios.post('/api/v1/media', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + withCredentials: true, + }); + + const imageUrl = response.data.data.url; + const imageMarkdown = `![${file.name}](${imageUrl})`; + + if (textareaRef.current) { + const start = textareaRef.current.selectionStart; + const end = textareaRef.current.selectionEnd; + const before = value.substring(0, start); + const after = value.substring(end); + const newValue = before + imageMarkdown + after; + onChange(newValue); + showToast('success', t('editor.uploadSuccess')); + } + } catch (error: any) { + console.error('Upload error:', error); + const errorMessage = error.response?.data?.error?.message || error.message; + showToast('error', t('editor.uploadError', { error: errorMessage })); + } + }; + + // 处理拖放事件 + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(false); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(false); + + const files = Array.from(e.dataTransfer.files); + for (const file of files) { + await handleFileUpload(file); + } + }; + + // 处理粘贴事件 + const handlePaste = async (e: React.ClipboardEvent) => { + const items = Array.from(e.clipboardData.items); + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + const file = item.getAsFile(); + if (file) { + await handleFileUpload(file); + } + } + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + const key = e.key.toLowerCase(); + switch (key) { + case 'b': // 粗体 + e.preventDefault(); + if (textareaRef.current) { + const text = textareaRef.current.value.substring( + textareaRef.current.selectionStart, + textareaRef.current.selectionEnd + ); + insertText(textareaRef.current, `**${text}**`); + } + break; + case 'i': // 斜体 + e.preventDefault(); + if (textareaRef.current) { + const text = textareaRef.current.value.substring( + textareaRef.current.selectionStart, + textareaRef.current.selectionEnd + ); + insertText(textareaRef.current, `_${text}_`); + } + break; + case 'k': // 链接 + e.preventDefault(); + if (textareaRef.current) { + const text = textareaRef.current.value.substring( + textareaRef.current.selectionStart, + textareaRef.current.selectionEnd + ); + insertText(textareaRef.current, `[${text}](url)`); + } + break; + case '1': // 标题 1 + if (e.shiftKey) { + e.preventDefault(); + if (textareaRef.current) { + const text = textareaRef.current.value.substring( + textareaRef.current.selectionStart, + textareaRef.current.selectionEnd + ); + insertText(textareaRef.current, `# ${text}`); + } + } + break; + case '2': // 标题 2 + if (e.shiftKey) { + e.preventDefault(); + if (textareaRef.current) { + const text = textareaRef.current.value.substring( + textareaRef.current.selectionStart, + textareaRef.current.selectionEnd + ); + insertText(textareaRef.current, `## ${text}`); + } + } + break; + case '3': // 标题 3 + if (e.shiftKey) { + e.preventDefault(); + if (textareaRef.current) { + const text = textareaRef.current.value.substring( + textareaRef.current.selectionStart, + textareaRef.current.selectionEnd + ); + insertText(textareaRef.current, `### ${text}`); + } + } + break; + case 'e': // 预览 + e.preventDefault(); + setIsPreview(!isPreview); + break; + } + } + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + tableMenuRef.current && + !tableMenuRef.current.contains(event.target as Node) + ) { + setShowTableMenu(false); + } + if ( + langSelectorRef.current && + !langSelectorRef.current.contains(event.target as Node) + ) { + setShowLangSelector(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+
+ {/* 左侧编辑工具栏 */} +
+ {toolbarButtons.map((button, index) => { + return ( + + ); + })} + + {/* 表格按钮 */} +
+ + {showTableMenu && ( +
+ {tableActions.map((action, index) => ( + + ))} +
+ )} +
+ + {/* 代码块按钮 */} +
+ + {showLangSelector && ( +
+
+ {t('editor.selectLanguage')} +
+
+ + {commonLanguages.map(({ value, label }) => ( + + ))} +
+
+ )} +
+
+ + {/* 右侧预览和全屏按钮 */} +
+ + +
+
+ +
+
+ {isDraggingOver && ( +
+
+ {t('editor.dragAndDrop')} +
+
+ )} +