feat: pro: drag tabs to reorder, drag to edge to split

This commit is contained in:
Linus Rath
2026-05-18 19:30:54 +02:00
parent 98879802ae
commit 43ac0725ce
3 changed files with 649 additions and 106 deletions
+238 -50
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState, type ComponentType } from "react";
import { useEffect, useMemo, useState, type ComponentType, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -11,8 +11,8 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded";
import { ProTabBar } from "@/components/pro/pro-tab-bar";
import { useProTabStore, type ProTabKind } from "@/stores/pro-tab-store";
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils";
import MailPage from "@/app/[locale]/page";
@@ -31,6 +31,69 @@ const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
settings: SettingsPage,
};
type DropTarget = 'left' | 'right' | 'top' | 'bottom' | null;
function renderTabBody(tab: ProTab): React.ReactNode {
if (tab.kind === 'compose' && tab.composeData) {
return <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
}
if (tab.kind === 'email' && tab.emailData) {
return <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
}
const Component = APP_TAB_COMPONENTS[tab.kind];
return Component ? <Component /> : null;
}
interface PaneProps {
paneId: ProPaneId;
tabs: ProTab[];
activeTabId: string | null;
loadedTabIds: string[];
allTabs: ProTab[];
onActivate: (id: string) => void;
onClose: (id: string) => void;
onDragStateChange: (dragging: boolean) => void;
onPaneFocus: (paneId: ProPaneId) => void;
isFocused: boolean;
}
function Pane({
paneId, tabs, activeTabId, loadedTabIds, allTabs,
onActivate, onClose, onDragStateChange, onPaneFocus, isFocused,
}: PaneProps) {
return (
<div
className="flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
>
<ProTabBar
tabs={tabs}
activeTabId={activeTabId}
paneId={paneId}
onActivate={onActivate}
onClose={onClose}
onDragStateChange={onDragStateChange}
/>
<div className="relative flex-1 min-h-0">
{allTabs
.filter((tab) => tab.paneId === paneId && loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
</div>
</div>
);
}
export default function ProHome() {
const t = useTranslations();
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
@@ -58,11 +121,19 @@ export default function ProHome() {
const isPushConnected = useEmailStore((s) => s.isPushConnected);
const tabs = useProTabStore((s) => s.tabs);
const activeTabId = useProTabStore((s) => s.activeTabId);
const activeMainTabId = useProTabStore((s) => s.activeTabId);
const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId);
const splitOrientation = useProTabStore((s) => s.splitOrientation);
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
const openTab = useProTabStore((s) => s.openTab);
const closeTab = useProTabStore((s) => s.closeTab);
const setActiveTab = useProTabStore((s) => s.setActiveTab);
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
const [isTabDragging, setIsTabDragging] = useState(false);
const [splitDropTarget, setSplitDropTarget] = useState<DropTarget>(null);
// Auth bootstrap (mirrors standard page)
useEffect(() => {
@@ -89,24 +160,87 @@ export default function ProHome() {
}
}, [initialCheckDone, isMobile, isTablet]);
const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0],
[tabs, activeTabId]
);
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
const focusedActiveTab = useMemo(() => {
const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId;
return tabs.find((t) => t.id === id) ?? null;
}, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]);
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
openTab(itemId);
return true;
};
// Only highlight the rail when an "app" tab is active; compose/email tabs
// don't correspond to any rail item.
const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null =
activeTab && (
activeTab.kind === 'mail' || activeTab.kind === 'calendar'
|| activeTab.kind === 'contacts' || activeTab.kind === 'files'
|| activeTab.kind === 'settings'
) ? activeTab.kind : null;
focusedActiveTab && (
focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar'
|| focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files'
|| focusedActiveTab.kind === 'settings'
) ? focusedActiveTab.kind : null;
// ---- Split drop zones ----
const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
const computeDropTarget = (e: DragEvent<HTMLDivElement>): DropTarget => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const xFrac = x / rect.width;
const yFrac = y / rect.height;
// Edges: outer 22% of the body becomes a split drop target. The cursor's
// dominant axis decides which side activates.
const fromLeft = xFrac;
const fromRight = 1 - xFrac;
const fromTop = yFrac;
const fromBottom = 1 - yFrac;
const min = Math.min(fromLeft, fromRight, fromTop, fromBottom);
if (min > 0.22) return null;
if (min === fromRight) return 'right';
if (min === fromLeft) return 'left';
if (min === fromBottom) return 'bottom';
return 'top';
};
const handleBodyDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
if (splitOrientation !== null) return; // already split — body drops disabled
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const next = computeDropTarget(e);
if (next !== splitDropTarget) setSplitDropTarget(next);
};
const handleBodyDragLeave = (e: DragEvent<HTMLDivElement>) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.contains(next)) return;
setSplitDropTarget(null);
};
const handleBodyDrop = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
if (splitOrientation !== null) return;
const target = computeDropTarget(e);
setSplitDropTarget(null);
setIsTabDragging(false);
if (!target) return;
e.preventDefault();
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId) return;
// The dragged tab must currently be in 'main' (the only pane right now).
// Moving it to 'split' creates the split.
const orientation = (target === 'left' || target === 'right') ? 'vertical' : 'horizontal';
moveTabToPane(draggedId, 'split', orientation);
// 'left'/'top' targets put the split pane on the leading edge — flipped
// visually by swapping the rendered order below. We track it via the
// splitLeading flag derived from the last drop.
setSplitLeading(target === 'left' || target === 'top');
};
// Whether the split pane renders before (true) or after (false) the main pane.
const [splitLeading, setSplitLeading] = useState(false);
// Loading state (matches standard page exactly)
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
@@ -122,6 +256,53 @@ export default function ProHome() {
if (!isDesktop) return null;
const isSplit = splitOrientation !== null && splitTabs.length > 0;
const mainPane = (
<Pane
paneId="main"
tabs={mainTabs}
activeTabId={activeMainTabId}
loadedTabIds={loadedTabIds}
allTabs={tabs}
onActivate={setActiveTab}
onClose={closeTab}
onDragStateChange={setIsTabDragging}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'main'}
/>
);
const splitPane = isSplit ? (
<Pane
paneId="split"
tabs={splitTabs}
activeTabId={activeSplitTabId}
loadedTabIds={loadedTabIds}
allTabs={tabs}
onActivate={setActiveTab}
onClose={closeTab}
onDragStateChange={setIsTabDragging}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'split'}
/>
) : null;
const splitDivider = isSplit ? (
<div
aria-hidden="true"
className={cn(
"flex-shrink-0 bg-transparent",
splitOrientation === 'vertical' ? "w-px" : "h-px",
)}
style={
splitOrientation === 'vertical'
? { borderLeft: '1px solid rgba(128, 128, 128, 0.3)' }
: { borderTop: '1px solid rgba(128, 128, 128, 0.3)' }
}
/>
) : null;
return (
<EmbeddedContext.Provider value={true}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
@@ -156,42 +337,31 @@ export default function ProHome() {
)}
{!inlineApp && (
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
<ProTabBar
tabs={tabs}
activeTabId={activeTabId}
onActivate={setActiveTab}
onClose={closeTab}
/>
<div
className={cn(
"relative flex flex-1 overflow-hidden min-w-0",
isSplit && splitOrientation === 'horizontal' ? "flex-col" : "flex-row",
)}
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
>
{isSplit
? (splitLeading
? <>{splitPane}{splitDivider}{mainPane}</>
: <>{mainPane}{splitDivider}{splitPane}</>)
: mainPane}
{/* Tab bodies — every loaded tab stays mounted so flipping tabs
preserves the page's internal state (selection, scroll,
drafts). Inactive ones are hidden via CSS. */}
<div className="relative flex-1 min-h-0">
{tabs
.filter((tab) => loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
let body: React.ReactNode = null;
if (tab.kind === 'compose' && tab.composeData) {
body = <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
} else if (tab.kind === 'email' && tab.emailData) {
body = <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
} else {
const Component = APP_TAB_COMPONENTS[tab.kind];
if (Component) body = <Component />;
}
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{body}
</div>
);
})}
</div>
{/* Split-creation drop zones — shown only while a tab is being
dragged and the body isn't already split. */}
{isTabDragging && !isSplit && (
<>
<DropZone active={splitDropTarget === 'left'} side="left" />
<DropZone active={splitDropTarget === 'right'} side="right" />
<DropZone active={splitDropTarget === 'top'} side="top" />
<DropZone active={splitDropTarget === 'bottom'} side="bottom" />
</>
)}
</div>
)}
</div>
@@ -207,3 +377,21 @@ export default function ProHome() {
</EmbeddedContext.Provider>
);
}
function DropZone({ active, side }: { active: boolean; side: 'left' | 'right' | 'top' | 'bottom' }) {
const isVertical = side === 'left' || side === 'right';
return (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute z-10 transition-colors duration-100",
isVertical ? "top-0 bottom-0 w-[22%]" : "left-0 right-0 h-[22%]",
side === 'left' && "left-0",
side === 'right' && "right-0",
side === 'top' && "top-0",
side === 'bottom' && "bottom-0",
active ? "bg-primary/15 ring-2 ring-primary/40 ring-inset" : "bg-transparent",
)}
/>
);
}