diff --git a/frontend/src/components/FloatingToc/FloatingToc.jsx b/frontend/src/components/FloatingToc/FloatingToc.jsx index 58dd1b5..0ca2f98 100644 --- a/frontend/src/components/FloatingToc/FloatingToc.jsx +++ b/frontend/src/components/FloatingToc/FloatingToc.jsx @@ -18,7 +18,7 @@ function buildAnchorItems(items, searchKeyword, renderTitle) { })) } -function TocContent({ items = [], getContainer, searchKeyword = '', renderTitle, onItemClick }) { +function TocContent({ items = [], getContainer, searchKeyword = '', renderTitle, onItemClick, onNavigate }) { const anchorItems = buildAnchorItems(items, searchKeyword, renderTitle) return ( @@ -30,6 +30,12 @@ function TocContent({ items = [], getContainer, searchKeyword = '', renderTitle, getContainer={getContainer} items={anchorItems} onClick={(e, link) => { + // antd Anchor 的 link 为 { href, title } 对象 + // 虚拟滚动场景下目标标题可能不在 DOM 中,由 onNavigate 接管滚动 + if (onNavigate) { + e.preventDefault() + onNavigate(link?.href) + } window.setTimeout(() => onItemClick?.(link), 120) }} /> @@ -47,6 +53,7 @@ export function TocDrawer({ getContainer, searchKeyword = '', renderTitle, + onNavigate, }) { return ( ) @@ -73,6 +81,7 @@ export default function FloatingToc({ getContainer, searchKeyword = '', renderTitle, + onNavigate, className = '', }) { const [dismissed, setDismissed] = useState(false) @@ -100,6 +109,7 @@ export default function FloatingToc({ searchKeyword={searchKeyword} renderTitle={renderTitle} onItemClick={() => setDismissed(true)} + onNavigate={onNavigate} /> diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.css b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.css new file mode 100644 index 0000000..b647a31 --- /dev/null +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.css @@ -0,0 +1,99 @@ +/* 外框:与 ByteMD 编辑器一致的边框容器 */ +.large-markdown-editor { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + min-width: 0; + width: 100%; + height: 100%; + border: 1px solid var(--border-color); + border-radius: 2px; + overflow: hidden; + box-sizing: border-box; + background: var(--bg-color); +} + +/* 顶部提示栏:复用 ByteMD 工具栏的视觉规范 */ +.large-markdown-editor-header { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border-bottom: 1px solid var(--border-color); + background-color: var(--toolbar-bg); + color: var(--text-color-secondary); + font-size: 13px; + box-sizing: border-box; +} + +.large-markdown-editor-header-icon { + color: var(--link-color); +} + +/* 编辑区域 */ +.large-markdown-editor-body { + flex: 1; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.large-markdown-editor-body .CodeMirror { + width: 100%; + height: 100%; + background: var(--bg-color); + color: var(--text-color); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 14px; + line-height: 1.8; +} + +/* 左右内边距对称 */ +.large-markdown-editor-body .CodeMirror-lines { + padding: 12px 0; +} + +.large-markdown-editor-body .CodeMirror pre.CodeMirror-line, +.large-markdown-editor-body .CodeMirror pre.CodeMirror-line-like { + padding: 0 16px; +} + +.large-markdown-editor-body .CodeMirror-cursor { + border-left-color: var(--text-color); +} + +.large-markdown-editor-body .CodeMirror-placeholder { + color: var(--text-color-secondary); +} + +.large-markdown-editor-body .CodeMirror-selected { + background: rgba(22, 119, 255, 0.16); +} + +/* 暗色模式下覆盖 cm-s-default 的浅色配色,提升对比度 */ +body.dark .large-markdown-editor-body .CodeMirror-selected { + background: rgba(22, 119, 255, 0.28); +} + +body.dark .large-markdown-editor-body .cm-s-default .cm-header { + color: #4ea1ff; +} + +body.dark .large-markdown-editor-body .cm-s-default .cm-quote { + color: #6cc070; +} + +body.dark .large-markdown-editor-body .cm-s-default .cm-link { + color: #58a6ff; +} + +body.dark .large-markdown-editor-body .cm-s-default .cm-url { + color: #d2545b; +} + +body.dark .large-markdown-editor-body .cm-s-default .cm-variable-2 { + color: #79b8ff; +} diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.jsx b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.jsx new file mode 100644 index 0000000..c7134b1 --- /dev/null +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.jsx @@ -0,0 +1,90 @@ +import { useEffect, useRef } from 'react' +import { InfoCircleOutlined } from '@ant-design/icons' +import factory from 'codemirror-ssr' +import usePlaceholder from 'codemirror-ssr/addon/display/placeholder.js' +import useContinuelist from 'codemirror-ssr/addon/edit/continuelist.js' +import useOverlay from 'codemirror-ssr/addon/mode/overlay.js' +import useGfm from 'codemirror-ssr/mode/gfm/gfm.js' +import useMarkdown from 'codemirror-ssr/mode/markdown/markdown.js' +import useXml from 'codemirror-ssr/mode/xml/xml.js' +import 'codemirror-ssr/lib/codemirror.css' +import { LARGE_MARKDOWN_NOTICE } from './LargeMarkdownViewer' +import './LargeMarkdownEditor.css' + +// 复用 ByteMD 底层的 codemirror-ssr,为超大文档提供带 Markdown 源码着色的纯文本编辑器。 +// 不引入新依赖,CodeMirror 5 的视口渲染让超大文档编辑保持流畅。 +function createCodeMirror() { + const codemirror = factory() + usePlaceholder(codemirror) + useOverlay(codemirror) + useXml(codemirror) + useMarkdown(codemirror) + useGfm(codemirror) + useContinuelist(codemirror) + return codemirror +} + +export default function LargeMarkdownEditor({ value = '', onChange, placeholder = '' }) { + const containerRef = useRef(null) + const editorRef = useRef(null) + const onChangeRef = useRef(onChange) + + // 保持 onChange 最新,但不因其变化而重建编辑器 + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + useEffect(() => { + if (!containerRef.current) return + + const codemirror = createCodeMirror() + const editor = codemirror(containerRef.current, { + value, + mode: 'gfm', + lineWrapping: true, + lineNumbers: false, + tabSize: 2, + indentUnit: 2, + placeholder, + extraKeys: { + Enter: 'newlineAndIndentContinueMarkdownList', + Tab: 'indentMore', + 'Shift-Tab': 'indentLess', + }, + }) + + editor.on('change', () => { + onChangeRef.current?.(editor.getValue()) + }) + + editorRef.current = editor + + return () => { + // 卸载时清理 CodeMirror 实例的 DOM + const wrapper = editor.getWrapperElement() + wrapper?.parentNode?.removeChild(wrapper) + editorRef.current = null + } + }, []) + + // 外部 value 变化(切换文件、重置)时同步,避免覆盖正在输入的光标位置 + useEffect(() => { + const editor = editorRef.current + if (!editor) return + if (value !== editor.getValue()) { + const cursor = editor.getCursor() + editor.setValue(value) + editor.setCursor(cursor) + } + }, [value]) + + return ( +
+
+ + {LARGE_MARKDOWN_NOTICE} +
+
+
+ ) +} diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css index 5a8befc..635d42b 100644 --- a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.css @@ -39,15 +39,35 @@ padding: 0 0 1px; } +/* 首块下移,避免被固定浮动的提示窗遮挡 */ +.markdown-block[data-index='0'] { + padding-top: 64px; +} + +/* 虚拟滚动条更纤细,减少视觉割裂 */ +.markdown-virtual-list::-webkit-scrollbar { + width: 10px; +} + +.markdown-virtual-list::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--text-color-secondary) 28%, transparent); + border-radius: 999px; + border: 3px solid transparent; + background-clip: padding-box; +} + +.markdown-virtual-list::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--text-color-secondary) 44%, transparent); + background-clip: padding-box; +} + @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 index 7d08a3a..5076589 100644 --- a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownViewer.jsx @@ -5,10 +5,14 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' import rehypeSlug from 'rehype-slug' +import rehypeHighlight from 'rehype-highlight' +import GithubSlugger from 'github-slugger' +import FloatingToc from '@/components/FloatingToc/FloatingToc' import './LargeMarkdownViewer.css' export const LARGE_MARKDOWN_THRESHOLD = 250000 export const LARGE_MARKDOWN_NOTICE = '此文档内容过长,将采用精简模式显示' +const MAX_TOC_ITEMS = 500 export function isLargeMarkdownContent(content = '') { return content.length > LARGE_MARKDOWN_THRESHOLD @@ -27,10 +31,14 @@ export function MarkdownSizeNotice({ className = '' }) { ) } -function splitMarkdownIntoBlocks(content) { - if (!content) return [] +// 将 markdown 切分为块,并在切分的同时提取标题用于目录导航。 +// 每个块以标题或空行为边界,目录项记录其所在块索引,便于虚拟滚动定位。 +function buildBlocksAndToc(content) { + if (!content) return { blocks: [], tocItems: [] } const blocks = [] + const tocItems = [] + const slugger = new GithubSlugger() const lines = content.split('\n') let current = [] let inFence = false @@ -44,10 +52,20 @@ function splitMarkdownIntoBlocks(content) { for (const line of lines) { const isFenceLine = /^\s*(```|~~~)/.test(line) - const isHeading = /^(#{1,6})\s+/.test(line) + const headingMatch = inFence ? null : line.match(/^(#{1,6})\s+(.+)$/) - if (!inFence && isHeading) { + if (headingMatch) { flush() + if (tocItems.length < MAX_TOC_ITEMS) { + const key = slugger.slug(headingMatch[2].trim()) + tocItems.push({ + key: `#${key}`, + href: `#${key}`, + title: headingMatch[2].trim(), + level: headingMatch[1].length, + blockIndex: blocks.length, // 该标题即将进入的块的索引 + }) + } } current.push(line) @@ -62,23 +80,48 @@ function splitMarkdownIntoBlocks(content) { } flush() - return blocks + return { blocks, tocItems } } -const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer({ content, components, onClick }, ref) { +const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer( + { content, components, onClick, searchKeyword = '', renderTitle }, + ref +) { const virtuosoRef = useRef(null) - const markdownBlocks = useMemo(() => splitMarkdownIntoBlocks(content), [content]) + const { blocks: markdownBlocks, tocItems } = useMemo( + () => buildBlocksAndToc(content), + [content] + ) + + const hrefToIndex = useMemo(() => { + const map = new Map() + for (const item of tocItems) { + if (!map.has(item.href)) { + map.set(item.href, item.blockIndex) + } + } + return map + }, [tocItems]) + + const scrollToIndex = (index) => { + virtuosoRef.current?.scrollToIndex({ + index, + align: 'start', + behavior: 'smooth', + }) + } useImperativeHandle(ref, () => ({ - scrollToTop: () => { - virtuosoRef.current?.scrollToIndex({ - index: 0, - align: 'start', - behavior: 'smooth', - }) - }, + scrollToTop: () => scrollToIndex(0), }), []) + const handleTocNavigate = (link) => { + const index = hrefToIndex.get(link) + if (typeof index === 'number') { + scrollToIndex(index) + } + } + return (
@@ -91,7 +134,7 @@ const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer({ content, c
{block} @@ -100,6 +143,12 @@ const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer({ content, c )} />
+
) }) diff --git a/frontend/src/components/MainLayout/MainLayout.jsx b/frontend/src/components/MainLayout/MainLayout.jsx index 726c328..2603908 100644 --- a/frontend/src/components/MainLayout/MainLayout.jsx +++ b/frontend/src/components/MainLayout/MainLayout.jsx @@ -1,4 +1,5 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' +import { useLocation } from 'react-router-dom' import { Layout } from 'antd' import AppSider from './AppSider' import AppHeader from './AppHeader' @@ -6,9 +7,19 @@ import './MainLayout.css' const { Content } = Layout +// 进入项目文档页/编辑页时自动折叠全局侧边栏,给文档内容腾出空间 +const COLLAPSE_PATTERNS = [/^\/projects\/[^/]+\/docs/, /^\/projects\/[^/]+\/editor/] + function MainLayout({ children }) { + const location = useLocation() const [collapsed, setCollapsed] = useState(false) + useEffect(() => { + if (COLLAPSE_PATTERNS.some((pattern) => pattern.test(location.pathname))) { + setCollapsed(true) + } + }, [location.pathname]) + const toggleCollapsed = () => { setCollapsed(!collapsed) } diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index e9ae3a1..4ab51a4 100644 --- a/frontend/src/pages/Document/DocumentEditor.css +++ b/frontend/src/pages/Document/DocumentEditor.css @@ -236,15 +236,12 @@ .bytemd-wrapper.large-file-editor { position: relative; - padding: 0; + display: flex; + flex-direction: column; } -.large-file-editor-notice { - top: 12px; -} - -/* Fix for bytemd-react wrapper div */ -.bytemd-wrapper>div { +/* Fix for bytemd-react wrapper div(仅普通编辑器,超大着色编辑器不受此约束) */ +.bytemd-wrapper:not(.large-file-editor)>div { flex: 1; display: flex; flex-direction: column; @@ -253,36 +250,6 @@ 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 7b3348f..7fc7e4a 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -38,7 +38,8 @@ import { } from '@/api/file' import Toast from '@/components/Toast/Toast' import ModeSwitch from '@/components/ModeSwitch/ModeSwitch' -import { MarkdownSizeNotice, isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer' +import { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer' +import LargeMarkdownEditor from '@/components/LargeMarkdownViewer/LargeMarkdownEditor' import './DocumentEditor.css' const { Sider, Content } = Layout @@ -1192,15 +1193,10 @@ function DocumentEditor() { onDragOver={(e) => e.preventDefault()} > {isLargeMarkdown ? ( - <> - -