import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypeSlug from 'rehype-slug'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css'
import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger'
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
import { gitPull, gitPush, getGitRepos } from '@/api/project'
import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share'
import { searchDocuments } from '@/api/search'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import FloatingToc from '@/components/FloatingToc/FloatingToc'
import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import './DocumentPage.css'
const { Sider, Content } = Layout
const MAX_TOC_ITEMS = 500
// 高亮渲染组件
const HighlightText = ({ text, keyword }) => {
if (!keyword || !text) return text;
return (
)
}
function DocumentPage() {
const { projectId } = useParams()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [selectedNodeKey, setSelectedNodeKey] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([])
const [tocItems, setTocItems] = useState([])
const [shareModalVisible, setShareModalVisible] = useState(false)
const [shareInfo, setShareInfo] = useState(null)
const [hasPassword, setHasPassword] = useState(false)
const [password, setPassword] = useState('')
const [userRole, setUserRole] = useState('viewer')
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
const [gitRepos, setGitRepos] = useState([])
const [projectName, setProjectName] = useState('')
const [refreshing, setRefreshing] = useState(false)
// 搜索相关状态
const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const [modeSwitchValue, setModeSwitchValue] = useState('view')
const contentRef = useRef(null)
const largeMarkdownRef = useRef(null)
const modeSwitchingRef = useRef(false)
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
const getHeaderDisplay = (filePath) => {
const resolvedPath = selectedNodeKey || filePath || 'README.md'
const fileName = resolvedPath.split('/').filter(Boolean).pop() || 'README.md'
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
const isFolder = Boolean(selectedNode && !selectedNode.isLeaf)
const isPdf = fileName.toLowerCase().endsWith('.pdf')
const FileIcon = isFolder ? FolderOutlined : isPdf ? FilePdfOutlined : FileTextOutlined
return {
fileName,
FileIcon,
isPdf,
}
}
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
document.startViewTransition(() => navigate(to))
return
}
navigate(to)
}
const updateSelectedParam = (path, isFile = true) => {
const nextParams = new URLSearchParams(searchParams)
nextParams.delete('file')
nextParams.delete('selected')
if (path) {
nextParams.set(isFile ? 'file' : 'selected', path)
}
setSearchParams(nextParams, { replace: true })
}
const updateFileParam = (filePath) => {
updateSelectedParam(filePath, true)
}
const expandParentFolders = (path) => {
const parts = path.split('/')
const allParentPaths = []
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
allParentPaths.push(currentPath)
}
if (allParentPaths.length > 0) {
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
}
}
const selectFolder = (folderPath, { syncUrl = false } = {}) => {
setSelectedFile('')
setSelectedNodeKey(folderPath)
setMarkdownContent('')
setTocItems([])
setViewMode('folder')
expandParentFolders(`${folderPath}/placeholder`)
if (syncUrl) {
updateSelectedParam(folderPath, false)
}
}
const openDocumentPath = (filePath, { syncUrl = false } = {}) => {
setSelectedFile(filePath)
setSelectedNodeKey(filePath)
expandParentFolders(filePath)
if (syncUrl) {
updateFileParam(filePath)
}
if (filePath.toLowerCase().endsWith('.pdf')) {
setPdfUrl(buildDocumentUrl(filePath))
setPdfFilename(filePath.split('/').pop())
setViewMode('pdf')
} else {
setViewMode('markdown')
loadMarkdown(filePath)
}
}
const buildDocumentUrl = (filePath, refreshKey = null) => {
const params = new URLSearchParams()
const token = localStorage.getItem('access_token')
if (token) {
params.set('token', token)
}
if (refreshKey) {
params.set('refresh', String(refreshKey))
}
const query = params.toString()
return `${getDocumentUrl(projectId, filePath)}${query ? `?${query}` : ''}`
}
useEffect(() => {
loadFileTree()
}, [projectId])
const handleClose = () => {
Modal.confirm({
title: '确认退出',
content: '确定要退出当前项目页面吗?',
okText: '退出',
cancelText: '取消',
onOk: () => {
if (userRole === 'owner') {
navigate('/projects/my')
} else {
navigate('/projects/share')
}
},
})
}
// 监听 URL 参数变化,处理文件导航和搜索
useEffect(() => {
// 只有当文件树加载完成后才处理导航,否则无法正确展开目录
if (fileTree.length === 0) return
const fileParam = searchParams.get('file')
const selectedParam = searchParams.get('selected')
const keywordParam = searchParams.get('keyword')
// 处理搜索
if (keywordParam && keywordParam !== searchKeyword) {
handleSearch(keywordParam)
}
// 处理文件加载
if (selectedParam) {
const targetNode = findNodeByKey(fileTree, selectedParam)
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedNodeKey) {
selectFolder(selectedParam)
}
} else if (fileParam) {
const targetNode = findNodeByKey(fileTree, fileParam)
if (targetNode && !targetNode.isLeaf) {
selectFolder(fileParam, { syncUrl: true })
} else if (fileParam !== selectedFile) {
openDocumentPath(fileParam)
}
} else {
// 如果没有指定文件,且当前没有选中文件,默认打开 README.md
if (!selectedFile && !selectedNodeKey) {
const readmeNode = findReadme(fileTree)
if (readmeNode) {
openDocumentPath(readmeNode.key, { syncUrl: true })
}
}
}
}, [searchParams, fileTree])
// 处理搜索
const handleSearch = async (value) => {
setSearchKeyword(value)
if (!value.trim()) {
setMatchedFilePaths(new Set())
return
}
setIsSearching(true)
try {
const res = await searchDocuments(value, projectId)
const paths = new Set(res.data.map(item => item.file_path))
setMatchedFilePaths(paths)
// 自动展开匹配的节点
const keysToExpand = new Set(openKeys)
res.data.forEach(item => {
const parts = item.file_path.split('/')
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
keysToExpand.add(currentPath)
}
})
setOpenKeys(Array.from(keysToExpand))
} catch (error) {
console.error('Search error:', error)
} finally {
setIsSearching(false)
}
}
// 过滤文件树
const filteredTreeData = useMemo(() => {
if (!searchKeyword.trim()) return fileTree
const loop = (data) => {
const result = []
for (const node of data) {
const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase())
const contentMatch = matchedFilePaths.has(node.key)
if (node.children) {
const children = loop(node.children)
if (children.length > 0 || titleMatch) {
result.push({ ...node, children })
}
} else {
if (titleMatch || contentMatch) {
result.push(node)
}
}
}
return result
}
return loop(fileTree)
}, [fileTree, searchKeyword, matchedFilePaths])
const loadGitRepos = async () => {
try {
const res = await getGitRepos(projectId)
setGitRepos(res.data || [])
} catch (error) {
console.error('Load git repos error:', error)
}
}
// 加载文件树
const loadFileTree = async ({ throwOnError = false } = {}) => {
try {
const res = await getProjectTree(projectId)
const data = res.data || {}
const tree = data.tree || data || [] // 兼容新旧格式
const role = data.user_role || 'viewer'
const name = data.project_name
setFileTree(tree)
setUserRole(role)
setProjectName(name)
return tree
} catch (error) {
console.error('Load file tree error:', error)
if (throwOnError) {
throw error
}
return []
}
}
// 查找根目录的 README.md
const findReadme = (nodes) => {
// 只在根目录查找
for (const node of nodes) {
if (node.title === 'README.md' && node.isLeaf) {
return node
}
}
return null
}
const findNodeByKey = (nodes, key) => {
for (const node of nodes) {
if (node.key === key) {
return node
}
if (node.children?.length) {
const found = findNodeByKey(node.children, key)
if (found) {
return found
}
}
}
return null
}
// 转换文件树为菜单项
const convertTreeToMenuItems = (nodes) => {
return nodes.map((node) => {
const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title
const labelNode = (
{titleText}
{node.is_shared && }
)
if (!node.isLeaf) {
const isOpen = openKeys.includes(node.key)
// 目录
return {
key: node.key,
label: labelNode,
icon: isOpen ? : ,
onTitleClick: () => selectFolder(node.key, { syncUrl: true }),
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
} else if (node.title && node.title.endsWith('.md')) {
// Markdown 文件
return {
key: node.key,
label: labelNode,
icon: ,
}
} else if (node.title && node.title.endsWith('.pdf')) {
// PDF 文件
return {
key: node.key,
label: labelNode,
icon: ,
}
}
return null
}).filter(Boolean)
}
// 加载 markdown 文件
const loadMarkdown = async (filePath) => {
setLoading(true)
setTocItems([]) // 清空旧的目录数据
try {
const res = await getFileContent(projectId, filePath)
setMarkdownContent(res.data?.content || '')
// 滚动到顶部
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'auto' })
}
} catch (error) {
console.error('Load markdown error:', error)
setMarkdownContent('# 文档加载失败\n\n无法加载该文档,请稍后重试。')
} finally {
setLoading(false)
}
}
// 提取 markdown 标题生成目录
useEffect(() => {
if (markdownContent && !isLargeMarkdown) {
let canceled = false
const schedule = window.requestIdleCallback || ((cb) => window.setTimeout(cb, 1))
const cancel = window.cancelIdleCallback || window.clearTimeout
const taskId = schedule(() => {
if (canceled) return
const slugger = new GithubSlugger()
const headings = []
const lines = markdownContent.split('\n')
for (const line of lines) {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (match) {
const level = match[1].length
const title = match[2]
// 使用标准的 github-slugger 生成 ID,确保与 rehype-slug 一致
const key = slugger.slug(title)
headings.push({
key: `#${key}`,
href: `#${key}`,
title,
level,
})
if (headings.length >= MAX_TOC_ITEMS) {
break
}
}
}
setTocItems(headings)
})
return () => {
canceled = true
cancel(taskId)
}
} else {
setTocItems([])
}
}, [markdownContent, isLargeMarkdown])
// 从知识库引用跳转而来时(URL 带 keyword),文档加载完成后滚动到第一个高亮处
useEffect(() => {
if (loading || !searchKeyword || !markdownContent) return
if (viewMode !== 'markdown') return
let canceled = false
const timer = window.setTimeout(() => {
if (canceled) return
const container = contentRef.current
const target = container?.querySelector('.search-highlight')
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 临时强调被引用的位置,短暂后淡出
target.classList.add('cited-highlight-flash')
window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400)
}
}, 260)
return () => {
canceled = true
window.clearTimeout(timer)
}
}, [loading, markdownContent, searchKeyword, viewMode])
// 处理菜单点击
const handleMenuClick = ({ key }) => {
const node = findNodeByKey(fileTree, key)
if (!node) return
if (!node.isLeaf) {
selectFolder(key, { syncUrl: true })
return
}
openDocumentPath(key, { syncUrl: true })
}
const scrollContentToTop = () => {
if (isLargeMarkdown) {
largeMarkdownRef.current?.scrollToTop()
return
}
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const handleExportMarkdownPDF = () => {
if (!selectedFile) return
let url = getExportPdfUrl(projectId, selectedFile)
const token = localStorage.getItem('access_token')
if (token) {
url += `&token=${encodeURIComponent(token)}`
}
window.open(url, '_blank')
}
// 解析相对路径
const resolveRelativePath = (currentPath, relativePath) => {
// 获取当前文件所在目录
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/'))
// 分割相对路径
const parts = relativePath.split('/')
const dirParts = currentDir ? currentDir.split('/') : []
// 处理 ../ 和 ./
for (const part of parts) {
if (part === '..') {
dirParts.pop()
} else if (part !== '.' && part !== '') {
dirParts.push(part)
}
}
return dirParts.join('/')
}
const normalizeMarkdownHref = (href) => {
if (!href) return href
const [pathPart, hashPart = ''] = href.split('#')
const [rawPath, searchPart = ''] = pathPart.split('?')
const decodedPath = rawPath
.split('/')
.map((part) => {
try {
return decodeURIComponent(part)
} catch (e) {
return part
}
})
.join('/')
const rebuilt = searchPart ? `${decodedPath}?${searchPart}` : decodedPath
return hashPart ? `${rebuilt}#${hashPart}` : rebuilt
}
const isExternalHref = (href) => {
return Boolean(href && (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//')))
}
// 处理markdown内部链接点击
const handleMarkdownLink = (e, href) => {
const normalizedHref = normalizeMarkdownHref(href)
// 检查是否是外部链接
if (!normalizedHref || isExternalHref(normalizedHref)) {
return // 外部链接,允许默认行为
}
// 检查是否是锚点链接
if (normalizedHref.startsWith('#')) {
return // 锚点链接,允许默认行为
}
// 检查是否是文档文件(.md 或 .pdf)
const pathOnly = normalizedHref.split(/[?#]/)[0]
const isMd = pathOnly.endsWith('.md')
const isPdf = pathOnly.toLowerCase().endsWith('.pdf')
if (!isMd && !isPdf) {
return // 不是文档文件,允许默认行为
}
// 阻止默认跳转
e.preventDefault()
// 解析路径
let targetPath
if (pathOnly.startsWith('.') || pathOnly.startsWith('..')) {
// 真正的相对路径,相对于当前文件
targetPath = resolveRelativePath(selectedFile, pathOnly)
} else {
// 项目内绝对路径(由编辑器生成),相对于项目根目录
targetPath = pathOnly.startsWith('/') ? pathOnly.substring(1) : pathOnly
}
// 自动展开父目录
const lastSlashIndex = targetPath.lastIndexOf('/')
if (lastSlashIndex !== -1) {
const parentPath = targetPath.substring(0, lastSlashIndex)
if (parentPath && !openKeys.includes(parentPath)) {
// 收集所有父路径
const pathParts = parentPath.split('/')
const allParentPaths = []
let currentPath = ''
for (const part of pathParts) {
currentPath = currentPath ? `${currentPath}/${part}` : part
allParentPaths.push(currentPath)
}
setOpenKeys([...new Set([...openKeys, ...allParentPaths])])
}
}
// 选中文件并加载
setSelectedFile(targetPath)
setSelectedNodeKey(targetPath)
updateFileParam(targetPath)
if (isPdf) {
// PDF文件:切换到PDF模式
setPdfUrl(buildDocumentUrl(targetPath))
setPdfFilename(targetPath.split('/').pop())
setViewMode('pdf')
} else {
// Markdown文件:加载内容
setViewMode('markdown')
loadMarkdown(targetPath)
}
}
const handleGitPull = async (repoId = null, force = false) => {
if (gitRepos.length === 0) {
message.warning('未配置Git仓库')
return
}
try {
const res = await gitPull(projectId, repoId, force)
message.success(res.message || 'Git Pull 成功')
// Refresh tree
loadFileTree()
// Reload current file if open
if (selectedFile) {
loadMarkdown(selectedFile)
}
} catch (error) {
console.error('Git Pull error:', error)
const errorMsg = error.response?.data?.detail || 'Git Pull 失败'
if (!force) {
Modal.confirm({
title: 'Git Pull 失败',
content: (
{errorMsg}
是否强制重置到远程版本?
警告:这将丢失所有本地未提交的修改!
),
okText: '强制重置',
okType: 'danger',
cancelText: '取消',
onOk: () => handleGitPull(repoId, true)
})
return
}
message.error(errorMsg)
}
}
const handleGitPush = async (repoId = null, force = false) => {
if (gitRepos.length === 0) {
message.warning('未配置Git仓库')
return
}
try {
const res = await gitPush(projectId, repoId, force)
message.success(res.message || 'Git Push 成功')
} catch (error) {
console.error('Git Push error:', error)
const errorMsg = error.response?.data?.detail || 'Git Push 失败'
if (!force) {
Modal.confirm({
title: 'Git Push 失败',
content: (
{errorMsg}
是否强制推送到远程?
警告:这将覆盖远程仓库的修改!
),
okText: '强制推送',
okType: 'danger',
cancelText: '取消',
onOk: () => handleGitPush(repoId, true)
})
return
}
message.error(errorMsg)
}
}
const renderGitActions = () => {
if (gitRepos.length <= 1) {
// 0 或 1 个仓库,显示普通按钮
return (
<>
}
onClick={() => handleGitPull()}
/>
}
onClick={() => handleGitPush()}
/>
>
)
}
// 多个仓库,显示下拉菜单
const pullItems = gitRepos.map(repo => ({
key: repo.id,
label: repo.name + (repo.is_default ? ' (默认)' : ''),
onClick: () => handleGitPull(repo.id),
}))
const pushItems = gitRepos.map(repo => ({
key: repo.id,
label: repo.name + (repo.is_default ? ' (默认)' : ''),
onClick: () => handleGitPush(repo.id),
}))
if (gitRepos.length <= 1) {
return (
<>
}
onClick={() => handleGitPull()}
/>
}
onClick={() => handleGitPush()}
/>
>
)
}
return (
<>
}
/>
}
/>
>
)
}
// 进入编辑模式
const handleEdit = () => {
const params = new URLSearchParams()
if (selectedFile) {
params.set('file', selectedFile)
} else if (selectedNodeKey) {
params.set('selected', selectedNodeKey)
}
const query = params.toString()
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`)
}
const handleRefresh = async () => {
setRefreshing(true)
try {
await loadFileTree({ throwOnError: true })
message.success('已刷新')
} catch (error) {
console.error('Refresh documents error:', error)
message.error('刷新失败')
} finally {
setRefreshing(false)
}
}
// 打开分享设置
const handleShare = async () => {
const selectedNode = selectedNodeKey ? findNodeByKey(fileTree, selectedNodeKey) : null
if (!selectedNode || !selectedNode.isLeaf) {
Toast.warning('提示', '请先选择一个文件再分享')
return
}
try {
const res = await getFileShareInfo(projectId, selectedNode.key)
const nextShareInfo = res.data
setShareInfo(nextShareInfo)
setHasPassword(Boolean(nextShareInfo?.has_password))
setPassword(nextShareInfo?.access_pass || '')
setShareModalVisible(true)
} catch (error) {
console.error('Get share info error:', error)
message.error('获取分享信息失败')
}
}
// 复制分享链接
const handleCopyLink = async () => {
if (!shareInfo) return
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(fullUrl)
Toast.success('复制成功', '分享链接已复制到剪贴板')
} else {
// Fallback for non-secure contexts or older browsers
const textArea = document.createElement("textarea")
textArea.value = fullUrl
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
textArea.style.top = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (successful) {
Toast.success('复制成功', '分享链接已复制到剪贴板')
} else {
Toast.error('复制失败', '请手动复制链接')
}
}
} catch (err) {
console.error('Failed to copy:', err)
Toast.error('复制失败', '无法访问剪贴板')
}
}
// 切换密码保护
const handlePasswordToggle = async (checked) => {
if (!selectedNodeKey) return
if (!checked) {
try {
if (shareInfo?.share_url) {
await createOrUpdateFileShare(projectId, { file_path: selectedNodeKey, access_pass: null })
} else {
await deleteFileShare(projectId, selectedNodeKey)
}
setHasPassword(false)
setPassword('')
message.success('已取消文件访问密码')
await loadFileTree()
const res = await getFileShareInfo(projectId, selectedNodeKey)
setShareInfo(res.data)
} catch (error) {
console.error('Update settings error:', error)
message.error('操作失败')
}
} else {
setHasPassword(true)
}
}
// 保存密码
const handleSavePassword = async () => {
if (!selectedNodeKey) {
message.warning('请先选择文件')
return
}
if (!password.trim()) {
message.warning('请输入访问密码')
return
}
try {
const res = await createOrUpdateFileShare(projectId, {
file_path: selectedNodeKey,
access_pass: hasPassword ? password : null,
})
message.success('文件分享已更新')
setShareInfo(res.data)
await loadFileTree()
const nextInfo = await getFileShareInfo(projectId, selectedNodeKey)
setShareInfo(nextInfo.data)
setHasPassword(Boolean(nextInfo.data?.has_password))
} catch (error) {
console.error('Save password error:', error)
message.error('设置文件分享失败')
}
}
const handleCreateShare = async () => {
if (!selectedNodeKey) {
message.warning('请先选择文件')
return
}
try {
const res = await createOrUpdateFileShare(projectId, {
file_path: selectedNodeKey,
access_pass: hasPassword ? password : null,
})
setShareInfo(res.data)
setHasPassword(Boolean(res.data?.has_password))
await loadFileTree()
message.success('文件分享已创建')
} catch (error) {
console.error('Create file share error:', error)
message.error('创建文件分享失败')
}
}
const handleDisableShare = async () => {
if (!selectedNodeKey) return
try {
await deleteFileShare(projectId, selectedNodeKey)
setShareInfo(null)
setHasPassword(false)
setPassword('')
await loadFileTree()
message.success('文件分享已关闭')
} catch (error) {
console.error('Delete file share error:', error)
message.error('关闭文件分享失败')
}
}
const menuItems = convertTreeToMenuItems(filteredTreeData)
// Markdown 内容高亮处理
// 使用 components 替换文本节点,但这只对直接文本子节点有效
// 对于深层嵌套,我们需要递归或使用 rehype 插件
// 这里使用简单组件替换
const markdownComponents = useMemo(() => {
if (!searchKeyword) {
return {
a: ({ node, href, children, ...props }) => {
const isExternal = isExternalHref(href);
return (
handleMarkdownLink(e, href)}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
{...props}
>
{children}
);
},
}
}
// 搜索模式下,尝试高亮
// 注意:这可能不完美,但比没有好
const highlightRenderer = (Tag) => ({ node, children, ...props }) => {
// 如果 children 是字符串,高亮
if (typeof children === 'string') {
return
}
// 如果是数组,遍历
if (Array.isArray(children)) {
const newChildren = children.map((child, idx) => {
if (typeof child === 'string') {
return
}
return child
})
return {newChildren}
}
return {children}
}
return {
a: ({ node, href, children, ...props }) => {
const isExternal = isExternalHref(href);
return (
handleMarkdownLink(e, href)}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
{...props}
>
{typeof children === 'string' ? : children}
);
},
p: highlightRenderer('p'),
li: highlightRenderer('li'),
h1: highlightRenderer('h1'),
h2: highlightRenderer('h2'),
h3: highlightRenderer('h3'),
h4: highlightRenderer('h4'),
h5: highlightRenderer('h5'),
h6: highlightRenderer('h6'),
span: highlightRenderer('span'),
td: highlightRenderer('td'),
th: highlightRenderer('th'),
div: highlightRenderer('div'),
}
}, [searchKeyword])
return (
{/* 左侧目录 */}
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
{userRole !== 'viewer' ? (
{
if (mode === 'edit' && !modeSwitchingRef.current) {
modeSwitchingRef.current = true
setModeSwitchValue('edit')
setTimeout(() => {
handleEdit()
}, 160)
}
}}
/>
) : (
)}
{userRole !== 'viewer' && renderGitActions()}
}
onClick={handleShare}
/>
}
onClick={handleRefresh}
disabled={refreshing}
/>
{/* 搜索框 */}
setSearchKeyword(e.target.value)}
onSearch={handleSearch}
loading={isSearching}
enterButton
/>
{filteredTreeData.length > 0 ? (
) : (
)}
{/* 右侧内容区 */}
{(() => {
const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile)
return (
<>
{fileName}
{viewMode === 'pdf' &&
}
{viewMode === 'markdown' && (
}
onClick={scrollContentToTop}
size="small"
>
回到顶部
}
onClick={handleExportMarkdownPDF}
size="small"
>
下载PDF
)}
>
)
})()}
{loading ? (
) : viewMode === 'pdf' ? (
) : viewMode === 'folder' ? (
已选择文件夹,请从左侧选择 Markdown 或 PDF 文件查看。
) : isLargeMarkdown ? (
}
/>
) : (
{markdownContent}
)}
{viewMode === 'markdown' && !isLargeMarkdown && (
contentRef.current}
renderTitle={(item, keyword) => }
/>
)}
{/* 分享模态框 */}
{/* ... keeping the modal ... */}
setShareModalVisible(false)}
footer={null}
width={500}
>
{shareInfo?.share_url ? (
<>
}
/>
访问密码保护
{hasPassword && (
setPassword(e.target.value)}
/>
)}
>
) : (
<>
当前文件尚未创建独立分享。
>
)}
{shareInfo?.share_url && (
)}
)
}
export default DocumentPage