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:
@@ -66,12 +66,14 @@ export function TopNavbar({
|
|||||||
currentX: number;
|
currentX: number;
|
||||||
startX: number;
|
startX: number;
|
||||||
targetIndex: number | null;
|
targetIndex: number | null;
|
||||||
|
hoverTabIndex: number | null;
|
||||||
}>({
|
}>({
|
||||||
draggedId: null,
|
draggedId: null,
|
||||||
draggedIndex: null,
|
draggedIndex: null,
|
||||||
currentX: 0,
|
currentX: 0,
|
||||||
startX: 0,
|
startX: 0,
|
||||||
targetIndex: null,
|
targetIndex: null,
|
||||||
|
hoverTabIndex: null,
|
||||||
});
|
});
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const tabRefs = React.useRef<Map<number, HTMLDivElement>>(new Map());
|
const tabRefs = React.useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
@@ -123,6 +125,7 @@ export function TopNavbar({
|
|||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
currentX: e.clientX,
|
currentX: e.clientX,
|
||||||
targetIndex: index,
|
targetIndex: index,
|
||||||
|
hoverTabIndex: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,6 +210,22 @@ export function TopNavbar({
|
|||||||
return newTargetIndex;
|
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) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
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();
|
const newTargetIndex = calculateTargetIndex();
|
||||||
if (newTargetIndex !== null && newTargetIndex !== dragState.targetIndex) {
|
if (newTargetIndex !== null && newTargetIndex !== dragState.targetIndex) {
|
||||||
setDragState((prev) => ({
|
setDragState((prev) => ({
|
||||||
@@ -240,7 +267,57 @@ export function TopNavbar({
|
|||||||
const fromIndex = dragState.draggedIndex;
|
const fromIndex = dragState.draggedIndex;
|
||||||
const toIndex = dragState.targetIndex;
|
const toIndex = dragState.targetIndex;
|
||||||
const draggedId = dragState.draggedId;
|
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) {
|
if (fromIndex !== null && toIndex !== null && fromIndex !== toIndex) {
|
||||||
prevTabsRef.current = tabs;
|
prevTabsRef.current = tabs;
|
||||||
|
|
||||||
@@ -252,6 +329,7 @@ export function TopNavbar({
|
|||||||
startX: 0,
|
startX: 0,
|
||||||
currentX: 0,
|
currentX: 0,
|
||||||
targetIndex: null,
|
targetIndex: null,
|
||||||
|
hoverTabIndex: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,6 +345,7 @@ export function TopNavbar({
|
|||||||
startX: 0,
|
startX: 0,
|
||||||
currentX: 0,
|
currentX: 0,
|
||||||
targetIndex: null,
|
targetIndex: null,
|
||||||
|
hoverTabIndex: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +363,7 @@ export function TopNavbar({
|
|||||||
startX: 0,
|
startX: 0,
|
||||||
currentX: 0,
|
currentX: 0,
|
||||||
targetIndex: null,
|
targetIndex: null,
|
||||||
|
hoverTabIndex: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -351,6 +431,25 @@ export function TopNavbar({
|
|||||||
? dragState.currentX - dragState.startX
|
? dragState.currentX - dragState.startX
|
||||||
: 0;
|
: 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 = "";
|
let transform = "";
|
||||||
|
|
||||||
if (!isInDropAnimation) {
|
if (!isInDropAnimation) {
|
||||||
@@ -466,6 +565,8 @@ export function TopNavbar({
|
|||||||
disableClose={disableClose}
|
disableClose={disableClose}
|
||||||
isDragging={isDraggingThisTab}
|
isDragging={isDraggingThisTab}
|
||||||
isDragOver={false}
|
isDragOver={false}
|
||||||
|
isValidDropTarget={isValidDropTarget}
|
||||||
|
isHoveredDropTarget={isHoveredDropTarget}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface TabProps {
|
|||||||
disableClose?: boolean;
|
disableClose?: boolean;
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
isDragOver?: boolean;
|
isDragOver?: boolean;
|
||||||
|
isValidDropTarget?: boolean;
|
||||||
|
isHoveredDropTarget?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tab({
|
export function Tab({
|
||||||
@@ -44,6 +46,8 @@ export function Tab({
|
|||||||
disableClose = false,
|
disableClose = false,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
isDragOver = false,
|
isDragOver = false,
|
||||||
|
isValidDropTarget = false,
|
||||||
|
isHoveredDropTarget = false,
|
||||||
}: TabProps): React.ReactElement {
|
}: TabProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -54,12 +58,21 @@ export function Tab({
|
|||||||
isDragOver &&
|
isDragOver &&
|
||||||
"bg-background/40 text-muted-foreground border-border opacity-60",
|
"bg-background/40 text-muted-foreground border-border opacity-60",
|
||||||
isDragging && "opacity-70",
|
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 &&
|
!isDragOver &&
|
||||||
!isDragging &&
|
!isDragging &&
|
||||||
|
!isValidDropTarget &&
|
||||||
|
!isHoveredDropTarget &&
|
||||||
isActive &&
|
isActive &&
|
||||||
"bg-background text-foreground border-border z-10",
|
"bg-background text-foreground border-border z-10",
|
||||||
!isDragOver &&
|
!isDragOver &&
|
||||||
!isDragging &&
|
!isDragging &&
|
||||||
|
!isValidDropTarget &&
|
||||||
|
!isHoveredDropTarget &&
|
||||||
!isActive &&
|
!isActive &&
|
||||||
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
|
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user