v0.3.10: 全面优化增强 — 17项Bug修复/稳定性/功能改进

P0 致命Bug修复:
- A1: openFolderDialog 类型/运行时崩溃(文件夹打开功能完全失效)
- A2: SourceEditor 受控textarea手动改DOM反模式
- A3: tabStore.updateTabContent 内容相同时误标isModified

死代码清理:
- B1: 删除menu:*/save/as/viewMode 三个永不触发的IPC通道

健壮性修复:
- E1: rehypeFixImages 路径规范化+防越界加固
- E2: getFileName 尾斜杠返回正确文件名
- E3: EditorToolbar 行内代码按钮改用toggleInlineCodeCommand
- E4: ErrorBoundary 内联样式抽为CSS类

功能增强:
- D1: 标签页拖拽排序(tabStore.moveTab + TabBar DnD + CSS)
- D2: Milkdown自动配对括号/引号(ProseMirror插件)
- D3: 状态栏自动保存开关可点击
- D4: 文档大纲活跃标题高亮(useActiveHeading hook)
- D5: 关闭窗口前flush防抖数据(flushSaveToDB)
- D6: 搜索替换支持正则表达式

类型/架构:
- C2: preload类型集中定义(ElectronAPI契约)
- __pycache__ typo修复

文档/版本:
- README/DESIGN同步为Milkdown + v0.3.10
- 项目结构树/技术栈/快捷键表更新

