v0.3.6: 修复文档大纲导航Bug + 新增自动保存功能

Bug修复:
  - 文档大纲点击标题未定位到编辑器内容 (P0)
  - 根因: raw markdown字符偏移 ≠ ProseMirror文档位置
  - 改为通过 view.state.doc.descendants() 遍历节点树,
    按 heading类型名 + level + textContent 三重匹配定位

新增功能:
  - 自动保存 (2秒防抖), 仅对文件标签页生效
  - 状态栏显示 自动/保存中... 指示器

版本同步: package.json / AboutDialog / README 均更新至 v0.3.6
This commit is contained in:
thzxx
2026-06-04 14:23:27 +08:00
parent a84ccb7353
commit c34a843d5c
9 changed files with 163 additions and 14 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript"> <img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React"> <img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License"> <img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.3.2-orange?style=flat-square" alt="Version"> <img src="https://img.shields.io/badge/Version-v0.3.6-orange?style=flat-square" alt="Version">
</p> </p>
<p align="center"> <p align="center">
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "marklite", "name": "marklite",
"version": "0.3.5", "version": "0.3.6",
"description": "Lightweight Markdown Editor for Windows", "description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js", "main": "./dist/main/index.js",
"scripts": { "scripts": {
+3 -1
View File
@@ -9,6 +9,7 @@ import { useUnsavedWarning } from './hooks/useUnsavedWarning'
import { useFileWatch } from './hooks/useFileWatch' import { useFileWatch } from './hooks/useFileWatch'
import { useFileOperations } from './hooks/useFileOperations' import { useFileOperations } from './hooks/useFileOperations'
import { useDragDrop } from './hooks/useDragDrop' import { useDragDrop } from './hooks/useDragDrop'
import { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners' import { useIpcListeners } from './hooks/useIpcListeners'
import { useToast } from './hooks/useToast' import { useToast } from './hooks/useToast'
import { useConfirm } from './hooks/useConfirm' import { useConfirm } from './hooks/useConfirm'
@@ -49,6 +50,7 @@ export function App() {
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast) const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
useDragDrop(showToast) useDragDrop(showToast)
useFileWatch() useFileWatch()
const { isAutoSaving, autoSaveEnabled } = useAutoSave()
// UX-01: 传入 confirm 函数替代原生 confirm() // UX-01: 传入 confirm 函数替代原生 confirm()
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => { const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
@@ -107,7 +109,7 @@ export function App() {
)} )}
</div> </div>
</div> </div>
<StatusBar /> <StatusBar isAutoSaving={isAutoSaving} autoSaveEnabled={autoSaveEnabled} />
<ToastContainer toasts={toasts} onDismiss={dismissToast} /> <ToastContainer toasts={toasts} onDismiss={dismissToast} />
<DropOverlay /> <DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />} {showAbout && <AboutDialog onClose={handleCloseAbout} />}
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { AppIcon, Gitee } from '../Icons' import { AppIcon, Gitee } from '../Icons'
const APP_VERSION = 'v0.3.4' const APP_VERSION = 'v0.3.6'
interface AboutDialogProps { interface AboutDialogProps {
onClose: () => void onClose: () => void
@@ -36,25 +36,27 @@ export function parseHeadings(markdown: string): Heading[] {
interface OutlinePanelProps { interface OutlinePanelProps {
headings: Heading[] headings: Heading[]
onNavigate: (pos: number) => void onNavigate: (heading: Heading, index: number) => void
activeHeadingIndex: number | null activeHeadingIndex: number | null
} }
interface OutlineItemProps { interface OutlineItemProps {
heading: Heading heading: Heading
index: number
isActive: boolean isActive: boolean
onNavigate: (pos: number) => void onNavigate: (heading: Heading, index: number) => void
} }
const OutlineItem = memo(function OutlineItem({ const OutlineItem = memo(function OutlineItem({
heading, heading,
index,
isActive, isActive,
onNavigate onNavigate
}: OutlineItemProps) { }: OutlineItemProps) {
return ( return (
<button <button
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`} className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
onClick={() => onNavigate(heading.pos)} onClick={() => onNavigate(heading, index)}
title={heading.text} title={heading.text}
aria-label={`跳转到标题:${heading.text}`} aria-label={`跳转到标题:${heading.text}`}
style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }} style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }}
@@ -85,8 +87,9 @@ export const OutlinePanel = memo(function OutlinePanel({
<div className="outline-list" role="list" aria-label="标题列表"> <div className="outline-list" role="list" aria-label="标题列表">
{headings.map((h, i) => ( {headings.map((h, i) => (
<OutlineItem <OutlineItem
key={`${h.text}-${h.pos}`} key={`${h.text}-${i}`}
heading={h} heading={h}
index={i}
isActive={i === activeHeadingIndex} isActive={i === activeHeadingIndex}
onNavigate={onNavigate} onNavigate={onNavigate}
/> />
+25 -5
View File
@@ -9,6 +9,7 @@ import { useSidebarResize } from '../../hooks/useSidebarResize'
import { useFolderOperations } from '../../hooks/useFolderOperations' import { useFolderOperations } from '../../hooks/useFolderOperations'
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir' import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
import { OutlinePanel, parseHeadings } from '../OutlinePanel' import { OutlinePanel, parseHeadings } from '../OutlinePanel'
import type { Heading } from '../OutlinePanel'
import { getEditorView, useEditorStore } from '../../stores/editorStore' import { getEditorView, useEditorStore } from '../../stores/editorStore'
import { TextSelection } from '@milkdown/prose/state' import { TextSelection } from '@milkdown/prose/state'
@@ -38,14 +39,33 @@ export const Sidebar = React.memo(function Sidebar() {
return parseHeadings(activeTab.content) return parseHeadings(activeTab.content)
}, [activeTab?.content]) }, [activeTab?.content])
// Navigate to heading position in editor // Navigate to heading in ProseMirror document tree
const handleHeadingNavigate = useCallback((pos: number) => { const handleHeadingNavigate = useCallback((heading: Heading, index: number) => {
const view = getEditorView() const view = getEditorView()
if (!view) return if (!view) return
try { try {
const tr = view.state.tr.setSelection( let foundPos: number | null = null
TextSelection.create(view.state.doc, pos, pos) let currentIndex = 0
)
view.state.doc.descendants((node, pos) => {
if (foundPos !== null) return false
if (node.type.name === 'heading' && node.attrs.level === heading.level) {
if (node.textContent.trim() === heading.text) {
if (currentIndex === index) {
foundPos = pos
return false
}
currentIndex++
}
}
return true
})
if (foundPos === null) return
const tr = view.state.tr
.setSelection(TextSelection.create(view.state.doc, foundPos, foundPos))
.scrollIntoView()
view.dispatch(tr) view.dispatch(tr)
view.focus() view.focus()
} catch { } catch {
@@ -5,7 +5,15 @@ import { useDocStats } from '../../hooks/useDocStats'
import { getFileName } from '../../lib/fileUtils' import { getFileName } from '../../lib/fileUtils'
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner' import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
export const StatusBar = React.memo(function StatusBar() { interface StatusBarProps {
isAutoSaving?: boolean
autoSaveEnabled?: boolean
}
export const StatusBar = React.memo(function StatusBar({
isAutoSaving = false,
autoSaveEnabled = true
}: StatusBarProps) {
const activeTab = useTabStore(s => s.getActiveTab()) const activeTab = useTabStore(s => s.getActiveTab())
const loadingStates = useEditorStore(s => s.loadingStates) const loadingStates = useEditorStore(s => s.loadingStates)
const stats = useDocStats(activeTab?.content) const stats = useDocStats(activeTab?.content)
@@ -50,6 +58,18 @@ export const StatusBar = React.memo(function StatusBar() {
<span className="status-divider">|</span> <span className="status-divider">|</span>
</> </>
)} )}
{activeTab?.filePath && autoSaveEnabled && (
<>
<span
className={`status-auto-save${isAutoSaving ? ' saving' : ''}`}
title={isAutoSaving ? '正在自动保存...' : '自动保存已开启'}
aria-label={isAutoSaving ? '正在自动保存' : '自动保存'}
>
{isAutoSaving ? '保存中...' : '自动'}
</span>
<span className="status-divider">|</span>
</>
)}
<span id="status-encoding">UTF-8</span> <span id="status-encoding">UTF-8</span>
<span className="status-divider">|</span> <span className="status-divider">|</span>
<span id="status-lang">Markdown</span> <span id="status-lang">Markdown</span>
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { logError } from '../lib/errorHandler'
/** Debounce delay for auto-save (ms) */
const AUTO_SAVE_DELAY = 2000
/**
* Auto-save hook: subscribes to Zustand tab store, debounces content changes
* and saves automatically after a pause in editing for file-backed tabs.
*
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
* re-render on every keystroke — the effect is triggered once and runs
* reactively via the store subscription.
*/
export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } {
const [isAutoSaving, setIsAutoSaving] = useState(false)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isSavingRef = useRef(false)
const enabledRef = useRef(true)
const toggleAutoSave = useCallback(() => {
setAutoSaveEnabled(prev => {
const next = !prev
enabledRef.current = next
if (!next && timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
return next
})
}, [])
useEffect(() => {
// Subscribe to Zustand store — fires on every state change
const unsub = useTabStore.subscribe((state) => {
if (!enabledRef.current) return
const tab = state.getActiveTab()
if (!tab || !tab.filePath || !tab.isModified) return
// Debounce: clear previous timer, start new one
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(async () => {
if (isSavingRef.current) return
// Re-read latest state inside the timeout callback
const currentState = useTabStore.getState()
const currentTab = currentState.getActiveTab()
if (!currentTab || !currentTab.filePath || !currentTab.isModified) return
isSavingRef.current = true
setIsAutoSaving(true)
try {
if (!window.electronAPI) return
const result = await window.electronAPI.saveFile({
filePath: currentTab.filePath,
content: currentTab.content
})
if (result.success) {
currentState.setModified(currentTab.id, false)
}
} catch (error) {
logError('自动保存失败', error)
} finally {
setIsAutoSaving(false)
isSavingRef.current = false
}
}, AUTO_SAVE_DELAY)
})
return () => {
unsub()
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [])
return { isAutoSaving, autoSaveEnabled, toggleAutoSave }
}
+17
View File
@@ -570,6 +570,23 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.status-auto-save {
white-space: nowrap;
font-size: 11px;
color: var(--text-tertiary);
transition: color 0.2s ease;
}
.status-auto-save.saving {
color: var(--primary, #1a73e8);
animation: auto-save-pulse 1s ease-in-out infinite;
}
@keyframes auto-save-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* Drop Overlay */ /* Drop Overlay */
#drop-overlay { #drop-overlay {
position: fixed; position: fixed;