152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
/**
|
|
* PaneContainer - Horizontal flex container that renders panes side by side.
|
|
* Wraps children with @dnd-kit DndContext provider for tab drag-and-drop.
|
|
*
|
|
* DnD interactions:
|
|
* - Drag within same TabBar → reorder tabs (reorderTabInPane)
|
|
* - Drag to another pane's TabBar → move tab to target pane (moveTabToPane)
|
|
* - Drag to pane edge zone → create new split pane (moveTabToNewPane)
|
|
* - Drag last tab out of pane → source pane auto-closes
|
|
*/
|
|
|
|
import { Fragment, useCallback, useState } from 'react';
|
|
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
pointerWithin,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core';
|
|
import { useStore } from '@renderer/store';
|
|
|
|
import { PaneResizeHandle } from './PaneResizeHandle';
|
|
import { PaneView } from './PaneView';
|
|
import { DragOverlayTab } from './SortableTab';
|
|
|
|
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
|
import type { Tab } from '@renderer/types/tabs';
|
|
|
|
export const PaneContainer = (): React.JSX.Element => {
|
|
const panes = useStore((s) => s.paneLayout.panes);
|
|
|
|
// Track the currently dragged tab for DragOverlay
|
|
const [activeTab, setActiveTab] = useState<Tab | null>(null);
|
|
|
|
// Configure pointer sensor with activation distance to avoid conflict with clicks
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 8, // 8px drag distance before activating
|
|
},
|
|
})
|
|
);
|
|
|
|
const handleDragStart = useCallback(
|
|
(event: DragStartEvent) => {
|
|
const { active } = event;
|
|
const data = active.data.current;
|
|
|
|
if (data?.type === 'tab') {
|
|
const sourcePaneId = data.paneId as string;
|
|
const tabId = data.tabId as string;
|
|
|
|
// Find the tab in the source pane
|
|
const pane = panes.find((p) => p.id === sourcePaneId);
|
|
const tab = pane?.tabs.find((t) => t.id === tabId);
|
|
if (tab) {
|
|
setActiveTab(tab);
|
|
}
|
|
}
|
|
},
|
|
[panes]
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
setActiveTab(null);
|
|
|
|
if (!over || !active.data.current) return;
|
|
|
|
const activeData = active.data.current;
|
|
const overData = over.data.current;
|
|
|
|
if (activeData.type !== 'tab') return;
|
|
|
|
const draggedTabId = activeData.tabId as string;
|
|
const sourcePaneId = activeData.paneId as string;
|
|
const state = useStore.getState();
|
|
|
|
// Case 1: Drop on a split-zone (edge of pane) → create new pane
|
|
if (overData?.type === 'split-zone') {
|
|
const targetPaneId = overData.paneId as string;
|
|
const side = overData.side as 'left' | 'right';
|
|
state.moveTabToNewPane(draggedTabId, sourcePaneId, targetPaneId, side);
|
|
return;
|
|
}
|
|
|
|
// Case 2: Drop on a tabbar (different pane) → move tab to that pane
|
|
if (overData?.type === 'tabbar') {
|
|
const targetPaneId = overData.paneId as string;
|
|
if (sourcePaneId !== targetPaneId) {
|
|
state.moveTabToPane(draggedTabId, sourcePaneId, targetPaneId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Case 3: Drop on another sortable tab
|
|
// This can mean either reorder within same pane or move to another pane's tab position
|
|
if (overData?.type === 'tab') {
|
|
const overTabId = overData.tabId as string;
|
|
const overPaneId = overData.paneId as string;
|
|
|
|
if (sourcePaneId === overPaneId) {
|
|
// Reorder within the same pane
|
|
const pane = panes.find((p) => p.id === sourcePaneId);
|
|
if (!pane) return;
|
|
|
|
const fromIndex = pane.tabs.findIndex((t) => t.id === draggedTabId);
|
|
const toIndex = pane.tabs.findIndex((t) => t.id === overTabId);
|
|
|
|
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
|
state.reorderTabInPane(sourcePaneId, fromIndex, toIndex);
|
|
}
|
|
} else {
|
|
// Move to another pane, inserting at the over tab's position
|
|
const targetPane = panes.find((p) => p.id === overPaneId);
|
|
if (!targetPane) return;
|
|
|
|
const insertIndex = targetPane.tabs.findIndex((t) => t.id === overTabId);
|
|
state.moveTabToPane(draggedTabId, sourcePaneId, overPaneId, insertIndex);
|
|
}
|
|
}
|
|
},
|
|
[panes]
|
|
);
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={pointerWithin}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div id="pane-container" className="flex flex-1 overflow-hidden">
|
|
{panes.map((pane, i) => (
|
|
<Fragment key={pane.id}>
|
|
{i > 0 && <PaneResizeHandle leftPaneId={panes[i - 1].id} rightPaneId={pane.id} />}
|
|
<PaneView paneId={pane.id} />
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
|
|
{/* Drag overlay - semi-transparent ghost of the dragged tab */}
|
|
<DragOverlay dropAnimation={null}>
|
|
{activeTab ? <DragOverlayTab tab={activeTab} /> : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
};
|