feat: 标签栏右键菜单 — 关闭其他/右侧/全部标签

新增功能:
- 右键点击标签弹出上下文菜单
- 关闭:关闭当前标签(带未保存确认)
- 关闭其他标签:保留当前,关闭其余(带未保存确认)
- 关闭右侧标签:关闭当前标签右边的所有标签
- 关闭全部标签:关闭所有标签(带未保存确认)
- 点击空白处自动关闭菜单

技术实现:
- tabStore 新增 closeOtherTabs/closeAllTabs/closeTabsToRight 方法
- TabBar 组件新增 ContextMenu 状态管理和右键事件处理
- CSS 新增 .tab-context-menu 样式(暗色主题兼容)
This commit is contained in:
thzxx
2026-05-27 20:56:56 +08:00
parent 959fde70e3
commit 2c681a4142
3 changed files with 175 additions and 1 deletions
+107 -1
View File
@@ -1,14 +1,28 @@
import React, { useCallback } from 'react'
import React, { useCallback, useState, useEffect, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { getFileName } from '../../lib/fileUtils'
interface ContextMenuState {
visible: boolean
x: number
y: number
tabId: string
}
export 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 [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const menuRef = useRef<HTMLDivElement>(null)
// 关闭标签(带未保存确认)
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
@@ -19,6 +33,71 @@ export function TabBar() {
closeTab(tabId)
}, [tabs, closeTab])
// 右键菜单
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
e.preventDefault()
e.stopPropagation()
setMenu({ visible: true, x: e.clientX, y: e.clientY, 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(() => {
const tab = tabs.find(t => t.id === menu.tabId)
if (tab?.isModified) {
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return
}
closeTab(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTab])
const handleMenuCloseOthers = useCallback(() => {
// 检查其他标签是否有未保存
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('、')
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
}
closeOtherTabs(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeOtherTabs])
const handleMenuCloseAll = useCallback(() => {
const modified = tabs.filter(t => t.isModified)
if (modified.length > 0) {
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
}
closeAllTabs()
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, closeAllTabs])
const handleMenuCloseRight = useCallback(() => {
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('、')
if (!confirm(`以下文件尚未保存:${names},确定要关闭吗?`)) return
}
closeTabsToRight(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight])
// 判断右侧是否还有标签
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 (
@@ -29,6 +108,7 @@ export function TabBar() {
key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
onClick={() => switchToTab(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
>
<span className="tab-name">
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
@@ -55,6 +135,32 @@ export function TabBar() {
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
{/* 右键菜单 */}
{menu.visible && (
<div
ref={menuRef}
className="tab-context-menu"
style={{ left: menu.x, top: menu.y }}
onClick={(e) => e.stopPropagation()}
>
<div className="tab-context-item" onClick={handleMenuClose}>
</div>
<div className="tab-context-item" onClick={handleMenuCloseOthers}>
</div>
{hasRightTabs && (
<div className="tab-context-item" onClick={handleMenuCloseRight}>
</div>
)}
<div className="tab-context-divider" />
<div className="tab-context-item" onClick={handleMenuCloseAll}>
</div>
</div>
)}
</div>
)
}
+35
View File
@@ -9,6 +9,9 @@ interface TabState {
createTab: (filePath?: string | null, content?: string) => Tab
closeTab: (tabId: string) => void
closeOtherTabs: (tabId: string) => void
closeAllTabs: () => void
closeTabsToRight: (tabId: string) => void
switchToTab: (tabId: string) => void
updateTabContent: (tabId: string, content: string) => void
setModified: (tabId: string, modified: boolean) => void
@@ -79,6 +82,38 @@ export const useTabStore = create<TabState>((set, get) => ({
})
},
closeOtherTabs: (tabId) => {
set(state => {
const target = state.tabs.find(t => t.id === tabId)
if (!target) return state
return {
tabs: [target],
activeTabId: tabId,
mruStack: []
}
})
},
closeAllTabs: () => {
set({ tabs: [], activeTabId: null, mruStack: [] })
},
closeTabsToRight: (tabId) => {
set(state => {
const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state
const newTabs = state.tabs.slice(0, index + 1)
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
? state.activeTabId
: tabId
return {
tabs: newTabs,
activeTabId: newActiveId,
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
}
})
},
switchToTab: (tabId) => {
set(state => {
if (state.activeTabId === tabId) return state
+33
View File
@@ -300,6 +300,39 @@ html, body {
color: var(--text);
}
/* Tab Context Menu */
.tab-context-menu {
position: fixed;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 4px 0;
min-width: 160px;
z-index: 9999;
font-size: 13px;
font-family: var(--font-ui);
}
.tab-context-item {
padding: 6px 16px;
cursor: pointer;
color: var(--text);
white-space: nowrap;
transition: background 0.1s ease;
}
.tab-context-item:hover {
background: var(--primary-light);
color: var(--primary);
}
.tab-context-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
/* Modified Banner */
#modified-banner {
display: flex;