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 ? (
- <>
-
-