feat: 替换 textarea 为 CodeMirror 6 编辑器

新增功能:
- CodeMirror 6 专业编辑器替代原生 textarea
- 语法高亮(Markdown 专用)
- 行号(内置,精确同步)
- 代码折叠(foldGutter)
- 括号匹配
- 多光标选区(rectangularSelection)
- 内置搜索高亮(highlightSelectionMatches)
- undo/redo 原生支持(history)
- Tab 缩进(indentWithTab)
- 暗色主题(oneDark)

新增文件:
- useCodeMirror.ts: CodeMirror 初始化 Hook
- EditorToolbar.tsx: 格式化工具栏(B/I/S/标题/列表/引用/代码/链接/图片)
- Editor.tsx: 重写为 CodeMirror 组件

格式化工具栏按钮:
- 粗体/斜体/删除线/行内代码
- H1/H2/H3 标题
- 无序列表/有序列表/引用/代码块
- 链接/图片

快捷键:
- Ctrl+B 粗体
- Ctrl+I 斜体
This commit is contained in:
thzxx
2026-05-27 23:04:05 +08:00
parent b3987e9abd
commit 1cc4206b32
6 changed files with 459 additions and 305 deletions
+83 -273
View File
@@ -1,298 +1,108 @@
import React, { useEffect, useRef, useCallback, useState } from 'react'
import React, { useEffect, useCallback } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useSearchStore } from '../../stores/searchStore'
import { findMatches, buildLineStarts, getLineNumber } from '../../lib/searchEngine'
import { useCodeMirror } from './useCodeMirror'
import { EditorToolbar } from './EditorToolbar'
export function Editor() {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const lineNumbersRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const highlightRef = useRef<HTMLDivElement | null>(null)
interface EditorProps {
darkMode: boolean
}
export function Editor({ darkMode }: EditorProps) {
const activeTabId = useTabStore(s => s.activeTabId)
const tabs = useTabStore(s => s.tabs)
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
const getActiveTab = useTabStore(s => s.getActiveTab)
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
// 搜索状态
const searchIsVisible = useSearchStore(s => s.isVisible)
const searchText = useSearchStore(s => s.searchText)
const searchMatches = useSearchStore(s => s.matches)
const searchCurrentIndex = useSearchStore(s => s.currentIndex)
const searchOptions = useSearchStore(s => s.options)
const {
containerRef,
viewRef,
setContent,
getContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
scrollTo
} = useCodeMirror({
content: activeTab?.content ?? '',
onChange: useCallback((value: string) => {
if (!activeTabId) return
updateTabContent(activeTabId, value)
setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified]),
darkMode,
onScroll: useCallback((ratio: number) => {
window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { ratio } }))
}, [])
})
const [lineCount, setLineCount] = useState(1)
const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 })
const [fileSize, setFileSize] = useState('')
const updateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 行高
const lineHeightRef = useRef(20.8)
// 切换标签时加载内容
useEffect(() => {
const textarea = textareaRef.current
if (!textarea) return
const style = getComputedStyle(textarea)
const measure = document.createElement('div')
measure.style.cssText = `position:absolute;visibility:hidden;font-family:${style.fontFamily};font-size:${style.fontSize};line-height:${style.lineHeight};white-space:pre;width:0;`
measure.textContent = 'X'
document.body.appendChild(measure)
const singleHeight = measure.offsetHeight
measure.textContent = 'X\nX'
const doubleHeight = measure.offsetHeight
document.body.removeChild(measure)
lineHeightRef.current = doubleHeight - singleHeight || 20.8
}, [])
// 字符宽度
const charWidthRef = useRef(8)
useEffect(() => {
const textarea = textareaRef.current
if (!textarea) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
const style = getComputedStyle(textarea)
ctx.font = `${style.fontSize} ${style.fontFamily}`
charWidthRef.current = ctx.measureText('M').width
}, [])
// 加载标签内容
useEffect(() => {
const tab = getActiveTab()
const textarea = textareaRef.current
if (!tab || !textarea) return
textarea.value = tab.content
setLineCount((tab.content.match(/\n/g) || []).length + 1)
const bytes = new Blob([tab.content]).size
setFileSize(bytes < 1024 ? bytes + ' B' : bytes < 1048576 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1048576).toFixed(1) + ' MB')
if (!activeTab) return
setContent(activeTab.content)
// 恢复滚动和选区
requestAnimationFrame(() => {
textarea.scrollTop = tab.scrollTop
textarea.scrollLeft = tab.scrollLeft
textarea.setSelectionRange(tab.selectionStart, tab.selectionEnd)
if (lineNumbersRef.current) lineNumbersRef.current.scrollTop = tab.scrollTop
setScrollTop(activeTab.scrollTop)
setSelection(activeTab.selectionStart, activeTab.selectionEnd)
})
}, [activeTabId, getActiveTab])
}, [activeTabId])
// 更新行号
const updateLineNumbers = useCallback((content: string) => {
setLineCount((content.match(/\n/g) || []).length + 1)
}, [])
// 输入事件
const handleInput = useCallback(() => {
const textarea = textareaRef.current
const tab = getActiveTab()
if (!textarea || !tab) return
const content = textarea.value
updateTabContent(tab.id, content)
setModified(tab.id, true)
updateLineNumbers(content)
const bytes = new Blob([content]).size
setFileSize(bytes < 1024 ? bytes + ' B' : bytes < 1048576 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1048576).toFixed(1) + ' MB')
}, [getActiveTab, updateTabContent, setModified, updateLineNumbers])
// 滚动同步
const handleScroll = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
if (lineNumbersRef.current) {
lineNumbersRef.current.scrollTop = textarea.scrollTop
}
// 更新搜索高亮位置
if (highlightRef.current) {
highlightRef.current.style.top = -textarea.scrollTop + 'px'
}
const maxScroll = textarea.scrollHeight - textarea.clientHeight
const ratio = maxScroll > 0 ? textarea.scrollTop / maxScroll : 0
window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { ratio } }))
}, [])
// 搜索:执行搜索并更新 matches
const activeContent = activeTab?.content
useEffect(() => {
if (!searchIsVisible || !searchText) {
if (highlightRef.current) highlightRef.current.innerHTML = ''
return
}
const textarea = textareaRef.current
if (!textarea) return
const matches = findMatches(textarea.value, searchText, searchOptions)
useSearchStore.getState().setMatches(matches)
renderHighlights(textarea.value, matches, searchCurrentIndex)
}, [searchIsVisible, searchText, searchOptions, activeContent])
// 搜索:currentIndex 变化时滚动到匹配位置
useEffect(() => {
if (searchCurrentIndex < 0 || searchMatches.length === 0) return
const textarea = textareaRef.current
if (!textarea) return
const m = searchMatches[searchCurrentIndex]
if (!m) return
// 选中匹配文本
textarea.focus()
textarea.setSelectionRange(m.start, m.end)
// 滚动到匹配位置
const lineStarts = buildLineStarts(textarea.value)
const line = getLineNumber(lineStarts, m.start)
const lineHeight = lineHeightRef.current
const matchTop = line * lineHeight
const viewTop = textarea.scrollTop
const viewBottom = viewTop + textarea.clientHeight
if (matchTop < viewTop + 40 || matchTop > viewBottom - 40) {
textarea.scrollTop = matchTop - textarea.clientHeight / 2
}
// 重新渲染高亮
renderHighlights(textarea.value, searchMatches, searchCurrentIndex)
}, [searchCurrentIndex, searchMatches])
// 渲染搜索高亮 overlay
const renderHighlights = useCallback((content: string, matches: Array<{start: number; end: number}>, currentIndex: number) => {
const wrapper = wrapperRef.current
const textarea = textareaRef.current
if (!wrapper || !textarea) return
// 创建或复用 overlay
if (!highlightRef.current) {
const overlay = document.createElement('div')
overlay.id = 'search-highlights'
overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;height:' + textarea.scrollHeight + 'px;pointer-events:none;overflow:visible;z-index:1;'
wrapper.style.position = 'relative'
wrapper.appendChild(overlay)
highlightRef.current = overlay
}
const overlay = highlightRef.current
overlay.style.top = -textarea.scrollTop + 'px'
overlay.style.height = textarea.scrollHeight + 'px'
overlay.innerHTML = ''
if (matches.length === 0) return
const style = getComputedStyle(textarea)
const paddingTop = parseFloat(style.paddingTop)
const paddingLeft = parseFloat(style.paddingLeft)
const lineHeight = lineHeightRef.current
const charWidth = charWidthRef.current
const lineStarts = buildLineStarts(content)
for (let i = 0; i < matches.length; i++) {
const m = matches[i]
const startLine = getLineNumber(lineStarts, m.start)
const startCol = m.start - lineStarts[startLine]
const endLine = getLineNumber(lineStarts, m.end)
const endCol = m.end - lineStarts[endLine]
if (startLine === endLine) {
// 单行匹配
const div = document.createElement('div')
div.className = 'search-hl' + (i === currentIndex ? ' current' : '')
div.style.cssText = `position:absolute;top:${startLine * lineHeight + paddingTop}px;left:${startCol * charWidth + paddingLeft}px;height:${lineHeight}px;width:${Math.max((endCol - startCol) * charWidth, 8)}px;`
overlay.appendChild(div)
} else {
// 多行匹配:第一行
const div1 = document.createElement('div')
div1.className = 'search-hl' + (i === currentIndex ? ' current' : '')
const firstLineWidth = textarea.clientWidth - (startCol * charWidth + paddingLeft) - 20
div1.style.cssText = `position:absolute;top:${startLine * lineHeight + paddingTop}px;left:${startCol * charWidth + paddingLeft}px;height:${lineHeight}px;width:${firstLineWidth}px;`
overlay.appendChild(div1)
// 中间行
for (let l = startLine + 1; l < endLine; l++) {
const divM = document.createElement('div')
divM.className = 'search-hl' + (i === currentIndex ? ' current' : '')
divM.style.cssText = `position:absolute;top:${l * lineHeight + paddingTop}px;left:${paddingLeft}px;height:${lineHeight}px;width:${textarea.clientWidth - paddingLeft * 2}px;`
overlay.appendChild(divM)
}
// 最后一行
const div2 = document.createElement('div')
div2.className = 'search-hl' + (i === currentIndex ? ' current' : '')
div2.style.cssText = `position:absolute;top:${endLine * lineHeight + paddingTop}px;left:${paddingLeft}px;height:${lineHeight}px;width:${endCol * charWidth}px;`
overlay.appendChild(div2)
}
}
}, [])
// Tab 键
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Tab') {
e.preventDefault()
const textarea = textareaRef.current
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
if (start !== end) {
const lines = textarea.value.substring(start, end).split('\n')
const indented = lines.map(line => ' ' + line).join('\n')
document.execCommand('insertText', false, indented)
} else {
document.execCommand('insertText', false, ' ')
}
}
if (e.key === 'Enter' && searchIsVisible) {
e.preventDefault()
const state = useSearchStore.getState()
if (e.shiftKey) state.findPrev()
else state.findNext()
}
}, [searchIsVisible])
// 更新光标位置
const updateCursorPos = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
const text = textarea.value
const pos = textarea.selectionStart
const textBefore = text.substring(0, pos)
const lines = textBefore.split('\n')
setCursorPos({ line: lines.length, col: lines[lines.length - 1].length + 1 })
}, [])
// 渲染行号
const renderLineNumbers = () => {
const lineHeight = lineHeightRef.current
return Array.from({ length: lineCount }, (_, i) => (
<div key={i} className="line-num" style={{ height: lineHeight }}>
{i + 1}
</div>
))
}
// 清理 overlay
// 保存当前标签状态(切换前)
useEffect(() => {
return () => {
if (highlightRef.current) {
highlightRef.current.remove()
highlightRef.current = null
if (!activeTabId) return
updateTabScroll(activeTabId, {
scrollTop: getScrollTop(),
selectionStart: getSelection().from,
selectionEnd: getSelection().to
})
}
}, [activeTabId])
// 搜索:Ctrl+F 聚焦搜索框,Enter 导航
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
// Ctrl+B 粗体
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 }
})
}
// Ctrl+I 斜体
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 }
})
}
}
}, [])
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [viewRef])
return (
<div id="editor-wrapper" ref={wrapperRef}>
<div id="line-numbers" ref={lineNumbersRef}>
{renderLineNumbers()}
</div>
<textarea
ref={textareaRef}
id="editor"
spellCheck={false}
placeholder="在此输入 Markdown 内容,或拖拽 .md 文件到窗口打开..."
onInput={handleInput}
onScroll={handleScroll}
onClick={updateCursorPos}
onKeyUp={updateCursorPos}
onKeyDown={handleKeyDown}
/>
<div className="editor-container">
<EditorToolbar viewRef={viewRef} />
<div ref={containerRef} className="codemirror-wrapper" />
</div>
)
}
@@ -0,0 +1,107 @@
import React, { useCallback } from 'react'
import type { EditorView } from '@codemirror/view'
interface EditorToolbarProps {
viewRef: React.MutableRefObject<EditorView | null>
}
export 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 }
})
view.focus()
}, [viewRef])
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])
return (
<div className="editor-toolbar">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)">
<strong>B</strong>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)">
<em>I</em>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线">
<s>S</s>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码">
{'</>'}
</button>
<div className="toolbar-divider-sm" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题">
H1
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题">
H2
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题">
H3
</button>
<div className="toolbar-divider-sm" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表">
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表">
1.
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用">
</button>
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块">
{'{ }'}
</button>
<div className="toolbar-divider-sm" />
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接">
🔗
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('![', '](url)', '图片描述')} title="图片">
🖼
</button>
</div>
)
}
@@ -0,0 +1,148 @@
import { useEffect, useRef, useCallback } from 'react'
import { EditorState, 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 { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { oneDark } from '@codemirror/theme-one-dark'
interface UseCodeMirrorOptions {
content: string
onChange: (value: string) => void
darkMode: boolean
onScroll?: (ratio: number) => void
}
export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCodeMirrorOptions) {
const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const isExternalUpdate = useRef(false)
// 初始化编辑器
useEffect(() => {
if (!containerRef.current) return
const extensions: Extension[] = [
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) {
onChange(update.state.doc.toString())
}
})
]
if (darkMode) extensions.push(oneDark)
const state = EditorState.create({
doc: content,
extensions
})
const view = new EditorView({
state,
parent: containerRef.current
})
viewRef.current = view
// 滚动事件监听
const handleScroll = () => {
if (onScroll) {
const dom = view.scrollDOM
const maxScroll = dom.scrollHeight - dom.clientHeight
const ratio = maxScroll > 0 ? dom.scrollTop / maxScroll : 0
onScroll(ratio)
}
}
view.scrollDOM.addEventListener('scroll', handleScroll)
return () => {
view.scrollDOM.removeEventListener('scroll', handleScroll)
view.destroy()
viewRef.current = null
}
}, [darkMode]) // darkMode 变化时重建
// 外部内容更新(切换标签时)
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 getContent = useCallback(() => {
return viewRef.current?.state.doc.toString() ?? ''
}, [])
// 获取/设置滚动位置
const getScrollTop = useCallback(() => {
return viewRef.current?.scrollDOM.scrollTop ?? 0
}, [])
const setScrollTop = useCallback((top: number) => {
if (viewRef.current) {
viewRef.current.scrollDOM.scrollTop = top
}
}, [])
// 获取/设置选区
const getSelection = useCallback(() => {
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()
}, [])
// 滚动到指定位置
const scrollTo = useCallback((pos: number) => {
const view = viewRef.current
if (!view) return
view.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) })
}, [])
return {
containerRef,
viewRef,
setContent,
getContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
scrollTo
}
}