feat: Add drag-and-drop split screen functionality for tabs

Implements intuitive drag-and-drop split screen feature for tabs:

Core Functionality:
- Drag a splittable tab (Terminal, File Manager, or Server) onto another splittable tab
- Automatically triggers split screen mode for the dragged tab
- Target tab becomes the active focused tab
- Works alongside existing tab reordering drag-and-drop

Visual Feedback:
- Valid drop targets show blue border (border-blue-400/50) when dragging begins
- Hovered drop target shows enhanced highlight with blue ring and background
- Clear visual distinction between reorder drag vs split screen drop
- Smooth transitions and hover states

Smart Detection:
- Only allows split screen between compatible tab types (terminal/server/file_manager)
- Prevents splitting onto tabs already in split screen mode
- Respects 3-tab split screen limit
- Automatically falls back to reorder if dropped between tabs instead of on a tab

Implementation Details:
- Added hoverTabIndex to dragState to track which tab is being hovered
- New findHoveredTab() function detects mouse position over tabs
- Modified handleDrop() to prioritize split screen over reorder
- Added isValidDropTarget and isHoveredDropTarget props to Tab component
- Updated Tab.tsx styles with conditional classes for drop target states

User Experience:
- Drag tab to empty space between tabs = reorder (existing behavior)
- Drag tab directly onto another tab = split screen (new behavior)
- Visual feedback guides user to valid drop targets
- Seamless integration with existing tab management
This commit is contained in:
ZacharyZcR
2025-11-09 17:45:44 +08:00
parent 2eb6c26c42
commit 303386806f
2 changed files with 114 additions and 0 deletions

View File

@@ -66,12 +66,14 @@ export function TopNavbar({
currentX: number;
startX: number;
targetIndex: number | null;
hoverTabIndex: number | null;
}>({
draggedId: null,
draggedIndex: null,
currentX: 0,
startX: 0,
targetIndex: null,
hoverTabIndex: null,
});
const containerRef = React.useRef<HTMLDivElement | null>(null);
const tabRefs = React.useRef<Map<number, HTMLDivElement>>(new Map());
@@ -123,6 +125,7 @@ export function TopNavbar({
startX: e.clientX,
currentX: e.clientX,
targetIndex: index,
hoverTabIndex: null,
});
};
@@ -207,6 +210,22 @@ export function TopNavbar({
return newTargetIndex;
};
const findHoveredTab = (clientX: number, clientY: number): number | null => {
for (const [index, tabEl] of tabRefs.current.entries()) {
if (!tabEl) continue;
const rect = tabEl.getBoundingClientRect();
if (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
) {
return index;
}
}
return null;
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
@@ -222,6 +241,14 @@ export function TopNavbar({
}));
}
const hoveredTabIndex = findHoveredTab(e.clientX, e.clientY);
if (hoveredTabIndex !== dragState.hoverTabIndex) {
setDragState((prev) => ({
...prev,
hoverTabIndex: hoveredTabIndex,
}));
}
const newTargetIndex = calculateTargetIndex();
if (newTargetIndex !== null && newTargetIndex !== dragState.targetIndex) {
setDragState((prev) => ({
@@ -240,7 +267,57 @@ export function TopNavbar({
const fromIndex = dragState.draggedIndex;
const toIndex = dragState.targetIndex;
const draggedId = dragState.draggedId;
const hoverTabIndex = dragState.hoverTabIndex;
// Check if dropping onto another tab for split screen
if (
fromIndex !== null &&
hoverTabIndex !== null &&
fromIndex !== hoverTabIndex &&
draggedId !== null
) {
const draggedTab = tabs[fromIndex];
const targetTab = tabs[hoverTabIndex];
const isDraggedSplittable =
draggedTab.type === "terminal" ||
draggedTab.type === "server" ||
draggedTab.type === "file_manager";
const isTargetSplittable =
targetTab.type === "terminal" ||
targetTab.type === "server" ||
targetTab.type === "file_manager";
// Both tabs must be splittable and target must not already be in split screen
if (
isDraggedSplittable &&
isTargetSplittable &&
!allSplitScreenTab.includes(targetTab.id) &&
allSplitScreenTab.length < 3
) {
// Trigger split screen for the dragged tab
setSplitScreenTab(draggedId);
setCurrentTab(targetTab.id);
setDragState({
draggedId: null,
draggedIndex: null,
startX: 0,
currentX: 0,
targetIndex: null,
hoverTabIndex: null,
});
setTimeout(() => {
isProcessingDropRef.current = false;
setIsInDropAnimation(false);
}, 50);
return;
}
}
// Original reorder logic
if (fromIndex !== null && toIndex !== null && fromIndex !== toIndex) {
prevTabsRef.current = tabs;
@@ -252,6 +329,7 @@ export function TopNavbar({
startX: 0,
currentX: 0,
targetIndex: null,
hoverTabIndex: null,
});
});
@@ -267,6 +345,7 @@ export function TopNavbar({
startX: 0,
currentX: 0,
targetIndex: null,
hoverTabIndex: null,
});
}
@@ -284,6 +363,7 @@ export function TopNavbar({
startX: 0,
currentX: 0,
targetIndex: null,
hoverTabIndex: null,
});
};
@@ -351,6 +431,25 @@ export function TopNavbar({
? dragState.currentX - dragState.startX
: 0;
// Check if this tab is a valid drop target for split screen
const draggedTab =
dragState.draggedIndex !== null
? tabs[dragState.draggedIndex]
: null;
const isDraggedSplittable =
draggedTab &&
(draggedTab.type === "terminal" ||
draggedTab.type === "server" ||
draggedTab.type === "file_manager");
const isValidDropTarget =
isDraggedSplittable &&
isSplittable &&
!isDraggingThisTab &&
!isSplit &&
allSplitScreenTab.length < 3;
const isHoveredDropTarget =
isValidDropTarget && dragState.hoverTabIndex === index;
let transform = "";
if (!isInDropAnimation) {
@@ -466,6 +565,8 @@ export function TopNavbar({
disableClose={disableClose}
isDragging={isDraggingThisTab}
isDragOver={false}
isValidDropTarget={isValidDropTarget}
isHoveredDropTarget={isHoveredDropTarget}
/>
</div>
);

View File

@@ -27,6 +27,8 @@ interface TabProps {
disableClose?: boolean;
isDragging?: boolean;
isDragOver?: boolean;
isValidDropTarget?: boolean;
isHoveredDropTarget?: boolean;
}
export function Tab({
@@ -44,6 +46,8 @@ export function Tab({
disableClose = false,
isDragging = false,
isDragOver = false,
isValidDropTarget = false,
isHoveredDropTarget = false,
}: TabProps): React.ReactElement {
const { t } = useTranslation();
@@ -54,12 +58,21 @@ export function Tab({
isDragOver &&
"bg-background/40 text-muted-foreground border-border opacity-60",
isDragging && "opacity-70",
isHoveredDropTarget &&
"bg-blue-500/20 border-blue-500 ring-2 ring-blue-500/50",
!isHoveredDropTarget &&
isValidDropTarget &&
"border-blue-400/50 bg-background/90",
!isDragOver &&
!isDragging &&
!isValidDropTarget &&
!isHoveredDropTarget &&
isActive &&
"bg-background text-foreground border-border z-10",
!isDragOver &&
!isDragging &&
!isValidDropTarget &&
!isHoveredDropTarget &&
!isActive &&
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
);