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 ( +
+ +
+ ) +} + +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 ( +
+ +
+ ( +
+ + {block} + +
+ )} + /> +
+
+ ) +}) + +export default LargeMarkdownViewer diff --git a/frontend/src/components/MarkdownViewer/MarkdownViewer.jsx b/frontend/src/components/MarkdownViewer/MarkdownViewer.jsx new file mode 100644 index 0000000..e9779d7 --- /dev/null +++ b/frontend/src/components/MarkdownViewer/MarkdownViewer.jsx @@ -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 ( +
+ + {content} + +
+ ) +}) + +export default MarkdownViewer diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index 6ebef86..e9ae3a1 100644 --- a/frontend/src/pages/Document/DocumentEditor.css +++ b/frontend/src/pages/Document/DocumentEditor.css @@ -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; diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index 87a8f9d..7b3348f 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -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() { /> - +