调整了对超大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.local
.gemini-clipboard
.memsearch
# Temporary files
*.tmp

View File

@ -7,3 +7,18 @@
## 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 {
height: calc(100vh - 64px);
/* width: calc(100% + 32px); */
@ -232,6 +234,15 @@
padding: 5px 10px;
}
.bytemd-wrapper.large-file-editor {
position: relative;
padding: 0;
}
.large-file-editor-notice {
top: 12px;
}
/* Fix for bytemd-react wrapper div */
.bytemd-wrapper>div {
flex: 1;
@ -242,6 +253,36 @@
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 {
height: 100%;
display: flex;

View File

@ -5,7 +5,6 @@ import {
FileOutlined,
FolderOutlined,
FolderOpenOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
SaveOutlined,
@ -14,7 +13,6 @@ import {
UploadOutlined,
DownloadOutlined,
SwapOutlined,
FileImageOutlined,
FilePdfOutlined,
FileTextOutlined,
UndoOutlined,
@ -35,11 +33,12 @@ import {
operateFile,
uploadFile,
importDocuments,
exportDirectory,
uploadDocument,
getDocumentUrl,
} from '@/api/file'
import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import { MarkdownSizeNotice, isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import './DocumentEditor.css'
const { Sider, Content } = Layout
@ -63,7 +62,6 @@ function DocumentEditor() {
const [creationParentPath, setCreationParentPath] = useState('')
const [moveTargetPath, setMoveTargetPath] = useState('')
const [dirOptions, setDirOptions] = useState([])
const [editorHeight, setEditorHeight] = useState(600) // 600px
const [openKeys, setOpenKeys] = useState([]) // Menu
const [uploadProgress, setUploadProgress] = useState(0) //
const [uploading, setUploading] = useState(false) //
@ -78,6 +76,7 @@ function DocumentEditor() {
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
const editorCtxRef = useRef(null)
const modeSwitchingRef = useRef(false)
const isLargeMarkdown = isLargeMarkdownContent(fileContent)
const isHeaderPdf = selectedFile?.toLowerCase().endsWith('.pdf')
const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined
@ -109,16 +108,139 @@ function DocumentEditor() {
navigate(to)
}
const updateFileParam = (filePath) => {
const updateSelectedParam = (path, isFile = true) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
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 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) => {
if (!targetPath) return targetPath
@ -159,22 +281,6 @@ function DocumentEditor() {
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(() => {
fetchTree()
}, [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) => {
for (const node of nodes) {
@ -271,8 +351,11 @@ function DocumentEditor() {
setSelectedMenuKey(key)
if (!targetNode.isLeaf) {
setSelectedFile(null)
setIsPdfSelected(false)
setFileContent('')
if (syncUrl) {
updateFileParam(null)
updateSelectedParam(key, false)
}
return
}
@ -306,6 +389,16 @@ function DocumentEditor() {
if (treeData.length === 0) return
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 === 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) => {
setRightClickNode(path)
@ -972,8 +1029,7 @@ function DocumentEditor() {
children: node.children ? convertTreeToMenuItems(node.children) : [],
className: isSelected ? 'folder-selected' : '',
onTitleClick: () => {
setSelectedNode(node)
setSelectedMenuKey(node.key)
selectFolder(node.key, { syncUrl: true })
},
}
} else if (node.title && node.title.endsWith('.md')) {
@ -1033,12 +1089,7 @@ function DocumentEditor() {
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}` : ''}`)
navigateWithTransition(getModeSwitchTarget())
}, 160)
}
}}
@ -1073,11 +1124,11 @@ function DocumentEditor() {
/>
</Tooltip>
</Upload>
<Tooltip title="导出文档">
<Tooltip title="下载选中文件">
<Button
size="middle"
icon={<DownloadOutlined />}
onClick={handleExportDirectory}
onClick={handleDownloadSelectedFile}
/>
</Tooltip>
</Space.Compact>
@ -1135,23 +1186,35 @@ function DocumentEditor() {
</div>
) : selectedFile ? (
<div
className="bytemd-wrapper"
className={`bytemd-wrapper ${isLargeMarkdown ? 'large-file-editor' : ''}`}
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
<Editor
key={selectedFile}
value={fileContent}
onChange={(v) => setFileContent(v)}
plugins={plugins}
locale={{
en: {
'Write': '编辑',
'Preview': '预览',
},
}}
/>
{isLargeMarkdown ? (
<>
<MarkdownSizeNotice className="large-file-editor-notice" />
<textarea
className="large-markdown-textarea"
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
spellCheck={false}
/>
</>
) : (
<Editor
key={selectedFile}
value={fileContent}
onChange={(v) => setFileContent(v)}
plugins={plugins}
locale={{
en: {
'Write': '编辑',
'Preview': '预览',
},
}}
/>
)}
</div>
) : (
<div className="empty-editor">

View File

@ -244,6 +244,13 @@
min-height: 100%;
}
.docs-content-wrapper.large-markdown-mode {
max-width: 100%;
height: calc(100% - 57px);
min-height: 0;
padding: 0 0 24px;
}
/* PDF模式下使用全宽 */
.docs-content-wrapper.pdf-mode {
max-width: 100%;
@ -260,6 +267,16 @@
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 {
font-size: 16px;
line-height: 1.6;

View File

@ -1,7 +1,7 @@
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, 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 remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
@ -18,9 +18,11 @@ 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 }) => {
@ -51,7 +53,6 @@ function DocumentPage() {
const [hasPassword, setHasPassword] = useState(false)
const [password, setPassword] = useState('')
const [userRole, setUserRole] = useState('viewer')
const [pdfViewerVisible, setPdfViewerVisible] = useState(false)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown')
@ -66,14 +67,18 @@ function DocumentPage() {
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 = filePath || 'README.md'
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 = isPdf ? FilePdfOutlined : FileTextOutlined
const FileIcon = isFolder ? FolderOutlined : isPdf ? FilePdfOutlined : FileTextOutlined
return {
fileName,
@ -90,16 +95,68 @@ function DocumentPage() {
navigate(to)
}
const updateFileParam = (filePath) => {
const updateSelectedParam = (path, isFile = true) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
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')
@ -141,6 +198,7 @@ function DocumentPage() {
if (fileTree.length === 0) return
const fileParam = searchParams.get('file')
const selectedParam = searchParams.get('selected')
const keywordParam = searchParams.get('keyword')
//
@ -149,42 +207,24 @@ function DocumentPage() {
}
//
if (fileParam) {
if (fileParam !== selectedFile) {
setSelectedFile(fileParam)
setSelectedNodeKey(fileParam)
//
const parts = fileParam.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])])
}
// PDF Markdown
if (fileParam.toLowerCase().endsWith('.pdf')) {
setPdfUrl(buildDocumentUrl(fileParam))
setPdfFilename(fileParam.split('/').pop())
setViewMode('pdf')
} else {
loadMarkdown(fileParam)
setViewMode('markdown')
}
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) {
if (!selectedFile && !selectedNodeKey) {
const readmeNode = findReadme(fileTree)
if (readmeNode) {
setSelectedFile(readmeNode.key)
setSelectedNodeKey(readmeNode.key)
updateFileParam(readmeNode.key)
loadMarkdown(readmeNode.key)
openDocumentPath(readmeNode.key, { syncUrl: true })
}
}
}
@ -325,7 +365,7 @@ function DocumentPage() {
key: node.key,
label: labelNode,
icon: isOpen ? <FolderOpenOutlined /> : <FolderOutlined />,
onTitleClick: () => setSelectedNodeKey(node.key),
onTitleClick: () => selectFolder(node.key, { syncUrl: true }),
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
} else if (node.title && node.title.endsWith('.md')) {
@ -357,7 +397,7 @@ function DocumentPage() {
//
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
contentRef.current.scrollTo({ top: 0, behavior: 'auto' })
}
} catch (error) {
console.error('Load markdown error:', error)
@ -369,12 +409,19 @@ function DocumentPage() {
// markdown
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 headings = []
const lines = markdownContent.split('\n')
lines.forEach((line) => {
for (const line of lines) {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (match) {
const level = match[1].length
@ -388,33 +435,44 @@ function DocumentPage() {
title,
level,
})
if (headings.length >= MAX_TOC_ITEMS) {
break
}
}
})
}
setTocItems(headings)
})
return () => {
canceled = true
cancel(taskId)
}
} else {
setTocItems([])
}
}, [markdownContent])
}, [markdownContent, isLargeMarkdown])
//
const handleMenuClick = ({ key }) => {
setSelectedFile(key)
setSelectedNodeKey(key)
updateFileParam(key)
const node = findNodeByKey(fileTree, key)
if (!node) return
// PDF
if (key.toLowerCase().endsWith('.pdf')) {
// PDF - tokenURL
setPdfUrl(buildDocumentUrl(key))
setPdfFilename(key.split('/').pop())
setViewMode('pdf')
} else {
// Markdown
setViewMode('markdown')
loadMarkdown(key)
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' })
}
@ -712,6 +770,8 @@ function DocumentPage() {
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}` : ''}`)
@ -1077,7 +1137,7 @@ function DocumentPage() {
)
})()}
</div>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''} ${isLargeMarkdown ? 'large-markdown-mode' : ''}`}>
{loading ? (
<div className="docs-loading">
<Spin size="large">
@ -1090,6 +1150,17 @@ function DocumentPage() {
filename={pdfFilename}
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">
<ReactMarkdown
@ -1105,7 +1176,7 @@ function DocumentPage() {
</Content>
{viewMode === 'markdown' && (
{viewMode === 'markdown' && !isLargeMarkdown && (
<FloatingToc
items={tocItems}
searchKeyword={searchKeyword}

View File

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

View File

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

View File

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