From b439fd5e6a874ea81ea247a635b4400fa305e004 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:11:54 +0100 Subject: [PATCH] fix: put the cursor at the end of the text when the space under it is clicked --- .../__tests__/composer-reply-focus.test.tsx | 52 ++++++++++++++++++- components/email/email-composer.tsx | 17 +++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/components/email/__tests__/composer-reply-focus.test.tsx b/components/email/__tests__/composer-reply-focus.test.tsx index 62bb0752e..8ac680ecf 100644 --- a/components/email/__tests__/composer-reply-focus.test.tsx +++ b/components/email/__tests__/composer-reply-focus.test.tsx @@ -1,4 +1,4 @@ -import { render, act } from '@testing-library/react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; @@ -9,17 +9,27 @@ import { useSettingsStore } from '@/stores/settings-store'; const toastError = vi.hoisted(() => vi.fn()); const editorFocus = vi.hoisted(() => vi.fn()); +const caret = vi.hoisted(() => ({ textEnd: null as number | null, selected: [] as number[], focused: 0 })); // Reports a stub editor as ready, the way the real one does once mounted. vi.mock('@/components/email/rich-text-editor', () => ({ RichTextEditor: ({ onEditorReady }: { onEditorReady?: (editor: unknown) => void }) => { React.useEffect(() => { - onEditorReady?.({ commands: { focus: editorFocus, setContent: () => {} }, isDestroyed: false }); + const chain = { + setTextSelection: (pos: number) => { caret.selected.push(pos); return chain; }, + focus: () => { caret.focused += 1; return chain; }, + run: () => true, + }; + onEditorReady?.({ commands: { focus: editorFocus, setContent: () => {} }, isDestroyed: false, isEditable: true, state: {}, chain: () => chain, on: () => {}, off: () => {} }); }, [onEditorReady]); return React.createElement('div', { 'data-testid': 'rich-text-editor' }); }, })); +vi.mock('@/components/email/reply-quote-collapse', async (importOriginal) => ({ + ...(await importOriginal()), + userTextEnd: () => caret.textEnd, +})); vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); @@ -234,3 +244,41 @@ describe('composer initial focus', () => { expect(document.activeElement?.tagName).toBe('TEXTAREA'); }); }); + +// compose-p2-9: the white space under the text is the message too. +describe('a click in the empty space under the text', () => { + beforeEach(() => { + editorFocus.mockClear(); + caret.textEnd = null; + caret.selected = []; + caret.focused = 0; + useSettingsStore.setState({ plainTextMode: false }); + }); + + const emptySpace = () => screen.getByTestId('rich-text-editor').parentElement!.parentElement!; + + it('puts the caret at the end of the text above the folded quote', () => { + caret.textEnd = 7; + render(); + editorFocus.mockClear(); + const event = fireEvent.mouseDown(emptySpace(), { button: 0 }); + expect(event).toBe(false); + expect(caret.selected).toEqual([7]); + expect(caret.focused).toBe(1); + }); + + it('puts the caret at the end of the body when nothing is folded', () => { + render(); + fireEvent.mouseDown(emptySpace(), { button: 0 }); + expect(editorFocus).toHaveBeenCalledWith('end'); + }); + + it('leaves clicks on buttons alone', () => { + caret.textEnd = 7; + render(); + const button = document.createElement('button'); + emptySpace().appendChild(button); + fireEvent.mouseDown(button, { button: 0 }); + expect(caret.selected).toEqual([]); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index fc292094a..6561bd670 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -69,7 +69,7 @@ import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; import { isEditableEventTarget } from "@/lib/keyboard"; import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; -import { markReplyQuoteHeader } from "@/components/email/reply-quote-collapse"; +import { markReplyQuoteHeader, userTextEnd } from "@/components/email/reply-quote-collapse"; import { attachmentIcon } from "@/components/email/attachment-chips"; import { attachmentsForDisplay } from "@/lib/compose/attachment-order"; import { showDraftSavedToast } from "@/lib/compose/draft-close-toast"; @@ -1455,6 +1455,19 @@ export function EmailComposer({ // when the user switches identity in "above quote" mode without rebuilding // the whole body (which would lose user edits to the surrounding draft). const editorRef = useRef(null); + // The white space under the text is the message too (compose-p2-9): a + // click there puts the caret at the end of the user's text, above a folded + // quote and the signature, instead of dropping the focus. + const focusBodyFromEmptySpace = (event: React.MouseEvent) => { + const editor = editorRef.current; + const target = event.target as HTMLElement; + if (event.button !== 0 || !editor || editor.isDestroyed || !editor.isEditable) return; + if (target.closest('.ProseMirror, button, input, textarea, select, a, label, [role="toolbar"], [role="dialog"], [data-plugin-slot]')) return; + event.preventDefault(); + const end = userTextEnd(editor.state); + if (end === null) editor.commands.focus('end'); + else editor.chain().setTextSelection(end).focus().run(); + }; // Set while the composer itself swaps the signature in the editor. const signatureSwapRef = useRef(false); // Set once a reply's body got its initial focus, so switching formats later @@ -5503,7 +5516,7 @@ export function EmailComposer({ -
+
{wantsPluginDraft && ( )}