验证: typecheck(仅7预存), lint, test 96/96, vite build
This commit is contained in:
2026-06-23 10:47:45 +08:00
parent 7f070eb11d
commit b65e34e288
27 changed files with 639 additions and 252 deletions
@@ -10,6 +10,7 @@ import {
wrapInBulletListCommand,
wrapInOrderedListCommand,
createCodeBlockCommand,
toggleInlineCodeCommand,
insertImageCommand,
toggleLinkCommand,
insertHrCommand
@@ -63,7 +64,7 @@ export const EditorToolbar = React.memo(function EditorToolbar({ action }: Edito
</button>
<button
className="toolbar-btn-sm"
onClick={() => exec(createCodeBlockCommand)}
onClick={() => exec(toggleInlineCodeCommand)}
title="行内代码"
aria-label="行内代码"
>
+63 -2
View File
@@ -18,6 +18,67 @@ import { Plugin, PluginKey } from '@milkdown/prose/state'
import { Decoration, DecorationSet } from '@milkdown/prose/view'
import type { EditorState } from '@milkdown/prose/state'
// --- Auto-pair plugin (D2) ---
const PAIRS: Record<string, string> = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' }
function createAutoPairPlugin(): Plugin {
return new Plugin({
props: {
handleTextInput(view, from, to, text) {
// 选中文本时用配对符号包裹
if (from !== to && PAIRS[text]) {
const close = PAIRS[text]
const { tr } = view.state
tr.insertText(text + view.state.doc.textBetween(from, to) + close, from, to)
view.dispatch(tr)
return true
}
return false
},
handleKeyDown(view, event) {
// 处理配对符号的自动闭合
const char = event.key
if (!PAIRS[char]) return false
const { state } = view
const { from, to } = state.selection
// Backspace: 删除配对符号(光标在两个配对符号中间时)
if (char === 'Backspace') {
if (from !== to || from < 2) return false
const before = state.doc.textBetween(from - 1, from)
const after = state.doc.textBetween(from, from + 1)
if (PAIRS[before] === after) {
const tr = state.tr.delete(from - 1, from + 1)
view.dispatch(tr)
return true
}
return false
}
if (from !== to) {
// 有选区时:包裹
const close = PAIRS[char]
const tr = state.tr.insertText(char + state.doc.textBetween(from, to) + close, from, to)
view.dispatch(tr)
return true
}
// 无选区:插入配对并光标置中
const close = PAIRS[char]
const tr = state.tr.insertText(char + close, from)
// 光标置于中间
tr.setSelection(
state.selection.constructor.create(tr.doc, from + 1)
)
view.dispatch(tr)
return true
}
}
})
}
// --- Search highlight plugin ---
export interface SearchMatch {
@@ -105,8 +166,8 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
ctx.set(rootCtx, containerRef.current!)
ctx.set(defaultValueCtx, initialContentRef.current)
// Inject search highlight plugin into ProseMirror plugin list
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()])
// Inject search highlight plugin and auto-pair plugin into ProseMirror plugin list
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createAutoPairPlugin(), createSearchPlugin()])
// Configure listener for content changes
const lm = ctx.get(listenerCtx)
@@ -35,52 +35,20 @@ export class ErrorBoundary extends Component<Props, State> {
return this.props.fallback
}
const isDev = (typeof import.meta !== 'undefined' && (import.meta as { env?: { DEV?: boolean } }).env?.DEV) ?? false
const isDev =
(typeof import.meta !== 'undefined' &&
(import.meta as { env?: { DEV?: boolean } }).env?.DEV) ??
false
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
padding: '2rem',
textAlign: 'center',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
</h2>
<div className="error-boundary-root" role="alert">
<h2 className="error-boundary-title"></h2>
{isDev && (
<pre
style={{
padding: '1rem',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
maxWidth: '600px',
overflow: 'auto',
fontSize: '0.875rem',
color: '#666',
}}
>
<pre className="error-boundary-detail">
{this.state.error?.message}
</pre>
)}
<button
onClick={this.handleReset}
style={{
marginTop: '1rem',
padding: '0.5rem 1.5rem',
border: 'none',
borderRadius: '6px',
backgroundColor: '#3498db',
color: 'white',
cursor: 'pointer',
fontSize: '1rem',
}}
>
<button className="error-boundary-reset" onClick={this.handleReset}>
</button>
</div>
@@ -13,13 +13,37 @@ interface SearchReplaceProps {
/**
* 在 ProseMirror 文档中查找所有匹配位置
* D6: 支持正则表达式 + 大小写敏感
*/
function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean): SearchMatch[] {
function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean, useRegex: boolean): SearchMatch[] {
const matches: SearchMatch[] = []
if (!query) return matches
const normalizedQuery = caseSensitive ? query : query.toLowerCase()
if (useRegex) {
let regex: RegExp
try {
const flags = caseSensitive ? 'g' : 'gi'
regex = new RegExp(query, flags)
} catch {
// 正则语法错误 — 返回空,UI 通过 regexError 状态提示用户
return []
}
doc.descendants((node, pos) => {
if (!node.isText) return true
const text = node.text || ''
regex.lastIndex = 0
let result: RegExpExecArray | null
while ((result = regex.exec(text)) !== null) {
matches.push({ from: pos + result.index, to: pos + result.index + result[0].length })
if (result[0].length === 0) regex.lastIndex++ // 避免空匹配死循环
}
return true
})
return matches
}
// 普通字符串匹配
const normalizedQuery = caseSensitive ? query : query.toLowerCase()
doc.descendants((node, pos) => {
if (!node.isText) return true
const text = node.text || ''
@@ -82,6 +106,8 @@ export const SearchReplace = memo(function SearchReplace({
const [replacement, setReplacement] = useState('')
const [showReplace, setShowReplace] = useState(false)
const [caseSensitive, setCaseSensitive] = useState(false)
const [useRegex, setUseRegex] = useState(false)
const [regexError, setRegexError] = useState(false)
const [matchCount, setMatchCount] = useState(0)
const [currentMatch, setCurrentMatch] = useState(-1)
@@ -92,17 +118,30 @@ export const SearchReplace = memo(function SearchReplace({
const matchesRef = useRef<SearchMatch[]>([])
const currentIndexRef = useRef(-1)
const caseSensitiveRef = useRef(false)
const useRegexRef = useRef(false)
const queryRef = useRef('')
// Sync refs
caseSensitiveRef.current = caseSensitive
useRegexRef.current = useRegex
queryRef.current = query
// 执行搜索
const doSearch = useCallback((searchQuery: string, cs: boolean) => {
const doSearch = useCallback((searchQuery: string, cs: boolean, rx: boolean) => {
const view = getView()
if (!view) return
// D6: 检查正则语法
setRegexError(false)
if (rx && searchQuery.trim()) {
try {
new RegExp(searchQuery, 'g')
} catch {
setRegexError(true)
return
}
}
if (!searchQuery.trim()) {
clearSearchDecorations(view)
matchesRef.current = []
@@ -112,7 +151,7 @@ export const SearchReplace = memo(function SearchReplace({
return
}
const matches = findMatches(view.state.doc, searchQuery, cs)
const matches = findMatches(view.state.doc, searchQuery, cs, rx)
matchesRef.current = matches
const newIdx = matches.length > 0 ? 0 : -1
currentIndexRef.current = newIdx
@@ -133,14 +172,21 @@ export const SearchReplace = memo(function SearchReplace({
const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setQuery(value)
doSearch(value, caseSensitiveRef.current)
doSearch(value, caseSensitiveRef.current, useRegexRef.current)
}, [doSearch])
// 大小写切换
const toggleCaseSensitive = useCallback(() => {
const newCS = !caseSensitiveRef.current
setCaseSensitive(newCS)
doSearch(queryRef.current, newCS)
doSearch(queryRef.current, newCS, useRegexRef.current)
}, [doSearch])
// 正则切换
const toggleRegex = useCallback(() => {
const newRx = !useRegexRef.current
setUseRegex(newRx)
doSearch(queryRef.current, caseSensitiveRef.current, newRx)
}, [doSearch])
// 导航到下一个/上一个匹配
@@ -196,7 +242,7 @@ export const SearchReplace = memo(function SearchReplace({
// Re-search after replacement (document changed)
// Use requestAnimationFrame to let ProseMirror process the transaction
requestAnimationFrame(() => {
doSearch(queryRef.current, caseSensitiveRef.current)
doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current)
})
}, [getView, replacement, doSearch])
@@ -206,7 +252,8 @@ export const SearchReplace = memo(function SearchReplace({
if (!view) return
// 重新搜索以获取匹配的最新位置
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current)
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current, useRegexRef.current)
if (freshMatches.length === 0) return
if (freshMatches.length === 0) return
// 从后往前替换以保持位置正确
@@ -221,7 +268,7 @@ export const SearchReplace = memo(function SearchReplace({
// 替换后重新搜索
requestAnimationFrame(() => {
doSearch(queryRef.current, caseSensitiveRef.current)
doSearch(queryRef.current, caseSensitiveRef.current, useRegexRef.current)
})
}, [getView, replacement, doSearch])
@@ -300,7 +347,7 @@ export const SearchReplace = memo(function SearchReplace({
aria-label="搜索文本"
/>
<span className="search-count" aria-live="polite">
{matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : ''}
{regexError ? '正则语法错误' : (matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : '')}
</span>
</div>
<button
@@ -312,6 +359,15 @@ export const SearchReplace = memo(function SearchReplace({
>
Aa
</button>
<button
className={`search-btn ${useRegex ? 'active' : ''}`}
onClick={toggleRegex}
title="使用正则表达式 (点击切换)"
aria-label="正则表达式"
aria-pressed={useRegex}
>
.*
</button>
<button
className="search-btn"
onClick={() => navigateMatch(-1)}
+20 -2
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react'
import React, { useCallback, useMemo, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils'
@@ -8,6 +8,7 @@ import { FileTree } from '../FileTree'
import { useSidebarResize } from '../../hooks/useSidebarResize'
import { useFolderOperations } from '../../hooks/useFolderOperations'
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
import { useActiveHeading } from '../../hooks/useActiveHeading'
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
import type { Heading } from '../OutlinePanel'
import { getEditorView, useEditorStore } from '../../stores/editorStore'
@@ -33,6 +34,7 @@ export const Sidebar = React.memo(function Sidebar() {
const { sidebarRef, startResize } = useSidebarResize()
const { handleOpenFolder } = useFolderOperations()
const setLoading = useEditorStore(s => s.setLoading)
const viewMode = useEditorStore(s => s.viewMode)
useAutoExpandDir(activeFilePath)
// Parse headings from active tab content
@@ -41,6 +43,22 @@ export const Sidebar = React.memo(function Sidebar() {
return parseHeadings(activeTab.content)
}, [activeTab?.content])
// D4: 追踪预览面板中的活跃标题
const previewRef = useRef<HTMLElement | null>(null)
useEffect(() => {
// 仅在预览模式时获取 preview DOM 节点
if (viewMode === 'preview') {
previewRef.current = document.getElementById('preview')
} else {
previewRef.current = null
}
}, [viewMode])
const activeHeadingIndex = useActiveHeading(
viewMode === 'preview' ? previewRef : { current: null },
headings
)
// Navigate to heading in ProseMirror document tree
const handleHeadingNavigate = useCallback((heading: Heading, index: number) => {
const view = getEditorView()
@@ -149,7 +167,7 @@ export const Sidebar = React.memo(function Sidebar() {
<OutlinePanel
headings={headings}
onNavigate={handleHeadingNavigate}
activeHeadingIndex={null}
activeHeadingIndex={activeHeadingIndex}
/>
</div>
<div
@@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react'
import React, { useCallback, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
interface SourceEditorProps {
@@ -8,6 +8,9 @@ interface SourceEditorProps {
/**
* 源码编辑模式 — 使用原生 textarea 编辑原始 Markdown 文本。
* 内容实时同步到 tabStore,与 WYSIWYG 编辑器共享同一数据源。
*
* A2: 完全受控 — 所有变更通过 updateTabContent 驱动,
* 手动写入 DOM 的反模式已移除;光标位置通过 ref + useEffect 恢复。
*/
export const SourceEditor = React.memo(function SourceEditor({ darkMode }: SourceEditorProps) {
const activeTab = useTabStore(s => s.getActiveTab())
@@ -16,6 +19,22 @@ export const SourceEditor = React.memo(function SourceEditor({ darkMode }: Sourc
const setModified = useTabStore(s => s.setModified)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// A2: 记录格式化按键后的期望光标位置,在 content 变化后恢复
const pendingSelectionRef = useRef<number | null>(null)
useEffect(() => {
if (pendingSelectionRef.current === null) return
const textarea = textareaRef.current
if (!textarea) return
const pos = pendingSelectionRef.current
// microtask:在 React 完成 DOM 更新后恢复光标
requestAnimationFrame(() => {
textarea.selectionStart = pos
textarea.selectionEnd = pos
})
pendingSelectionRef.current = null
}, [activeTab?.content])
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!activeTabId) return
updateTabContent(activeTabId, e.target.value)
@@ -24,56 +43,49 @@ export const SourceEditor = React.memo(function SourceEditor({ darkMode }: Sourc
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isCtrl = e.ctrlKey || e.metaKey
const textarea = textareaRef.current
if (!textarea || !activeTabId) return
// Tab 插入两个空格而非跳转焦点
if (e.key === 'Tab') {
e.preventDefault()
const textarea = textareaRef.current
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
const newValue = value.substring(0, start) + ' ' + value.substring(end)
textarea.value = newValue
textarea.selectionStart = textarea.selectionEnd = start + 2
if (activeTabId) {
updateTabContent(activeTabId, newValue)
setModified(activeTabId, true)
}
const newPos = start + 2
updateTabContent(activeTabId, newValue)
setModified(activeTabId, true)
pendingSelectionRef.current = newPos
return
}
// Ctrl+B: 粗体
if (isCtrl && e.key === 'b') {
e.preventDefault()
const textarea = textareaRef.current
if (!textarea || !activeTabId) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
const selected = value.substring(start, end)
const newValue = value.substring(0, start) + '**' + selected + '**' + value.substring(end)
textarea.value = newValue
textarea.selectionStart = start + 2
textarea.selectionEnd = end + 2
updateTabContent(activeTabId, newValue)
setModified(activeTabId, true)
pendingSelectionRef.current = selected ? start + 2 + selected.length + 2 : start + 2
return
}
// Ctrl+I: 斜体
if (isCtrl && e.key === 'i') {
e.preventDefault()
const textarea = textareaRef.current
if (!textarea || !activeTabId) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
const selected = value.substring(start, end)
const newValue = value.substring(0, start) + '*' + selected + '*' + value.substring(end)
textarea.value = newValue
textarea.selectionStart = start + 1
textarea.selectionEnd = end + 1
updateTabContent(activeTabId, newValue)
setModified(activeTabId, true)
pendingSelectionRef.current = selected ? start + 1 + selected.length + 1 : start + 1
return
}
}, [activeTabId, updateTabContent, setModified])
@@ -8,11 +8,13 @@ import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
interface StatusBarProps {
isAutoSaving?: boolean
autoSaveEnabled?: boolean
onToggleAutoSave?: () => void
}
export const StatusBar = React.memo(function StatusBar({
isAutoSaving = false,
autoSaveEnabled = true
autoSaveEnabled = true,
onToggleAutoSave
}: StatusBarProps) {
const activeTab = useTabStore(s => s.getActiveTab())
const loadingStates = useEditorStore(s => s.loadingStates)
@@ -58,15 +60,16 @@ export const StatusBar = React.memo(function StatusBar({
<span className="status-divider">|</span>
</>
)}
{activeTab?.filePath && autoSaveEnabled && (
{activeTab?.filePath && onToggleAutoSave !== undefined && (
<>
<span
<button
className={`status-auto-save${isAutoSaving ? ' saving' : ''}`}
title={isAutoSaving ? '正在自动保存...' : '自动保存已开启'}
aria-label={isAutoSaving ? '正在自动保存' : '自动保存'}
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
onClick={onToggleAutoSave}
>
{isAutoSaving ? '保存中...' : '自动'}
</span>
{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}
</button>
<span className="status-divider">|</span>
</>
)}
+52 -2
View File
@@ -21,9 +21,12 @@ export const TabBar = React.memo(function TabBar() {
const closeOtherTabs = useTabStore(s => s.closeOtherTabs)
const closeAllTabs = useTabStore(s => s.closeAllTabs)
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
const moveTab = useTabStore(s => s.moveTab)
const tabListRef = useRef<HTMLDivElement>(null)
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const dragTabIdRef = useRef<string | null>(null)
const { confirm, confirmDialogProps } = useConfirm()
// 滚动到活动标签
@@ -173,6 +176,46 @@ export const TabBar = React.memo(function TabBar() {
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight, confirm])
// D1: 拖拽排序事件处理
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
dragTabIdRef.current = tabId
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', tabId)
// 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获
requestAnimationFrame(() => {
const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement
el?.classList.add('dragging')
})
}, [])
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverIndex(index)
}, [])
const handleDragLeave = useCallback(() => {
setDragOverIndex(null)
}, [])
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
e.preventDefault()
setDragOverIndex(null)
const fromId = dragTabIdRef.current
if (fromId) {
moveTab(fromId, toIndex)
}
dragTabIdRef.current = null
// 清理 dragging 类
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
}, [moveTab])
const handleDragEnd = useCallback(() => {
setDragOverIndex(null)
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
dragTabIdRef.current = null
}, [])
const hasRightTabs = menu.visible && (() => {
const index = tabs.findIndex(t => t.id === menu.tabId)
return index < tabs.length - 1
@@ -184,15 +227,22 @@ export const TabBar = React.memo(function TabBar() {
<>
<div id="tab-bar">
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
{tabs.map(tab => (
{tabs.map((tab, index) => (
<div
key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''} ${dragOverIndex === index ? 'drag-over' : ''}`}
role="tab"
aria-selected={tab.id === activeTabId}
tabIndex={tab.id === activeTabId ? 0 : -1}
data-tab-id={tab.id}
draggable
onClick={() => switchToTab(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
onDragStart={(e) => handleDragStart(e, tab.id)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
>
<span className="tab-name">
{tab.filePath ? getFileName(tab.filePath) : '未命名'}