v0.2.0: 全面代码质量优化

- ESLint flat config + Prettier + EditorConfig
- Markdown处理器LRU缓存
- Zustand选择器优化减少重渲染
- CodeMirror Compartment主题热切换
- Preview防抖(150ms) + IndexedDB去抖(500ms)
- 组件拆分: App.tsx 310→104行, Sidebar.tsx 244→90行
- 统一错误处理 errorHandler.ts
- ConfirmDialog替代原生confirm
- LoadingSpinner加载状态
- Toast多条堆叠+类型区分
- 可访问性增强(ARIA属性、键盘导航)
- Vitest测试框架(78个测试用例)
- Git hooks(husky + lint-staged)
- 项目文档(README.md, CONTRIBUTING.md)
This commit is contained in:
thzxx
2026-06-03 22:13:32 +08:00
parent 3cecb0f9eb
commit 7a4e2b0a67
72 changed files with 5103 additions and 894 deletions
+2 -3
View File
@@ -8,9 +8,8 @@ interface EditorProps {
}
export function Editor({ darkMode }: EditorProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId)
const tabs = useTabStore(s => s.tabs)
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
@@ -94,4 +93,4 @@ export function Editor({ darkMode }: EditorProps) {
)
}
Editor.displayName = 'Editor'
Editor.displayName = 'Editor'
@@ -5,16 +5,14 @@ interface EditorToolbarProps {
viewRef: React.MutableRefObject<EditorView | null>
}
export function EditorToolbar({ viewRef }: EditorToolbarProps) {
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 }
@@ -25,12 +23,9 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
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: '' }
@@ -46,11 +41,9 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
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 }
@@ -59,49 +52,51 @@ export function EditorToolbar({ viewRef }: EditorToolbarProps) {
}, [viewRef])
return (
<div className="editor-toolbar">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)">
<div className="editor-toolbar" role="toolbar" aria-label="Markdown格式工具">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('**', '**', '粗体')} title="粗体 (Ctrl+B)" aria-label="粗体">
<strong>B</strong>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('*', '*', '斜体')} title="斜体 (Ctrl+I)" aria-label="斜体">
<em>I</em>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('~~', '~~', '删除线')} title="删除线" aria-label="删除线">
<s>S</s>
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('`', '`', '代码')} title="行内代码" aria-label="行内代码">
{'</>'}
</button>
<div className="toolbar-divider-sm" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题">
<div className="toolbar-divider-sm" role="separator" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('# ')} title="标题" aria-label="一级标题">
H1
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题">
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('## ')} title="二级标题" aria-label="二级标题">
H2
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题">
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('### ')} title="三级标题" aria-label="三级标题">
H3
</button>
<div className="toolbar-divider-sm" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表">
<div className="toolbar-divider-sm" role="separator" />
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('- ')} title="无序列表" aria-label="无序列表">
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表">
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('1. ')} title="有序列表" aria-label="有序列表">
1.
</button>
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用">
<button className="toolbar-btn-sm" onClick={() => insertLinePrefix('> ')} title="引用" aria-label="引用">
</button>
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块">
<button className="toolbar-btn-sm" onClick={() => insertBlock('```\n代码\n```')} title="代码块" aria-label="代码块">
{'{ }'}
</button>
<div className="toolbar-divider-sm" />
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接">
<div className="toolbar-divider-sm" role="separator" />
<button className="toolbar-btn-sm" onClick={() => insertFormatting('[', '](url)', '链接文本')} title="链接" aria-label="插入链接">
🔗
</button>
<button className="toolbar-btn-sm" onClick={() => insertFormatting('![', '](url)', '图片描述')} title="图片">
<button className="toolbar-btn-sm" onClick={() => insertFormatting('![', '](url)', '图片描述')} title="图片" aria-label="插入图片">
🖼
</button>
</div>
)
}
})
EditorToolbar.displayName = 'EditorToolbar'
+25 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useCallback } from 'react'
import { EditorState, type Extension } from '@codemirror/state'
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'
@@ -13,22 +13,35 @@ interface UseCodeMirrorOptions {
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': '替换',
@@ -65,15 +78,14 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
markdown({ base: markdownLanguage }),
syntaxHighlighting(defaultHighlightStyle),
EditorView.lineWrapping,
EditorView.updateListener.of(update => {
EditorView.updateListener.of((update) => {
if (update.docChanged && !isExternalUpdate.current) {
onChangeRef.current(update.state.doc.toString())
}
})
}),
themeCompartment.of(darkModeRef.current ? oneDark : [])
]
if (darkMode) extensions.push(oneDark)
const state = EditorState.create({
doc: content,
extensions
@@ -90,7 +102,8 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
view.destroy()
viewRef.current = null
}
}, [darkMode]) // darkMode 变化时重建
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 外部内容更新(切换标签时)
const setContent = useCallback((newContent: string) => {
@@ -105,8 +118,8 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
isExternalUpdate.current = false
}
}, [])
// 获取/设置滚动位置
const getScrollTop = useCallback(() => {
const getScrollTop = useCallback((): number => {
return viewRef.current?.scrollDOM.scrollTop ?? 0
}, [])
@@ -116,8 +129,7 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
}
}, [])
// 获取/设置选区
const getSelection = useCallback(() => {
const getSelection = useCallback((): { from: number; to: number } => {
const view = viewRef.current
if (!view) return { from: 0, to: 0 }
const ranges = view.state.selection.ranges
@@ -130,6 +142,7 @@ export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOpti
view.dispatch({ selection: { anchor: from, head: to } })
view.focus()
}, [])
return {
containerRef,
viewRef,