From f3ebb7af8878e2bc3316c6cb92fb7640e919b2a6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:50:09 +0100 Subject: [PATCH] fix: let a Pro tab move or split when dropped on a composer or calendar pane --- components/calendar/calendar-month-view.tsx | 5 +++-- components/email/email-composer.tsx | 12 ++++++++++++ .../__tests__/use-time-grid-interactions.test.ts | 15 +++++++++++++++ hooks/use-time-grid-interactions.ts | 5 +++-- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index c0f3ae89f..e40694a19 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -328,10 +328,11 @@ export function CalendarMonthView({ }, []); const handleCellDrop = useCallback(async (e: DragEvent, day: Date) => { - e.preventDefault(); - setDropDayKey(null); + // Anything else (a Pro tab or message moving to this pane) is not ours. const json = e.dataTransfer.getData("application/x-calendar-event"); if (!json) return; + e.preventDefault(); + setDropDayKey(null); try { const data = JSON.parse(json); const originalStart = parseISO(data.originalStart); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 07e011052..d7a9bd994 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -79,6 +79,7 @@ import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@ import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; import { ComposerPanelToggle, ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars"; +import { dragKindFromTypes } from "@/components/pro/pro-shell-drop"; import { ComposerSelectionMenu, useComposerSelectionMenu } from "./composer-selection-menu"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; @@ -168,6 +169,10 @@ import { usePolicyStore } from "@/stores/policy-store"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; import { toUnicodeEmail } from "@/lib/idn"; +// A Pro tab, message or folder dragged across a composer tab is on its way to +// the pane or the split edge: those drags belong to the shell. +const isShellDrag = (e: React.DragEvent) => dragKindFromTypes(e.dataTransfer.types) !== null; + /** * Derives the text/plain alternative from the composer's HTML body, preserving * line structure from block elements and
tags. Paragraph spacing is on so @@ -2484,6 +2489,7 @@ export function EmailComposer({ }, [clearDragState]); const handleDragEnter = useCallback((e: React.DragEvent) => { + if (isShellDrag(e)) return; e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) { @@ -2493,18 +2499,21 @@ export function EmailComposer({ }, [resetDragTimeout]); const handleDragLeave = useCallback((e: React.DragEvent) => { + if (isShellDrag(e)) return; e.preventDefault(); e.stopPropagation(); resetDragTimeout(); }, [resetDragTimeout]); const handleDragOver = useCallback((e: React.DragEvent) => { + if (isShellDrag(e)) return; e.preventDefault(); e.stopPropagation(); resetDragTimeout(); }, [resetDragTimeout]); const handleDrop = useCallback((e: React.DragEvent) => { + if (isShellDrag(e)) return; e.preventDefault(); e.stopPropagation(); clearDragState(); @@ -4670,6 +4679,7 @@ export function EmailComposer({ }} onDragLeave={() => setIsDraggingChipOverCc(false)} onDrop={(e) => { + if (isShellDrag(e)) return; e.preventDefault(); setIsDraggingChipOverCc(false); const raw = e.dataTransfer.getData('application/x-recipient-chip'); @@ -4694,6 +4704,7 @@ export function EmailComposer({ }} onDragLeave={() => setIsDraggingChipOverBcc(false)} onDrop={(e) => { + if (isShellDrag(e)) return; e.preventDefault(); setIsDraggingChipOverBcc(false); const raw = e.dataTransfer.getData('application/x-recipient-chip'); @@ -6023,6 +6034,7 @@ export function RecipientChipInput({ // Insert the dragged chip at `target`. Same-field is a local reorder; // cross-field routes through onMoveChip with the destination index (#593). const performDrop = (e: React.DragEvent, target: number) => { + if (isShellDrag(e)) return; e.preventDefault(); setIsDragOver(false); setDropIndex(null); diff --git a/hooks/__tests__/use-time-grid-interactions.test.ts b/hooks/__tests__/use-time-grid-interactions.test.ts index 0069ee79a..ad9629a86 100644 --- a/hooks/__tests__/use-time-grid-interactions.test.ts +++ b/hooks/__tests__/use-time-grid-interactions.test.ts @@ -215,6 +215,21 @@ describe.each<["0.16" | "1.0"]>([["0.16"], ["1.0"]])("useTimeGridInteractions mo // The grid places events by wall clock (7845b2ca). On 2026-10-25 Berlin's // 02:00-03:00 happens twice, so a resize across it has to land where the // edge was dropped, not an hour off (#1348). +describe("useTimeGridInteractions drops that are not events", () => { + it("leaves a Pro tab dropped on a column to the shell", async () => { + const { result } = renderHook(() => useTimeGridInteractions({ + hourHeight: 60, + onCreateRange: () => {}, + errorMessages, + } as never)); + const preventDefault = vi.fn(); + const e = { preventDefault, dataTransfer: { getData: () => "" } } as unknown as DragEvent; + await act(() => result.current.handleColumnDrop(e, new Date(2026, 8, 3))); + // The shell's own drop handler stands down on a prevented event. + expect(preventDefault).not.toHaveBeenCalled(); + }); +}); + describe("useTimeGridInteractions resize on a DST change day (#1348)", () => { const original = useCalendarStore.getState().updateEvent; const originalTimeZone = useSettingsStore.getState().timeZone; diff --git a/hooks/use-time-grid-interactions.ts b/hooks/use-time-grid-interactions.ts index 3994d3087..aa576c563 100644 --- a/hooks/use-time-grid-interactions.ts +++ b/hooks/use-time-grid-interactions.ts @@ -398,10 +398,11 @@ export function useTimeGridInteractions({ }, []); const handleColumnDrop = useCallback(async (e: DragEvent, day: Date) => { - e.preventDefault(); - setDropTarget(null); + // Anything else (a Pro tab or message moving to this pane) is not ours. const json = e.dataTransfer.getData("application/x-calendar-event"); if (!json) return; + e.preventDefault(); + setDropTarget(null); try { const data = JSON.parse(json); const minutes = snapDragMinutes(e);