🔴 Bug修复 (6): - Sidebar反斜杠正则修复 (Windows路径规范化) - FileWatcher文件删除后自动恢复监听 - SearchReplace replaceAll防过期匹配位置 - openFolderDialog null结果守卫 - 保存异常时空路径保护 - Markdown绝对路径图片拼接修复 🟡 性能优化: - Editor切换标签时内容比较,相同跳过replaceAll 🔵 代码质量: - 创建共享常量模块 src/shared/constants.ts,消除双副本 🎯 新增功能: - 文档大纲 (Table of Contents) — 侧边栏标题导航 🔧 换行符统一: CRLF → LF
100 lines
2.4 KiB
TypeScript
100 lines
2.4 KiB
TypeScript
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'
|