Skip to content

Commit a4b59a8

Browse files
committed
feat: Implement drag-and-drop functionality for tab management, including visual indicators and reordering based on pinned status for improved user experience
1 parent 391955e commit a4b59a8

4 files changed

Lines changed: 300 additions & 15 deletions

File tree

src/renderer/src/components/TabsArea.css

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,83 @@
113113
.tabs-area .ant-tabs-tab-active {
114114
font-weight: 500;
115115
}
116+
117+
/* 固定标签页区域分隔线 */
118+
.tabs-area .ant-tabs-nav-wrap {
119+
position: relative;
120+
}
121+
122+
.tabs-area .ant-tabs-nav-wrap::after {
123+
content: '';
124+
position: absolute;
125+
top: 50%;
126+
left: 0;
127+
right: 0;
128+
height: 1px;
129+
background: linear-gradient(to right, transparent, rgba(24, 144, 255, 0.2), transparent);
130+
transform: translateY(-50%);
131+
pointer-events: none;
132+
z-index: 1;
133+
}
134+
135+
/* 拖拽相关样式 */
136+
.tab-label-content {
137+
cursor: move;
138+
}
139+
140+
.tab-label-content.dragging {
141+
opacity: 0.5;
142+
transform: scale(0.95);
143+
transition: all 0.2s ease;
144+
}
145+
146+
.tab-label-content.drag-over {
147+
background-color: rgba(24, 144, 255, 0.1);
148+
border-radius: 4px;
149+
transform: scale(1.05);
150+
transition: all 0.2s ease;
151+
}
152+
153+
.tab-label-content.drag-over-forbidden {
154+
background-color: rgba(255, 77, 79, 0.1);
155+
border-radius: 4px;
156+
cursor: not-allowed;
157+
transition: all 0.2s ease;
158+
}
159+
160+
.tabs-area .ant-tabs-tab.dragging {
161+
opacity: 0.6;
162+
transform: scale(0.95);
163+
z-index: 1000;
164+
}
165+
166+
.tabs-area .ant-tabs-tab.drag-over {
167+
background-color: rgba(24, 144, 255, 0.15) !important;
168+
border-color: rgba(24, 144, 255, 0.5) !important;
169+
transform: scale(1.02);
170+
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.3);
171+
}
172+
173+
/* 拖拽时的指示器 */
174+
.tabs-area .ant-tabs-tab.drag-over::before {
175+
content: '';
176+
position: absolute;
177+
left: -2px;
178+
top: 0;
179+
bottom: 0;
180+
width: 4px;
181+
background-color: #1890ff;
182+
border-radius: 2px;
183+
animation: dragIndicator 0.3s ease;
184+
}
185+
186+
@keyframes dragIndicator {
187+
from {
188+
opacity: 0;
189+
transform: scaleY(0.5);
190+
}
191+
to {
192+
opacity: 1;
193+
transform: scaleY(1);
194+
}
195+
}

src/renderer/src/components/TabsArea.tsx

