feat: replace CodeMirror 6 with Milkdown 7.21 editor

- Remove 7 @codemirror/* packages, add 7 @milkdown/* packages
- Rewrite useCodeMirror -> useMilkdown hook (ProseMirror-based)
- Rewrite Editor.tsx with Milkdown lifecycle management
- Update EditorToolbar to use Milkdown callCommand API
- Replace CodeMirror CSS styles with Milkdown/ProseMirror styles
- Support: CommonMark + GFM, dark mode, toolbar formatting
- Bump version 0.2.0 -> 0.3.0
This commit is contained in:
thzxx
2026-06-04 09:39:10 +08:00
parent 7a4e2b0a67
commit b1803c6467
6 changed files with 405 additions and 522 deletions
+18 -24
View File
@@ -1,6 +1,8 @@
import React, { useEffect, useCallback } from 'react'
import { callCommand } from '@milkdown/utils'
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
import { useTabStore } from '../../stores/tabStore'
import { useCodeMirror } from './useCodeMirror'
import { useMilkdown } from './useMilkdown'
import { EditorToolbar } from './EditorToolbar'
interface EditorProps {
@@ -16,13 +18,13 @@ export function Editor({ darkMode }: EditorProps) {
const {
containerRef,
viewRef,
action,
setContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection
} = useCodeMirror({
} = useMilkdown({
content: activeTab?.content ?? '',
onChange: useCallback((value: string) => {
if (!activeTabId) return
@@ -32,17 +34,18 @@ export function Editor({ darkMode }: EditorProps) {
darkMode
})
// 切换标签时加载内容
// Load content when switching tabs
useEffect(() => {
if (!activeTab) return
setContent(activeTab.content)
requestAnimationFrame(() => {
setScrollTop(activeTab.scrollTop)
setSelection(activeTab.selectionStart, activeTab.selectionEnd)
setSelection()
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// 保存当前标签状态
// Save current tab state on unmount
useEffect(() => {
return () => {
if (!activeTabId) return
@@ -52,43 +55,34 @@ export function Editor({ darkMode }: EditorProps) {
selectionEnd: getSelection().to
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Ctrl+B 粗体, Ctrl+I 斜体
// Ctrl+B bold, Ctrl+I italic
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'b') {
e.preventDefault()
const view = viewRef.current
if (!view) return
const { from, to } = view.state.selection.main
const selected = view.state.sliceDoc(from, to) || '粗体'
view.dispatch({
changes: { from, to, insert: `**${selected}**` },
selection: { anchor: from + 2, head: from + 2 + selected.length }
action((editor) => {
editor.action(callCommand(toggleStrongCommand.key))
})
}
if (isCtrl && e.key === 'i') {
e.preventDefault()
const view = viewRef.current
if (!view) return
const { from, to } = view.state.selection.main
const selected = view.state.sliceDoc(from, to) || '斜体'
view.dispatch({
changes: { from, to, insert: `*${selected}*` },
selection: { anchor: from + 1, head: from + 1 + selected.length }
action((editor) => {
editor.action(callCommand(toggleEmphasisCommand.key))
})
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [viewRef])
}, [action])
return (
<div className="editor-container" role="region" aria-label="Markdown编辑器">
<EditorToolbar viewRef={viewRef} />
<div ref={containerRef} className="codemirror-wrapper" />
<EditorToolbar action={action} />
<div ref={containerRef} className="milkdown-wrapper" />
</div>
)
}
+115 -58
View File
@@ -1,100 +1,157 @@
import React, { useCallback } from 'react'
import type { EditorView } from '@codemirror/view'
import type { Editor } from '@milkdown/core'
import { callCommand } from '@milkdown/utils'
import type { $Command } from '@milkdown/utils'
import {
toggleStrongCommand,
toggleEmphasisCommand,
wrapInHeadingCommand,
wrapInBlockquoteCommand,
wrapInBulletListCommand,
wrapInOrderedListCommand,
createCodeBlockCommand,
insertImageCommand,
toggleLinkCommand,
insertHrCommand
} from '@milkdown/preset-commonmark'
import { toggleStrikethroughCommand } from '@milkdown/preset-gfm'
interface EditorToolbarProps {
viewRef: React.MutableRefObject<EditorView | null>
action: (fn: (editor: Editor) => void) => void
}
export const EditorToolbar = React.memo(function EditorToolbar({ viewRef }: EditorToolbarProps) {
const insertFormatting = useCallback((before: string, after: string, placeholder: string) => {
const view = viewRef.current
if (!view) return
const { from, to } = view.state.selection.main
const selected = view.state.sliceDoc(from, to)
const text = selected || placeholder
const insert = before + text + after
view.dispatch({
changes: { from, to, insert },
selection: { anchor: from + before.length, head: from + before.length + text.length }
export const EditorToolbar = React.memo(function EditorToolbar({ action }: EditorToolbarProps) {
const exec = useCallback(<T,>(command: $Command<T>, payload?: T) => {
action((editor) => {
try {
editor.action(callCommand(command.key, payload))
} catch {
// command may not be registered
}
})
view.focus()
}, [viewRef])
}, [action])
const insertLinePrefix = useCallback((prefix: string) => {
const view = viewRef.current
if (!view) return
const { from } = view.state.selection.main
const line = view.state.doc.lineAt(from)
const currentLine = view.state.sliceDoc(line.from, line.to)
if (currentLine.startsWith(prefix)) {
view.dispatch({
changes: { from: line.from, to: line.from + prefix.length, insert: '' }
})
} else {
view.dispatch({
changes: { from: line.from, to: line.from, insert: prefix }
})
}
view.focus()
}, [viewRef])
const insertBlock = useCallback((text: string) => {
const view = viewRef.current
if (!view) return
const { from } = view.state.selection.main
const line = view.state.doc.lineAt(from)
const insertPos = line.to + 1
view.dispatch({
changes: { from: insertPos, to: insertPos, insert: '\n' + text + '\n' },
selection: { anchor: insertPos + 1, head: insertPos + 1 + text.length }
})
view.focus()
}, [viewRef])
const handleHeading = useCallback((level: number) => {
exec(wrapInHeadingCommand, level)
}, [exec])
return (
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)" aria-label="粗体">
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleStrongCommand)}
title="粗体 (Ctrl+B)"
aria-label="粗体"
>
<strong>B</strong>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)" aria-label="斜体">
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleEmphasisCommand)}
title="斜体 (Ctrl+I)"
aria-label="斜体"
>
<em>I</em>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线" aria-label="删除线">
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleStrikethroughCommand)}
title="删除线"
aria-label="删除线"
>
<s>S</s>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码" aria-label="行内代码">
<button
className="toolbar-btn-sm"
onClick={() => exec(createCodeBlockCommand)}
title="行内代码"
aria-label="行内代码"
>
{'</>'}
</button>
<div className="toolbar-divider-sm" role="separator" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题" aria-label="一级标题">
<button
className="toolbar-btn-sm"
onClick={() => handleHeading(1)}
title="标题"
aria-label="一级标题"
>
H1
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题" aria-label="二级标题">
<button
className="toolbar-btn-sm"
onClick={() => handleHeading(2)}
title="二级标题"
aria-label="二级标题"
>
H2
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题" aria-label="三级标题">
<button
className="toolbar-btn-sm"
onClick={() => handleHeading(3)}
title="三级标题"
aria-label="三级标题"
>
H3
</button>
<div className="toolbar-divider-sm" role="separator" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表" aria-label="无序列表">
<button
className="toolbar-btn-sm"
onClick={() => exec(wrapInBulletListCommand)}
title="无序列表"
aria-label="无序列表"
>
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表" aria-label="有序列表">
<button
className="toolbar-btn-sm"
onClick={() => exec(wrapInOrderedListCommand)}
title="有序列表"
aria-label="有序列表"
>
1.
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用" aria-label="引用">
<button
className="toolbar-btn-sm"
onClick={() => exec(wrapInBlockquoteCommand)}
title="引用"
aria-label="引用"
>
</button>
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块" aria-label="代码块">
<button
className="toolbar-btn-sm"
onClick={() => exec(createCodeBlockCommand)}
title="代码块"
aria-label="代码块"
>
{'{ }'}
</button>
<div className="toolbar-divider-sm" role="separator" />
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接" aria-label="插入链接">
<button
className="toolbar-btn-sm"
onClick={() => exec(toggleLinkCommand, { href: '', title: '' })}
title="链接"
aria-label="插入链接"
>
🔗
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('![', '](url)', '图片描述')} title="图片" aria-label="插入图片">
<button
className="toolbar-btn-sm"
onClick={() => exec(insertImageCommand, { src: '', alt: '' })}
title="图片"
aria-label="插入图片"
>
🖼
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(insertHrCommand)}
title="分割线"
aria-label="插入分割线"
>
</button>
</div>
)
})
@@ -1,155 +0,0 @@
import { useEffect, useRef, useCallback } from 'react'
import { EditorState, Compartment, type Extension } from '@codemirror/state'
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightSpecialChars, drawSelection, rectangularSelection } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, foldGutter } from '@codemirror/language'
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { oneDark } from '@codemirror/theme-one-dark'
interface UseCodeMirrorOptions {
content: string
onChange: (value: string) => void
darkMode: boolean
}
// PF-04: 创建主题 Compartment,用于动态切换主题而不重建 EditorView
const themeCompartment = new Compartment()
export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOptions) {
const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const isExternalUpdate = useRef(false)
const onChangeRef = useRef(onChange)
const darkModeRef = useRef(darkMode)
// 始终保持 onChangeRef 为最新回调
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
// PF-04: darkMode 变化时通过 Compartment.reconfigure() 切换主题
useEffect(() => {
darkModeRef.current = darkMode
const view = viewRef.current
if (!view) return
view.dispatch({
effects: themeCompartment.reconfigure(darkMode ? oneDark : [])
})
}, [darkMode])
// 初始化编辑器(仅在首次挂载时执行)
useEffect(() => {
if (!containerRef.current) return
const zhPhrases = EditorState.phrases.of({
'Find': '查找',
'Replace': '替换',
'next': '下一个',
'previous': '上一个',
'all': '全部',
'match case': '区分大小写',
'regexp': '正则',
'by word': '全字匹配',
'replace': '替换',
'replace all': '全部替换',
'close': '关闭',
})
const extensions: Extension[] = [
zhPhrases,
search({ top: true }),
lineNumbers(),
highlightActiveLine(),
highlightSpecialChars(),
drawSelection(),
rectangularSelection(),
history(),
indentOnInput(),
bracketMatching(),
foldGutter(),
highlightSelectionMatches(),
keymap.of([
...defaultKeymap,
...historyKeymap,
...searchKeymap,
indentWithTab
]),
markdown({ base: markdownLanguage }),
syntaxHighlighting(defaultHighlightStyle),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged && !isExternalUpdate.current) {
onChangeRef.current(update.state.doc.toString())
}
}),
themeCompartment.of(darkModeRef.current ? oneDark : [])
]
const state = EditorState.create({
doc: content,
extensions
})
const view = new EditorView({
state,
parent: containerRef.current
})
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 外部内容更新(切换标签时)
const setContent = useCallback((newContent: string) => {
const view = viewRef.current
if (!view) return
const current = view.state.doc.toString()
if (current !== newContent) {
isExternalUpdate.current = true
view.dispatch({
changes: { from: 0, to: current.length, insert: newContent }
})
isExternalUpdate.current = false
}
}, [])
const getScrollTop = useCallback((): number => {
return viewRef.current?.scrollDOM.scrollTop ?? 0
}, [])
const setScrollTop = useCallback((top: number) => {
if (viewRef.current) {
viewRef.current.scrollDOM.scrollTop = top
}
}, [])
const getSelection = useCallback((): { from: number; to: number } => {
const view = viewRef.current
if (!view) return { from: 0, to: 0 }
const ranges = view.state.selection.ranges
return { from: ranges[0].from, to: ranges[0].to }
}, [])
const setSelection = useCallback((from: number, to: number) => {
const view = viewRef.current
if (!view) return
view.dispatch({ selection: { anchor: from, head: to } })
view.focus()
}, [])
return {
containerRef,
viewRef,
setContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection
}
}
@@ -0,0 +1,159 @@
import { useCallback, useRef, useEffect } from 'react'
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core'
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
import { commonmark } from '@milkdown/preset-commonmark'
import { gfm } from '@milkdown/preset-gfm'
import { history } from '@milkdown/plugin-history'
import { listener, listenerCtx } from '@milkdown/plugin-listener'
import { indent } from '@milkdown/plugin-indent'
import { trailing } from '@milkdown/plugin-trailing'
import { clipboard } from '@milkdown/plugin-clipboard'
interface UseMilkdownOptions {
content: string
onChange: (value: string) => void
darkMode: boolean
}
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Editor | null>(null)
const onChangeRef = useRef(onChange)
const isExternalUpdate = useRef(false)
const initialContentRef = useRef(content)
// Keep onChangeRef fresh
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
// Initialize editor
useEffect(() => {
if (!containerRef.current) return
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, containerRef.current!)
ctx.set(defaultValueCtx, initialContentRef.current)
// Configure listener for content changes
const lm = ctx.get(listenerCtx)
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
if (markdown === prevMarkdown) return
if (!isExternalUpdate.current) {
onChangeRef.current(markdown)
}
})
// Listen for blur events (used for potential future state saving)
lm.blur(() => {
// blur handler placeholder
})
})
.use(commonmark)
.use(gfm)
.use(history)
.use(listener)
.use(indent)
.use(trailing)
.use(clipboard)
editor.create().then((created) => {
editorRef.current = created
})
return () => {
editorRef.current?.destroy()
editorRef.current = null
}
}, [])
// Dark mode: update CSS custom properties on the container
useEffect(() => {
const container = containerRef.current
if (!container) return
if (darkMode) {
container.classList.add('milkdown-dark')
} else {
container.classList.remove('milkdown-dark')
}
}, [darkMode])
// External content update (tab switch)
const setContent = useCallback((newContent: string) => {
const editor = editorRef.current
if (!editor) return
isExternalUpdate.current = true
try {
editor.action(milkdownReplaceAll(newContent))
} catch {
// replaceAll may fail if editor is not fully ready
} finally {
isExternalUpdate.current = false
}
}, [])
// Scroll position management
const getScrollTop = useCallback((): number => {
const container = containerRef.current
if (!container) return 0
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
return scroller?.scrollTop ?? container.scrollTop ?? 0
}, [])
const setScrollTop = useCallback((top: number) => {
const container = containerRef.current
if (!container) return
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
if (scroller) {
scroller.scrollTop = top
} else {
container.scrollTop = top
}
}, [])
// Selection management
const getSelection = useCallback((): { from: number; to: number } => {
const editor = editorRef.current
if (!editor) return { from: 0, to: 0 }
try {
return editor.action((ctx) => {
const view = ctx.get('editorView' as never) as { state?: { selection?: { from: number; to: number } } } | undefined
if (view?.state?.selection) {
return { from: view.state.selection.from, to: view.state.selection.to }
}
return { from: 0, to: 0 }
})
} catch {
return { from: 0, to: 0 }
}
}, [])
const setSelection = useCallback(() => {
// ProseMirror selection setting requires the view instance
// which we access lazily; for now we focus the editor
const container = containerRef.current
if (!container) return
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
pmEditor?.focus()
}, [])
// Execute a Milkdown action (for toolbar commands)
const action = useCallback((fn: (editor: Editor) => void) => {
const editor = editorRef.current
if (!editor) return
fn(editor)
}, [])
return {
containerRef,
editorRef,
action,
setContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection
}
}