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:
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { AppIcon, Gitee } from '../Icons'
|
||||
|
||||
const APP_VERSION = 'v0.3.4'
|
||||
const APP_VERSION = 'v0.3.6'
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void
|
||||
|
||||
@@ -36,25 +36,27 @@ export function parseHeadings(markdown: string): Heading[] {
|
||||
|
||||
interface OutlinePanelProps {
|
||||
headings: Heading[]
|
||||
onNavigate: (pos: number) => void
|
||||
onNavigate: (heading: Heading, index: number) => void
|
||||
activeHeadingIndex: number | null
|
||||
}
|
||||
|
||||
interface OutlineItemProps {
|
||||
heading: Heading
|
||||
index: number
|
||||
isActive: boolean
|
||||
onNavigate: (pos: number) => void
|
||||
onNavigate: (heading: Heading, index: number) => void
|
||||
}
|
||||
|
||||
const OutlineItem = memo(function OutlineItem({
|
||||
heading,
|
||||
index,
|
||||
isActive,
|
||||
onNavigate
|
||||
}: OutlineItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
|
||||
onClick={() => onNavigate(heading.pos)}
|
||||
onClick={() => onNavigate(heading, index)}
|
||||
title={heading.text}
|
||||
aria-label={`跳转到标题:${heading.text}`}
|
||||
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="标题列表">
|
||||
{headings.map((h, i) => (
|
||||
<OutlineItem
|
||||
key={`${h.text}-${h.pos}`}
|
||||
key={`${h.text}-${i}`}
|
||||
heading={h}
|
||||
index={i}
|
||||
isActive={i === activeHeadingIndex}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
|
||||
import type { Heading } from '../OutlinePanel'
|
||||
import { getEditorView, useEditorStore } from '../../stores/editorStore'
|
||||
import { TextSelection } from '@milkdown/prose/state'
|
||||
|
||||
@@ -38,14 +39,33 @@ export const Sidebar = React.memo(function Sidebar() {
|
||||
return parseHeadings(activeTab.content)
|
||||
}, [activeTab?.content])
|
||||
|
||||
// Navigate to heading position in editor
|
||||
const handleHeadingNavigate = useCallback((pos: number) => {
|
||||
// Navigate to heading in ProseMirror document tree
|
||||
const handleHeadingNavigate = useCallback((heading: Heading, index: number) => {
|
||||
const view = getEditorView()
|
||||
if (!view) return
|
||||
try {
|
||||
const tr = view.state.tr.setSelection(
|
||||
TextSelection.create(view.state.doc, pos, pos)
|
||||
)
|
||||
let foundPos: number | null = null
|
||||
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.focus()
|
||||
} catch {
|
||||
|
||||
@@ -5,7 +5,15 @@ import { useDocStats } from '../../hooks/useDocStats'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
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 loadingStates = useEditorStore(s => s.loadingStates)
|
||||
const stats = useDocStats(activeTab?.content)
|
||||
@@ -50,6 +58,18 @@ export const StatusBar = React.memo(function StatusBar() {
|
||||
<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 className="status-divider">|</span>
|
||||
<span id="status-lang">Markdown</span>
|
||||
|
||||
Reference in New Issue
Block a user