Files
MarkLite/src/renderer/components/TabBar/TabBar.tsx
T
thzxx ee5f35177e release: v0.6.0 — 内置解析器迁移 / Toast 全面接入 / Sqlark 深度集成
- 渲染管线迁移至 MetonaEditor 内置解析器,移除 unified/rehype 全家桶(9 个依赖)
- 修复相对路径图片修复的目录前缀碰撞与路径解析 bug
- ConfirmDialog/useConfirm/LoadingSpinner/useDocStats 移除,改用 MeToast.confirm/loading/promise
- 新增状态栏(字数/行数/阅读时间/光标位置)、Zen 模式、数据备份导出导入
- Sqlark: 版本化迁移(addMigration/migrateTo)、subscribe 表变更、备份 exportAll/importTable
- 数据库损坏自愈:异常退出残留残缺 SSTable 导致打开失败时自动重建
- 大纲导航改用 scrollToLine 官方 API
- 修复 rollup 平台包互删(postinstall 自动补齐)+ 集成测试(fake-indexeddb)
2026-08-09 16:50:06 +08:00

307 lines
11 KiB
TypeScript

import React, { useCallback, useState, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { getFileName } from '../../lib/fileUtils'
import { MeToast } from '../../lib/toast'
import { Close, Plus } from '../Icons'
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 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 MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
title: '关闭标签',
confirmText: '关闭',
cancelText: '取消',
})
if (!confirmed) return
}
closeTab(tabId)
},
[tabs, closeTab],
)
// 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 MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
title: '关闭标签',
confirmText: '关闭',
cancelText: '取消',
})
if (!confirmed) return
}
closeTab(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTab])
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 MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭其他标签',
confirmText: '关闭',
cancelText: '取消',
})
if (!confirmed) return
}
closeOtherTabs(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeOtherTabs])
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 MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭全部标签',
confirmText: '关闭',
cancelText: '取消',
})
if (!confirmed) return
}
closeAllTabs()
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, closeAllTabs])
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 MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭右侧标签',
confirmText: '关闭',
cancelText: '取消',
})
if (!confirmed) return
}
closeTabsToRight(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight])
// 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>
</>
)
})
TabBar.displayName = 'TabBar'