diff --git a/.gitignore b/.gitignore
index d5c161b..498ebe8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,7 @@ logs/
.env
.env.local
.gemini-clipboard
+.memsearch
# Temporary files
*.tmp
diff --git a/.memsearch/memory/2026-06-03.md b/.memsearch/memory/2026-06-03.md
index c806275..59098be 100644
--- a/.memsearch/memory/2026-06-03.md
+++ b/.memsearch/memory/2026-06-03.md
@@ -7,3 +7,18 @@
## Session 16:54
+
+## Session 17:15
+
+
+## Session 17:16
+
+
+## Session 17:20
+
+
+## Session 17:54
+
+
+## Session 17:54
+
diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css
new file mode 100644
index 0000000..5a8befc
--- /dev/null
+++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css
@@ -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);
+ }
+}
diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx
new file mode 100644
index 0000000..7d08a3a
--- /dev/null
+++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx
@@ -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 (
+
+
}
- onClick={handleExportDirectory}
+ onClick={handleDownloadSelectedFile}
/>
@@ -1135,23 +1186,35 @@ function DocumentEditor() {
) : selectedFile ? (
e.preventDefault()}
>
- setFileContent(v)}
- plugins={plugins}
- locale={{
- en: {
- 'Write': '编辑',
- 'Preview': '预览',
- },
- }}
- />
+ {isLargeMarkdown ? (
+ <>
+
+
) : (
diff --git a/frontend/src/pages/Document/DocumentPage.css b/frontend/src/pages/Document/DocumentPage.css
index 257db55..8251608 100644
--- a/frontend/src/pages/Document/DocumentPage.css
+++ b/frontend/src/pages/Document/DocumentPage.css
@@ -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;
diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx
index 891b1ab..181afb6 100644
--- a/frontend/src/pages/Document/DocumentPage.jsx
+++ b/frontend/src/pages/Document/DocumentPage.jsx
@@ -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 ? : ,
- 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 - 添加token到URL
- 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() {
)
})()}
-
+
{loading ? (
@@ -1090,6 +1150,17 @@ function DocumentPage() {
filename={pdfFilename}
toolbarTarget={pdfToolbarTarget}
/>
+ ) : viewMode === 'folder' ? (
+
+
+ 已选择文件夹,请从左侧选择 Markdown 或 PDF 文件查看。
+
+ ) : isLargeMarkdown ? (
+
) : (
- {viewMode === 'markdown' && (
+ {viewMode === 'markdown' && !isLargeMarkdown && (
{
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"
/>
-
- }
- onClick={() => setTocDrawerVisible(true)}
- size="small"
- type="text"
- aria-label="文档索引"
- />
-
+ {!isLargeMarkdown && (
+
+ }
+ onClick={() => setTocDrawerVisible(true)}
+ size="small"
+ type="text"
+ aria-label="文档索引"
+ />
+
+ )}
) : (
@@ -240,7 +250,7 @@ function FileSharePage() {
) : (
-
+
{contentInfo?.type === 'pdf' ? (
+ ) : isLargeMarkdown ? (
+
) : (
- {contentInfo?.content || ''}
+ {markdownContent}
)}
@@ -264,7 +280,7 @@ function FileSharePage() {
- {!isMobile && contentInfo?.type === 'markdown' && (
+ {!isMobile && contentInfo?.type === 'markdown' && !isLargeMarkdown && (
contentRef.current}
diff --git a/frontend/src/pages/Preview/PreviewPage.css b/frontend/src/pages/Preview/PreviewPage.css
index 4c37db0..ce1e44a 100644
--- a/frontend/src/pages/Preview/PreviewPage.css
+++ b/frontend/src/pages/Preview/PreviewPage.css
@@ -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;
}
diff --git a/frontend/src/pages/Preview/ProjectSharePage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx
index fef9f7f..a41a507 100644
--- a/frontend/src/pages/Preview/ProjectSharePage.jsx
+++ b/frontend/src/pages/Preview/ProjectSharePage.jsx
@@ -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"
/>
-
- }
- onClick={() => setTocDrawerVisible(true)}
- size="small"
- type="text"
- aria-label="文档索引"
- />
-
+ {!isLargeMarkdown && (
+
+ }
+ onClick={() => setTocDrawerVisible(true)}
+ size="small"
+ type="text"
+ aria-label="文档索引"
+ />
+
+ )}
) : (
@@ -580,7 +593,7 @@ function ProjectSharePage() {
)}
{viewMode === 'pdf' && }
-
+
{loading ? (
@@ -589,6 +602,20 @@ function ProjectSharePage() {
) : viewMode === 'pdf' ? (
+ ) : isLargeMarkdown ? (
+
{
+ if (e.defaultPrevented) return
+ const target = e.target.closest('a')
+ if (target) {
+ const href = target.getAttribute('href')
+ if (href) handleMarkdownLink(e, href)
+ }
+ }}
+ />
) : (
{
if (e.defaultPrevented) return
@@ -611,7 +638,7 @@ function ProjectSharePage() {
- {!isMobile && viewMode === 'markdown' && (
+ {!isMobile && viewMode === 'markdown' && !isLargeMarkdown && (