[feature/frontend] markdown editor

This commit is contained in:
CDN 2025-02-23 02:41:36 +08:00
parent 086c9761a9
commit 6e1be3d513
Signed by: CDN
GPG key ID: 0C656827F9F80080
11 changed files with 2265 additions and 12 deletions

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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",

View file

@ -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 (
<Suspense fallback={<LoadingSpinner fullScreen />}>
<AuthProvider>
<UserProvider>
<RouterProvider router={router} />
</UserProvider>
</AuthProvider>
</Suspense>
<>
<Suspense fallback={<LoadingSpinner fullScreen />}>
<AuthProvider>
<UserProvider>
<RouterProvider router={router} />
</UserProvider>
</AuthProvider>
</Suspense>
<ToastContainer />
</>
);
}

View file

@ -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<HTMLElement>;
const MarkdownEditor: FC<MarkdownEditorProps> = ({
value,
onChange,
placeholder,
}) => {
const { t } = useTranslation();
const { showToast } = useToast();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const tableMenuRef = useRef<HTMLDivElement>(null);
const langSelectorRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
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 (
<div
className={classNames(
'flex flex-col border border-slate-300 dark:border-slate-600 rounded-lg',
{ 'fixed inset-0 z-50 bg-white dark:bg-slate-900': isFullscreen }
)}
>
<div className="flex items-center gap-1 p-2 border-b border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800">
{/* 左侧编辑工具栏 */}
<div className="flex-1 flex items-center gap-1 border-r border-slate-300 dark:border-slate-600 pr-2">
{toolbarButtons.map((button, index) => {
return (
<button
key={index}
onClick={() => textareaRef.current && button.action(textareaRef.current)}
className="p-1.5 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
title={`${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}`}
>
<button.icon className="text-lg" />
</button>
);
})}
{/* 表格按钮 */}
<div className="relative">
<button
onClick={() => setShowTableMenu(!showTableMenu)}
className="p-1.5 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
title={t('editor.table')}
>
<RiTableLine className="text-lg" />
</button>
{showTableMenu && (
<div
ref={tableMenuRef}
className="absolute bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg shadow-lg z-50"
style={{
width: '200px',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '0.5rem',
}}
>
{tableActions.map((action, index) => (
<button
key={index}
onClick={() => {
action.action();
setShowTableMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
>
{t(action.label)}
</button>
))}
</div>
)}
</div>
{/* 代码块按钮 */}
<div className="relative">
<button
onClick={() => setShowLangSelector(!showLangSelector)}
className="p-1.5 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
title={`${t('editor.codeBlock')} (${t('editor.codeBlockShortcut')})`}
>
<RiCodeBoxLine className="text-lg" />
</button>
{showLangSelector && (
<div
ref={langSelectorRef}
className="absolute bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg shadow-lg z-50"
style={{
width: '200px',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '0.5rem',
}}
>
<div className="px-4 py-1 text-sm font-medium text-slate-900 dark:text-white border-b border-slate-200 dark:border-slate-700">
{t('editor.selectLanguage')}
</div>
<div className="max-h-64 overflow-y-auto">
<button
onClick={() => {
insertCodeBlock();
setShowLangSelector(false);
}}
className="w-full px-4 py-2 text-left text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
>
{t('editor.plainText')}
</button>
{commonLanguages.map(({ value, label }) => (
<button
key={value}
onClick={() => {
insertCodeBlock(value);
setShowLangSelector(false);
}}
className="w-full px-4 py-2 text-left text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
>
{label}
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* 右侧预览和全屏按钮 */}
<div className="flex items-center gap-1 pl-2">
<button
onClick={() => setIsPreview(!isPreview)}
className={classNames(
'p-1.5 rounded',
isPreview
? 'text-slate-900 dark:text-white bg-slate-200 dark:bg-slate-700'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-700'
)}
title={t('editor.togglePreview')}
>
{isPreview ? (
<RiEyeOffLine className="text-lg" />
) : (
<RiEyeLine className="text-lg" />
)}
</button>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-1.5 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
title={t('editor.toggleFullscreen')}
>
<RiFullscreenLine className="text-lg" />
</button>
</div>
</div>
<div className="flex-1 flex">
<div
className={classNames(
'flex-1 relative',
{ hidden: isPreview },
{ 'before:absolute before:inset-0 before:bg-slate-900/10 before:z-10 before:pointer-events-none': isDraggingOver }
)}
>
{isDraggingOver && (
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-lg px-4 py-2">
{t('editor.dragAndDrop')}
</div>
</div>
)}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full h-full p-4 bg-white dark:bg-slate-900 text-slate-900 dark:text-white resize-vertical focus:outline-none"
style={{ minHeight: '200px' }}
placeholder={placeholder}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
/>
</div>
{isPreview && (
<div className="flex-1 overflow-auto">
<div className="prose dark:prose-invert max-w-none p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
components={{
code: ({ inline, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const lang = match ? match[1] : '';
if (inline) {
return (
<code className="bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100 px-1 py-0.5 rounded" {...props}>
{children}
</code>
);
}
return (
<div className="not-prose">
<pre className={classNames(
'bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100 p-4 rounded-lg overflow-x-auto',
lang && `language-${lang}`
)}>
<code className={lang ? `language-${lang}` : ''} {...props}>
{children}
</code>
</pre>
</div>
);
},
pre: ({ children, ...props }) => children,
p: ({ children, ...props }) => (
<p className="text-slate-900 dark:text-slate-100 mb-4" {...props}>{children}</p>
),
h1: ({ children, ...props }) => (
<h1 className="text-slate-900 dark:text-slate-100 text-4xl font-bold mt-6 mb-4" {...props}>{children}</h1>
),
h2: ({ children, ...props }) => (
<h2 className="text-slate-900 dark:text-slate-100 text-3xl font-bold mt-5 mb-3" {...props}>{children}</h2>
),
h3: ({ children, ...props }) => (
<h3 className="text-slate-900 dark:text-slate-100 text-2xl font-bold mt-4 mb-3" {...props}>{children}</h3>
),
h4: ({ children, ...props }) => (
<h4 className="text-slate-900 dark:text-slate-100 text-xl font-bold mt-4 mb-2" {...props}>{children}</h4>
),
h5: ({ children, ...props }) => (
<h5 className="text-slate-900 dark:text-slate-100 text-lg font-bold mt-3 mb-2" {...props}>{children}</h5>
),
h6: ({ children, ...props }) => (
<h6 className="text-slate-900 dark:text-slate-100 text-base font-bold mt-3 mb-2" {...props}>{children}</h6>
),
a: ({ href, children, ...props }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" {...props}>{children}</a>
),
ul: ({ children, ...props }) => (
<ul className="text-slate-900 dark:text-slate-100 list-disc pl-5 mb-4 space-y-1" {...props}>{children}</ul>
),
ol: ({ children, ...props }) => (
<ol className="text-slate-900 dark:text-slate-100 list-decimal pl-5 mb-4 space-y-1" {...props}>{children}</ol>
),
li: ({ children, ...props }) => (
<li className="text-slate-900 dark:text-slate-100" {...props}>{children}</li>
),
blockquote: ({ children, ...props }) => (
<blockquote className="border-l-4 border-slate-300 dark:border-slate-600 pl-4 italic text-slate-700 dark:text-slate-300 my-4" {...props}>{children}</blockquote>
),
table: ({ children, ...props }) => (
<div className="overflow-x-auto mb-4">
<table className="min-w-full divide-y divide-slate-300 dark:divide-slate-600" {...props}>
{children}
</table>
</div>
),
thead: ({ children, ...props }) => (
<thead className="bg-slate-50 dark:bg-slate-800" {...props}>{children}</thead>
),
tbody: ({ children, ...props }) => (
<tbody className="divide-y divide-slate-200 dark:divide-slate-700" {...props}>{children}</tbody>
),
tr: ({ children, ...props }) => (
<tr {...props}>{children}</tr>
),
th: ({ children, ...props }) => (
<th className="px-3 py-2 text-left text-sm font-semibold text-slate-900 dark:text-slate-100" {...props}>{children}</th>
),
td: ({ children, ...props }) => (
<td className="px-3 py-2 text-sm text-slate-900 dark:text-slate-100" {...props}>{children}</td>
),
img: ({ src, alt, ...props }) => (
<img src={src} alt={alt} className="max-w-full h-auto rounded-lg my-4" {...props} />
),
hr: (props) => (
<hr className="border-t border-slate-300 dark:border-slate-600 my-8" {...props} />
),
}}
>
{value}
</ReactMarkdown>
</div>
</div>
)}
</div>
<div className="p-2 text-xs text-slate-500 dark:text-slate-400 border-t border-slate-300 dark:border-slate-600">
{t('editor.dropToUpload')}
</div>
</div>
);
};
export default MarkdownEditor;

View file

@ -0,0 +1,30 @@
import { useCallback } from 'react';
import { toast, ToastOptions } from 'react-toastify';
type ToastType = 'success' | 'error' | 'info' | 'warning';
const darkModeToastStyle: ToastOptions = {
theme: 'dark',
style: {
background: '#1e293b', // slate-800
color: '#f1f5f9', // slate-100
},
};
export const useToast = () => {
const isDarkMode = document.documentElement.classList.contains('dark');
const showToast = useCallback((type: ToastType, message: string) => {
toast[type](message, {
position: 'bottom-right',
autoClose: 3000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
...(isDarkMode ? darkModeToastStyle : {}),
});
}, [isDarkMode]);
return { showToast };
};

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useBlocker } from 'react-router-dom';
import LoadingSpinner from '../../../components/LoadingSpinner';
import { usePageTitle } from '../../../contexts/PageTitleContext';
import MarkdownEditor from '../../../components/admin/MarkdownEditor';
import classNames from 'classnames';
interface PostContent {
@ -218,11 +219,10 @@ const PostEditor: FC = () => {
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
{t('admin.posts.content')}
</label>
<textarea
<MarkdownEditor
value={activeContent.content_markdown}
onChange={e => updateContent({ content_markdown: e.target.value })}
rows={10}
className="w-full px-3 py-2 bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400"
onChange={(value) => updateContent({ content_markdown: value })}
placeholder={t('admin.posts.content')}
/>
</div>

View file

@ -1,4 +1,9 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },

View file

@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from "@tailwindcss/vite";
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
@ -8,6 +9,11 @@ export default defineConfig({
react(),
tailwindcss()
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
optimizeDeps: {
exclude: ['lucide-react'],
},