feat: pro: drag tabs to reorder, drag to edge to split
This commit is contained in:
+238
-50
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useTranslations } from "next-intl";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
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 { useEmailStore } from "@/stores/email-store";
|
||||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { EmbeddedContext } from "@/hooks/use-is-embedded";
|
import { EmbeddedContext } from "@/hooks/use-is-embedded";
|
||||||
import { ProTabBar } from "@/components/pro/pro-tab-bar";
|
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
|
||||||
import { useProTabStore, type ProTabKind } from "@/stores/pro-tab-store";
|
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import MailPage from "@/app/[locale]/page";
|
import MailPage from "@/app/[locale]/page";
|
||||||
@@ -31,6 +31,69 @@ const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
|
|||||||
settings: SettingsPage,
|
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() {
|
export default function ProHome() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
|
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
|
||||||
@@ -58,11 +121,19 @@ export default function ProHome() {
|
|||||||
const isPushConnected = useEmailStore((s) => s.isPushConnected);
|
const isPushConnected = useEmailStore((s) => s.isPushConnected);
|
||||||
|
|
||||||
const tabs = useProTabStore((s) => s.tabs);
|
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 loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
||||||
const openTab = useProTabStore((s) => s.openTab);
|
const openTab = useProTabStore((s) => s.openTab);
|
||||||
const closeTab = useProTabStore((s) => s.closeTab);
|
const closeTab = useProTabStore((s) => s.closeTab);
|
||||||
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
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)
|
// Auth bootstrap (mirrors standard page)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,24 +160,87 @@ export default function ProHome() {
|
|||||||
}
|
}
|
||||||
}, [initialCheckDone, isMobile, isTablet]);
|
}, [initialCheckDone, isMobile, isTablet]);
|
||||||
|
|
||||||
const activeTab = useMemo(
|
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
|
||||||
() => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0],
|
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
|
||||||
[tabs, activeTabId]
|
|
||||||
);
|
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') => {
|
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
|
||||||
openTab(itemId);
|
openTab(itemId);
|
||||||
return true;
|
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 =
|
const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null =
|
||||||
activeTab && (
|
focusedActiveTab && (
|
||||||
activeTab.kind === 'mail' || activeTab.kind === 'calendar'
|
focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar'
|
||||||
|| activeTab.kind === 'contacts' || activeTab.kind === 'files'
|
|| focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files'
|
||||||
|| activeTab.kind === 'settings'
|
|| focusedActiveTab.kind === 'settings'
|
||||||
) ? activeTab.kind : null;
|
) ? 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)
|
// Loading state (matches standard page exactly)
|
||||||
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
|
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
|
||||||
@@ -122,6 +256,53 @@ export default function ProHome() {
|
|||||||
|
|
||||||
if (!isDesktop) return null;
|
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 (
|
return (
|
||||||
<EmbeddedContext.Provider value={true}>
|
<EmbeddedContext.Provider value={true}>
|
||||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
<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 && (
|
{!inlineApp && (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
|
<div
|
||||||
<ProTabBar
|
className={cn(
|
||||||
tabs={tabs}
|
"relative flex flex-1 overflow-hidden min-w-0",
|
||||||
activeTabId={activeTabId}
|
isSplit && splitOrientation === 'horizontal' ? "flex-col" : "flex-row",
|
||||||
onActivate={setActiveTab}
|
)}
|
||||||
onClose={closeTab}
|
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
|
{/* Split-creation drop zones — shown only while a tab is being
|
||||||
preserves the page's internal state (selection, scroll,
|
dragged and the body isn't already split. */}
|
||||||
drafts). Inactive ones are hidden via CSS. */}
|
{isTabDragging && !isSplit && (
|
||||||
<div className="relative flex-1 min-h-0">
|
<>
|
||||||
{tabs
|
<DropZone active={splitDropTarget === 'left'} side="left" />
|
||||||
.filter((tab) => loadedTabIds.includes(tab.id))
|
<DropZone active={splitDropTarget === 'right'} side="right" />
|
||||||
.map((tab) => {
|
<DropZone active={splitDropTarget === 'top'} side="top" />
|
||||||
const isActive = tab.id === activeTabId;
|
<DropZone active={splitDropTarget === 'bottom'} side="bottom" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -207,3 +377,21 @@ export default function ProHome() {
|
|||||||
</EmbeddedContext.Provider>
|
</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",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+149
-11
@@ -1,15 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, X, type LucideIcon } from "lucide-react";
|
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, X, type LucideIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ProTab, ProTabKind } from "@/stores/pro-tab-store";
|
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||||
|
|
||||||
|
/** Custom MIME type used to carry the dragged Pro tab id between handlers. */
|
||||||
|
export const PRO_TAB_DRAG_MIME = "application/x-pro-tab-id";
|
||||||
|
|
||||||
interface ProTabBarProps {
|
interface ProTabBarProps {
|
||||||
|
/** Tabs belonging to this bar's pane, already filtered + ordered. */
|
||||||
tabs: ProTab[];
|
tabs: ProTab[];
|
||||||
activeTabId: string;
|
activeTabId: string | null;
|
||||||
|
paneId: ProPaneId;
|
||||||
onActivate: (id: string) => void;
|
onActivate: (id: string) => void;
|
||||||
onClose: (id: string) => void;
|
onClose: (id: string) => void;
|
||||||
|
onDragStateChange?: (dragging: boolean) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,45 +30,154 @@ const TAB_ICONS: Record<ProTabKind, LucideIcon> = {
|
|||||||
email: MailOpen,
|
email: MailOpen,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProTabBar({ tabs, activeTabId, onActivate, onClose, className }: ProTabBarProps) {
|
type DropIndicator = { targetId: string; edge: "before" | "after" } | null;
|
||||||
|
|
||||||
|
export function ProTabBar({
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
paneId,
|
||||||
|
onActivate,
|
||||||
|
onClose,
|
||||||
|
onDragStateChange,
|
||||||
|
className,
|
||||||
|
}: ProTabBarProps) {
|
||||||
const tSidebar = useTranslations("sidebar");
|
const tSidebar = useTranslations("sidebar");
|
||||||
|
const reorderTab = useProTabStore((s) => s.reorderTab);
|
||||||
|
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
||||||
|
const splitOrientation = useProTabStore((s) => s.splitOrientation);
|
||||||
|
|
||||||
|
const [dropIndicator, setDropIndicator] = useState<DropIndicator>(null);
|
||||||
|
const dragLeaveTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const isProTabDrag = (e: DragEvent) =>
|
||||||
|
e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
|
||||||
|
|
||||||
|
const handleDragStart = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
|
||||||
|
e.dataTransfer.setData(PRO_TAB_DRAG_MIME, tab.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
onDragStateChange?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDropIndicator(null);
|
||||||
|
onDragStateChange?.(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabDragOver = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
|
||||||
|
if (!isProTabDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const edge: "before" | "after" =
|
||||||
|
e.clientX < rect.left + rect.width / 2 ? "before" : "after";
|
||||||
|
setDropIndicator((prev) =>
|
||||||
|
prev && prev.targetId === tab.id && prev.edge === edge
|
||||||
|
? prev
|
||||||
|
: { targetId: tab.id, edge },
|
||||||
|
);
|
||||||
|
if (dragLeaveTimer.current !== null) {
|
||||||
|
window.clearTimeout(dragLeaveTimer.current);
|
||||||
|
dragLeaveTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStripDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
// Clear the indicator only when the cursor truly leaves the strip; the
|
||||||
|
// intermediate dragleave events that fire as the cursor crosses tab
|
||||||
|
// boundaries would otherwise flicker the indicator off.
|
||||||
|
const next = e.relatedTarget as Node | null;
|
||||||
|
if (next && e.currentTarget.contains(next)) return;
|
||||||
|
if (dragLeaveTimer.current !== null) window.clearTimeout(dragLeaveTimer.current);
|
||||||
|
dragLeaveTimer.current = window.setTimeout(() => {
|
||||||
|
setDropIndicator(null);
|
||||||
|
dragLeaveTimer.current = null;
|
||||||
|
}, 40);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabDrop = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
|
||||||
|
if (!isProTabDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||||
|
if (!draggedId || draggedId === tab.id) {
|
||||||
|
handleDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const edge = dropIndicator?.targetId === tab.id ? dropIndicator.edge : "after";
|
||||||
|
reorderTab(draggedId, tab.id, edge);
|
||||||
|
handleDragEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dropping on the empty trailing area moves the tab to the end of this bar's
|
||||||
|
// pane. If the dragged tab was in the other pane, that also moves it here.
|
||||||
|
const handleStripEndDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!isProTabDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||||
|
if (!draggedId) {
|
||||||
|
handleDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const last = tabs[tabs.length - 1];
|
||||||
|
if (!last) {
|
||||||
|
// Bar is empty — move into this pane.
|
||||||
|
moveTabToPane(draggedId, paneId, splitOrientation ?? "vertical");
|
||||||
|
} else if (last.id !== draggedId) {
|
||||||
|
reorderTab(draggedId, last.id, "after");
|
||||||
|
}
|
||||||
|
handleDragEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStripEndDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!isProTabDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
const last = tabs[tabs.length - 1];
|
||||||
|
if (last) {
|
||||||
|
setDropIndicator({ targetId: last.id, edge: "after" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
|
"flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
|
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||||
role="tablist"
|
role="tablist"
|
||||||
|
onDragLeave={handleStripDragLeave}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = TAB_ICONS[tab.kind];
|
const Icon = TAB_ICONS[tab.kind];
|
||||||
const isActive = tab.id === activeTabId;
|
const isActive = tab.id === activeTabId;
|
||||||
const label = tab.title ?? tSidebar(tab.labelKey);
|
const label = tab.title ?? tSidebar(tab.labelKey);
|
||||||
|
const showBefore = dropIndicator?.targetId === tab.id && dropIndicator.edge === "before";
|
||||||
|
const showAfter = dropIndicator?.targetId === tab.id && dropIndicator.edge === "after";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
|
data-tab-id={tab.id}
|
||||||
|
draggable
|
||||||
onClick={() => onActivate(tab.id)}
|
onClick={() => onActivate(tab.id)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// Middle-click closes the tab (when closeable) — matches browser-tab behavior.
|
|
||||||
if (e.button === 1 && tab.closeable) {
|
if (e.button === 1 && tab.closeable) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose(tab.id);
|
onClose(tab.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDragStart={(e) => handleDragStart(e, tab)}
|
||||||
|
onDragOver={(e) => handleTabDragOver(e, tab)}
|
||||||
|
onDrop={(e) => handleTabDrop(e, tab)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
className={cn(
|
className={cn(
|
||||||
// Equal-width tabs that grow up to 200px when there's room and
|
|
||||||
// shrink down to ~64px when the bar would overflow — matches
|
|
||||||
// browser/Thunderbird tab behaviour.
|
|
||||||
"group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer select-none transition-colors",
|
"group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer select-none transition-colors",
|
||||||
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
|
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
|
||||||
"border-r border-border first:border-l",
|
"border-r border-border first:border-l",
|
||||||
isActive
|
isActive
|
||||||
? "bg-background text-foreground font-medium"
|
? "bg-background text-foreground font-medium"
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
isActive
|
isActive
|
||||||
@@ -81,9 +197,9 @@ export function ProTabBar({ tabs, activeTabId, onActivate, onClose, className }:
|
|||||||
className={cn(
|
className={cn(
|
||||||
"ml-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
|
"ml-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
|
||||||
"text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground",
|
"text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground",
|
||||||
!isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
!isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
||||||
)}
|
)}
|
||||||
aria-label={tSidebar("close") /* falls back gracefully if missing */}
|
aria-label={tSidebar("close")}
|
||||||
tabIndex={isActive ? 0 : -1}
|
tabIndex={isActive ? 0 : -1}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
@@ -96,9 +212,31 @@ export function ProTabBar({ tabs, activeTabId, onActivate, onClose, className }:
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showBefore && (
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute top-1 bottom-1 left-0 w-0.5 -translate-x-1/2 bg-primary rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showAfter && (
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute top-1 bottom-1 right-0 w-0.5 translate-x-1/2 bg-primary rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Trailing area soaks up drops past the last tab and lets cross-pane
|
||||||
|
moves drop onto an empty bar. */}
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-[8px]"
|
||||||
|
onDragOver={handleStripEndDragOver}
|
||||||
|
onDrop={handleStripEndDrop}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+262
-45
@@ -6,6 +6,9 @@ export type ProTabKind =
|
|||||||
| 'mail' | 'calendar' | 'contacts' | 'files' | 'settings'
|
| 'mail' | 'calendar' | 'contacts' | 'files' | 'settings'
|
||||||
| 'compose' | 'email';
|
| 'compose' | 'email';
|
||||||
|
|
||||||
|
export type ProPaneId = 'main' | 'split';
|
||||||
|
export type ProSplitOrientation = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward';
|
export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,15 +39,12 @@ export interface ProReplyContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProComposeTabData {
|
export interface ProComposeTabData {
|
||||||
/** Stable session id; used by the composer for draft autosave keying. */
|
|
||||||
sessionId: number;
|
sessionId: number;
|
||||||
mode: ProComposerMode;
|
mode: ProComposerMode;
|
||||||
replyTo?: ProReplyContext;
|
replyTo?: ProReplyContext;
|
||||||
initialDraftText?: string;
|
initialDraftText?: string;
|
||||||
initialData?: ComposerDraftData | null;
|
initialData?: ComposerDraftData | null;
|
||||||
/** The id of the source email when replying/forwarding (for $answered/$forwarded). */
|
|
||||||
sourceEmailId?: string | null;
|
sourceEmailId?: string | null;
|
||||||
/** Tab title derived on open; updated as the composer subject changes. */
|
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,16 +60,22 @@ export interface ProTab {
|
|||||||
kind: ProTabKind;
|
kind: ProTabKind;
|
||||||
/** i18n key under `sidebar.*` for built-in app tabs. Empty for compose/email. */
|
/** i18n key under `sidebar.*` for built-in app tabs. Empty for compose/email. */
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
/** Dynamic title for compose/email tabs (overrides labelKey when present). */
|
|
||||||
title?: string;
|
title?: string;
|
||||||
closeable: boolean;
|
closeable: boolean;
|
||||||
composeData?: ProComposeTabData;
|
composeData?: ProComposeTabData;
|
||||||
emailData?: ProEmailTabData;
|
emailData?: ProEmailTabData;
|
||||||
|
/** Which pane this tab lives in. Defaults to 'main' for the single-pane case. */
|
||||||
|
paneId: ProPaneId;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProTabState {
|
interface ProTabState {
|
||||||
tabs: ProTab[];
|
tabs: ProTab[];
|
||||||
|
/** Active tab in each pane. `split` is null when there is no split. */
|
||||||
activeTabId: string;
|
activeTabId: string;
|
||||||
|
activeSplitTabId: string | null;
|
||||||
|
/** When the user last clicked into a tab/body, which pane was it? */
|
||||||
|
focusedPaneId: ProPaneId;
|
||||||
|
splitOrientation: ProSplitOrientation | null;
|
||||||
loadedTabIds: string[];
|
loadedTabIds: string[];
|
||||||
|
|
||||||
openTab: (kind: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => string;
|
openTab: (kind: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => string;
|
||||||
@@ -77,10 +83,30 @@ interface ProTabState {
|
|||||||
openEmailTab: (data: ProEmailTabData) => string;
|
openEmailTab: (data: ProEmailTabData) => string;
|
||||||
closeTab: (id: string) => void;
|
closeTab: (id: string) => void;
|
||||||
setActiveTab: (id: string) => void;
|
setActiveTab: (id: string) => void;
|
||||||
moveTab: (fromIdx: number, toIdx: number) => void;
|
setFocusedPane: (paneId: ProPaneId) => void;
|
||||||
/** Update the dynamic title of a tab (used by compose tabs as the subject changes). */
|
|
||||||
|
/**
|
||||||
|
* Move a tab next to another tab. `edge` controls whether it lands before
|
||||||
|
* or after the target — used by the tab bar's drop indicator. Reordering
|
||||||
|
* works both within a pane and across panes (cross-pane drops move the
|
||||||
|
* tab to the target pane).
|
||||||
|
*/
|
||||||
|
reorderTab: (draggedId: string, targetTabId: string, edge: 'before' | 'after') => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a tab to a specific pane. If moving into `split` and no split
|
||||||
|
* exists, opens a new split using the supplied orientation.
|
||||||
|
*/
|
||||||
|
moveTabToPane: (
|
||||||
|
tabId: string,
|
||||||
|
paneId: ProPaneId,
|
||||||
|
orientation?: ProSplitOrientation,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/** Collapse the split: every split-pane tab returns to main. */
|
||||||
|
collapseSplit: () => void;
|
||||||
|
|
||||||
updateTabTitle: (id: string, title: string) => void;
|
updateTabTitle: (id: string, title: string) => void;
|
||||||
/** Persist updated draft state for a compose tab — used by the composer's onSaveState. */
|
|
||||||
updateComposeDraft: (id: string, draft: ComposerDraftData) => void;
|
updateComposeDraft: (id: string, draft: ComposerDraftData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +123,7 @@ const HOME_TAB: ProTab = {
|
|||||||
kind: 'mail',
|
kind: 'mail',
|
||||||
labelKey: TAB_BLUEPRINTS.mail.labelKey,
|
labelKey: TAB_BLUEPRINTS.mail.labelKey,
|
||||||
closeable: false,
|
closeable: false,
|
||||||
|
paneId: 'main',
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeId(): string {
|
function makeId(): string {
|
||||||
@@ -106,24 +133,42 @@ function makeId(): string {
|
|||||||
return `pro-tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
return `pro-tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function neighborInPane(tabs: ProTab[], removedId: string, paneId: ProPaneId): string | null {
|
||||||
|
const inPane = tabs.filter((t) => t.paneId === paneId);
|
||||||
|
const idx = inPane.findIndex((t) => t.id === removedId);
|
||||||
|
if (idx === -1) return inPane[0]?.id ?? null;
|
||||||
|
return (inPane[idx + 1] ?? inPane[idx - 1])?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export const useProTabStore = create<ProTabState>()(
|
export const useProTabStore = create<ProTabState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
tabs: [HOME_TAB],
|
tabs: [HOME_TAB],
|
||||||
activeTabId: HOME_TAB.id,
|
activeTabId: HOME_TAB.id,
|
||||||
|
activeSplitTabId: null,
|
||||||
|
focusedPaneId: 'main',
|
||||||
|
splitOrientation: null,
|
||||||
loadedTabIds: [HOME_TAB.id],
|
loadedTabIds: [HOME_TAB.id],
|
||||||
|
|
||||||
openTab: (kind) => {
|
openTab: (kind) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const existing = state.tabs.find((tab) => tab.kind === kind);
|
const targetPane = state.focusedPaneId;
|
||||||
|
const existing = state.tabs.find((tab) => tab.kind === kind && tab.paneId === targetPane);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (state.activeTabId !== existing.id) {
|
if (targetPane === 'main') {
|
||||||
set({
|
set({
|
||||||
activeTabId: existing.id,
|
activeTabId: existing.id,
|
||||||
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
||||||
? state.loadedTabIds
|
? state.loadedTabIds
|
||||||
: [...state.loadedTabIds, existing.id],
|
: [...state.loadedTabIds, existing.id],
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
activeSplitTabId: existing.id,
|
||||||
|
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
||||||
|
? state.loadedTabIds
|
||||||
|
: [...state.loadedTabIds, existing.id],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return existing.id;
|
return existing.id;
|
||||||
}
|
}
|
||||||
@@ -133,10 +178,13 @@ export const useProTabStore = create<ProTabState>()(
|
|||||||
kind,
|
kind,
|
||||||
labelKey: blueprint.labelKey,
|
labelKey: blueprint.labelKey,
|
||||||
closeable: true,
|
closeable: true,
|
||||||
|
paneId: targetPane,
|
||||||
};
|
};
|
||||||
set({
|
set({
|
||||||
tabs: [...state.tabs, newTab],
|
tabs: [...state.tabs, newTab],
|
||||||
activeTabId: newTab.id,
|
...(targetPane === 'main'
|
||||||
|
? { activeTabId: newTab.id }
|
||||||
|
: { activeSplitTabId: newTab.id }),
|
||||||
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
||||||
});
|
});
|
||||||
return newTab.id;
|
return newTab.id;
|
||||||
@@ -144,6 +192,7 @@ export const useProTabStore = create<ProTabState>()(
|
|||||||
|
|
||||||
openComposeTab: (data) => {
|
openComposeTab: (data) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
const targetPane = state.focusedPaneId;
|
||||||
const newTab: ProTab = {
|
const newTab: ProTab = {
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
kind: 'compose',
|
kind: 'compose',
|
||||||
@@ -151,10 +200,13 @@ export const useProTabStore = create<ProTabState>()(
|
|||||||
title: data.title,
|
title: data.title,
|
||||||
closeable: true,
|
closeable: true,
|
||||||
composeData: data,
|
composeData: data,
|
||||||
|
paneId: targetPane,
|
||||||
};
|
};
|
||||||
set({
|
set({
|
||||||
tabs: [...state.tabs, newTab],
|
tabs: [...state.tabs, newTab],
|
||||||
activeTabId: newTab.id,
|
...(targetPane === 'main'
|
||||||
|
? { activeTabId: newTab.id }
|
||||||
|
: { activeSplitTabId: newTab.id }),
|
||||||
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
||||||
});
|
});
|
||||||
return newTab.id;
|
return newTab.id;
|
||||||
@@ -162,16 +214,26 @@ export const useProTabStore = create<ProTabState>()(
|
|||||||
|
|
||||||
openEmailTab: (data) => {
|
openEmailTab: (data) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
// Focus an existing email tab for the same message instead of duplicating.
|
const targetPane = state.focusedPaneId;
|
||||||
const existing = state.tabs.find(
|
const existing = state.tabs.find(
|
||||||
(tab) => tab.kind === 'email'
|
(tab) => tab.kind === 'email'
|
||||||
&& tab.emailData?.emailId === data.emailId
|
&& tab.emailData?.emailId === data.emailId
|
||||||
&& tab.emailData?.accountId === data.accountId
|
&& tab.emailData?.accountId === data.accountId
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (state.activeTabId !== existing.id) {
|
// Focus the existing email tab in its current pane.
|
||||||
|
if (existing.paneId === 'main') {
|
||||||
set({
|
set({
|
||||||
activeTabId: existing.id,
|
activeTabId: existing.id,
|
||||||
|
focusedPaneId: 'main',
|
||||||
|
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
||||||
|
? state.loadedTabIds
|
||||||
|
: [...state.loadedTabIds, existing.id],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
activeSplitTabId: existing.id,
|
||||||
|
focusedPaneId: 'split',
|
||||||
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
||||||
? state.loadedTabIds
|
? state.loadedTabIds
|
||||||
: [...state.loadedTabIds, existing.id],
|
: [...state.loadedTabIds, existing.id],
|
||||||
@@ -186,10 +248,13 @@ export const useProTabStore = create<ProTabState>()(
|
|||||||
title: data.title,
|
title: data.title,
|
||||||
closeable: true,
|
closeable: true,
|
||||||
emailData: data,
|
emailData: data,
|
||||||
|
paneId: targetPane,
|
||||||
};
|
};
|
||||||
set({
|
set({
|
||||||
tabs: [...state.tabs, newTab],
|
tabs: [...state.tabs, newTab],
|
||||||
activeTabId: newTab.id,
|
...(targetPane === 'main'
|
||||||
|
? { activeTabId: newTab.id }
|
||||||
|
: { activeSplitTabId: newTab.id }),
|
||||||
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
||||||
});
|
});
|
||||||
return newTab.id;
|
return newTab.id;
|
||||||
@@ -200,53 +265,187 @@ export const useProTabStore = create<ProTabState>()(
|
|||||||
const tab = state.tabs.find((t) => t.id === id);
|
const tab = state.tabs.find((t) => t.id === id);
|
||||||
if (!tab || !tab.closeable) return;
|
if (!tab || !tab.closeable) return;
|
||||||
|
|
||||||
const idx = state.tabs.findIndex((t) => t.id === id);
|
const removedPane = tab.paneId;
|
||||||
const newTabs = state.tabs.filter((t) => t.id !== id);
|
const newTabs = state.tabs.filter((t) => t.id !== id);
|
||||||
const newLoaded = state.loadedTabIds.filter((tid) => tid !== id);
|
const newLoaded = state.loadedTabIds.filter((tid) => tid !== id);
|
||||||
|
|
||||||
let newActive = state.activeTabId;
|
let activeTabId = state.activeTabId;
|
||||||
if (state.activeTabId === id) {
|
let activeSplitTabId = state.activeSplitTabId;
|
||||||
const neighbor = newTabs[idx] ?? newTabs[idx - 1] ?? newTabs[0];
|
let splitOrientation = state.splitOrientation;
|
||||||
newActive = neighbor?.id ?? HOME_TAB.id;
|
let focusedPaneId = state.focusedPaneId;
|
||||||
|
|
||||||
|
if (removedPane === 'main' && state.activeTabId === id) {
|
||||||
|
activeTabId = neighborInPane(newTabs, id, 'main') ?? HOME_TAB.id;
|
||||||
|
}
|
||||||
|
if (removedPane === 'split' && state.activeSplitTabId === id) {
|
||||||
|
activeSplitTabId = neighborInPane(newTabs, id, 'split');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the split pane is empty, collapse the split.
|
||||||
|
const stillSplit = newTabs.some((t) => t.paneId === 'split');
|
||||||
|
if (!stillSplit) {
|
||||||
|
activeSplitTabId = null;
|
||||||
|
splitOrientation = null;
|
||||||
|
focusedPaneId = 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: never let the tab list be fully empty.
|
||||||
if (newTabs.length === 0) {
|
if (newTabs.length === 0) {
|
||||||
set({
|
set({
|
||||||
tabs: [HOME_TAB],
|
tabs: [HOME_TAB],
|
||||||
activeTabId: HOME_TAB.id,
|
activeTabId: HOME_TAB.id,
|
||||||
|
activeSplitTabId: null,
|
||||||
|
splitOrientation: null,
|
||||||
|
focusedPaneId: 'main',
|
||||||
loadedTabIds: [HOME_TAB.id],
|
loadedTabIds: [HOME_TAB.id],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make sure the chosen active tab is loaded.
|
||||||
|
const ensureLoaded = (loaded: string[], id: string | null) =>
|
||||||
|
id && !loaded.includes(id) ? [...loaded, id] : loaded;
|
||||||
|
const loaded = ensureLoaded(ensureLoaded(newLoaded, activeTabId), activeSplitTabId);
|
||||||
|
|
||||||
set({
|
set({
|
||||||
tabs: newTabs,
|
tabs: newTabs,
|
||||||
activeTabId: newActive,
|
activeTabId,
|
||||||
loadedTabIds: newLoaded.includes(newActive) ? newLoaded : [...newLoaded, newActive],
|
activeSplitTabId,
|
||||||
|
splitOrientation,
|
||||||
|
focusedPaneId,
|
||||||
|
loadedTabIds: loaded,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setActiveTab: (id) => {
|
setActiveTab: (id) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (!state.tabs.some((t) => t.id === id)) return;
|
const tab = state.tabs.find((t) => t.id === id);
|
||||||
if (state.activeTabId === id) return;
|
if (!tab) return;
|
||||||
set({
|
const loaded = state.loadedTabIds.includes(id)
|
||||||
activeTabId: id,
|
? state.loadedTabIds
|
||||||
loadedTabIds: state.loadedTabIds.includes(id)
|
: [...state.loadedTabIds, id];
|
||||||
? state.loadedTabIds
|
if (tab.paneId === 'main') {
|
||||||
: [...state.loadedTabIds, id],
|
if (state.activeTabId === id && state.focusedPaneId === 'main') return;
|
||||||
});
|
set({ activeTabId: id, focusedPaneId: 'main', loadedTabIds: loaded });
|
||||||
|
} else {
|
||||||
|
if (state.activeSplitTabId === id && state.focusedPaneId === 'split') return;
|
||||||
|
set({ activeSplitTabId: id, focusedPaneId: 'split', loadedTabIds: loaded });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
moveTab: (fromIdx, toIdx) => {
|
setFocusedPane: (paneId) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (fromIdx === toIdx) return;
|
if (state.focusedPaneId === paneId) return;
|
||||||
if (fromIdx < 0 || fromIdx >= state.tabs.length) return;
|
// Switching focus to the split pane is only meaningful when it exists.
|
||||||
if (toIdx < 0 || toIdx >= state.tabs.length) return;
|
if (paneId === 'split' && state.splitOrientation === null) return;
|
||||||
const tabs = [...state.tabs];
|
set({ focusedPaneId: paneId });
|
||||||
const [moved] = tabs.splice(fromIdx, 1);
|
},
|
||||||
tabs.splice(toIdx, 0, moved);
|
|
||||||
set({ tabs });
|
reorderTab: (draggedId, targetTabId, edge) => {
|
||||||
|
const state = get();
|
||||||
|
if (draggedId === targetTabId) return;
|
||||||
|
const dragged = state.tabs.find((t) => t.id === draggedId);
|
||||||
|
const target = state.tabs.find((t) => t.id === targetTabId);
|
||||||
|
if (!dragged || !target) return;
|
||||||
|
|
||||||
|
const next = state.tabs.filter((t) => t.id !== draggedId);
|
||||||
|
const insertAt = next.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0);
|
||||||
|
const reassigned: ProTab = dragged.paneId === target.paneId
|
||||||
|
? dragged
|
||||||
|
: { ...dragged, paneId: target.paneId };
|
||||||
|
next.splice(insertAt, 0, reassigned);
|
||||||
|
|
||||||
|
// If the dragged tab was active in its old pane and just moved to a
|
||||||
|
// different pane, fix up the active ids so the empty side doesn't
|
||||||
|
// hang on to a stale id.
|
||||||
|
const patch: Partial<ProTabState> = { tabs: next };
|
||||||
|
if (dragged.paneId !== target.paneId) {
|
||||||
|
if (dragged.paneId === 'main' && state.activeTabId === draggedId) {
|
||||||
|
patch.activeTabId = neighborInPane(next, draggedId, 'main') ?? HOME_TAB.id;
|
||||||
|
}
|
||||||
|
if (dragged.paneId === 'split' && state.activeSplitTabId === draggedId) {
|
||||||
|
patch.activeSplitTabId = neighborInPane(next, draggedId, 'split');
|
||||||
|
}
|
||||||
|
// Make the dragged tab active in its new home.
|
||||||
|
if (target.paneId === 'main') {
|
||||||
|
patch.activeTabId = draggedId;
|
||||||
|
patch.focusedPaneId = 'main';
|
||||||
|
} else {
|
||||||
|
patch.activeSplitTabId = draggedId;
|
||||||
|
patch.focusedPaneId = 'split';
|
||||||
|
}
|
||||||
|
// Collapse the split if it just emptied.
|
||||||
|
const stillSplit = next.some((t) => t.paneId === 'split');
|
||||||
|
if (!stillSplit) {
|
||||||
|
patch.activeSplitTabId = null;
|
||||||
|
patch.splitOrientation = null;
|
||||||
|
patch.focusedPaneId = 'main';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set(patch);
|
||||||
|
},
|
||||||
|
|
||||||
|
moveTabToPane: (tabId, paneId, orientation) => {
|
||||||
|
const state = get();
|
||||||
|
const tab = state.tabs.find((t) => t.id === tabId);
|
||||||
|
if (!tab) return;
|
||||||
|
if (tab.paneId === paneId) return;
|
||||||
|
|
||||||
|
// The home tab can move freely (and the unsplit guard below restores
|
||||||
|
// sanity), but if moving it would leave main empty we prevent it.
|
||||||
|
const movingFromMain = tab.paneId === 'main';
|
||||||
|
if (movingFromMain) {
|
||||||
|
const otherMainTabs = state.tabs.filter((t) => t.paneId === 'main' && t.id !== tabId);
|
||||||
|
if (otherMainTabs.length === 0) return; // refuse to empty main
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTabs = state.tabs.map((t) => t.id === tabId ? { ...t, paneId } : t);
|
||||||
|
|
||||||
|
const patch: Partial<ProTabState> = { tabs: newTabs };
|
||||||
|
|
||||||
|
if (paneId === 'split') {
|
||||||
|
// Creating or extending a split.
|
||||||
|
patch.splitOrientation = state.splitOrientation ?? orientation ?? 'vertical';
|
||||||
|
patch.activeSplitTabId = tabId;
|
||||||
|
patch.focusedPaneId = 'split';
|
||||||
|
// If main lost its active tab, pick a neighbor.
|
||||||
|
if (state.activeTabId === tabId) {
|
||||||
|
patch.activeTabId = neighborInPane(newTabs, tabId, 'main') ?? HOME_TAB.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
patch.activeTabId = tabId;
|
||||||
|
patch.focusedPaneId = 'main';
|
||||||
|
if (state.activeSplitTabId === tabId) {
|
||||||
|
patch.activeSplitTabId = neighborInPane(newTabs, tabId, 'split');
|
||||||
|
}
|
||||||
|
// Collapse if split just emptied.
|
||||||
|
const stillSplit = newTabs.some((t) => t.paneId === 'split');
|
||||||
|
if (!stillSplit) {
|
||||||
|
patch.activeSplitTabId = null;
|
||||||
|
patch.splitOrientation = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = state.loadedTabIds.includes(tabId)
|
||||||
|
? state.loadedTabIds
|
||||||
|
: [...state.loadedTabIds, tabId];
|
||||||
|
patch.loadedTabIds = loaded;
|
||||||
|
|
||||||
|
set(patch);
|
||||||
|
},
|
||||||
|
|
||||||
|
collapseSplit: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.splitOrientation === null) return;
|
||||||
|
const newTabs = state.tabs.map((t) =>
|
||||||
|
t.paneId === 'split' ? { ...t, paneId: 'main' as const } : t
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
tabs: newTabs,
|
||||||
|
activeSplitTabId: null,
|
||||||
|
splitOrientation: null,
|
||||||
|
focusedPaneId: 'main',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTabTitle: (id, title) => {
|
updateTabTitle: (id, title) => {
|
||||||
@@ -271,33 +470,51 @@ export const useProTabStore = create<ProTabState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'pro-tabs',
|
name: 'pro-tabs',
|
||||||
version: 2,
|
version: 3,
|
||||||
// Don't persist transient compose drafts in tab metadata — the composer's
|
// Don't persist transient compose drafts in tab metadata — the composer's
|
||||||
// own draft-store already handles that. Persisted email tabs are fine to
|
// own draft-store already handles that. Persisted email tabs are fine to
|
||||||
// restore (the tab body refetches the email by id).
|
// restore (the tab body refetches the email by id).
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
tabs: state.tabs.map((tab) =>
|
tabs: state.tabs
|
||||||
tab.kind === 'compose'
|
.filter((tab) => tab.kind !== 'compose')
|
||||||
? { ...tab, composeData: undefined } // drop compose tabs on reload
|
.map((tab) => tab.kind === 'compose'
|
||||||
: tab
|
? { ...tab, composeData: undefined }
|
||||||
).filter((tab) => tab.kind !== 'compose'),
|
: tab),
|
||||||
activeTabId: state.activeTabId,
|
activeTabId: state.activeTabId,
|
||||||
|
activeSplitTabId: state.activeSplitTabId,
|
||||||
|
splitOrientation: state.splitOrientation,
|
||||||
|
focusedPaneId: state.focusedPaneId,
|
||||||
loadedTabIds: state.loadedTabIds,
|
loadedTabIds: state.loadedTabIds,
|
||||||
}),
|
}),
|
||||||
onRehydrateStorage: () => (state) => {
|
onRehydrateStorage: () => (state) => {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
// Backfill paneId in case the user upgrades from version 2.
|
||||||
|
state.tabs = state.tabs.map((tab) => tab.paneId ? tab : { ...tab, paneId: 'main' as const });
|
||||||
if (state.tabs.length === 0) {
|
if (state.tabs.length === 0) {
|
||||||
state.tabs = [HOME_TAB];
|
state.tabs = [HOME_TAB];
|
||||||
state.activeTabId = HOME_TAB.id;
|
state.activeTabId = HOME_TAB.id;
|
||||||
|
state.activeSplitTabId = null;
|
||||||
|
state.splitOrientation = null;
|
||||||
|
state.focusedPaneId = 'main';
|
||||||
state.loadedTabIds = [HOME_TAB.id];
|
state.loadedTabIds = [HOME_TAB.id];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.tabs.some((t) => t.id === state.activeTabId)) {
|
if (!state.tabs.some((t) => t.id === state.activeTabId && t.paneId === 'main')) {
|
||||||
state.activeTabId = state.tabs[0].id;
|
state.activeTabId = state.tabs.find((t) => t.paneId === 'main')?.id ?? HOME_TAB.id;
|
||||||
|
}
|
||||||
|
if (state.activeSplitTabId !== null && !state.tabs.some((t) => t.id === state.activeSplitTabId && t.paneId === 'split')) {
|
||||||
|
state.activeSplitTabId = state.tabs.find((t) => t.paneId === 'split')?.id ?? null;
|
||||||
|
}
|
||||||
|
if (state.activeSplitTabId === null) {
|
||||||
|
state.splitOrientation = null;
|
||||||
|
state.focusedPaneId = 'main';
|
||||||
}
|
}
|
||||||
if (!state.loadedTabIds.includes(state.activeTabId)) {
|
if (!state.loadedTabIds.includes(state.activeTabId)) {
|
||||||
state.loadedTabIds = [...state.loadedTabIds, state.activeTabId];
|
state.loadedTabIds = [...state.loadedTabIds, state.activeTabId];
|
||||||
}
|
}
|
||||||
|
if (state.activeSplitTabId && !state.loadedTabIds.includes(state.activeSplitTabId)) {
|
||||||
|
state.loadedTabIds = [...state.loadedTabIds, state.activeSplitTabId];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user