mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
fix: put the cursor at the end of the text when the space under it is clicked
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user