fix: let a Pro tab move or split when dropped on a composer or calendar pane

This commit is contained in:
Linus Rath
2026-11-03 07:50:09 +01:00
parent b2633691ec
commit f3ebb7af88
4 changed files with 33 additions and 4 deletions
+3 -2
View File
@@ -328,10 +328,11 @@ export function CalendarMonthView({
}, []);
const handleCellDrop = useCallback(async (e: DragEvent<HTMLDivElement>, 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);
+12
View File
@@ -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 <br> 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);
@@ -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<HTMLDivElement>;
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;
+3 -2
View File
@@ -398,10 +398,11 @@ export function useTimeGridInteractions({
}, []);
const handleColumnDrop = useCallback(async (e: DragEvent<HTMLDivElement>, 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);