1032 lines
31 KiB
JavaScript
1032 lines
31 KiB
JavaScript
import { useState, useEffect, useRef, useMemo } from 'react'
|
||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||
import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, Select, Progress, TreeSelect } from 'antd'
|
||
import {
|
||
FileOutlined,
|
||
FolderOutlined,
|
||
PlusOutlined,
|
||
DeleteOutlined,
|
||
EditOutlined,
|
||
SaveOutlined,
|
||
FileAddOutlined,
|
||
FolderAddOutlined,
|
||
UploadOutlined,
|
||
SwapOutlined,
|
||
ReloadOutlined,
|
||
FileImageOutlined,
|
||
FilePdfOutlined,
|
||
FileTextOutlined,
|
||
UndoOutlined,
|
||
CloseOutlined,
|
||
} from '@ant-design/icons'
|
||
import { Editor } from '@bytemd/react'
|
||
import gfm from '@bytemd/plugin-gfm'
|
||
import highlight from '@bytemd/plugin-highlight'
|
||
import breaks from '@bytemd/plugin-breaks'
|
||
import frontmatter from '@bytemd/plugin-frontmatter'
|
||
import gemoji from '@bytemd/plugin-gemoji'
|
||
import 'bytemd/dist/index.css'
|
||
import 'highlight.js/styles/github.css'
|
||
import {
|
||
saveFile,
|
||
operateFile,
|
||
uploadFile,
|
||
importDocuments,
|
||
uploadDocument,
|
||
} from '@/api/file'
|
||
import Toast from '@/components/Toast/Toast'
|
||
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
||
import { findNodeByKey } from './documentBrowserUtils'
|
||
import useDocumentEditorWorkspace from './useDocumentEditorWorkspace'
|
||
import './DocumentEditor.css'
|
||
|
||
const { Sider, Content } = Layout
|
||
|
||
function DocumentEditor() {
|
||
const { projectId } = useParams()
|
||
const navigate = useNavigate()
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const fileInputRef = useRef(null)
|
||
const {
|
||
treeData,
|
||
selectedFile,
|
||
selectedNode,
|
||
fileContent,
|
||
loading,
|
||
openKeys,
|
||
selectedMenuKey,
|
||
isPdfSelected,
|
||
refreshing,
|
||
projectName,
|
||
userRole,
|
||
setOpenKeys,
|
||
setSelectedNode,
|
||
setSelectedFile,
|
||
setSelectedMenuKey,
|
||
setFileContent,
|
||
fetchTree,
|
||
clearSelection,
|
||
openNodeByKey,
|
||
handleMenuClick,
|
||
refreshCurrentDocument,
|
||
} = useDocumentEditorWorkspace({
|
||
projectId,
|
||
searchParams,
|
||
setSearchParams,
|
||
})
|
||
const [saving, setSaving] = useState(false)
|
||
const [modalVisible, setModalVisible] = useState(false)
|
||
const [moveModalVisible, setMoveModalVisible] = useState(false)
|
||
const [operationType, setOperationType] = useState(null)
|
||
const [newName, setNewName] = useState('')
|
||
const [rightClickNode, setRightClickNode] = useState(null)
|
||
const [creationParentPath, setCreationParentPath] = useState('')
|
||
const [moveTargetPath, setMoveTargetPath] = useState('')
|
||
const [dirOptions, setDirOptions] = useState([])
|
||
const [editorHeight, setEditorHeight] = useState(600) // 设置初始高度为600px
|
||
const [uploadProgress, setUploadProgress] = useState(0) // 上传进度
|
||
const [uploading, setUploading] = useState(false) // 是否正在上传
|
||
const [fileList, setFileList] = useState([]) // 控制上传文件列表
|
||
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
||
const [linkModalVisible, setLinkModalVisible] = useState(false)
|
||
const [linkTarget, setLinkTarget] = useState(null)
|
||
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
|
||
const editorCtxRef = useRef(null)
|
||
const modeSwitchingRef = useRef(false)
|
||
|
||
const navigateWithTransition = (to) => {
|
||
if (document.startViewTransition) {
|
||
document.startViewTransition(() => navigate(to))
|
||
return
|
||
}
|
||
navigate(to)
|
||
}
|
||
|
||
// 插入内链接
|
||
const handleInsertLink = () => {
|
||
if (!linkTarget) {
|
||
Toast.warning('提示', '请选择文件')
|
||
return
|
||
}
|
||
|
||
if (editorCtxRef.current && editorCtxRef.current.editor) {
|
||
const editor = editorCtxRef.current.editor
|
||
// 获取当前选中的文字
|
||
const selection = editor.getSelection()
|
||
|
||
// 简单的从路径获取文件名作为备选
|
||
const fileName = linkTarget.split('/').pop()
|
||
// 如果没有选中文字,则使用文件名作为链接文字;否则保留原文字
|
||
const linkTitle = selection || fileName
|
||
const linkText = `[${linkTitle}](${linkTarget})`
|
||
|
||
editor.replaceSelection(linkText)
|
||
editor.focus()
|
||
}
|
||
|
||
setLinkModalVisible(false)
|
||
setLinkTarget(null)
|
||
}
|
||
|
||
// 在组件挂载后立即计算正确的高度
|
||
useEffect(() => {
|
||
const calculateHeight = () => {
|
||
const windowHeight = window.innerHeight
|
||
const newHeight = Math.max(windowHeight - 180, 400) // 最小高度400px
|
||
setEditorHeight(newHeight)
|
||
}
|
||
|
||
// 立即执行一次
|
||
calculateHeight()
|
||
|
||
// 监听窗口大小变化
|
||
window.addEventListener('resize', calculateHeight)
|
||
return () => window.removeEventListener('resize', calculateHeight)
|
||
}, [])
|
||
|
||
const handleRefresh = async () => {
|
||
await refreshCurrentDocument()
|
||
}
|
||
|
||
const handleClose = () => {
|
||
Modal.confirm({
|
||
title: '确认退出',
|
||
content: '确定要退出编辑页面吗?未保存的修改可能会丢失。',
|
||
okText: '退出',
|
||
cancelText: '取消',
|
||
onOk: () => {
|
||
if (userRole === 'owner') {
|
||
navigate('/projects/my')
|
||
} else {
|
||
navigate('/projects/share')
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
// 重置当前编辑内容(重新从服务器加载)
|
||
const handleReset = () => {
|
||
if (!selectedFile) return
|
||
|
||
Modal.confirm({
|
||
title: '确认重置',
|
||
content: '确定要重置当前修改吗?所有未保存的更改都将丢失。',
|
||
onOk: async () => {
|
||
await openNodeByKey(selectedFile, null, false)
|
||
Toast.success('重置成功', '已恢复至最后保存的版本')
|
||
},
|
||
})
|
||
}
|
||
|
||
const handleSaveFile = async () => {
|
||
if (!selectedFile) {
|
||
Toast.warning('提示', '请先选择文件')
|
||
return
|
||
}
|
||
|
||
setSaving(true)
|
||
try {
|
||
await saveFile(projectId, {
|
||
path: selectedFile,
|
||
content: fileContent,
|
||
})
|
||
Toast.success('成功', '保存成功')
|
||
} catch (error) {
|
||
// 错误已通过request interceptor处理
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const getParentPath = (node) => {
|
||
if (!node) return ''
|
||
if (!node.isLeaf) return node.key
|
||
const parts = node.key.split('/')
|
||
parts.pop()
|
||
return parts.join('/')
|
||
}
|
||
|
||
const handleCreateFile = (node = null) => {
|
||
// If clicked from button (node is event or undefined), use selectedNode
|
||
// If clicked from context menu (node is passed), use it
|
||
const targetNode = (node && node.key) ? node : selectedNode
|
||
const parentPath = getParentPath(targetNode)
|
||
|
||
setCreationParentPath(parentPath)
|
||
setOperationType('create_file')
|
||
setModalVisible(true)
|
||
}
|
||
|
||
const handleCreateDir = (node = null) => {
|
||
const targetNode = (node && node.key) ? node : selectedNode
|
||
const parentPath = getParentPath(targetNode)
|
||
|
||
setCreationParentPath(parentPath)
|
||
setOperationType('create_dir')
|
||
setModalVisible(true)
|
||
}
|
||
|
||
const handleDelete = (path = selectedFile) => {
|
||
if (!path) {
|
||
Toast.warning('提示', '请先选择文件')
|
||
return
|
||
}
|
||
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定要删除 ${path} 吗?`,
|
||
onOk: async () => {
|
||
try {
|
||
await operateFile(projectId, {
|
||
action: 'delete',
|
||
path: path,
|
||
})
|
||
Toast.success('成功', '删除成功')
|
||
if (selectedFile === path) {
|
||
clearSelection()
|
||
}
|
||
fetchTree()
|
||
} catch (error) {
|
||
console.error('Delete error:', error)
|
||
Toast.error('错误', '删除失败')
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
const handleRename = (path) => {
|
||
const fileName = path.split('/').pop()
|
||
|
||
// 保护README.md
|
||
if (fileName === 'README.md' && path.indexOf('/') === -1) {
|
||
Toast.error('错误', '根目录的README.md不允许重命名')
|
||
return
|
||
}
|
||
|
||
setRightClickNode(path)
|
||
setNewName(fileName)
|
||
setOperationType('rename')
|
||
setModalVisible(true)
|
||
}
|
||
|
||
const handleOperation = async () => {
|
||
if (!newName.trim()) {
|
||
Toast.warning('提示', '请输入名称')
|
||
return
|
||
}
|
||
|
||
try {
|
||
let path, params
|
||
|
||
if (operationType === 'rename') {
|
||
// 重命名操作
|
||
const oldPath = rightClickNode
|
||
const pathParts = oldPath.split('/')
|
||
pathParts[pathParts.length - 1] = newName
|
||
const newPath = pathParts.join('/')
|
||
|
||
params = {
|
||
action: 'rename',
|
||
path: oldPath,
|
||
new_path: newPath,
|
||
}
|
||
} else {
|
||
// 自动补全后缀 (仅针对新建文件)
|
||
let finalName = newName
|
||
if (operationType === 'create_file' && finalName.indexOf('.') === -1) {
|
||
finalName += '.md'
|
||
}
|
||
|
||
// 创建操作 - 使用预先计算的父目录路径
|
||
path = creationParentPath ? `${creationParentPath}/${finalName}` : finalName
|
||
|
||
// 检查文件是否已存在
|
||
const fileExists = checkFileExists(path)
|
||
if (fileExists) {
|
||
Modal.confirm({
|
||
title: '文件已存在',
|
||
content: `文件 "${finalName}" 已存在,是否覆盖?`,
|
||
okText: '覆盖',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
await executeOperation(operationType, path)
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
params = {
|
||
action: operationType,
|
||
path: path,
|
||
content: operationType === 'create_file' ? '# 新文件\n' : undefined,
|
||
}
|
||
}
|
||
|
||
await operateFile(projectId, params)
|
||
Toast.success('成功', '操作成功')
|
||
setModalVisible(false)
|
||
setNewName('')
|
||
setRightClickNode(null)
|
||
fetchTree()
|
||
|
||
// 如果重命名的是当前打开的文件,清空编辑器
|
||
if (operationType === 'rename' && selectedFile === rightClickNode) {
|
||
clearSelection()
|
||
}
|
||
} catch (error) {
|
||
console.error('Operation error:', error)
|
||
Toast.error('错误', '操作失败')
|
||
}
|
||
}
|
||
|
||
// 检查文件是否存在
|
||
const checkFileExists = (path) => {
|
||
const checkInTree = (nodes, targetPath) => {
|
||
for (const node of nodes) {
|
||
if (node.key === targetPath) {
|
||
return true
|
||
}
|
||
if (node.children) {
|
||
if (checkInTree(node.children, targetPath)) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
return checkInTree(treeData, path)
|
||
}
|
||
|
||
// 执行创建操作
|
||
const executeOperation = async (operation, path) => {
|
||
const params = {
|
||
action: operation,
|
||
path: path,
|
||
content: operation === 'create_file' ? '# 新文件\n' : undefined,
|
||
}
|
||
await operateFile(projectId, params)
|
||
Toast.success('成功', '操作成功')
|
||
setModalVisible(false)
|
||
setNewName('')
|
||
setRightClickNode(null)
|
||
fetchTree()
|
||
}
|
||
|
||
// 上传文档(支持MD和PDF)
|
||
const handleImportDocuments = async (info) => {
|
||
// 防止重复触发
|
||
if (uploadingRef.current) {
|
||
return
|
||
}
|
||
|
||
const { fileList: currentFileList } = info
|
||
|
||
// 立即更新state以显示当前选择的文件
|
||
setFileList(currentFileList)
|
||
|
||
// 过滤出有效文件
|
||
const mdFiles = currentFileList.filter((f) => f.name.endsWith('.md'))
|
||
const pdfFiles = currentFileList.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
|
||
const allFiles = [...mdFiles, ...pdfFiles]
|
||
|
||
if (allFiles.length === 0) {
|
||
Toast.warning('提示', '请选择.md或.pdf格式的文档')
|
||
setFileList([]) // 清空文件列表
|
||
return
|
||
}
|
||
|
||
// 设置上传标记
|
||
uploadingRef.current = true
|
||
|
||
// 确定目标路径(如果选中了目录,上传到该目录)
|
||
const targetPath = selectedNode && !selectedNode.isLeaf ? selectedNode.key : ''
|
||
|
||
// 检查是否有重名文件
|
||
const existingFiles = []
|
||
allFiles.forEach((f) => {
|
||
const filePath = targetPath ? `${targetPath}/${f.name}` : f.name
|
||
if (checkFileExists(filePath)) {
|
||
existingFiles.push(f.name)
|
||
}
|
||
})
|
||
|
||
// 如果有重名文件,询问是否覆盖
|
||
if (existingFiles.length > 0) {
|
||
Modal.confirm({
|
||
title: '文件已存在',
|
||
content: (
|
||
<div>
|
||
<p>以下文件已存在,是否覆盖?</p>
|
||
<ul>
|
||
{existingFiles.map((name) => (
|
||
<li key={name}>{name}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
),
|
||
okText: '覆盖',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
await executeImport(mdFiles, pdfFiles, targetPath)
|
||
},
|
||
onCancel: () => {
|
||
// 取消时重置标记
|
||
uploadingRef.current = false
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
// 没有重名文件,直接导入
|
||
await executeImport(mdFiles, pdfFiles, targetPath)
|
||
}
|
||
|
||
// 执行导入操作
|
||
const executeImport = async (mdFiles, pdfFiles, targetPath) => {
|
||
setUploading(true)
|
||
setUploadProgress(0)
|
||
|
||
try {
|
||
let successCount = 0
|
||
const totalFiles = mdFiles.length + pdfFiles.length
|
||
|
||
// 上传MD文件
|
||
if (mdFiles.length > 0) {
|
||
const files = mdFiles.map((f) => f.originFileObj)
|
||
await importDocuments(projectId, files, targetPath)
|
||
successCount += files.length
|
||
setUploadProgress(Math.round((successCount / totalFiles) * 100))
|
||
}
|
||
|
||
// 上传PDF文件
|
||
if (pdfFiles.length > 0) {
|
||
for (const pdfFile of pdfFiles) {
|
||
await uploadDocument(projectId, pdfFile.originFileObj, targetPath)
|
||
successCount++
|
||
setUploadProgress(Math.round((successCount / totalFiles) * 100))
|
||
}
|
||
}
|
||
|
||
Toast.success('成功', `成功上传 ${successCount} 个文档`)
|
||
fetchTree()
|
||
// 清除文件选择
|
||
setFileList([])
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = ''
|
||
}
|
||
} catch (error) {
|
||
Toast.error('错误', '上传失败')
|
||
} finally {
|
||
setUploading(false)
|
||
setUploadProgress(0)
|
||
uploadingRef.current = false // 重置上传标记
|
||
}
|
||
}
|
||
|
||
// 移动文件/目录
|
||
const handleMove = (path) => {
|
||
setRightClickNode(path)
|
||
// 生成目录选项(排除自己和其子目录)
|
||
const options = buildDirOptions(treeData, path)
|
||
setDirOptions(options)
|
||
setMoveTargetPath('')
|
||
setMoveModalVisible(true)
|
||
}
|
||
|
||
// 构建目录选项
|
||
const buildDirOptions = (nodes, excludePath, prefix = '') => {
|
||
let options = [{ label: '根目录', value: '' }]
|
||
|
||
const traverse = (items, currentPrefix) => {
|
||
items.forEach((node) => {
|
||
if (!node.isLeaf && node.key !== excludePath && !node.key.startsWith(excludePath + '/')) {
|
||
const path = node.key
|
||
options.push({
|
||
label: `${currentPrefix}${node.title}`,
|
||
value: path,
|
||
})
|
||
if (node.children) {
|
||
traverse(node.children, `${currentPrefix}${node.title}/`)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
traverse(nodes, prefix)
|
||
return options
|
||
}
|
||
|
||
// 确认移动
|
||
const handleConfirmMove = async () => {
|
||
if (!rightClickNode) return
|
||
|
||
const fileName = rightClickNode.split('/').pop()
|
||
const newPath = moveTargetPath ? `${moveTargetPath}/${fileName}` : fileName
|
||
|
||
try {
|
||
await operateFile(projectId, {
|
||
action: 'move',
|
||
path: rightClickNode,
|
||
new_path: newPath,
|
||
})
|
||
Toast.success('成功', '移动成功')
|
||
setMoveModalVisible(false)
|
||
setRightClickNode(null)
|
||
fetchTree()
|
||
|
||
// 如果移动的是当前打开的文件,清空编辑器
|
||
if (selectedFile === rightClickNode) {
|
||
clearSelection()
|
||
}
|
||
} catch (error) {
|
||
console.error('Move error:', error)
|
||
Toast.error('错误', '移动失败')
|
||
}
|
||
}
|
||
|
||
const handleImageUpload = async (file) => {
|
||
try {
|
||
// 使用 'images' 作为子文件夹,后端会自动保存到 _assets/images/ 目录
|
||
const res = await uploadFile(projectId, file, 'images')
|
||
// 使用相对路径,便于统一部署
|
||
return res.data.url
|
||
} catch (error) {
|
||
console.error('Upload error:', error)
|
||
Toast.error('错误', '图片上传失败')
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 处理PDF上传(已移除,PDF应该通过侧边栏上传)
|
||
|
||
// ByteMD 插件配置
|
||
const plugins = useMemo(() => {
|
||
// 内链接插件
|
||
const internalLinkPlugin = {
|
||
actions: [
|
||
{
|
||
title: '内链接',
|
||
icon: '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10.59 13.41c.41.39.41 1.03 0 1.42-.39.39-1.03.39-1.42 0a5.003 5.003 0 0 1 0-7.07l3.54-3.54a5.003 5.003 0 0 1 7.07 0 5.003 5.003 0 0 1 0 7.07l-1.49 1.49c.01-.82-.12-1.64-.4-2.42l.47-.48a2.982 2.982 0 0 0 0-4.24 2.982 2.982 0 0 0-4.24 0l-3.53 3.53a2.98 2.98 0 0 0 0 4.24zm2.82-4.24c-.41-.39-.41-1.03 0-1.42a1 1 0 0 1 1.42 0 5.003 5.003 0 0 1 0 7.07l-3.54 3.54a5.003 5.003 0 0 1-7.07 0 5.003 5.003 0 0 1 0-7.07l1.49-1.49c-.01.82.12 1.64.4 2.43l-.47.47a2.982 2.982 0 0 0 0 4.24 2.982 2.982 0 0 0 4.24 0l3.53-3.53a2.98 2.98 0 0 0 0-4.24.973.973 0 0 1 0-1.42z"/></svg>',
|
||
handler: {
|
||
type: 'action',
|
||
click: (ctx) => {
|
||
editorCtxRef.current = ctx
|
||
setLinkModalVisible(true)
|
||
},
|
||
},
|
||
},
|
||
],
|
||
}
|
||
|
||
// 自定义图片上传插件
|
||
const uploadImagesPlugin = {
|
||
actions: [
|
||
{
|
||
title: '上传图片',
|
||
icon: '<svg width="16" height="16" viewBox="0 0 16 16"><path fill="currentColor" d="M14.998 2l.002.002v11.996l-.002.002H1.002L1 13.998V2.002L1.002 2h13.996zM15 1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h14c.55 0 1-.45 1-1V2c0-.55-.45-1-1-1z"/><path fill="currentColor" d="M13 4.5a1.5 1.5 0 1 1-3.001-.001A1.5 1.5 0 0 1 13 4.5zM14 13H2v-2l3.5-6 4 5h1L14 7z"/></svg>',
|
||
handler: {
|
||
type: 'action',
|
||
click: (ctx) => {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'image/*'
|
||
input.onchange = async (e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
const url = await handleImageUpload(file)
|
||
if (url) {
|
||
ctx.editor.replaceSelection(``)
|
||
ctx.editor.focus()
|
||
Toast.success('成功', '图片上传成功')
|
||
}
|
||
}
|
||
}
|
||
input.click()
|
||
},
|
||
},
|
||
},
|
||
],
|
||
}
|
||
|
||
// 捕获编辑器实例的插件
|
||
const editorRefPlugin = {
|
||
editorEffect: (ctx) => {
|
||
editorCtxRef.current = ctx
|
||
},
|
||
}
|
||
|
||
return [
|
||
gfm(),
|
||
highlight(),
|
||
breaks(),
|
||
frontmatter(),
|
||
gemoji(),
|
||
uploadImagesPlugin,
|
||
internalLinkPlugin,
|
||
editorRefPlugin,
|
||
]
|
||
}, [projectId])
|
||
|
||
// 处理粘贴图片
|
||
const handlePaste = async (event) => {
|
||
const items = event.clipboardData?.items
|
||
if (!items) return
|
||
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i]
|
||
if (item.type.indexOf('image') !== -1) {
|
||
event.preventDefault()
|
||
const file = item.getAsFile()
|
||
if (file) {
|
||
const url = await handleImageUpload(file)
|
||
|
||
if (url) {
|
||
// 插入markdown图片语法
|
||
const imageMarkdown = ``
|
||
if (editorCtxRef.current && editorCtxRef.current.editor) {
|
||
const editor = editorCtxRef.current.editor
|
||
editor.replaceSelection(imageMarkdown)
|
||
editor.focus()
|
||
} else {
|
||
setFileContent(prev => prev + '\n' + imageMarkdown)
|
||
}
|
||
Toast.success('成功', '图片上传成功')
|
||
}
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理拖拽上传图片
|
||
const handleDrop = async (event) => {
|
||
const files = event.dataTransfer?.files
|
||
if (!files || files.length === 0) return
|
||
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i]
|
||
if (file.type.indexOf('image') !== -1) {
|
||
event.preventDefault()
|
||
const url = await handleImageUpload(file)
|
||
|
||
if (url) {
|
||
const imageMarkdown = ``
|
||
if (editorCtxRef.current && editorCtxRef.current.editor) {
|
||
const editor = editorCtxRef.current.editor
|
||
editor.replaceSelection(imageMarkdown)
|
||
editor.focus()
|
||
} else {
|
||
setFileContent(prev => prev + '\n' + imageMarkdown)
|
||
}
|
||
Toast.success('成功', '图片上传成功')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取指定节点的右键菜单项
|
||
const getNodeMenuItems = (node) => {
|
||
const items = []
|
||
|
||
// 只有目录才显示新建操作
|
||
if (!node.isLeaf) {
|
||
items.push(
|
||
{
|
||
key: 'create_file',
|
||
label: '新建文件',
|
||
icon: <FileAddOutlined />,
|
||
onClick: () => handleCreateFile(node),
|
||
},
|
||
{
|
||
key: 'create_dir',
|
||
label: '新建文件夹',
|
||
icon: <FolderAddOutlined />,
|
||
onClick: () => handleCreateDir(node),
|
||
},
|
||
{
|
||
type: 'divider',
|
||
}
|
||
)
|
||
}
|
||
|
||
items.push(
|
||
{
|
||
key: 'rename',
|
||
label: '重命名',
|
||
icon: <EditOutlined />,
|
||
onClick: () => handleRename(node.key),
|
||
},
|
||
{
|
||
key: 'move',
|
||
label: '移动',
|
||
icon: <SwapOutlined />,
|
||
onClick: () => handleMove(node.key),
|
||
},
|
||
{
|
||
key: 'delete',
|
||
label: '删除',
|
||
icon: <DeleteOutlined />,
|
||
danger: true,
|
||
onClick: () => handleDelete(node.key),
|
||
}
|
||
)
|
||
|
||
return items
|
||
}
|
||
|
||
// 转换文件树为菜单项
|
||
const convertTreeToMenuItems = (nodes) => {
|
||
return nodes.map((node) => {
|
||
// 使用Dropdown包裹label,实现右键菜单
|
||
const isSelected = selectedMenuKey === node.key
|
||
const labelContent = (
|
||
<Dropdown
|
||
menu={{ items: getNodeMenuItems(node) }}
|
||
trigger={['contextMenu']}
|
||
>
|
||
<div className="tree-node-wrapper">
|
||
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
|
||
</div>
|
||
</Dropdown>
|
||
)
|
||
|
||
if (!node.isLeaf) {
|
||
// 目录 - 通过className和style控制选中样式
|
||
return {
|
||
key: node.key,
|
||
label: labelContent,
|
||
icon: <FolderOutlined style={isSelected ? { color: '#1890ff' } : {}} />,
|
||
children: node.children ? convertTreeToMenuItems(node.children) : [],
|
||
className: isSelected ? 'folder-selected' : '',
|
||
onTitleClick: () => {
|
||
setSelectedNode(node)
|
||
setSelectedMenuKey(node.key)
|
||
},
|
||
}
|
||
} else if (node.title && node.title.endsWith('.md')) {
|
||
// Markdown 文件
|
||
return {
|
||
key: node.key,
|
||
label: labelContent,
|
||
icon: <FileTextOutlined />,
|
||
}
|
||
} else if (node.title && node.title.toLowerCase().endsWith('.pdf')) {
|
||
// PDF 文件
|
||
return {
|
||
key: node.key,
|
||
label: labelContent,
|
||
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
|
||
}
|
||
} else {
|
||
// 其他文件
|
||
return {
|
||
key: node.key,
|
||
label: labelContent,
|
||
icon: <FileOutlined />,
|
||
}
|
||
}
|
||
}).filter(Boolean)
|
||
}
|
||
|
||
const menuItems = convertTreeToMenuItems(treeData)
|
||
|
||
return (
|
||
<div className="document-editor-page">
|
||
<Layout className="document-editor-container document-workspace-frame">
|
||
<Sider
|
||
width={280}
|
||
theme="light"
|
||
className="document-sider"
|
||
>
|
||
<div className="sider-header">
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
|
||
{projectName}
|
||
</h2>
|
||
<Tooltip title="关闭">
|
||
<Button
|
||
type="text"
|
||
icon={<CloseOutlined />}
|
||
onClick={handleClose}
|
||
style={{ marginLeft: 8 }}
|
||
/>
|
||
</Tooltip>
|
||
</div>
|
||
<div className="sider-actions">
|
||
<div className="mode-actions-row">
|
||
<ModeSwitch
|
||
value={modeSwitchValue}
|
||
onChange={(mode) => {
|
||
if (mode === 'view' && !modeSwitchingRef.current) {
|
||
modeSwitchingRef.current = true
|
||
setModeSwitchValue('view')
|
||
setTimeout(() => {
|
||
const params = new URLSearchParams()
|
||
if (selectedFile) {
|
||
params.set('file', selectedFile)
|
||
}
|
||
const query = params.toString()
|
||
navigateWithTransition(`/projects/${projectId}/docs${query ? `?${query}` : ''}`)
|
||
}, 160)
|
||
}
|
||
}}
|
||
/>
|
||
<Space.Compact className="mode-actions-group">
|
||
<Tooltip title="刷新">
|
||
<Button
|
||
size="middle"
|
||
icon={<ReloadOutlined />}
|
||
onClick={handleRefresh}
|
||
loading={refreshing}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="添加文件">
|
||
<Button
|
||
size="middle"
|
||
icon={<FileAddOutlined />}
|
||
onClick={handleCreateFile}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="添加目录">
|
||
<Button
|
||
size="middle"
|
||
icon={<FolderAddOutlined />}
|
||
onClick={handleCreateDir}
|
||
/>
|
||
</Tooltip>
|
||
<Upload
|
||
multiple
|
||
accept=".md,.pdf,application/pdf"
|
||
showUploadList={false}
|
||
fileList={fileList}
|
||
beforeUpload={() => false}
|
||
onChange={handleImportDocuments}
|
||
>
|
||
<Tooltip title="上传文档">
|
||
<Button
|
||
size="middle"
|
||
icon={<UploadOutlined />}
|
||
/>
|
||
</Tooltip>
|
||
</Upload>
|
||
</Space.Compact>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 上传进度条 */}
|
||
{uploading && (
|
||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
|
||
<div style={{ marginBottom: 4, fontSize: 12, color: '#666' }}>上传中...</div>
|
||
<Progress percent={uploadProgress} size="small" />
|
||
</div>
|
||
)}
|
||
<Menu
|
||
mode="inline"
|
||
selectedKeys={selectedMenuKey ? [selectedMenuKey] : []}
|
||
openKeys={openKeys}
|
||
onOpenChange={setOpenKeys}
|
||
items={menuItems}
|
||
onClick={handleMenuClick}
|
||
className="file-tree"
|
||
/>
|
||
</Sider>
|
||
|
||
<Content className="document-content">
|
||
<div className="content-header">
|
||
<h3>{selectedFile || '请选择文件'}</h3>
|
||
<Space>
|
||
<Button
|
||
type="primary"
|
||
icon={<SaveOutlined />}
|
||
onClick={handleSaveFile}
|
||
loading={saving}
|
||
disabled={!selectedFile}
|
||
>
|
||
保存
|
||
</Button>
|
||
<Button
|
||
icon={<UndoOutlined />}
|
||
onClick={handleReset}
|
||
disabled={!selectedFile || loading}
|
||
>
|
||
重置
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
|
||
<div className="editor-container">
|
||
{isPdfSelected ? (
|
||
<div className="empty-editor">
|
||
<p>PDF文件请在浏览模式下查看</p>
|
||
</div>
|
||
) : selectedFile ? (
|
||
<div
|
||
className="bytemd-wrapper"
|
||
onPaste={handlePaste}
|
||
onDrop={handleDrop}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
>
|
||
<Editor
|
||
key={selectedFile}
|
||
value={fileContent}
|
||
onChange={(v) => setFileContent(v)}
|
||
plugins={plugins}
|
||
locale={{
|
||
en: {
|
||
'Write': '编辑',
|
||
'Preview': '预览',
|
||
},
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="empty-editor">
|
||
<p>请从左侧选择要编辑的文件</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Content>
|
||
</Layout>
|
||
|
||
<Modal
|
||
title={
|
||
operationType === 'create_file'
|
||
? `创建文件 (dir: /${creationParentPath || ''})`
|
||
: operationType === 'create_dir'
|
||
? `创建文件夹 (dir: /${creationParentPath || ''})`
|
||
: '重命名'
|
||
}
|
||
open={modalVisible}
|
||
onOk={handleOperation}
|
||
onCancel={() => {
|
||
setModalVisible(false)
|
||
setNewName('')
|
||
setRightClickNode(null)
|
||
}}
|
||
>
|
||
<Input
|
||
placeholder={
|
||
operationType === 'create_file' ? '文件名(如:新文档.md)' :
|
||
operationType === 'create_dir' ? '文件夹名' :
|
||
'新名称'
|
||
}
|
||
value={newName}
|
||
onChange={(e) => setNewName(e.target.value)}
|
||
onPressEnter={handleOperation}
|
||
/>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="移动文件/文件夹"
|
||
open={moveModalVisible}
|
||
onOk={handleConfirmMove}
|
||
onCancel={() => {
|
||
setMoveModalVisible(false)
|
||
setRightClickNode(null)
|
||
}}
|
||
>
|
||
<div>
|
||
<p>选择目标目录:</p>
|
||
<Select
|
||
style={{ width: '100%' }}
|
||
placeholder="选择目标目录"
|
||
value={moveTargetPath}
|
||
onChange={setMoveTargetPath}
|
||
options={dirOptions}
|
||
/>
|
||
</div>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="插入内链接"
|
||
open={linkModalVisible}
|
||
onOk={handleInsertLink}
|
||
onCancel={() => {
|
||
setLinkModalVisible(false)
|
||
setLinkTarget(null)
|
||
}}
|
||
>
|
||
<div>
|
||
<p>选择要链接的文件:</p>
|
||
<TreeSelect
|
||
style={{ width: '100%' }}
|
||
value={linkTarget}
|
||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||
treeData={treeData}
|
||
placeholder="请选择文件"
|
||
treeDefaultExpandAll
|
||
onChange={setLinkTarget}
|
||
fieldNames={{ label: 'title', value: 'key', children: 'children' }}
|
||
showSearch
|
||
filterTreeNode={(inputValue, treeNode) => {
|
||
return treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
|
||
}}
|
||
/>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default DocumentEditor
|