v0.3.4: Bug修复+性能优化+文档大纲功能

🔴 Bug修复 (6):
- Sidebar反斜杠正则修复 (Windows路径规范化)
- FileWatcher文件删除后自动恢复监听
- SearchReplace replaceAll防过期匹配位置
- openFolderDialog null结果守卫
- 保存异常时空路径保护
- Markdown绝对路径图片拼接修复

🟡 性能优化:
- Editor切换标签时内容比较,相同跳过replaceAll

🔵 代码质量:
- 创建共享常量模块 src/shared/constants.ts,消除双副本

🎯 新增功能:
- 文档大纲 (Table of Contents) — 侧边栏标题导航

🔧 换行符统一: CRLF → LF
This commit is contained in:
thzxx
2026-06-04 13:24:23 +08:00
parent efa7f61809
commit 899d2b7914
28 changed files with 2657 additions and 2374 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marklite",
"version": "0.3.2",
"version": "0.3.4",
"description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js",
"scripts": {
+3 -7
View File
@@ -1,13 +1,9 @@
import { readFile, stat, writeFile, rename, readdir, unlink } from 'fs/promises'
import { join, extname, dirname, basename } from 'path'
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
const ALLOWED_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
'.next', '.nuxt', '__pycache__', '.DS_Store'
])
const ALLOWED_EXTENSIONS_SET = new Set<string>(ALLOWED_EXTENSIONS)
export async function readFileContent(filePath: string): Promise<ReadFileResult> {
try {
@@ -67,7 +63,7 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P
}
} else {
const ext = extname(entry.name).toLowerCase()
if (ALLOWED_EXTENSIONS.has(ext)) {
if (ALLOWED_EXTENSIONS_SET.has(ext)) {
children.push({ name: entry.name, path: childPath, type: 'file' })
}
}
+15 -5
View File
@@ -24,12 +24,22 @@ export class FileWatcher {
})
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
this.watcher.on('error', () => {
if (this.watcher) {
this.watcher.close()
this.watcher = null
this.stop()
const originalPath = this.currentPath
if (originalPath) {
const pollInterval = setInterval(() => {
try {
if (fs.existsSync(originalPath)) {
clearInterval(pollInterval)
this.start(originalPath)
}
} catch {
clearInterval(pollInterval)
}
}, 2000)
// 最多轮询30秒
setTimeout(() => clearInterval(pollInterval), 30000)
}
this.currentPath = null
this.isSelfWriting = false
})
} catch {
// silently ignore
+3 -1
View File
@@ -100,7 +100,9 @@ export function registerIpcHandlers(
}
} catch (err) {
fileWatcher.setSelfWriting(false)
fileWatcher.start(state.activeFilePath || '')
if (state.activeFilePath) {
fileWatcher.start(state.activeFilePath)
}
return { success: false, error: (err as Error).message }
}
})
@@ -1,7 +1,7 @@
import React from 'react'
import { AppIcon, Gitee } from '../Icons'
const APP_VERSION = 'v0.3.2'
const APP_VERSION = 'v0.3.4'
interface AboutDialogProps {
onClose: () => void
@@ -35,6 +35,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
+12 -1
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useCallback, useState } from 'react'
import React, { useEffect, useCallback, useState, useRef } from 'react'
import { callCommand } from '@milkdown/utils'
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
import { useTabStore } from '../../stores/tabStore'
import { useMilkdown } from './useMilkdown'
import { EditorToolbar } from './EditorToolbar'
import { SearchReplace } from '../SearchReplace'
import { setEditorViewGetter } from '../../stores/editorStore'
interface EditorProps {
darkMode: boolean
@@ -17,6 +18,7 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
const [showSearch, setShowSearch] = useState(false)
const currentContentRef = useRef('')
const {
containerRef,
@@ -40,15 +42,24 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
// Load content when switching tabs (only trigger on tab switch, not content edits)
useEffect(() => {
if (!activeTab) return
if (activeTab.content !== currentContentRef.current) {
currentContentRef.current = activeTab.content
setContent(activeTab.content)
requestAnimationFrame(() => {
setScrollTop(activeTab.scrollTop)
setSelection()
})
}
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// Register getView for OutlinePanel navigation
useEffect(() => {
setEditorViewGetter(getView)
return () => setEditorViewGetter(() => null)
}, [getView])
// Save current tab state on unmount or tab switch
useEffect(() => {
return () => {
@@ -0,0 +1,99 @@
import React, { memo } from 'react'
// --- Types ---
export interface Heading {
level: number
text: string
/** Position in document (character offset from markdown source) */
pos: number
}
// --- Heading Parser ---
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
/**
* Parse headings from raw markdown content using regex.
*/
export function parseHeadings(markdown: string): Heading[] {
const headings: Heading[] = []
let match: RegExpExecArray | null
// Reset regex state
HEADING_RE.lastIndex = 0
while ((match = HEADING_RE.exec(markdown)) !== null) {
const level = match[1].length
const text = match[2].trim()
headings.push({ level, text, pos: match.index })
}
return headings
}
// --- Component ---
interface OutlinePanelProps {
headings: Heading[]
onNavigate: (pos: number) => void
activeHeadingIndex: number | null
}
interface OutlineItemProps {
heading: Heading
isActive: boolean
onNavigate: (pos: number) => void
}
const OutlineItem = memo(function OutlineItem({
heading,
isActive,
onNavigate
}: OutlineItemProps) {
return (
<button
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
onClick={() => onNavigate(heading.pos)}
title={heading.text}
aria-label={`跳转到标题:${heading.text}`}
style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }}
>
<span className="outline-level-dot" />
<span className="outline-item-text">{heading.text}</span>
</button>
)
})
export const OutlinePanel = memo(function OutlinePanel({
headings,
onNavigate,
activeHeadingIndex
}: OutlinePanelProps) {
if (headings.length === 0) {
return (
<div className="outline-panel" role="region" aria-label="文档大纲">
<div className="outline-header"></div>
<div className="outline-empty"></div>
</div>
)
}
return (
<div className="outline-panel" role="region" aria-label="文档大纲">
<div className="outline-header"></div>
<div className="outline-list" role="list" aria-label="标题列表">
{headings.map((h, i) => (
<OutlineItem
key={`${h.text}-${h.pos}`}
heading={h}
isActive={i === activeHeadingIndex}
onNavigate={onNavigate}
/>
))}
</div>
</div>
)
})
OutlinePanel.displayName = 'OutlinePanel'
@@ -0,0 +1,2 @@
export { OutlinePanel, parseHeadings } from './OutlinePanel'
export type { Heading } from './OutlinePanel'
@@ -205,17 +205,21 @@ export const SearchReplace = memo(function SearchReplace({
const view = getView()
if (!view) return
const matches = matchesRef.current
if (matches.length === 0) return
// 重新搜索以获取匹配的最新位置
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current)
if (freshMatches.length === 0) return
// Process matches in reverse order to preserve positions
// 从后往前替换以保持位置正确
const tr = view.state.tr
for (let i = matches.length - 1; i >= 0; i--) {
tr.insertText(replacement, matches[i].from, matches[i].to)
for (let i = freshMatches.length - 1; i >= 0; i--) {
tr.insertText(replacement, freshMatches[i].from, freshMatches[i].to)
}
view.dispatch(tr)
// Re-search after replacement
// 更新ref
matchesRef.current = []
// 替换后重新搜索
requestAnimationFrame(() => {
doSearch(queryRef.current, caseSensitiveRef.current)
})
+34 -3
View File
@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useSidebarStore } from '../../stores/sidebarStore'
import { getFileName } from '../../lib/fileUtils'
@@ -8,8 +8,11 @@ import { FileTree } from '../FileTree'
import { useSidebarResize } from '../../hooks/useSidebarResize'
import { useFolderOperations } from '../../hooks/useFolderOperations'
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
import { getEditorView } from '../../stores/editorStore'
import { TextSelection } from '@milkdown/prose/state'
const norm = (p: string) => p.replace(/\\\\/g, '/')
const norm = (p: string) => p.replace(/\\/g, '/')
export const Sidebar = React.memo(function Sidebar() {
const tabs = useTabStore(s => s.tabs)
@@ -28,6 +31,27 @@ export const Sidebar = React.memo(function Sidebar() {
const { handleOpenFolder } = useFolderOperations()
useAutoExpandDir(activeFilePath)
// Parse headings from active tab content
const headings = useMemo(() => {
if (!activeTab?.content) return []
return parseHeadings(activeTab.content)
}, [activeTab?.content])
// Navigate to heading position in editor
const handleHeadingNavigate = useCallback((pos: number) => {
const view = getEditorView()
if (!view) return
try {
const tr = view.state.tr.setSelection(
TextSelection.create(view.state.doc, pos, pos)
)
view.dispatch(tr)
view.focus()
} catch {
// Position may be invalid if content has changed
}
}, [])
const handleFileClick = useCallback(async (path: string) => {
const existing = tabs.find(t => t.filePath === path)
if (existing) { switchToTab(existing.id); return }
@@ -86,13 +110,20 @@ export const Sidebar = React.memo(function Sidebar() {
<>
<div className="sidebar-section-header" id="folder-tree-label"></div>
<FileTree
nodes={[{ name: rootPath.split(/[/\\\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
/>
</>
)}
</nav>
<div className="sidebar-outline-section">
<OutlinePanel
headings={headings}
onNavigate={handleHeadingNavigate}
activeHeadingIndex={null}
/>
</div>
<div
className="sidebar-resize-handle"
onMouseDown={startResize}
@@ -16,6 +16,7 @@ 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)
+2 -8
View File
@@ -1,8 +1,2 @@
// 常量定义
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
'.next', '.nuxt', '__pycache__', '.DS_Store'
])
// 重新导出共享常量
export { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../../shared/constants'
+8
View File
@@ -39,6 +39,14 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
if (child.type === 'element' && child.tagName === 'img') {
const src = child.properties?.src as string | undefined
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
// 处理Unix风格绝对路径(以/开头)
if (src.startsWith('/')) {
child.properties = {
...child.properties,
src: 'file://' + src
}
continue
}
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
const resolvedPath = resolveRelativePath(dir, src)
if (!resolvedPath.startsWith(dir + sep)) return
+13
View File
@@ -1,6 +1,19 @@
import { create } from 'zustand'
import type { EditorView } from '@milkdown/prose/view'
import type { ViewMode } from '../types/settings'
// Module-level getter/setter for the ProseMirror EditorView
// Used by OutlinePanel for heading navigation
let _getView: (() => EditorView | null) | null = null
export function setEditorViewGetter(fn: () => EditorView | null) {
_getView = fn
}
export function getEditorView(): EditorView | null {
return _getView ? _getView() : null
}
interface EditorState {
viewMode: ViewMode
darkMode: boolean
+104
View File
@@ -1600,3 +1600,107 @@ button:focus-visible,
background: rgba(255, 180, 0, 0.4);
outline-color: rgba(255, 180, 0, 0.6);
}
/* ===== Outline Panel (Table of Contents) ===== */
.sidebar-outline-section {
flex-shrink: 0;
max-height: 40%;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.outline-panel {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.outline-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-light);
}
.outline-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.outline-list::-webkit-scrollbar {
width: 4px;
}
.outline-list::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 2px;
}
.outline-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 3px 8px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
cursor: pointer;
text-align: left;
transition: all 0.1s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.outline-item:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.outline-item.active {
color: var(--primary);
background: var(--primary-light);
}
.outline-item:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.outline-level-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--text-tertiary);
flex-shrink: 0;
}
.outline-item:hover .outline-level-dot,
.outline-item.active .outline-level-dot {
background: var(--primary);
}
.outline-item-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outline-empty {
padding: 13px 12px;
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
+7
View File
@@ -0,0 +1,7 @@
// 共享常量 — 主进程和渲染进程共用
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
'.next', '.nuxt', '__pycache__', '.DS_Store'
])