Lines changed: 124 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export default function TabsArea() {
2323
const { state, dispatch } = useAppContext()
2424
const chatWindowRefs = useRef<Map<string, ChatWindowRef>>(new Map())
2525
const [settingsOpen, setSettingsOpen] = React.useState(false)
26+
const [draggedTabId, setDraggedTabId] = React.useState<string | null>(null)
27+
const [dragOverTabId, setDragOverTabId] = React.useState<string | null>(null)
2628

2729
// 设置ChatWindow引用的回调函数
2830
const setChatWindowRef = useCallback((chatId: string, ref: ChatWindowRef | null) => {
@@ -62,6 +64,88 @@ export default function TabsArea() {
6264
setSettingsOpen(true)
6365
}, [])
6466

67+
// 拖拽排序处理函数
68+
const handleDragStart = useCallback((event: React.DragEvent, chatId: string) => {
69+
setDraggedTabId(chatId)
70+
event.dataTransfer.setData('text/plain', chatId)
71+
event.dataTransfer.effectAllowed = 'move'
72+
}, [])
73+
74+
const handleDragOver = useCallback((event: React.DragEvent, chatId: string) => {
75+
event.preventDefault()
76+
77+
if (draggedTabId) {
78+
const sourceChat = state.pages.find(p => p.id === draggedTabId)
79+
const targetChat = state.pages.find(p => p.id === chatId)
80+
81+
const sourcePinned = sourceChat?.pinned || false
82+
const targetPinned = targetChat?.pinned || false
83+
84+
// 检查是否可以拖拽
85+
if (sourcePinned !== targetPinned) {
86+
event.dataTransfer.dropEffect = 'none'
87+
return
88+
}
89+
}
90+
91+
event.dataTransfer.dropEffect = 'move'
92+
setDragOverTabId(chatId)
93+
}, [draggedTabId, state.pages])
94+
95+
const handleDragLeave = useCallback((event: React.DragEvent) => {
96+
// 只在离开整个标签区域时清除
97+
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
98+
setDragOverTabId(null)
99+
}
100+
}, [])
101+
102+
const handleDrop = useCallback((event: React.DragEvent, targetChatId: string) => {
103+
event.preventDefault()
104+
const sourceChatId = event.dataTransfer.getData('text/plain')
105+
106+
if (sourceChatId && sourceChatId !== targetChatId) {
107+
const sourceChat = state.pages.find(p => p.id === sourceChatId)
108+
const targetChat = state.pages.find(p => p.id === targetChatId)
109+
110+
// 检查是否可以进行拖拽排序
111+
const sourcePinned = sourceChat?.pinned || false
112+
const targetPinned = targetChat?.pinned || false
113+
114+
// 固定标签页和非固定标签页不能互相拖拽
115+
if (sourcePinned !== targetPinned) {
116+
setDraggedTabId(null)
117+
setDragOverTabId(null)
118+
return
119+
}
120+
121+
const currentTabs = [...state.openTabs]
122+
const sourceIndex = currentTabs.indexOf(sourceChatId)
123+
const targetIndex = currentTabs.indexOf(targetChatId)
124+
125+
if (sourceIndex !== -1 && targetIndex !== -1) {
126+
// 移除源标签
127+
currentTabs.splice(sourceIndex, 1)
128+
// 在目标位置插入
129+
const newTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
130+
currentTabs.splice(newTargetIndex, 0, sourceChatId)
131+
132+
// 通过reducer进行排序,确保固定标签页在前
133+
dispatch({
134+
type: 'REORDER_TABS',
135+
payload: { newOrder: currentTabs }
136+
})
137+
}
138+
}
139+
140+
setDraggedTabId(null)
141+
setDragOverTabId(null)
142+
}, [state.openTabs, state.pages, dispatch])
143+
144+
const handleDragEnd = useCallback(() => {
145+
setDraggedTabId(null)
146+
setDragOverTabId(null)
147+
}, [])
148+
65149
// 键盘快捷键处理
66150
useEffect(() => {
67151
const handleKeyDown = (event: KeyboardEvent) => {
@@ -173,11 +257,9 @@ export default function TabsArea() {
173257
</span>
174258
),
175259
icon: <CloseOutlined />,
176-
disabled: isPinned && hasOtherTabs,
260+
disabled: false,
177261
onClick: () => {
178-
if (!isPinned || !hasOtherTabs) {
179-
handleTabClose(chatId)
180-
}
262+
handleTabClose(chatId)
181263
}
182264
},
183265
{
@@ -331,7 +413,7 @@ export default function TabsArea() {
331413
description={
332414
<div style={{ textAlign: 'center' }}>
333415
<h3 style={{ color: '#262626', marginBottom: 8 }}>暂无打开的聊天</h3>
334-
<p style={{ color: '#8c8c8c', marginBottom: 24 }}>
416+
<p style={{ color: '#8c8c8c', marginBottom: 24 }}>
335417
创建一个新聊天开始对话,或者尝试新的交叉视图分析
336418
</p>
337419
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
@@ -377,9 +459,25 @@ export default function TabsArea() {
377459
const chatStatus = getChatStatus(chat)
378460
const isPinned = chat.pinned || false
379461

462+
// 检查是否可以与当前拖拽的标签页进行拖拽
463+
const canDragToThis = !draggedTabId || (() => {
464+
const sourceChat = state.pages.find(p => p.id === draggedTabId)
465+
const sourcePinned = sourceChat?.pinned || false
466+
const targetPinned = chat.pinned || false
467+
return sourcePinned === targetPinned
468+
})()
469+
380470
const tabLabel = (
381471
<Dropdown menu={{ items: getContextMenuItems(chatId) }} trigger={['contextMenu']}>
382-
<span className="tab-label-content">
472+
<span
473+
className={`tab-label-content ${draggedTabId === chatId ? 'dragging' : ''} ${dragOverTabId === chatId ? (canDragToThis ? 'drag-over' : 'drag-over-forbidden') : ''}`}
474+
draggable
475+
onDragStart={(e) => handleDragStart(e, chatId)}
476+
onDragOver={(e) => handleDragOver(e, chatId)}
477+
onDragLeave={handleDragLeave}
478+
onDrop={(e) => handleDrop(e, chatId)}
479+
onDragEnd={handleDragEnd}
480+
>
383481
{chat.type === 'crosstab' ? (
384482
<TableOutlined className="message-icon" />
385483
) : chat.type === 'object' ? (
@@ -407,7 +505,8 @@ export default function TabsArea() {
407505
) : (
408506
<ChatWindow chatId={chatId} ref={(ref) => setChatWindowRef(chatId, ref)} />
409507
),
410-
closable: !isPinned || state.openTabs.length === 1
508+
closable: true,
509+
className: `${draggedTabId === chatId ? 'dragging' : ''} ${dragOverTabId === chatId ? 'drag-over' : ''}`
411510
}
412511
})
413512
.filter((item): item is NonNullable<typeof item> => item !== null)
@@ -434,7 +533,11 @@ export default function TabsArea() {
434533
return chat?.pinned
435534
})
436535
.map((id) => `[data-node-key="${id}"]`)
437-
.join(',')
536+
.join(','),
537+
'--pinned-count': state.openTabs.filter((id) => {
538+
const chat = state.pages.find((c) => c.id === id)
539+
return chat?.pinned
540+
}).length
438541
} as React.CSSProperties
439542
}
440543
/>
@@ -445,7 +548,7 @@ export default function TabsArea() {
445548
return chat?.pinned
446549
})
447550
.map(
448-
(id) => `
551+
(id, index, pinnedTabs) => `
449552
.tabs-area .ant-tabs-tab[data-node-key="${id}"] {
450553
background: linear-gradient(135deg, rgba(24, 144, 255, 0.1), rgba(24, 144, 255, 0.05)) !important;
451554
border-color: rgba(24, 144, 255, 0.3) !important;
@@ -461,10 +564,19 @@ export default function TabsArea() {
461564
background: #1890ff;
462565
border-radius: 0 2px 2px 0;
463566
}
464-
.tabs-area .ant-tabs-tab[data-node-key="${id}"] .ant-tabs-tab-remove {
465-
opacity: 0.5 !important;
466-
pointer-events: none !important;
567+
${index === pinnedTabs.length - 1 ? `
568+
.tabs-area .ant-tabs-tab[data-node-key="${id}"]::after {
569+
content: '';
570+
position: absolute;
571+
right: -8px;
572+
top: 50%;
573+
transform: translateY(-50%);
574+
width: 1px;
575+
height: 20px;
576+
background: rgba(24, 144, 255, 0.3);
577+
border-radius: 1px;
467578
}
579+
` : ''}
468580
`
469581
)
470582
.join('')}

0 commit comments

Comments
 (0)