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 { useTabStore } from '../../stores/tabStore'
|
||||||
import { getFileName } from '../../lib/fileUtils'
|
import { getFileName } from '../../lib/fileUtils'
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
visible: boolean
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
tabId: string
|
||||||
|
}
|
||||||
|
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const tabs = useTabStore(s => s.tabs)
|
const tabs = useTabStore(s => s.tabs)
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
const switchToTab = useTabStore(s => s.switchToTab)
|
const switchToTab = useTabStore(s => s.switchToTab)
|
||||||
const closeTab = useTabStore(s => s.closeTab)
|
const closeTab = useTabStore(s => s.closeTab)
|
||||||
const createTab = useTabStore(s => s.createTab)
|
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) => {
|
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const tab = tabs.find(t => t.id === tabId)
|
const tab = tabs.find(t => t.id === tabId)
|
||||||
@@ -19,6 +33,71 @@ export function TabBar() {
|
|||||||
closeTab(tabId)
|
closeTab(tabId)
|
||||||
}, [tabs, closeTab])
|
}, [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
|
if (tabs.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +108,7 @@ export function TabBar() {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||||
onClick={() => switchToTab(tab.id)}
|
onClick={() => switchToTab(tab.id)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||||
>
|
>
|
||||||
<span className="tab-name">
|
<span className="tab-name">
|
||||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||||
@@ -55,6 +135,32 @@ export function TabBar() {
|
|||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ interface TabState {
|
|||||||
|
|
||||||
createTab: (filePath?: string | null, content?: string) => Tab
|
createTab: (filePath?: string | null, content?: string) => Tab
|
||||||
closeTab: (tabId: string) => void
|
closeTab: (tabId: string) => void
|
||||||
|
closeOtherTabs: (tabId: string) => void
|
||||||
|
closeAllTabs: () => void
|
||||||
|
closeTabsToRight: (tabId: string) => void
|
||||||
switchToTab: (tabId: string) => void
|
switchToTab: (tabId: string) => void
|
||||||
updateTabContent: (tabId: string, content: string) => void
|
updateTabContent: (tabId: string, content: string) => void
|
||||||
setModified: (tabId: string, modified: boolean) => 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) => {
|
switchToTab: (tabId) => {
|
||||||
set(state => {
|
set(state => {
|
||||||
if (state.activeTabId === tabId) return state
|
if (state.activeTabId === tabId) return state
|
||||||
|
|||||||
@@ -300,6 +300,39 @@ html, body {
|
|||||||
color: var(--text);
|
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 */
|
||||||
#modified-banner {
|
#modified-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user