优化超大md文档编辑器

main
mula.liu 2026-06-22 14:05:10 +08:00
parent 2bb7db0d56
commit e42a5525f6
10 changed files with 312 additions and 66 deletions

View File

@ -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 (
<Drawer
@ -63,6 +70,7 @@ export function TocDrawer({
searchKeyword={searchKeyword}
renderTitle={renderTitle}
onItemClick={onClose}
onNavigate={onNavigate}
/>
</Drawer>
)
@ -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}
/>
</div>
</aside>

View File

@ -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;
}

View File

@ -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 (
<div className="large-markdown-editor">
<div className="large-markdown-editor-header">
<InfoCircleOutlined className="large-markdown-editor-header-icon" />
<span>{LARGE_MARKDOWN_NOTICE}</span>
</div>
<div className="large-markdown-editor-body" ref={containerRef} />
</div>
)
}

View File

@ -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);
}

View File

@ -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 (
<div className="large-markdown-viewer">
<MarkdownSizeNotice />
@ -91,7 +134,7 @@ const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer({ content, c
<div className="markdown-block" data-index={index}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
components={components}
>
{block}
@ -100,6 +143,12 @@ const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer({ content, c
)}
/>
</div>
<FloatingToc
items={tocItems}
searchKeyword={searchKeyword}
renderTitle={renderTitle}
onNavigate={handleTocNavigate}
/>
</div>
)
})

View File

@ -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)
}

View File

@ -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;

View File

@ -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 ? (
<>
<MarkdownSizeNotice className="large-file-editor-notice" />
<textarea
className="large-markdown-textarea"
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
spellCheck={false}
/>
</>
<LargeMarkdownEditor
value={fileContent}
onChange={setFileContent}
/>
) : (
<Editor
key={selectedFile}

View File

@ -1160,6 +1160,8 @@ function DocumentPage() {
ref={largeMarkdownRef}
content={markdownContent}
components={markdownComponents}
searchKeyword={searchKeyword}
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
/>
) : (
<div className="markdown-body">

View File

@ -607,6 +607,8 @@ function ProjectSharePage() {
ref={largeMarkdownRef}
content={markdownContent}
components={markdownComponents}
searchKeyword={searchKeyword}
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
onClick={(e) => {
if (e.defaultPrevented) return
const target = e.target.closest('a')