fix: put the cursor at the end of the text when the space under it is clicked

This commit is contained in:
Linus Rath
2026-11-04 12:11:54 +01:00
parent 8be420b41e
commit b439fd5e6a
2 changed files with 65 additions and 4 deletions
@@ -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<typeof import('@/components/email/reply-quote-collapse')>()),
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(<EmailComposer initialData={{ ...DATA, mode: 'reply' }} mode="reply" />);
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(<EmailComposer initialData={{ ...DATA, mode: 'compose' }} mode="compose" />);
fireEvent.mouseDown(emptySpace(), { button: 0 });
expect(editorFocus).toHaveBeenCalledWith('end');
});
it('leaves clicks on buttons alone', () => {
caret.textEnd = 7;
render(<EmailComposer initialData={{ ...DATA, mode: 'reply' }} mode="reply" />);
const button = document.createElement('button');
emptySpace().appendChild(button);
fireEvent.mouseDown(button, { button: 0 });
expect(caret.selected).toEqual([]);
});
});
+15 -2
View File
@@ -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<Editor | null>(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<HTMLElement>) => {
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({
</div>
</div>
<div className={cn("flex-1", pinFields && "min-h-0 overflow-auto")}>
<div className={cn("flex-1", pinFields && "min-h-0 overflow-auto")} onMouseDown={focusBodyFromEmptySpace}>
{wantsPluginDraft && (
<PluginSlot name="composer-above-body" className="w-full" extraProps={pluginDraftProps} />
)}