diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 4295ca25..3d1444ef 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -66,6 +66,8 @@ export interface RichTextEditorHandle { */ getHtml(timeoutMs?: number): Promise; focus(): void; + /** Focus with the caret where new text goes (before signature / quote). */ + focusEnd(): void; /** Drop the caret, which closes the keyboard. */ blur(): void; } @@ -186,6 +188,9 @@ const RichTextEditor = React.forwardRef(function Ri pendingSnapshots.current.set(id, { resolve, reject, timer }); call(`window.__rne && window.__rne.getHtml(${id})`); }), + focusEnd: () => { + call(`window.__rne && window.__rne.focusEnd()`); + }, focus: () => { call(`window.__rne && window.__rne.focus()`); }, diff --git a/src/lib/__tests__/editor-focus-end.test.ts b/src/lib/__tests__/editor-focus-end.test.ts new file mode 100644 index 00000000..28e3bd3d --- /dev/null +++ b/src/lib/__tests__/editor-focus-end.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { buildEditorHtml, FOCUS_END_SOURCE } from '../editor-html'; +import { LIGHT_COLORS } from '../../theme/tokens'; + +// On the iPad most of the composer's body area is empty space under the +// editor; a tap there did nothing and the keyboard kept typing into Subject. +// That space now calls the page's focusEnd(), which must put the caret where +// the user's own text goes: in front of the signature or the quoted message. + +interface FakeNode { name: string; previousElementSibling?: FakeNode | null } + +function run(stop: FakeNode | null) { + const calls: string[] = []; + const editor = { + name: 'editor', + focus: () => calls.push('focus'), + querySelector: () => stop, + }; + const range = { + selectNodeContents: (n: FakeNode) => calls.push(`select ${n.name}`), + setStartBefore: (n: FakeNode) => calls.push(`before ${n.name}`), + collapse: (toStart: boolean) => calls.push(toStart ? 'collapse start' : 'collapse end'), + }; + const selection = { + removeAllRanges: () => calls.push('clear'), + addRange: (r: unknown) => calls.push(r === range ? 'add range' : 'add other'), + }; + const fakeDocument = { createRange: () => range }; + const fakeWindow = { getSelection: () => selection }; + const focusEnd = new Function('document', 'window', `${FOCUS_END_SOURCE}; return focusEnd;`)(fakeDocument, fakeWindow); + focusEnd(editor); + return calls; +} + +describe('focusEnd', () => { + it('puts the caret at the end of the paragraph in front of the signature', () => { + const mine = { name: 'mine' }; + expect(run({ name: 'signature', previousElementSibling: mine })).toEqual([ + 'focus', 'select mine', 'collapse end', 'clear', 'add range', + ]); + }); + + it('puts the caret in front of a signature or quote that comes first', () => { + expect(run({ name: 'quote', previousElementSibling: null })).toEqual([ + 'focus', 'before quote', 'collapse start', 'clear', 'add range', + ]); + }); + + it('puts the caret at the end without signature or quote', () => { + expect(run(null)).toEqual(['focus', 'select editor', 'collapse end', 'clear', 'add range']); + }); + + it('is part of the page script and of its bridge', () => { + const html = buildEditorHtml({ initialHtml: '


', placeholder: 'Write…', c: LIGHT_COLORS }); + expect(html).toContain('function focusEnd(editor)'); + expect(html).toContain('focusEnd: function () { focusEnd(editor); }'); + }); +}); diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts index 7dd7e30e..6db806ae 100644 --- a/src/lib/editor-html.ts +++ b/src/lib/editor-html.ts @@ -64,6 +64,33 @@ export function shouldBlockEditorRemoteImages(opts: { return !senderTrusted; } +/** + * Page-script function for a tap on the empty space under the editor: focus + * it with the caret where the user's own text goes, in front of the + * signature or the quoted message, else at the end. A string, embedded in + * the page script and run by the unit test against stand-in DOM objects. + */ +export const FOCUS_END_SOURCE = `function focusEnd(editor) { + editor.focus(); + var sel = window.getSelection(); + if (!sel) return; + var stop = editor.querySelector('[data-signature-block="start"], [data-signature-block="separator"], [data-quoted-html]'); + var range = document.createRange(); + var prev = stop ? stop.previousElementSibling : null; + if (prev) { + range.selectNodeContents(prev); + range.collapse(false); + } else if (stop) { + range.setStartBefore(stop); + range.collapse(true); + } else { + range.selectNodeContents(editor); + range.collapse(false); + } + sel.removeAllRanges(); + sel.addRange(range); + }`; + export function buildEditorHtml(opts: { initialHtml: string; placeholder: string; @@ -261,6 +288,8 @@ export function buildEditorHtml(opts: { }); // ── Bridge: receive commands from RN ─────────────────────────────── + ${FOCUS_END_SOURCE} + window.__rne = { exec: function (command, value) { editor.focus(); @@ -363,6 +392,7 @@ export function buildEditorHtml(opts: { post('htmlSnapshot', { id: id, html: editor.innerHTML }); }, focus: function () { editor.focus(); }, + focusEnd: function () { focusEnd(editor); }, }; reportHeight(); diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 90c6e71b..10aa9a1b 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -2495,6 +2495,16 @@ export default function ComposeScreen({ route, navigation }: Props) { onViewportShift={onEditorViewportShift} /> )} + {/* The empty space under the body looks like part of it (on a + tablet, most of the panel): a tap there starts typing in it. */} + { + if (plainTextMode) plainBodyRef.current?.focus(); + else editorRef.current?.focusEnd(); + }} + /> {/* A phone in landscape with the keyboard up has room for a few lines @@ -2991,6 +3001,7 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.background, }, scrollContent: { flexGrow: 1 }, + bodyFiller: { flexGrow: 1, minHeight: 48 }, header: { flexDirection: 'row',