[feature/frontend] markdown editor
This commit is contained in:
parent
086c9761a9
commit
6e1be3d513
11 changed files with 2265 additions and 12 deletions
|
@ -126,5 +126,45 @@
|
||||||
"passwordMismatch": "Passwords do not match",
|
"passwordMismatch": "Passwords do not match",
|
||||||
"passwordTooShort": "Password must be at least 8 characters long"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -126,5 +126,45 @@
|
||||||
"passwordMismatch": "两次输入的密码不一致",
|
"passwordMismatch": "两次输入的密码不一致",
|
||||||
"passwordTooShort": "密码长度不能少于8个字符"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -112,5 +112,45 @@
|
||||||
"passwordMismatch": "兩次輸入的密碼不一致",
|
"passwordMismatch": "兩次輸入的密碼不一致",
|
||||||
"passwordTooShort": "密碼長度不能少於8個字符"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,10 +12,14 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@tss-rocks/api": "workspace:*",
|
"@tss-rocks/api": "workspace:*",
|
||||||
|
"@types/axios": "^0.14.4",
|
||||||
"@types/classnames": "^2.3.4",
|
"@types/classnames": "^2.3.4",
|
||||||
|
"@types/highlight.js": "^10.1.0",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"axios": "^1.7.9",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
|
@ -24,7 +28,13 @@
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-icons": "^5.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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.1",
|
"@eslint/js": "^9.9.1",
|
||||||
|
|
|
@ -4,9 +4,12 @@ import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { UserProvider } from './contexts/UserContext';
|
import { UserProvider } from './contexts/UserContext';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
import LoadingSpinner from './components/LoadingSpinner';
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
|
@ -14,6 +17,8 @@ function App() {
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<ToastContainer />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
958
frontend/src/components/admin/MarkdownEditor.tsx
Normal file
958
frontend/src/components/admin/MarkdownEditor.tsx
Normal 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 = ``;
|
||||||
|
|
||||||
|
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;
|
30
frontend/src/hooks/useToast.ts
Normal file
30
frontend/src/hooks/useToast.ts
Normal 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 };
|
||||||
|
};
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate, useParams, useBlocker } from 'react-router-dom';
|
import { useNavigate, useParams, useBlocker } from 'react-router-dom';
|
||||||
import LoadingSpinner from '../../../components/LoadingSpinner';
|
import LoadingSpinner from '../../../components/LoadingSpinner';
|
||||||
import { usePageTitle } from '../../../contexts/PageTitleContext';
|
import { usePageTitle } from '../../../contexts/PageTitleContext';
|
||||||
|
import MarkdownEditor from '../../../components/admin/MarkdownEditor';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
interface PostContent {
|
interface PostContent {
|
||||||
|
@ -218,11 +219,10 @@ const PostEditor: FC = () => {
|
||||||
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
<label className="block text-sm font-medium mb-2 text-slate-900 dark:text-white">
|
||||||
{t('admin.posts.content')}
|
{t('admin.posts.content')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<MarkdownEditor
|
||||||
value={activeContent.content_markdown}
|
value={activeContent.content_markdown}
|
||||||
onChange={e => updateContent({ content_markdown: e.target.value })}
|
onChange={(value) => updateContent({ content_markdown: value })}
|
||||||
rows={10}
|
placeholder={t('admin.posts.content')}
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
{
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
@ -8,6 +9,11 @@ export default defineConfig({
|
||||||
react(),
|
react(),
|
||||||
tailwindcss()
|
tailwindcss()
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src')
|
||||||
|
}
|
||||||
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['lucide-react'],
|
exclude: ['lucide-react'],
|
||||||
},
|
},
|
||||||
|
|
1119
pnpm-lock.yaml
generated
1119
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue