nex_docus/frontend/src/pages/Document/DocumentEditor.jsx

1032 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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(`![image](${url})`)
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 = `![image](${url})`
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 = `![${file.name}](${url})`
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