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
+12 -23
View File
@@ -1,12 +1,14 @@
import { contextBridge, ipcRenderer, shell } from 'electron'
import { IPC_CHANNELS } from '../shared/ipc-channels'
import type { ElectronAPI } from '../renderer/types/ipc'
contextBridge.exposeInMainWorld('electronAPI', {
// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
const api: ElectronAPI = {
// File operations
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
saveFileAs: (data: { content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
@@ -37,39 +39,26 @@ contextBridge.exposeInMainWorld('electronAPI', {
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
// Events from main process — 返回取消订阅函数
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => {
onFileOpenInTab: (callback) => {
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data)
ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) }
},
onMenuSave: (callback: () => void) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.MENU_SAVE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE, handler) }
},
onMenuSaveAs: (callback: () => void) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.MENU_SAVE_AS, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE_AS, handler) }
},
onViewModeChange: (callback: (mode: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, mode: string) => callback(mode)
ipcRenderer.on(IPC_CHANNELS.MENU_VIEW_MODE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_VIEW_MODE, handler) }
},
onExternalModification: (callback: (filePath: string) => void) => {
onExternalModification: (callback) => {
const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) }
},
onDirChanged: (callback: () => void) => {
onDirChanged: (callback) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) }
},
onConfirmClose: (callback: () => void) => {
onConfirmClose: (callback) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) }
}
})
}
contextBridge.exposeInMainWorld('electronAPI', api)
+4 -48
View File
@@ -1,55 +1,11 @@
/**
* DX-02: Preload 类型安全声明
* 为 window.electronAPI 提供完整的 TypeScript 类型定义,
* 确保渲染进程访问 API 时有完整的类型提示和编译时检查。
* ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
* preload/index.ts 引入该类型做编译期契约检查。
* 本文件仅保留全局 Window 增强声明。
*/
import type {
OpenFileResponse,
ReadFileResult,
SaveFilePayload,
SaveAsPayload,
SaveFileResult,
ReloadFileResult,
FileStatsResult,
ReadDirTreeResult,
} from '../shared/types'
interface ElectronAPI {
// File operations
openFile: () => Promise<OpenFileResponse>
readFile: (filePath: string) => Promise<ReadFileResult>
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
getCurrentPath: () => Promise<string | null>
getFileStats: (filePath: string) => Promise<FileStatsResult>
reloadFile: () => Promise<ReloadFileResult>
// Tab management
tabSwitched: (filePath: string | null) => Promise<void>
// Window control
forceClose: () => Promise<void>
cancelClose: () => Promise<void>
// Shell
openExternal: (url: string) => void
// File Tree (Sidebar)
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
// Events from main process — 返回取消订阅函数
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => () => void
onMenuSave: (callback: () => void) => () => void
onMenuSaveAs: (callback: () => void) => () => void
onViewModeChange: (callback: (mode: string) => void) => () => void
onExternalModification: (callback: (filePath: string) => void) => () => void
onDirChanged: (callback: () => void) => () => void
onConfirmClose: (callback: () => void) => () => void
}
import type { ElectronAPI } from '../renderer/types/ipc'
declare global {
interface Window {
+5 -4
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useTabStore } from './stores/tabStore'
import { flushSaveToDB } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useTheme } from './hooks/useTheme'
import { useSettings } from './hooks/useSettings'
@@ -51,7 +52,7 @@ export function App() {
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
useDragDrop(showToast)
useFileWatch()
const { isAutoSaving, autoSaveEnabled } = useAutoSave()
const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
// UX-01: 传入 confirm 函数替代原生 confirm()
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
@@ -64,9 +65,9 @@ export function App() {
})
}, [confirm])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose)
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB)
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners(handleSave, handleSaveAs)
useIpcListeners()
useEffect(() => {
if (!window.electronAPI) return
@@ -117,7 +118,7 @@ export function App() {
)}
</div>
</div>
<StatusBar isAutoSaving={isAutoSaving} autoSaveEnabled={autoSaveEnabled} />
<StatusBar isAutoSaving={isAutoSaving} autoSaveEnabled={autoSaveEnabled} onToggleAutoSave={toggleAutoSave} />
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
@@ -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) : '未命名'}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState, useCallback } from 'react'
/**
* D4: 基于视口的活跃标题追踪 hook
* 在 preview 模式下监听目标容器的滚动事件,
* 根据文档中标题元素的 offsetTop 判断当前可见的标题索引。
*
* 仅在目标容器 ref 存在时生效(preview 面板挂载后)。
*
* @param containerRef - 包含 Markdown 渲染结果的 DOM 元素 ref
* @param headings - 解析出的标题列表
* @returns - 当前活跃标题的索引(null 表示无法判定或不在范围内)
*/
export function useActiveHeading(
containerRef: React.RefObject<HTMLElement | null>,
headings: { level: number; text: string }[]
): number | null {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const handleScroll = useCallback(() => {
const container = containerRef.current
if (!container || headings.length === 0) {
setActiveIndex(null)
return
}
// 收集容器内所有 h1-h6 元素的 offsetTop
const headingElements = Array.from(
container.querySelectorAll('h1, h2, h3, h4, h5, h6')
) as HTMLElement[]
if (headingElements.length === 0) {
setActiveIndex(null)
return
}
const scrollTop = container.scrollTop
const containerHeight = container.clientHeight
const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达"
let bestIndex: number | null = null
for (let i = 0; i < headingElements.length; i++) {
const el = headingElements[i]
// 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window
const top = el.offsetTop - (container.offsetTop || 0)
if (top <= threshold) {
// 找到 headings 中匹配的索引
const text = el.textContent?.trim() ?? ''
const matchIdx = headings.findIndex(
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level)
)
if (matchIdx >= 0) bestIndex = matchIdx
}
}
setActiveIndex(bestIndex)
}, [containerRef, headings])
useEffect(() => {
const container = containerRef.current
if (!container || headings.length === 0) {
setActiveIndex(null)
return
}
// 监听滚动事件
container.addEventListener('scroll', handleScroll, { passive: true })
// 初始计算
handleScroll()
const observer = observerRef.current
return () => {
container.removeEventListener('scroll', handleScroll)
observer?.disconnect()
}
}, [containerRef, headings, handleScroll])
return activeIndex
}
+12 -15
View File
@@ -15,22 +15,19 @@ export function useFolderOperations() {
const handleOpenFolder = useCallback(async () => {
if (!window.electronAPI) return
const result = await window.electronAPI.openFolderDialog()
if (!result) return
if (!result.canceled && result.filePaths[0]) {
const dirPath = result.filePaths[0]
setRootPath(dirPath)
expandDirs([dirPath])
setLoading('dir-load', true)
try {
const dirTree = await window.electronAPI.readDirTree(dirPath)
if (dirTree.success && dirTree.tree) {
setTree(dirTree.tree)
window.electronAPI.watchDir(dirPath)
}
} finally {
setLoading('dir-load', false)
const dirPath = await window.electronAPI.openFolderDialog()
if (!dirPath) return
setRootPath(dirPath)
expandDirs([dirPath])
setLoading('dir-load', true)
try {
const dirTree = await window.electronAPI.readDirTree(dirPath)
if (dirTree.success && dirTree.tree) {
setTree(dirTree.tree)
window.electronAPI.watchDir(dirPath)
}
} finally {
setLoading('dir-load', false)
}
}, [setRootPath, setTree, expandDirs, setLoading])
+4 -11
View File
@@ -1,17 +1,13 @@
import { useEffect, useRef } from 'react'
import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
/**
* 主进程事件注册 hook
* 处理菜单触发的文件打开、保存、另存为事
* 处理通过命令行或文件关联打开的文
*/
export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void) {
export function useIpcListeners() {
const createTab = useTabStore(s => s.createTab)
const handleSaveRef = useRef(handleSave)
const handleSaveAsRef = useRef(handleSaveAs)
handleSaveRef.current = handleSave
handleSaveAsRef.current = handleSaveAs
useEffect(() => {
if (!window.electronAPI) return
@@ -21,9 +17,6 @@ export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void
if (data.filePath) recentFilesRepository.add(data.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
const unsub1 = api.onFileOpenInTab(onOpen)
const unsub2 = api.onMenuSave(() => handleSaveRef.current())
const unsub3 = api.onMenuSaveAs(() => handleSaveAsRef.current())
return () => { unsub1(); unsub2(); unsub3() }
return api.onFileOpenInTab(onOpen)
}, [createTab])
}
+7 -1
View File
@@ -6,10 +6,14 @@ import { useEffect, useCallback, useRef } from 'react'
*/
export function useUnsavedWarning(
hasUnsaved: () => boolean,
confirmFn?: (message: string) => Promise<boolean>
confirmFn?: (message: string) => Promise<boolean>,
// D5: 关闭前回调(flush 待保存数据)
onBeforeForceClose?: () => Promise<void>
) {
const confirmFnRef = useRef(confirmFn)
confirmFnRef.current = confirmFn
const onBeforeForceCloseRef = useRef(onBeforeForceClose)
onBeforeForceCloseRef.current = onBeforeForceClose
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
if (confirmFnRef.current) {
@@ -35,11 +39,13 @@ export function useUnsavedWarning(
const unsubscribe = api.onConfirmClose(async () => {
if (!hasUnsaved()) {
await onBeforeForceCloseRef.current?.()
api.forceClose()
return
}
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
if (shouldClose) {
await onBeforeForceCloseRef.current?.()
api.forceClose()
} else {
api.cancelClose()
+6 -2
View File
@@ -16,8 +16,12 @@ describe('fileUtils', () => {
})
it('should handle path with trailing slash', () => {
// getFileName uses pop() || filePath, empty string is falsy so returns filePath
expect(getFileName('/path/to/dir/')).toBe('/path/to/dir/')
expect(getFileName('/path/to/dir/')).toBe('dir')
})
it('should handle path with trailing backslash', () => {
expect(getFileName('C:\\Users\\test\\')).toBe('test')
})
it('should handle mixed path separators', () => {
+3 -1
View File
@@ -1,7 +1,9 @@
import { ALLOWED_EXTENSIONS } from './constants'
export function getFileName(filePath: string): string {
return filePath.split(/[/\\]/).pop() || filePath
// E2: 先剔除尾部分隔符再取最后段,避免 /a/b/ 返回 /a/b/
const clean = filePath.replace(/[/\\]+$/, '')
return clean.split(/[/\\]/).pop() || clean || filePath
}
export function isAllowedFile(filePath: string): boolean {
+11 -13
View File
@@ -9,9 +9,13 @@ import rehypeHighlight from 'rehype-highlight'
import type { Element, Root } from 'hast'
// 简单的路径解析(Electron renderer 中没有 path 模块)
// E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
function normPath(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+$/, '')
}
function resolveRelativePath(base: string, rel: string): string {
const sep = base.includes('\\') ? '\\' : '/'
const parts = base.split(sep)
const parts = normPath(base).split('/')
parts.pop() // 移除文件名
const relParts = rel.split('/')
for (const p of relParts) {
@@ -22,7 +26,7 @@ function resolveRelativePath(base: string, rel: string): string {
parts.push(p)
}
}
return parts.join(sep)
return parts.join('/')
}
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
@@ -31,8 +35,6 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
if (!filePath) return
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
const sep: string = dir.includes('\\') ? '\\' : '/'
const normalizedDir = dir + sep
function visit(node: Element | Root): void {
if (!node.children) return
@@ -50,16 +52,12 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
}
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
const resolvedPath = resolveRelativePath(dir, src)
const normalizedResolved = resolvedPath.includes('\\')
? resolvedPath.replace(/\\/g, '/') + '/'
: resolvedPath + '/'
const normalizedBase = normalizedDir.includes('\\')
? normalizedDir.replace(/\\/g, '/')
: normalizedDir
if (!normalizedResolved.startsWith(normalizedBase + '/')) return
// E1: 统一规范化后比较,防止 ../ 路径越界
const normalizedBase = normPath(dir)
if (!normPath(resolvedPath).startsWith(normalizedBase)) return
child.properties = {
...child.properties,
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
src: 'file://' + (normPath(dir) + '/' + src).replace(/\/+/g, '/')
}
}
}
+67 -1
View File
@@ -248,4 +248,70 @@ describe('tabStore', () => {
expect(updated.selectionEnd).toBe(20)
})
})
})
describe('updateTabContent same-content guard (A3)', () => {
it('should not mark as modified when content is unchanged', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Same Content')
updateTabContent(tab.id, '# Same Content')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# Same Content')
expect(updated.isModified).toBe(false)
})
it('should mark as modified when content changes', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Old')
updateTabContent(tab.id, '# New')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# New')
expect(updated.isModified).toBe(true)
})
})
describe('moveTab (D1)', () => {
it('should move tab to a new position', () => {
const { createTab, moveTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md')
const tab3 = createTab('/file3.md')
// tab1 移到末尾:期望 [tab2, tab3, tab1]
moveTab(tab1.id, 2)
const state = useTabStore.getState()
// 用引用相等而非基于全局计数器 ID
expect(state.tabs[0]).toBe(tab2)
expect(state.tabs[1]).toBe(tab3)
expect(state.tabs[2]).toBe(tab1)
})
it('should handle moving to same position (no-op)', () => {
const { createTab, moveTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
moveTab(tab1.id, 0)
const state = useTabStore.getState()
expect(state.tabs[0].id).toBe(tab1.id)
expect(state.tabs.length).toBe(2)
})
it('should handle moving non-existent tab gracefully', () => {
const { createTab, moveTab } = useTabStore.getState()
createTab('/file1.md')
moveTab('non-existent', 0)
const state = useTabStore.getState()
expect(state.tabs.length).toBe(1)
})
})
})
+28 -3
View File
@@ -33,6 +33,7 @@ interface TabState {
closeOtherTabs: (tabId: string) => void
closeAllTabs: () => void
closeTabsToRight: (tabId: string) => void
moveTab: (fromId: string, toIndex: number) => void
switchToTab: (tabId: string) => void
updateTabContent: (tabId: string, content: string) => void
setModified: (tabId: string, modified: boolean) => void
@@ -203,6 +204,21 @@ export const useTabStore = create<TabState>((set, get) => {
_debouncedSaveToDB?.()
},
// D1: 拖拽排序 — 将 fromId 标签移动到新的数组位置
moveTab: (fromId: string, toIndex: number) => {
set(state => {
const fromIndex = state.tabs.findIndex(t => t.id === fromId)
if (fromIndex === -1 || fromIndex === toIndex) return state
const newTabs = [...state.tabs]
const [moved] = newTabs.splice(fromIndex, 1)
// 移除后直接插入目标位置即可(splice 在移除元素后数组已缩短,
// toIndex 相对于原数组的位置在移除后无需调整)
newTabs.splice(toIndex, 0, moved)
return { tabs: newTabs }
})
_debouncedSaveToDB?.()
},
switchToTab: (tabId: string) => {
set(state => {
if (state.activeTabId === tabId) return state
@@ -216,9 +232,12 @@ export const useTabStore = create<TabState>((set, get) => {
updateTabContent: (tabId: string, content: string) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, content, isModified: true } : t
)
tabs: state.tabs.map(t => {
if (t.id !== tabId) return t
// A3: 内容未变时不变更对象引用(避免误标记 isModified
if (t.content === content) return t
return { ...t, content, isModified: true }
})
}))
},
@@ -244,3 +263,9 @@ export const useTabStore = create<TabState>((set, get) => {
}
}
})
// D5: 立即 flush 待保存的标签状态到 IndexedDB(取消防抖后同步写入)
export function flushSaveToDB(): Promise<void> {
_debouncedSaveToDB?.cancel()
return getActualSaveToDB(useTabStore.getState)()
}
+76
View File
@@ -525,6 +525,15 @@ body {
margin: 4px 0;
}
/* D1: 标签拖拽排序样式 */
.tab-item.dragging {
opacity: 0.4;
}
.tab-item.drag-over {
border-left: 2px solid var(--primary);
}
/* Modified Banner */
#modified-banner {
display: flex;
@@ -604,6 +613,15 @@ body {
font-size: 11px;
color: var(--text-tertiary);
transition: color 0.2s ease;
border: none;
background: transparent;
cursor: pointer;
font-family: var(--font-ui);
padding: 0;
}
.status-auto-save:hover {
color: var(--primary);
}
.status-auto-save.saving {
@@ -1521,6 +1539,56 @@ button:focus-visible,
border: 0;
}
/* ===== ErrorBoundary ===== */
.error-boundary-root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 2rem;
text-align: center;
font-family: var(--font-ui);
background: var(--bg);
color: var(--text);
}
.error-boundary-title {
margin-bottom: 1rem;
color: #e74c3c;
font-size: 1.25rem;
font-weight: 600;
}
.error-boundary-detail {
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
max-width: 600px;
overflow: auto;
font-size: 0.875rem;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.error-boundary-reset {
margin-top: 1rem;
padding: 0.5rem 1.5rem;
border: none;
border-radius: 6px;
background: var(--primary);
color: white;
cursor: pointer;
font-size: 1rem;
font-family: var(--font-ui);
transition: background 0.15s ease;
}
.error-boundary-reset:hover {
background: var(--primary-dark);
}
/* ===== Search & Replace Panel ===== */
.search-replace-panel {
@@ -1590,6 +1658,14 @@ button:focus-visible,
white-space: nowrap;
}
/* D6: 正则语法错误提示 */
.search-input-group .search-count {
color: #c5221f;
}
.search-input-group .search-count:not(:empty) {
/* 仅当有正则错误时变红 */
}
.search-btn {
display: flex;
align-items: center;
+2 -7
View File
@@ -21,7 +21,7 @@ export interface IpcInvokeMap {
'window:forceClose': [void, void]
'window:cancelClose': [void, void]
'dir:readTree': [string, ReadDirTreeResult]
'dir:openDialog': [void, { canceled: boolean; filePaths: string[] }]
'dir:openDialog': [void, string | null]
'dir:watch': [string, void]
'dir:unwatch': [void, void]
}
@@ -41,16 +41,11 @@ export interface ElectronAPI {
cancelClose: () => Promise<void>
openExternal: (url: string) => void
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
openFolderDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>
openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
onMenuSave: (callback: () => void) => Unsubscribe
onMenuSaveAs: (callback: () => void) => Unsubscribe
onViewModeChange: (callback: (mode: string) => void) => Unsubscribe
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
onDirChanged: (callback: () => void) => Unsubscribe
onConfirmClose: (callback: () => void) => Unsubscribe
}
+1 -1
View File
@@ -1,5 +1,5 @@
// 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.3.8'
export const APP_VERSION = 'v0.3.10'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([
-3
View File
@@ -19,9 +19,6 @@ export const IPC_CHANNELS = {
// 主进程 → 渲染进程 (send)
FILE_OPEN_IN_TAB: 'file:openInTab',
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
MENU_SAVE: 'menu:save',
MENU_SAVE_AS: 'menu:saveAs',
MENU_VIEW_MODE: 'menu:viewMode',
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
} as const