feat: 标签栏右键菜单 — 关闭其他/右侧/全部标签
新增功能: - 右键点击标签弹出上下文菜单 - 关闭:关闭当前标签(带未保存确认) - 关闭其他标签:保留当前,关闭其余(带未保存确认) - 关闭右侧标签:关闭当前标签右边的所有标签 - 关闭全部标签:关闭所有标签(带未保存确认) - 点击空白处自动关闭菜单 技术实现: - tabStore 新增 closeOtherTabs/closeAllTabs/closeTabsToRight 方法 - TabBar 组件新增 ContextMenu 状态管理和右键事件处理 - CSS 新增 .tab-context-menu 样式(暗色主题兼容)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user