feat(editor): 将 Milkdown/ProseMirror 替换为 @metona-team/metona-editor v0.1.3 - 三模式视图 (edit/split/preview) 由编辑器内置工具栏切换 - searchReplace + imagePaste 预设插件 - unified/rehype 渲染管线通过 render 钩子集成 - 主题双向同步 (应用暗色模式 ↔ 编辑器主题) feat(toast): metona-toast 迁移为 @metona-team/metona-toast v2.0.1 refactor: 删除冗余组件与代码 - 移除 SourceEditor、Preview、SearchReplace、EditorToolbar、useMilkdown - 移除 StatusBar 组件及状态栏扩展架构(编辑器内置底栏替代) - 移除 useSettings、useStatusBarItem、useStatusBarItems、statusBarStore - 移除 EditMode/PreviewMode/SourceMode 图标 refactor(ui): 简化布局 - 工具栏移除模式切换按钮,新增自动保存开关 - useKeyboard 移除 Ctrl+1/2/3/B/I 快捷键 - Editor 三模式统一由 MetonaEditor 容器渲染 chore: 版本号 v0.3.13 → v0.4.0 docs: 全面更新 README/DESIGN/CONTRIBUTING/DEVSETUP
191 lines
7.4 KiB
TypeScript
191 lines
7.4 KiB
TypeScript
import React, { useCallback, useMemo, useEffect, useRef } from 'react'
|
||
import { useTabStore } from '../../stores/tabStore'
|
||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||
import { getFileName } from '../../lib/fileUtils'
|
||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||
import { FolderPlus, File } from '../Icons'
|
||
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 { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
|
||
|
||
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
|
||
|
||
function escapeRegex(s: string): string {
|
||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
}
|
||
|
||
export const Sidebar = React.memo(function Sidebar() {
|
||
const tabs = useTabStore(s => s.tabs)
|
||
const activeTabId = useTabStore(s => s.activeTabId)
|
||
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(),
|
||
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
|
||
const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
|
||
const switchToTab = useTabStore(s => s.switchToTab)
|
||
const createTab = useTabStore(s => s.createTab)
|
||
const rootPath = useSidebarStore(s => s.rootPath)
|
||
const tree = useSidebarStore(s => s.tree)
|
||
const expandedDirs = useSidebarStore(s => s.expandedDirs)
|
||
const toggleDir = useSidebarStore(s => s.toggleDir)
|
||
const isVisible = useSidebarStore(s => s.isVisible)
|
||
|
||
const activeFilePath = activeTab?.filePath ?? null
|
||
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
|
||
const headings = useMemo(() => {
|
||
if (!activeTab?.content) return []
|
||
return parseHeadings(activeTab.content)
|
||
}, [activeTab?.content])
|
||
|
||
// D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview)
|
||
const previewRef = useRef<HTMLElement | null>(null)
|
||
useEffect(() => {
|
||
if (viewMode === 'preview') {
|
||
previewRef.current = document.querySelector('.me-preview') as HTMLElement | null
|
||
} else {
|
||
previewRef.current = null
|
||
}
|
||
}, [viewMode])
|
||
|
||
const activeHeadingIndex = useActiveHeading(
|
||
viewMode === 'preview' ? previewRef : { current: null },
|
||
headings
|
||
)
|
||
|
||
// Navigate to heading in MetonaEditor
|
||
const handleHeadingNavigate = useCallback((heading: Heading) => {
|
||
const editor = getMetonaEditor()
|
||
if (!editor) return
|
||
|
||
try {
|
||
// 获取当前内容,查找标题文本在源代码中的位置
|
||
const content = editor.getValue()
|
||
const headingPattern = new RegExp(
|
||
`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`,
|
||
'm'
|
||
)
|
||
const match = headingPattern.exec(content)
|
||
if (!match) return
|
||
|
||
const pos = match.index
|
||
|
||
// 通过 DOM 操作滚动 textarea 到对应位置
|
||
const container = document.querySelector('.metona-editor-wrapper') as HTMLElement | null
|
||
if (!container) return
|
||
|
||
const textarea = container.querySelector('textarea')
|
||
if (!textarea) return
|
||
|
||
// 估算滚动位置(简单方法:按行数比例)
|
||
const linesBefore = content.substring(0, pos).split('\n').length
|
||
const lineHeight = 24 // 估算行高
|
||
textarea.scrollTop = linesBefore * lineHeight
|
||
|
||
// 设置光标位置
|
||
textarea.focus()
|
||
textarea.setSelectionRange(pos, pos)
|
||
} catch {
|
||
// 导航失败,静默忽略
|
||
}
|
||
}, [])
|
||
|
||
const handleFileClick = useCallback(async (path: string) => {
|
||
const existing = tabs.find(t => t.filePath === path)
|
||
if (existing) { switchToTab(existing.id); return }
|
||
if (!window.electronAPI) return
|
||
setLoading('file-open', true)
|
||
try {
|
||
const result = await window.electronAPI.readFile(path)
|
||
if (result.success && result.content !== undefined) {
|
||
createTab(path, result.content)
|
||
recentFilesRepository.add(path)
|
||
}
|
||
} finally {
|
||
setLoading('file-open', false)
|
||
}
|
||
}, [tabs, switchToTab, createTab, setLoading])
|
||
|
||
const independentFiles = tabs.filter(t => {
|
||
if (!t.filePath) return false
|
||
if (!rootPath) return true
|
||
return !norm(t.filePath).startsWith(norm(rootPath))
|
||
})
|
||
|
||
if (!isVisible) return null
|
||
|
||
return (
|
||
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
|
||
<div id="sidebar-header">
|
||
<span id="sidebar-title">资源管理器</span>
|
||
<button
|
||
className="sidebar-header-btn"
|
||
onClick={handleOpenFolder}
|
||
title="打开文件夹"
|
||
aria-label="打开文件夹"
|
||
>
|
||
<FolderPlus size={14} />
|
||
</button>
|
||
</div>
|
||
<nav id="sidebar-tree" role="tree" aria-label="文件树">
|
||
{independentFiles.length > 0 && (
|
||
<div className="independent-files-section" role="group" aria-label="已打开的文件">
|
||
<div className="independent-files-header" id="independent-files-label">已打开的文件</div>
|
||
{independentFiles.map(tab => (
|
||
<div key={tab.id}
|
||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||
style={{ paddingLeft: '8px' }}
|
||
role="treeitem"
|
||
tabIndex={0}
|
||
aria-selected={tab.id === activeTabId}
|
||
aria-label={getFileName(tab.filePath!)}
|
||
onClick={() => switchToTab(tab.id)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
|
||
>
|
||
<span className="tree-icon"><File size={14} /></span>
|
||
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
||
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> •</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{rootPath && (
|
||
<>
|
||
<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 }]}
|
||
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
|
||
activeFilePath={activeFilePath} onFileClick={handleFileClick}
|
||
/>
|
||
</>
|
||
)}
|
||
</nav>
|
||
<div className="sidebar-outline-section">
|
||
<OutlinePanel
|
||
headings={headings}
|
||
onNavigate={handleHeadingNavigate}
|
||
activeHeadingIndex={activeHeadingIndex}
|
||
/>
|
||
</div>
|
||
<div
|
||
className="sidebar-resize-handle"
|
||
onMouseDown={startResize}
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label="调整侧边栏宽度"
|
||
tabIndex={0}
|
||
/>
|
||
</aside>
|
||
)
|
||
})
|
||
|
||
Sidebar.displayName = 'Sidebar'
|