调整了对超大md文档的支持

main
mula.liu 2026-06-16 21:09:15 +08:00
parent 9f707e879e
commit 2bb7db0d56
12 changed files with 677 additions and 202 deletions

1
.gitignore vendored
View File

@ -26,6 +26,7 @@ logs/
.env .env
.env.local .env.local
.gemini-clipboard .gemini-clipboard
.memsearch
# Temporary files # Temporary files
*.tmp *.tmp

View File

@ -7,3 +7,18 @@
## Session 16:54 ## Session 16:54
## Session 17:15
## Session 17:16
## Session 17:20
## Session 17:54
## Session 17:54

View File

@ -0,0 +1,54 @@
.large-markdown-viewer {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.large-markdown-notice {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 3;
width: min(920px, calc(100% - 48px));
pointer-events: none;
}
.large-markdown-notice .large-markdown-alert {
margin: 0;
pointer-events: auto;
}
.markdown-body-large {
width: 100%;
height: 100%;
min-height: 0;
}
.markdown-virtual-list {
height: 100%;
width: 100%;
}
.markdown-block {
max-width: 920px;
width: calc(100% - 48px);
margin: 0 auto;
padding: 0 0 1px;
}
@media (max-width: 768px) {
.large-markdown-notice,
.markdown-block {
width: calc(100% - 32px);
}
}
@media (max-width: 480px) {
.large-markdown-notice,
.markdown-block {
width: calc(100% - 24px);
}
}

View File

@ -0,0 +1,107 @@
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'
import { Alert } from 'antd'
import { Virtuoso } from 'react-virtuoso'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypeSlug from 'rehype-slug'
import './LargeMarkdownViewer.css'
export const LARGE_MARKDOWN_THRESHOLD = 250000
export const LARGE_MARKDOWN_NOTICE = '此文档内容过长,将采用精简模式显示'
export function isLargeMarkdownContent(content = '') {
return content.length > LARGE_MARKDOWN_THRESHOLD
}
export function MarkdownSizeNotice({ className = '' }) {
return (
<div className={`large-markdown-notice${className ? ` ${className}` : ''}`}>
<Alert
className="large-markdown-alert"
type="info"
showIcon
message={LARGE_MARKDOWN_NOTICE}
/>
</div>
)
}
function splitMarkdownIntoBlocks(content) {
if (!content) return []
const blocks = []
const lines = content.split('\n')
let current = []
let inFence = false
const flush = () => {
if (current.length > 0) {
blocks.push(current.join('\n'))
current = []
}
}
for (const line of lines) {
const isFenceLine = /^\s*(```|~~~)/.test(line)
const isHeading = /^(#{1,6})\s+/.test(line)
if (!inFence && isHeading) {
flush()
}
current.push(line)
if (isFenceLine) {
inFence = !inFence
}
if (!inFence && line.trim() === '') {
flush()
}
}
flush()
return blocks
}
const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer({ content, components, onClick }, ref) {
const virtuosoRef = useRef(null)
const markdownBlocks = useMemo(() => splitMarkdownIntoBlocks(content), [content])
useImperativeHandle(ref, () => ({
scrollToTop: () => {
virtuosoRef.current?.scrollToIndex({
index: 0,
align: 'start',
behavior: 'smooth',
})
},
}), [])
return (
<div className="large-markdown-viewer">
<MarkdownSizeNotice />
<div className="markdown-body markdown-body-large" onClick={onClick}>
<Virtuoso
ref={virtuosoRef}
className="markdown-virtual-list"
data={markdownBlocks}
itemContent={(index, block) => (
<div className="markdown-block" data-index={index}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug]}
components={components}
>
{block}
</ReactMarkdown>
</div>
)}
/>
</div>
</div>
)
})
export default LargeMarkdownViewer

View File

@ -0,0 +1,48 @@
import { forwardRef, useMemo } from 'react'
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'
const MarkdownViewer = forwardRef(function MarkdownViewer(
{
content = '',
components,
onClick,
className = '',
allowRaw = false,
enableHighlight = true,
},
ref
) {
const rehypePlugins = useMemo(() => {
const plugins = []
if (allowRaw) {
plugins.push(rehypeRaw)
}
plugins.push(rehypeSlug)
if (enableHighlight) {
plugins.push(rehypeHighlight)
}
return plugins
}, [allowRaw, enableHighlight])
return (
<div
className={`markdown-body${className ? ` ${className}` : ''}`}
onClick={onClick}
ref={ref}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</ReactMarkdown>
</div>
)
})
export default MarkdownViewer

View File

@ -1,3 +1,5 @@
@import '@/components/LargeMarkdownViewer/LargeMarkdownViewer.css';
.document-editor-page { .document-editor-page {
height: calc(100vh - 64px); height: calc(100vh - 64px);
/* width: calc(100% + 32px); */ /* width: calc(100% + 32px); */
@ -232,6 +234,15 @@
padding: 5px 10px; padding: 5px 10px;
} }
.bytemd-wrapper.large-file-editor {
position: relative;
padding: 0;
}
.large-file-editor-notice {
top: 12px;
}
/* Fix for bytemd-react wrapper div */ /* Fix for bytemd-react wrapper div */
.bytemd-wrapper>div { .bytemd-wrapper>div {
flex: 1; flex: 1;
@ -242,6 +253,36 @@
min-width: 0; min-width: 0;
} }
.bytemd-wrapper.large-file-editor>.large-markdown-notice {
flex: none !important;
display: block !important;
width: min(920px, calc(100% - 48px)) !important;
height: auto !important;
min-height: 0 !important;
}
.large-markdown-textarea {
flex: 1;
width: 100%;
height: 100%;
min-height: 0;
resize: none;
border: 1px solid var(--border-color);
border-radius: 2px;
padding: 64px 16px 16px;
background: var(--bg-color);
color: var(--text-color);
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 14px;
line-height: 1.8;
outline: none;
}
.large-markdown-textarea:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15);
}
.empty-editor { .empty-editor {
height: 100%; height: 100%;
display: flex; display: flex;

View File

@ -5,7 +5,6 @@ import {
FileOutlined, FileOutlined,
FolderOutlined, FolderOutlined,
FolderOpenOutlined, FolderOpenOutlined,
PlusOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
SaveOutlined, SaveOutlined,
@ -14,7 +13,6 @@ import {
UploadOutlined, UploadOutlined,
DownloadOutlined, DownloadOutlined,
SwapOutlined, SwapOutlined,
FileImageOutlined,
FilePdfOutlined, FilePdfOutlined,
FileTextOutlined, FileTextOutlined,
UndoOutlined, UndoOutlined,
@ -35,11 +33,12 @@ import {
operateFile, operateFile,
uploadFile, uploadFile,
importDocuments, importDocuments,
exportDirectory,
uploadDocument, uploadDocument,
getDocumentUrl,
} from '@/api/file' } from '@/api/file'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch' import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import { MarkdownSizeNotice, isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import './DocumentEditor.css' import './DocumentEditor.css'
const { Sider, Content } = Layout const { Sider, Content } = Layout
@ -63,7 +62,6 @@ function DocumentEditor() {
const [creationParentPath, setCreationParentPath] = useState('') const [creationParentPath, setCreationParentPath] = useState('')
const [moveTargetPath, setMoveTargetPath] = useState('') const [moveTargetPath, setMoveTargetPath] = useState('')
const [dirOptions, setDirOptions] = useState([]) const [dirOptions, setDirOptions] = useState([])
const [editorHeight, setEditorHeight] = useState(600) // 600px
const [openKeys, setOpenKeys] = useState([]) // Menu const [openKeys, setOpenKeys] = useState([]) // Menu
const [uploadProgress, setUploadProgress] = useState(0) // const [uploadProgress, setUploadProgress] = useState(0) //
const [uploading, setUploading] = useState(false) // const [uploading, setUploading] = useState(false) //
@ -78,6 +76,7 @@ function DocumentEditor() {
const [modeSwitchValue, setModeSwitchValue] = useState('edit') const [modeSwitchValue, setModeSwitchValue] = useState('edit')
const editorCtxRef = useRef(null) const editorCtxRef = useRef(null)
const modeSwitchingRef = useRef(false) const modeSwitchingRef = useRef(false)
const isLargeMarkdown = isLargeMarkdownContent(fileContent)
const isHeaderPdf = selectedFile?.toLowerCase().endsWith('.pdf') const isHeaderPdf = selectedFile?.toLowerCase().endsWith('.pdf')
const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined
@ -109,16 +108,139 @@ function DocumentEditor() {
navigate(to) navigate(to)
} }
const updateFileParam = (filePath) => { const updateSelectedParam = (path, isFile = true) => {
const nextParams = new URLSearchParams(searchParams) const nextParams = new URLSearchParams(searchParams)
if (filePath) { nextParams.delete('file')
nextParams.set('file', filePath) nextParams.delete('selected')
} else {
nextParams.delete('file') if (path) {
nextParams.set(isFile ? 'file' : 'selected', path)
} }
setSearchParams(nextParams, { replace: true }) setSearchParams(nextParams, { replace: true })
} }
const updateFileParam = (filePath) => {
updateSelectedParam(filePath, true)
}
const buildDocumentUrl = (filePath) => {
const params = new URLSearchParams()
const token = localStorage.getItem('access_token')
if (token) {
params.set('token', token)
}
const query = params.toString()
return `${getDocumentUrl(projectId, filePath)}${query ? `?${query}` : ''}`
}
const downloadByUrl = (url, filename) => {
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const downloadBlob = (blob, filename) => {
const url = window.URL.createObjectURL(blob)
downloadByUrl(url, filename)
window.URL.revokeObjectURL(url)
}
const getSelectedDownloadNode = () => {
if (selectedNode) return selectedNode
if (selectedMenuKey) return findNodeByKey(treeData, selectedMenuKey)
if (selectedFile) return findNodeByKey(treeData, selectedFile)
return null
}
const getDownloadFilename = (path) => path.split('/').filter(Boolean).pop() || 'document'
const downloadMarkdownFile = () => {
const filename = getDownloadFilename(selectedFile)
downloadBlob(new Blob([fileContent], { type: 'text/markdown;charset=utf-8' }), filename)
}
const handleDownloadSelectedFile = () => {
const targetNode = getSelectedDownloadNode()
if (!targetNode) {
Toast.warning('提示', '请先选择文件')
return
}
if (!targetNode.isLeaf) {
Toast.warning('暂不支持文件夹下载', '请选择 Markdown 或 PDF 文件')
return
}
const path = targetNode.key
const lowerPath = path.toLowerCase()
if (!lowerPath.endsWith('.md') && !lowerPath.endsWith('.pdf')) {
Toast.warning('暂不支持该文件类型', '请选择 Markdown 或 PDF 文件')
return
}
if (lowerPath.endsWith('.md')) {
if (selectedFile === path) {
downloadMarkdownFile()
} else {
getFileContent(projectId, path).then((res) => {
downloadBlob(
new Blob([res.data?.content || ''], { type: 'text/markdown;charset=utf-8' }),
getDownloadFilename(path)
)
}).catch((error) => {
console.error('Download markdown error:', error)
})
}
return
}
downloadByUrl(buildDocumentUrl(path), getDownloadFilename(path))
}
const selectFolder = (folderPath, { syncUrl = false } = {}) => {
const targetNode = findNodeByKey(treeData, folderPath)
if (targetNode) {
setSelectedNode(targetNode)
}
setSelectedMenuKey(folderPath)
setSelectedFile(null)
setIsPdfSelected(false)
setFileContent('')
if (syncUrl) {
updateSelectedParam(folderPath, false)
}
}
const getModeSwitchTarget = () => {
const params = new URLSearchParams()
if (selectedFile) {
params.set('file', selectedFile)
} else if (selectedMenuKey) {
const targetNode = findNodeByKey(treeData, selectedMenuKey)
if (targetNode && !targetNode.isLeaf) {
params.set('selected', selectedMenuKey)
}
} else {
const selectedParam = searchParams.get('selected')
if (selectedParam) {
params.set('selected', selectedParam)
}
}
const query = params.toString()
return `/projects/${projectId}/docs${query ? `?${query}` : ''}`
}
const encodeMarkdownLinkTarget = (targetPath) => { const encodeMarkdownLinkTarget = (targetPath) => {
if (!targetPath) return targetPath if (!targetPath) return targetPath
@ -159,22 +281,6 @@ function DocumentEditor() {
setLinkTarget(null) 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)
}, [])
useEffect(() => { useEffect(() => {
fetchTree() fetchTree()
}, [projectId]) }, [projectId])
@ -210,32 +316,6 @@ function DocumentEditor() {
}) })
} }
const handleSelectFile = async (selectedKeys, info) => {
//
setSelectedNode(info.node)
if (info.node.isLeaf) {
const filePath = selectedKeys[0]
// PDF
if (filePath.toLowerCase().endsWith('.pdf')) {
Toast.info('提示', 'PDF文件请在浏览模式下查看')
return
}
setLoading(true)
try {
const res = await getFileContent(projectId, filePath)
setSelectedFile(filePath)
setFileContent(res.data.content)
} catch (error) {
// request interceptor
} finally {
setLoading(false)
}
}
}
// //
const findNodeByKey = (nodes, key) => { const findNodeByKey = (nodes, key) => {
for (const node of nodes) { for (const node of nodes) {
@ -271,8 +351,11 @@ function DocumentEditor() {
setSelectedMenuKey(key) setSelectedMenuKey(key)
if (!targetNode.isLeaf) { if (!targetNode.isLeaf) {
setSelectedFile(null)
setIsPdfSelected(false)
setFileContent('')
if (syncUrl) { if (syncUrl) {
updateFileParam(null) updateSelectedParam(key, false)
} }
return return
} }
@ -306,6 +389,16 @@ function DocumentEditor() {
if (treeData.length === 0) return if (treeData.length === 0) return
const fileParam = searchParams.get('file') const fileParam = searchParams.get('file')
const selectedParam = searchParams.get('selected')
if (selectedParam) {
const targetNode = findNodeByKey(treeData, selectedParam)
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedMenuKey) {
selectFolder(selectedParam)
}
return
}
if (!fileParam) return if (!fileParam) return
if (fileParam === selectedFile) return if (fileParam === selectedFile) return
@ -651,42 +744,6 @@ function DocumentEditor() {
} }
} }
//
const handleExportDirectory = async () => {
//
const directoryPath = selectedNode && !selectedNode.isLeaf ? selectedNode.key : ''
try {
const response = await exportDirectory(projectId, directoryPath)
//
const contentDisposition = response.headers['content-disposition']
let filename = `${directoryPath || 'root'}.zip`
if (contentDisposition) {
const matches = /filename=(.+)/.exec(contentDisposition)
if (matches && matches[1]) {
filename = matches[1]
}
}
// blob URL
const url = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
Toast.success('成功', '导出成功')
} catch (error) {
console.error('Export error:', error)
Toast.error('错误', '导出失败')
}
}
// / // /
const handleMove = (path) => { const handleMove = (path) => {
setRightClickNode(path) setRightClickNode(path)
@ -972,8 +1029,7 @@ function DocumentEditor() {
children: node.children ? convertTreeToMenuItems(node.children) : [], children: node.children ? convertTreeToMenuItems(node.children) : [],
className: isSelected ? 'folder-selected' : '', className: isSelected ? 'folder-selected' : '',
onTitleClick: () => { onTitleClick: () => {
setSelectedNode(node) selectFolder(node.key, { syncUrl: true })
setSelectedMenuKey(node.key)
}, },
} }
} else if (node.title && node.title.endsWith('.md')) { } else if (node.title && node.title.endsWith('.md')) {
@ -1033,12 +1089,7 @@ function DocumentEditor() {
modeSwitchingRef.current = true modeSwitchingRef.current = true
setModeSwitchValue('view') setModeSwitchValue('view')
setTimeout(() => { setTimeout(() => {
const params = new URLSearchParams() navigateWithTransition(getModeSwitchTarget())
if (selectedFile) {
params.set('file', selectedFile)
}
const query = params.toString()
navigateWithTransition(`/projects/${projectId}/docs${query ? `?${query}` : ''}`)
}, 160) }, 160)
} }
}} }}
@ -1073,11 +1124,11 @@ function DocumentEditor() {
/> />
</Tooltip> </Tooltip>
</Upload> </Upload>
<Tooltip title="导出文档"> <Tooltip title="下载选中文件">
<Button <Button
size="middle" size="middle"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
onClick={handleExportDirectory} onClick={handleDownloadSelectedFile}
/> />
</Tooltip> </Tooltip>
</Space.Compact> </Space.Compact>
@ -1135,23 +1186,35 @@ function DocumentEditor() {
</div> </div>
) : selectedFile ? ( ) : selectedFile ? (
<div <div
className="bytemd-wrapper" className={`bytemd-wrapper ${isLargeMarkdown ? 'large-file-editor' : ''}`}
onPaste={handlePaste} onPaste={handlePaste}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
> >
<Editor {isLargeMarkdown ? (
key={selectedFile} <>
value={fileContent} <MarkdownSizeNotice className="large-file-editor-notice" />
onChange={(v) => setFileContent(v)} <textarea
plugins={plugins} className="large-markdown-textarea"
locale={{ value={fileContent}
en: { onChange={(e) => setFileContent(e.target.value)}
'Write': '编辑', spellCheck={false}
'Preview': '预览', />
}, </>
}} ) : (
/> <Editor
key={selectedFile}
value={fileContent}
onChange={(v) => setFileContent(v)}
plugins={plugins}
locale={{
en: {
'Write': '编辑',
'Preview': '预览',
},
}}
/>
)}
</div> </div>
) : ( ) : (
<div className="empty-editor"> <div className="empty-editor">

View File

@ -244,6 +244,13 @@
min-height: 100%; min-height: 100%;
} }
.docs-content-wrapper.large-markdown-mode {
max-width: 100%;
height: calc(100% - 57px);
min-height: 0;
padding: 0 0 24px;
}
/* PDF模式下使用全宽 */ /* PDF模式下使用全宽 */
.docs-content-wrapper.pdf-mode { .docs-content-wrapper.pdf-mode {
max-width: 100%; max-width: 100%;
@ -260,6 +267,16 @@
min-height: 400px; min-height: 400px;
} }
.docs-folder-placeholder {
min-height: 360px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-color-secondary);
font-size: 14px;
}
.markdown-body { .markdown-body {
font-size: 16px; font-size: 16px;
line-height: 1.6; line-height: 1.6;

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd' import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons' import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
@ -18,9 +18,11 @@ import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import FloatingToc from '@/components/FloatingToc/FloatingToc' import FloatingToc from '@/components/FloatingToc/FloatingToc'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch' import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import './DocumentPage.css' import './DocumentPage.css'
const { Sider, Content } = Layout const { Sider, Content } = Layout
const MAX_TOC_ITEMS = 500
// //
const HighlightText = ({ text, keyword }) => { const HighlightText = ({ text, keyword }) => {
@ -51,7 +53,6 @@ function DocumentPage() {
const [hasPassword, setHasPassword] = useState(false) const [hasPassword, setHasPassword] = useState(false)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [userRole, setUserRole] = useState('viewer') const [userRole, setUserRole] = useState('viewer')
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
const [pdfUrl, setPdfUrl] = useState('') const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('') const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown') const [viewMode, setViewMode] = useState('markdown')
@ -66,14 +67,18 @@ function DocumentPage() {
const [modeSwitchValue, setModeSwitchValue] = useState('view') const [modeSwitchValue, setModeSwitchValue] = useState('view')
const contentRef = useRef(null) const contentRef = useRef(null)
const largeMarkdownRef = useRef(null)
const modeSwitchingRef = useRef(false) const modeSwitchingRef = useRef(false)
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null) const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
const getHeaderDisplay = (filePath) => { const getHeaderDisplay = (filePath) => {
const resolvedPath = filePath || 'README.md' const resolvedPath = selectedNodeKey || filePath || 'README.md'
const fileName = resolvedPath.split('/').filter(Boolean).pop() || '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 isPdf = fileName.toLowerCase().endsWith('.pdf')
const FileIcon = isPdf ? FilePdfOutlined : FileTextOutlined const FileIcon = isFolder ? FolderOutlined : isPdf ? FilePdfOutlined : FileTextOutlined
return { return {
fileName, fileName,
@ -90,16 +95,68 @@ function DocumentPage() {
navigate(to) navigate(to)
} }
const updateFileParam = (filePath) => { const updateSelectedParam = (path, isFile = true) => {
const nextParams = new URLSearchParams(searchParams) const nextParams = new URLSearchParams(searchParams)
if (filePath) { nextParams.delete('file')
nextParams.set('file', filePath) nextParams.delete('selected')
} else {
nextParams.delete('file') if (path) {
nextParams.set(isFile ? 'file' : 'selected', path)
} }
setSearchParams(nextParams, { replace: true }) 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 buildDocumentUrl = (filePath, refreshKey = null) => {
const params = new URLSearchParams() const params = new URLSearchParams()
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
@ -141,6 +198,7 @@ function DocumentPage() {
if (fileTree.length === 0) return if (fileTree.length === 0) return
const fileParam = searchParams.get('file') const fileParam = searchParams.get('file')
const selectedParam = searchParams.get('selected')
const keywordParam = searchParams.get('keyword') const keywordParam = searchParams.get('keyword')
// //
@ -149,42 +207,24 @@ function DocumentPage() {
} }
// //
if (fileParam) { if (selectedParam) {
if (fileParam !== selectedFile) { const targetNode = findNodeByKey(fileTree, selectedParam)
setSelectedFile(fileParam) if (targetNode && !targetNode.isLeaf && selectedParam !== selectedNodeKey) {
setSelectedNodeKey(fileParam) selectFolder(selectedParam)
}
// } else if (fileParam) {
const parts = fileParam.split('/') const targetNode = findNodeByKey(fileTree, fileParam)
const allParentPaths = [] if (targetNode && !targetNode.isLeaf) {
let currentPath = '' selectFolder(fileParam, { syncUrl: true })
for (let i = 0; i < parts.length - 1; i++) { } else if (fileParam !== selectedFile) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] openDocumentPath(fileParam)
allParentPaths.push(currentPath)
}
if (allParentPaths.length > 0) {
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
}
// PDF Markdown
if (fileParam.toLowerCase().endsWith('.pdf')) {
setPdfUrl(buildDocumentUrl(fileParam))
setPdfFilename(fileParam.split('/').pop())
setViewMode('pdf')
} else {
loadMarkdown(fileParam)
setViewMode('markdown')
}
} }
} else { } else {
// README.md // README.md
if (!selectedFile) { if (!selectedFile && !selectedNodeKey) {
const readmeNode = findReadme(fileTree) const readmeNode = findReadme(fileTree)
if (readmeNode) { if (readmeNode) {
setSelectedFile(readmeNode.key) openDocumentPath(readmeNode.key, { syncUrl: true })
setSelectedNodeKey(readmeNode.key)
updateFileParam(readmeNode.key)
loadMarkdown(readmeNode.key)
} }
} }
} }
@ -325,7 +365,7 @@ function DocumentPage() {
key: node.key, key: node.key,
label: labelNode, label: labelNode,
icon: isOpen ? <FolderOpenOutlined /> : <FolderOutlined />, icon: isOpen ? <FolderOpenOutlined /> : <FolderOutlined />,
onTitleClick: () => setSelectedNodeKey(node.key), onTitleClick: () => selectFolder(node.key, { syncUrl: true }),
children: node.children ? convertTreeToMenuItems(node.children) : [], children: node.children ? convertTreeToMenuItems(node.children) : [],
} }
} else if (node.title && node.title.endsWith('.md')) { } else if (node.title && node.title.endsWith('.md')) {
@ -357,7 +397,7 @@ function DocumentPage() {
// //
if (contentRef.current) { if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) contentRef.current.scrollTo({ top: 0, behavior: 'auto' })
} }
} catch (error) { } catch (error) {
console.error('Load markdown error:', error) console.error('Load markdown error:', error)
@ -369,12 +409,19 @@ function DocumentPage() {
// markdown // markdown
useEffect(() => { useEffect(() => {
if (markdownContent) { 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 slugger = new GithubSlugger()
const headings = [] const headings = []
const lines = markdownContent.split('\n') const lines = markdownContent.split('\n')
lines.forEach((line) => { for (const line of lines) {
const match = line.match(/^(#{1,6})\s+(.+)$/) const match = line.match(/^(#{1,6})\s+(.+)$/)
if (match) { if (match) {
const level = match[1].length const level = match[1].length
@ -388,33 +435,44 @@ function DocumentPage() {
title, title,
level, level,
}) })
if (headings.length >= MAX_TOC_ITEMS) {
break
}
} }
}) }
setTocItems(headings) setTocItems(headings)
})
return () => {
canceled = true
cancel(taskId)
}
} else {
setTocItems([])
} }
}, [markdownContent]) }, [markdownContent, isLargeMarkdown])
// //
const handleMenuClick = ({ key }) => { const handleMenuClick = ({ key }) => {
setSelectedFile(key) const node = findNodeByKey(fileTree, key)
setSelectedNodeKey(key) if (!node) return
updateFileParam(key)
// PDF if (!node.isLeaf) {
if (key.toLowerCase().endsWith('.pdf')) { selectFolder(key, { syncUrl: true })
// PDF - tokenURL return
setPdfUrl(buildDocumentUrl(key))
setPdfFilename(key.split('/').pop())
setViewMode('pdf')
} else {
// Markdown
setViewMode('markdown')
loadMarkdown(key)
} }
openDocumentPath(key, { syncUrl: true })
} }
const scrollContentToTop = () => { const scrollContentToTop = () => {
if (isLargeMarkdown) {
largeMarkdownRef.current?.scrollToTop()
return
}
if (contentRef.current) { if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
} }
@ -712,6 +770,8 @@ function DocumentPage() {
const params = new URLSearchParams() const params = new URLSearchParams()
if (selectedFile) { if (selectedFile) {
params.set('file', selectedFile) params.set('file', selectedFile)
} else if (selectedNodeKey) {
params.set('selected', selectedNodeKey)
} }
const query = params.toString() const query = params.toString()
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`) navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`)
@ -1077,7 +1137,7 @@ function DocumentPage() {
) )
})()} })()}
</div> </div>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}> <div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
{loading ? ( {loading ? (
<div className="docs-loading"> <div className="docs-loading">
<Spin size="large"> <Spin size="large">
@ -1090,6 +1150,17 @@ function DocumentPage() {
filename={pdfFilename} filename={pdfFilename}
toolbarTarget={pdfToolbarTarget} toolbarTarget={pdfToolbarTarget}
/> />
) : viewMode === 'folder' ? (
<div className="docs-folder-placeholder">
<FolderOpenOutlined />
<span>已选择文件夹请从左侧选择 Markdown PDF 文件查看</span>
</div>
) : isLargeMarkdown ? (
<LargeMarkdownViewer
ref={largeMarkdownRef}
content={markdownContent}
components={markdownComponents}
/>
) : ( ) : (
<div className="markdown-body"> <div className="markdown-body">
<ReactMarkdown <ReactMarkdown
@ -1105,7 +1176,7 @@ function DocumentPage() {
</Content> </Content>
{viewMode === 'markdown' && ( {viewMode === 'markdown' && !isLargeMarkdown && (
<FloatingToc <FloatingToc
items={tocItems} items={tocItems}
searchKeyword={searchKeyword} searchKeyword={searchKeyword}

View File

@ -11,6 +11,7 @@ import GithubSlugger from 'github-slugger'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc' import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import { import {
getFileSharePublicInfo, getFileSharePublicInfo,
verifyFileSharePassword, verifyFileSharePassword,
@ -25,6 +26,7 @@ function FileSharePage() {
const { shareCode } = useParams() const { shareCode } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const contentRef = useRef(null) const contentRef = useRef(null)
const largeMarkdownRef = useRef(null)
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null) const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const [shareInfo, setShareInfo] = useState(null) const [shareInfo, setShareInfo] = useState(null)
const [contentInfo, setContentInfo] = useState(null) const [contentInfo, setContentInfo] = useState(null)
@ -34,6 +36,8 @@ function FileSharePage() {
const [tocDrawerVisible, setTocDrawerVisible] = useState(false) const [tocDrawerVisible, setTocDrawerVisible] = useState(false)
const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const markdownContent = contentInfo?.type === 'markdown' ? (contentInfo.content || '') : ''
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
useEffect(() => { useEffect(() => {
loadFileShare() loadFileShare()
@ -47,8 +51,7 @@ function FileSharePage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
const markdownContent = contentInfo?.type === 'markdown' ? (contentInfo.content || '') : '' if (!markdownContent || isLargeMarkdown) {
if (!markdownContent) {
setTocItems([]) setTocItems([])
return return
} }
@ -64,7 +67,7 @@ function FileSharePage() {
headings.push({ key: `#${key}`, href: `#${key}`, title, level }) headings.push({ key: `#${key}`, href: `#${key}`, title, level })
}) })
setTocItems(headings) setTocItems(headings)
}, [contentInfo]) }, [markdownContent, isLargeMarkdown])
const handleClose = () => { const handleClose = () => {
if (window.history.length > 1) { if (window.history.length > 1) {
@ -119,6 +122,11 @@ function FileSharePage() {
} }
const scrollContentToTop = () => { const scrollContentToTop = () => {
if (isLargeMarkdown) {
largeMarkdownRef.current?.scrollToTop()
return
}
if (contentRef.current) { if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
} }
@ -202,15 +210,17 @@ function FileSharePage() {
aria-label="下载PDF" aria-label="下载PDF"
/> />
</Tooltip> </Tooltip>
<Tooltip title="文档索引"> {!isLargeMarkdown && (
<Button <Tooltip title="文档索引">
icon={<MenuOutlined />} <Button
onClick={() => setTocDrawerVisible(true)} icon={<MenuOutlined />}
size="small" onClick={() => setTocDrawerVisible(true)}
type="text" size="small"
aria-label="文档索引" type="text"
/> aria-label="文档索引"
</Tooltip> />
</Tooltip>
)}
</Space> </Space>
) : ( ) : (
<Space className="preview-header-actions"> <Space className="preview-header-actions">
@ -240,7 +250,7 @@ function FileSharePage() {
</Spin> </Spin>
</div> </div>
) : ( ) : (
<div className={`preview-content-wrapper ${contentInfo?.type === 'pdf' ? 'pdf-mode' : ''}`}> <div className={`preview-content-wrapper ${contentInfo?.type === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
{contentInfo?.type === 'pdf' ? ( {contentInfo?.type === 'pdf' ? (
<VirtualPDFViewer <VirtualPDFViewer
url={contentInfo.document_url} url={contentInfo.document_url}
@ -248,6 +258,12 @@ function FileSharePage() {
toolbarTarget={pdfToolbarTarget} toolbarTarget={pdfToolbarTarget}
compactToolbar={isMobile} compactToolbar={isMobile}
/> />
) : isLargeMarkdown ? (
<LargeMarkdownViewer
ref={largeMarkdownRef}
content={markdownContent}
components={markdownComponents}
/>
) : ( ) : (
<div className="markdown-body"> <div className="markdown-body">
<ReactMarkdown <ReactMarkdown
@ -255,7 +271,7 @@ function FileSharePage() {
rehypePlugins={[rehypeSlug, rehypeHighlight]} rehypePlugins={[rehypeSlug, rehypeHighlight]}
components={markdownComponents} components={markdownComponents}
> >
{contentInfo?.content || ''} {markdownContent}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
@ -264,7 +280,7 @@ function FileSharePage() {
</Content> </Content>
{!isMobile && contentInfo?.type === 'markdown' && ( {!isMobile && contentInfo?.type === 'markdown' && !isLargeMarkdown && (
<FloatingToc <FloatingToc
items={tocItems} items={tocItems}
getContainer={() => contentRef.current} getContainer={() => contentRef.current}

View File

@ -253,6 +253,13 @@
min-height: 100%; min-height: 100%;
} }
.preview-content-wrapper.large-markdown-mode {
max-width: 100%;
height: calc(100% - 57px);
min-height: 0;
padding: 0 0 24px;
}
/* PDF模式下使用全宽 */ /* PDF模式下使用全宽 */
.preview-content-wrapper.pdf-mode { .preview-content-wrapper.pdf-mode {
max-width: 100%; max-width: 100%;
@ -403,6 +410,10 @@
padding: 16px; padding: 16px;
} }
.preview-content-wrapper.large-markdown-mode {
padding: 0 0 16px;
}
.preview-content-wrapper.pdf-mode { .preview-content-wrapper.pdf-mode {
padding: 0; padding: 0;
} }
@ -459,6 +470,10 @@
padding: 12px; padding: 12px;
} }
.preview-content-wrapper.large-markdown-mode {
padding: 0 0 12px;
}
.preview-content-wrapper.pdf-mode { .preview-content-wrapper.pdf-mode {
padding: 0; padding: 0;
} }

View File

@ -13,6 +13,7 @@ import GithubSlugger from 'github-slugger'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc' import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import { import {
getProjectSharePublicInfo, getProjectSharePublicInfo,
getProjectShareTree, getProjectShareTree,
@ -64,7 +65,9 @@ function ProjectSharePage() {
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const contentRef = useRef(null) const contentRef = useRef(null)
const viewerRef = useRef(null) const viewerRef = useRef(null)
const largeMarkdownRef = useRef(null)
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null) const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
const handleClose = () => { const handleClose = () => {
if (window.history.length > 1) { if (window.history.length > 1) {
@ -82,7 +85,7 @@ function ProjectSharePage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (viewerRef.current && viewMode === 'markdown') { if (viewerRef.current && viewMode === 'markdown' && !isLargeMarkdown) {
const instance = new Mark(viewerRef.current) const instance = new Mark(viewerRef.current)
instance.unmark() instance.unmark()
if (searchKeyword.trim()) { if (searchKeyword.trim()) {
@ -93,7 +96,7 @@ function ProjectSharePage() {
}) })
} }
} }
}, [markdownContent, searchKeyword, viewMode]) }, [markdownContent, searchKeyword, viewMode, isLargeMarkdown])
useEffect(() => { useEffect(() => {
loadProjectInfo() loadProjectInfo()
@ -236,7 +239,10 @@ function ProjectSharePage() {
} }
useEffect(() => { useEffect(() => {
if (!markdownContent) return if (!markdownContent || isLargeMarkdown) {
setTocItems([])
return
}
const slugger = new GithubSlugger() const slugger = new GithubSlugger()
const headings = [] const headings = []
@ -249,7 +255,7 @@ function ProjectSharePage() {
headings.push({ key: `#${key}`, href: `#${key}`, title, level }) headings.push({ key: `#${key}`, href: `#${key}`, title, level })
}) })
setTocItems(headings) setTocItems(headings)
}, [markdownContent]) }, [markdownContent, isLargeMarkdown])
const findReadme = (nodes) => { const findReadme = (nodes) => {
for (const node of nodes) { for (const node of nodes) {
@ -383,6 +389,11 @@ function ProjectSharePage() {
} }
const scrollContentToTop = () => { const scrollContentToTop = () => {
if (isLargeMarkdown) {
largeMarkdownRef.current?.scrollToTop()
return
}
if (contentRef.current) { if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
} }
@ -549,15 +560,17 @@ function ProjectSharePage() {
aria-label="下载PDF" aria-label="下载PDF"
/> />
</Tooltip> </Tooltip>
<Tooltip title="文档索引"> {!isLargeMarkdown && (
<Button <Tooltip title="文档索引">
icon={<MenuOutlined />} <Button
onClick={() => setTocDrawerVisible(true)} icon={<MenuOutlined />}
size="small" onClick={() => setTocDrawerVisible(true)}
type="text" size="small"
aria-label="文档索引" type="text"
/> aria-label="文档索引"
</Tooltip> />
</Tooltip>
)}
</Space> </Space>
) : ( ) : (
<Space className="preview-header-actions"> <Space className="preview-header-actions">
@ -580,7 +593,7 @@ function ProjectSharePage() {
)} )}
{viewMode === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />} {viewMode === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
</div> </div>
<div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}> <div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
{loading ? ( {loading ? (
<div className="preview-loading"> <div className="preview-loading">
<Spin size="large"> <Spin size="large">
@ -589,6 +602,20 @@ function ProjectSharePage() {
</div> </div>
) : viewMode === 'pdf' ? ( ) : viewMode === 'pdf' ? (
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} toolbarTarget={pdfToolbarTarget} compactToolbar={isMobile} /> <VirtualPDFViewer url={pdfUrl} filename={pdfFilename} toolbarTarget={pdfToolbarTarget} compactToolbar={isMobile} />
) : isLargeMarkdown ? (
<LargeMarkdownViewer
ref={largeMarkdownRef}
content={markdownContent}
components={markdownComponents}
onClick={(e) => {
if (e.defaultPrevented) return
const target = e.target.closest('a')
if (target) {
const href = target.getAttribute('href')
if (href) handleMarkdownLink(e, href)
}
}}
/>
) : ( ) : (
<div className="markdown-body" onClick={(e) => { <div className="markdown-body" onClick={(e) => {
if (e.defaultPrevented) return if (e.defaultPrevented) return
@ -611,7 +638,7 @@ function ProjectSharePage() {
</Content> </Content>
{!isMobile && viewMode === 'markdown' && ( {!isMobile && viewMode === 'markdown' && !isLargeMarkdown && (
<FloatingToc <FloatingToc
items={tocItems} items={tocItems}
searchKeyword={searchKeyword} searchKeyword={searchKeyword}