From 47cf0ed90d277d0249100b65b6951c886fd6f949 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 16 Aug 2026 03:03:40 +0200 Subject: [PATCH] feat: rework pro split-screen shell, per-pane tab strips, drag & drop, pane-scoped overlays --- app/(main)/[locale]/pro/page.tsx | 299 ++++--- components/calendar/event-modal.tsx | 15 +- components/email/email-viewer.tsx | 194 +++- components/mail/mail-app.tsx | 45 +- .../pro/__tests__/pro-shell-drop.test.ts | 103 +++ components/pro/__tests__/pro-tab-bar.test.tsx | 177 ++++ components/pro/pro-shell-drop.ts | 83 ++ components/pro/pro-tab-bar.tsx | 102 ++- hooks/use-pane-context.ts | 34 + stores/__tests__/pro-tab-store.test.ts | 446 ++++++++++ stores/pro-tab-store.ts | 829 +++++++++--------- 11 files changed, 1743 insertions(+), 584 deletions(-) create mode 100644 components/pro/__tests__/pro-shell-drop.test.ts create mode 100644 components/pro/__tests__/pro-tab-bar.test.tsx create mode 100644 components/pro/pro-shell-drop.ts create mode 100644 hooks/use-pane-context.ts create mode 100644 stores/__tests__/pro-tab-store.test.ts diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index 6118db409..f33413593 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } 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"; @@ -13,8 +13,17 @@ import { useSettingsStore } from "@/stores/settings-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { EmbeddedContext } from "@/hooks/use-is-embedded"; import { PaneSizeContext } from "@/hooks/use-pane-size"; -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 { PaneIdContext } from "@/hooks/use-pane-context"; +import { ProTabBar } from "@/components/pro/pro-tab-bar"; +import { + PRO_TAB_DRAG_MIME, + EMAIL_IDS_DRAG_MIME, + dragKindFromTypes, + parseEmailIdsPayload, + resolveBodyDropTarget, + type BodyDropTarget, +} from "@/components/pro/pro-shell-drop"; +import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId, type ProSplitSide } from "@/stores/pro-tab-store"; import { cn } from "@/lib/utils"; import { getPathPrefix } from "@/lib/browser-navigation"; @@ -37,8 +46,6 @@ const APP_TAB_COMPONENTS: Partial> = { settings: SettingsApp, }; -type DropTarget = 'left' | 'right' | null; - function renderTabBody(tab: ProTab): React.ReactNode { if (tab.kind === 'compose' && tab.composeData) { return ; @@ -50,6 +57,40 @@ function renderTabBody(tab: ProTab): React.ReactNode { return Component ? : null; } +/** + * Open dragged emails as Pro tabs in `pane`. A single email reuses the + * shared reader tab (so repeated drags/double-clicks drive one tab); a + * multi-selection opens one tab per message, capped so a stray + * select-all drag can't explode the tab strip. + */ +function openDroppedEmails( + emailIds: string[], + pane: ProPaneId, + side: ProSplitSide | undefined, + fallbackTitle: string, +) { + const { setSplitSide, openEmailTab } = useProTabStore.getState(); + const known = useEmailStore.getState().emails; + const targets = emailIds + .map((id) => known.find((e) => e.id === id)) + .filter((e): e is NonNullable => !!e) + .slice(0, 10); + if (targets.length === 0) return; + if (side) setSplitSide(side); + const reuseReader = targets.length === 1; + for (const email of targets) { + openEmailTab( + { + accountId: email.accountId ?? '', + emailId: email.id, + mailboxId: null, + title: email.subject?.trim() || fallbackTitle, + }, + { pane, reuseReader }, + ); + } +} + interface PaneProps { paneId: ProPaneId; tabs: ProTab[]; @@ -60,7 +101,9 @@ interface PaneProps { } function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) { - const paneRef = useRef(null); + // Held in state (not a ref) so the ResizeObserver effect re-runs once the + // element exists. + const [paneEl, setPaneEl] = useState(null); // Measured pane width, published to children via PaneSizeContext so that // useDeviceDetection / useIsMobile / etc. branch on pane width - not full // viewport - and inner pages collapse to their mobile/tablet layouts when @@ -68,9 +111,8 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused const [paneWidth, setPaneWidth] = useState(null); useEffect(() => { - const el = paneRef.current; - if (!el || typeof ResizeObserver === "undefined") return; - const initialRect = el.getBoundingClientRect(); + if (!paneEl || typeof ResizeObserver === "undefined") return; + const initialRect = paneEl.getBoundingClientRect(); if (initialRect.width > 0) setPaneWidth(initialRect.width); const ro = new ResizeObserver((entries) => { const entry = entries[0]; @@ -78,32 +120,34 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused const w = entry.contentRect.width; setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w)); }); - ro.observe(el); + ro.observe(paneEl); return () => ro.disconnect(); - }, []); + }, [paneEl]); return (
{ if (!isFocused) onPaneFocus(paneId); }} > - - {tabs - .filter((tab) => loadedTabIds.includes(tab.id)) - .map((tab) => { - const isActive = tab.id === activeTabId; - return ( -
- {renderTabBody(tab)} -
- ); - })} -
+ + + {tabs + .filter((tab) => loadedTabIds.includes(tab.id)) + .map((tab) => { + const isActive = tab.id === activeTabId; + return ( +
+ {renderTabBody(tab)} +
+ ); + })} +
+
); } @@ -138,8 +182,8 @@ export default function ProHome() { const tabs = useProTabStore((s) => s.tabs); 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 splitSide = useProTabStore((s) => s.splitSide); const loadedTabIds = useProTabStore((s) => s.loadedTabIds); const openTab = useProTabStore((s) => s.openTab); const requestCloseTab = useProTabStore((s) => s.requestCloseTab); @@ -147,10 +191,9 @@ export default function ProHome() { const setFocusedPane = useProTabStore((s) => s.setFocusedPane); const moveTabToPane = useProTabStore((s) => s.moveTabToPane); - const [isTabDragging, setIsTabDragging] = useState(false); - const [splitDropTarget, setSplitDropTarget] = useState(null); - /** Whether the split pane visually renders before (true) or after (false) main. */ - const [splitLeading, setSplitLeading] = useState(false); + /** Tab id currently being dragged from either strip, if any. */ + const [draggedTabId, setDraggedTabId] = useState(null); + const [bodyDropTarget, setBodyDropTarget] = useState(null); // Auth bootstrap (mirrors standard page) useEffect(() => { @@ -182,6 +225,7 @@ export default function ProHome() { const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]); const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]); + const isSplit = splitTabs.length > 0; const focusedActiveTab = useMemo(() => { const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId; @@ -200,59 +244,88 @@ export default function ProHome() { || focusedActiveTab.kind === 'settings' ) ? focusedActiveTab.kind : null; - const isSplit = splitOrientation !== null && splitTabs.length > 0; + // ---- Body-level drop targets (tab drags and email drags) ---- - // ---- Body-level drop targets ---- + const emailFallbackTitle = t('email_composer.new_message'); - const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME); - - const computeDropTarget = (e: DragEvent): DropTarget => { + const resolveTargetForEvent = (e: DragEvent): BodyDropTarget => { + const kind = dragKindFromTypes(e.dataTransfer.types); + if (!kind) return null; const rect = e.currentTarget.getBoundingClientRect(); - const xFrac = (e.clientX - rect.left) / rect.width; - return xFrac < 0.5 ? 'left' : 'right'; + const xFrac = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5; + return resolveBodyDropTarget({ + xFrac, + isSplit, + splitSide, + kind, + // A tab can only create a split if another tab stays behind; emails + // always can (the mail list keeps existing). + canCreateSplit: kind === 'email' ? true : tabs.length > 1, + draggedTabPane: kind === 'tab' + ? (tabs.find((t) => t.id === draggedTabId)?.paneId ?? null) + : null, + }); }; - const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => { - if (!target || !isSplit) return null; - const leftIsSplit = splitLeading; - if (target === 'left') return leftIsSplit ? 'split' : 'main'; - return leftIsSplit ? 'main' : 'split'; - }; + const sameTarget = (a: BodyDropTarget, b: BodyDropTarget) => + a === b || (!!a && !!b && a.type === b.type && a.side === b.side + && (a.type !== 'pane' || b.type !== 'pane' || a.pane === b.pane)); const handleBodyDragOver = (e: DragEvent) => { - if (!isProTabDrag(e)) return; + // Something inside the pane (a folder drop target, a tab strip) already + // claimed this position - stand down so we never double-handle. + if (e.defaultPrevented) { + if (bodyDropTarget) setBodyDropTarget(null); + return; + } + const kind = dragKindFromTypes(e.dataTransfer.types); + if (!kind) return; + const target = resolveTargetForEvent(e); + if (!target) { + // Not calling preventDefault leaves the browser's no-drop feedback in + // place - an illegal drop is visibly illegal, never a silent no-op. + if (bodyDropTarget) setBodyDropTarget(null); + return; + } e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - const next = computeDropTarget(e); - if (next !== splitDropTarget) setSplitDropTarget(next); + e.dataTransfer.dropEffect = kind === 'tab' ? 'move' : 'copy'; + setBodyDropTarget((prev) => (sameTarget(prev, target) ? prev : target)); }; const handleBodyDragLeave = (e: DragEvent) => { const next = e.relatedTarget as Node | null; if (next && e.currentTarget.contains(next)) return; - setSplitDropTarget(null); + setBodyDropTarget(null); }; const handleBodyDrop = (e: DragEvent) => { - if (!isProTabDrag(e)) return; - const target = computeDropTarget(e); - setSplitDropTarget(null); - setIsTabDragging(false); + setBodyDropTarget(null); + if (e.defaultPrevented) return; + const kind = dragKindFromTypes(e.dataTransfer.types); + if (!kind) return; + const target = resolveTargetForEvent(e); + setDraggedTabId(null); if (!target) return; e.preventDefault(); - const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME); - if (!draggedId) return; - if (isSplit) { - // Move tab to whichever pane occupies the dropped side. - const destPane = targetPaneFromDrop(target); - if (destPane) moveTabToPane(draggedId, destPane); + if (kind === 'tab') { + const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME); + if (!draggedId) return; + if (target.type === 'create-split') { + moveTabToPane(draggedId, 'split', { side: target.side }); + } else { + moveTabToPane(draggedId, target.pane); + } return; } - // Create a new side-by-side split. `splitLeading` controls which side - // visually hosts the split pane. - moveTabToPane(draggedId, 'split', 'vertical'); - setSplitLeading(target === 'left'); + + const ids = parseEmailIdsPayload(e.dataTransfer.getData(EMAIL_IDS_DRAG_MIME)); + if (ids.length === 0) return; + if (target.type === 'create-split') { + openDroppedEmails(ids, 'split', target.side, emailFallbackTitle); + } else { + openDroppedEmails(ids, target.pane, undefined, emailFallbackTitle); + } }; // Loading state (matches standard page exactly) @@ -269,37 +342,39 @@ export default function ProHome() { if (!isDesktop) return null; + // Each pane renders as a column: its own tab strip directly above its + // body, so a tab visibly belongs to the pane beneath it and clicking a + // tab only ever changes that pane. + // // Stable keys are essential: when the split collapses, the row's child - // list goes from [splitPane, divider, mainPane] (or the leading variant) - // to [mainPane]. Without keys, React would reuse the Pane instance at + // list changes shape. Without keys, React would reuse the column at // index 0 - repurposing the *split* pane's instance into the main pane, // which strands the main pane's ResizeObserver/paneWidth on a now- // unmounted DOM node and reparents the mail tab body (causing remount // + stale "still-narrow" measurements after the split is closed). - const mainPane = ( - + const renderColumn = (paneId: ProPaneId) => ( +
+ setDraggedTabId(dragging ? tabId : null)} + onEmailDrop={(ids) => openDroppedEmails(ids, paneId, undefined, emailFallbackTitle)} + /> + +
); - const splitPane = isSplit ? ( - - ) : null; - const splitDivider = isSplit ? (
) : null; - // Drop-zone overlay: a single half-body preview of where the dragged tab - // would land. The whole body is always a drop target (the entire surface - // maps to one of the four sides), so we only render the active side. - const dropZone = isTabDragging && splitDropTarget ? ( - - ) : null; + // Drop-zone overlay: a half-body preview of where the dragged tab/email + // would land, driven by the same resolver that performs the drop. + const dropZone = bodyDropTarget ? : null; return ( @@ -350,32 +422,19 @@ export default function ProHome() { )} {!inlineApp && ( -
- {/* Single, unified tab bar above both panes. */} - +
+ {isSplit + ? (splitSide === 'left' + ? <>{renderColumn('split')}{splitDivider}{renderColumn('main')} + : <>{renderColumn('main')}{splitDivider}{renderColumn('split')}) + : renderColumn('main')} - {/* Panes container - accepts body drops for split/move. */} -
- {isSplit - ? (splitLeading - ? <>{splitPane}{splitDivider}{mainPane} - : <>{mainPane}{splitDivider}{splitPane}) - : mainPane} - - {dropZone} -
+ {dropZone}
)}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 4d5aa0483..7069d9185 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -24,6 +24,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useSettingsStore } from "@/stores/settings-store"; import { generateUUID } from "@/lib/utils"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; +import { useIsPaneScoped } from "@/hooks/use-pane-context"; import { calendarHooks } from "@/lib/plugin-hooks"; import type { ConflictWarning } from "@/lib/plugin-types"; @@ -199,6 +200,14 @@ export function EventModal({ const locale = useLocale(); const timeFormat = useSettingsStore((s) => s.timeFormat); const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; + // Inside a Pro pane the "mobile" layout must cover the pane, not the + // viewport - a `fixed inset-0` editor from a split pane would take over + // the whole app. The absolute variant anchors to the pane's tab-body + // wrapper (the nearest positioned ancestor), covering exactly the pane. + const isPaneScoped = useIsPaneScoped(); + const mobileRootClass = isPaneScoped + ? "absolute inset-0 z-40 flex flex-col bg-background" + : "fixed inset-0 z-50 flex flex-col bg-background"; const isEdit = !!event; const formatEventDate = useFormatEventDate(); const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit"); @@ -690,7 +699,7 @@ export function EventModal({ const participants = getParticipantList(event); return ( -
+

{event.title || t("events.no_title")}

- )} +
+
+
- )} +

{t('no_conversation_selected')}

+

{t('no_conversation_description')}

+ {onCompose && ( + + )} +
); } @@ -3148,7 +3220,7 @@ export function EmailViewer({ {moreMenuOpen && !isMobile && (