P0 致命Bug修复: - A1: openFolderDialog 类型/运行时崩溃(文件夹打开功能完全失效) - A2: SourceEditor 受控textarea手动改DOM反模式 - A3: tabStore.updateTabContent 内容相同时误标isModified 死代码清理: - B1: 删除menu:*/save/as/viewMode 三个永不触发的IPC通道 健壮性修复: - E1: rehypeFixImages 路径规范化+防越界加固 - E2: getFileName 尾斜杠返回正确文件名 - E3: EditorToolbar 行内代码按钮改用toggleInlineCodeCommand - E4: ErrorBoundary 内联样式抽为CSS类 功能增强: - D1: 标签页拖拽排序(tabStore.moveTab + TabBar DnD + CSS) - D2: Milkdown自动配对括号/引号(ProseMirror插件) - D3: 状态栏自动保存开关可点击 - D4: 文档大纲活跃标题高亮(useActiveHeading hook) - D5: 关闭窗口前flush防抖数据(flushSaveToDB) - D6: 搜索替换支持正则表达式 类型/架构: - C2: preload类型集中定义(ElectronAPI契约) - __pycache__ typo修复 文档/版本: - README/DESIGN同步为Milkdown + v0.3.10 - 项目结构树/技术栈/快捷键表更新 验证: typecheck(仅7预存), lint✅, test 96/96, vite build✅
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
|
import { useTabStore } from '../../stores/tabStore'
|
|
import { useConfirm } from '../../hooks/useConfirm'
|
|
import { getFileName } from '../../lib/fileUtils'
|
|
import { Close, Plus } from '../Icons'
|
|
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
|
|
|
|
interface ContextMenuState {
|
|
visible: boolean
|
|
x: number
|
|
y: number
|
|
tabId: string
|
|
}
|
|
|
|
export const TabBar = React.memo(function TabBar() {
|
|
const tabs = useTabStore(s => s.tabs)
|
|
const activeTabId = useTabStore(s => s.activeTabId)
|
|
const switchToTab = useTabStore(s => s.switchToTab)
|
|
const closeTab = useTabStore(s => s.closeTab)
|
|
const createTab = useTabStore(s => s.createTab)
|
|
const closeOtherTabs = useTabStore(s => s.closeOtherTabs)
|
|
const closeAllTabs = useTabStore(s => s.closeAllTabs)
|
|
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
|
|
const moveTab = useTabStore(s => s.moveTab)
|
|
|
|
const tabListRef = useRef<HTMLDivElement>(null)
|
|
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
|
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
|
const dragTabIdRef = useRef<string | null>(null)
|
|
const { confirm, confirmDialogProps } = useConfirm()
|
|
|
|
// 滚动到活动标签
|
|
const scrollToActiveTab = useCallback(() => {
|
|
const tabList = tabListRef.current
|
|
if (!tabList) return
|
|
const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
|
|
if (!activeTab) return
|
|
|
|
const listRect = tabList.getBoundingClientRect()
|
|
const tabRect = activeTab.getBoundingClientRect()
|
|
|
|
// 如果标签在可视区域左侧之外
|
|
if (tabRect.left < listRect.left) {
|
|
tabList.scrollLeft -= (listRect.left - tabRect.left + 20)
|
|
}
|
|
// 如果标签在可视区域右侧之外
|
|
else if (tabRect.right > listRect.right) {
|
|
tabList.scrollLeft += (tabRect.right - listRect.right + 20)
|
|
}
|
|
}, [])
|
|
|
|
// 自动滚动到活动标签
|
|
useEffect(() => {
|
|
requestAnimationFrame(scrollToActiveTab)
|
|
}, [activeTabId, scrollToActiveTab])
|
|
|
|
// 支持鼠标滚轮水平滚动标签栏
|
|
useEffect(() => {
|
|
const tabList = tabListRef.current
|
|
if (!tabList) return
|
|
|
|
const handleWheel = (e: WheelEvent) => {
|
|
// 检查是否有水平溢出
|
|
if (tabList.scrollWidth <= tabList.clientWidth) return
|
|
|
|
// 阻止默认滚动
|
|
e.preventDefault()
|
|
|
|
// 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY
|
|
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
|
tabList.scrollLeft += delta
|
|
}
|
|
|
|
// 直接绑定到 tabList,使用 passive: false 允许 preventDefault
|
|
tabList.addEventListener('wheel', handleWheel, { passive: false })
|
|
return () => tabList.removeEventListener('wheel', handleWheel)
|
|
}, [])
|
|
|
|
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => {
|
|
e.stopPropagation()
|
|
const tab = tabs.find(t => t.id === tabId)
|
|
if (tab?.isModified) {
|
|
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
|
const confirmed = await confirm({
|
|
title: '关闭标签',
|
|
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
|
variant: 'warning',
|
|
confirmLabel: '关闭'
|
|
})
|
|
if (!confirmed) return
|
|
}
|
|
closeTab(tabId)
|
|
}, [tabs, closeTab, confirm])
|
|
|
|
// C-06: 右键菜单(带边界修正)
|
|
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const MENU_WIDTH = 170
|
|
const MENU_HEIGHT = 140
|
|
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
|
|
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
|
|
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!menu.visible) return
|
|
const handleClick = () => setMenu(prev => ({ ...prev, visible: false }))
|
|
document.addEventListener('click', handleClick)
|
|
return () => document.removeEventListener('click', handleClick)
|
|
}, [menu.visible])
|
|
|
|
const handleMenuClose = useCallback(async () => {
|
|
const tab = tabs.find(t => t.id === menu.tabId)
|
|
if (tab?.isModified) {
|
|
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
|
const confirmed = await confirm({
|
|
title: '关闭标签',
|
|
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
|
variant: 'warning',
|
|
confirmLabel: '关闭'
|
|
})
|
|
if (!confirmed) return
|
|
}
|
|
closeTab(menu.tabId)
|
|
setMenu(prev => ({ ...prev, visible: false }))
|
|
}, [tabs, menu.tabId, closeTab, confirm])
|
|
|
|
const handleMenuCloseOthers = useCallback(async () => {
|
|
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
|
|
if (otherModified.length > 0) {
|
|
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
|
const confirmed = await confirm({
|
|
title: '关闭其他标签',
|
|
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
|
variant: 'warning',
|
|
confirmLabel: '关闭'
|
|
})
|
|
if (!confirmed) return
|
|
}
|
|
closeOtherTabs(menu.tabId)
|
|
setMenu(prev => ({ ...prev, visible: false }))
|
|
}, [tabs, menu.tabId, closeOtherTabs, confirm])
|
|
|
|
const handleMenuCloseAll = useCallback(async () => {
|
|
const modified = tabs.filter(t => t.isModified)
|
|
if (modified.length > 0) {
|
|
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
|
const confirmed = await confirm({
|
|
title: '关闭全部标签',
|
|
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
|
variant: 'warning',
|
|
confirmLabel: '关闭'
|
|
})
|
|
if (!confirmed) return
|
|
}
|
|
closeAllTabs()
|
|
setMenu(prev => ({ ...prev, visible: false }))
|
|
}, [tabs, closeAllTabs, confirm])
|
|
|
|
const handleMenuCloseRight = useCallback(async () => {
|
|
const index = tabs.findIndex(t => t.id === menu.tabId)
|
|
const rightTabs = tabs.slice(index + 1)
|
|
const modified = rightTabs.filter(t => t.isModified)
|
|
if (modified.length > 0) {
|
|
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
|
const confirmed = await confirm({
|
|
title: '关闭右侧标签',
|
|
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
|
variant: 'warning',
|
|
confirmLabel: '关闭'
|
|
})
|
|
if (!confirmed) return
|
|
}
|
|
closeTabsToRight(menu.tabId)
|
|
setMenu(prev => ({ ...prev, visible: false }))
|
|
}, [tabs, menu.tabId, closeTabsToRight, confirm])
|
|
|
|
// D1: 拖拽排序事件处理
|
|
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
|
|
dragTabIdRef.current = tabId
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
e.dataTransfer.setData('text/plain', tabId)
|
|
// 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获
|
|
requestAnimationFrame(() => {
|
|
const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement
|
|
el?.classList.add('dragging')
|
|
})
|
|
}, [])
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
setDragOverIndex(index)
|
|
}, [])
|
|
|
|
const handleDragLeave = useCallback(() => {
|
|
setDragOverIndex(null)
|
|
}, [])
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
|
|
e.preventDefault()
|
|
setDragOverIndex(null)
|
|
const fromId = dragTabIdRef.current
|
|
if (fromId) {
|
|
moveTab(fromId, toIndex)
|
|
}
|
|
dragTabIdRef.current = null
|
|
// 清理 dragging 类
|
|
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
|
|
}, [moveTab])
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
setDragOverIndex(null)
|
|
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
|
|
dragTabIdRef.current = null
|
|
}, [])
|
|
|
|
const hasRightTabs = menu.visible && (() => {
|
|
const index = tabs.findIndex(t => t.id === menu.tabId)
|
|
return index < tabs.length - 1
|
|
})()
|
|
|
|
if (tabs.length === 0) return null
|
|
|
|
return (
|
|
<>
|
|
<div id="tab-bar">
|
|
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
|
{tabs.map((tab, index) => (
|
|
<div
|
|
key={tab.id}
|
|
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''} ${dragOverIndex === index ? 'drag-over' : ''}`}
|
|
role="tab"
|
|
aria-selected={tab.id === activeTabId}
|
|
tabIndex={tab.id === activeTabId ? 0 : -1}
|
|
data-tab-id={tab.id}
|
|
draggable
|
|
onClick={() => switchToTab(tab.id)}
|
|
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
|
onDragStart={(e) => handleDragStart(e, tab.id)}
|
|
onDragOver={(e) => handleDragOver(e, index)}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={(e) => handleDrop(e, index)}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<span className="tab-name">
|
|
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
|
</span>
|
|
<button
|
|
className="tab-close"
|
|
onClick={(e) => handleClose(e, tab.id)}
|
|
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
|
|
>
|
|
<Close size={10} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
className="tab-add-btn"
|
|
onClick={() => createTab(null, '')}
|
|
title="新建标签页 (Ctrl+T)"
|
|
aria-label="新建标签页"
|
|
>
|
|
<Plus size={14} />
|
|
</button>
|
|
|
|
{/* 右键菜单 */}
|
|
{menu.visible && (
|
|
<div
|
|
className="tab-context-menu"
|
|
style={{ left: menu.x, top: menu.y }}
|
|
role="menu"
|
|
aria-label="标签操作"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
|
关闭
|
|
</div>
|
|
{tabs.length > 1 && (
|
|
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
|
|
关闭其他标签
|
|
</div>
|
|
)}
|
|
{hasRightTabs && (
|
|
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
|
|
关闭右侧标签
|
|
</div>
|
|
)}
|
|
<div className="tab-context-divider" role="separator" />
|
|
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
|
|
关闭全部标签
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<ConfirmDialog {...confirmDialogProps} />
|
|
</>
|
|
)
|
|
})
|
|
|
|
TabBar.displayName = 'TabBar'
|