Files
MarkLite/src/renderer/components/Sidebar/Sidebar.tsx
T
thzxx 05e5667e48 release: v0.4.0 — MetonaEditor 集成与架构重构
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
2026-07-23 22:55:45 +08:00

191 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'