fix: start typing in the message body when the empty space under it is tapped

This commit is contained in:
Linus Rath
2026-10-22 22:04:29 +02:00
parent 33eb8f99e5
commit 0109273037
4 changed files with 104 additions and 0 deletions
+5
View File
@@ -66,6 +66,8 @@ export interface RichTextEditorHandle {
*/
getHtml(timeoutMs?: number): Promise<string>;
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<RichTextEditorHandle, Props>(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()`);
},
@@ -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: '<p><br></p>', placeholder: 'Write…', c: LIGHT_COLORS });
expect(html).toContain('function focusEnd(editor)');
expect(html).toContain('focusEnd: function () { focusEnd(editor); }');
});
});
+30
View File
@@ -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();
+11
View File
@@ -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. */}
<Pressable
style={styles.bodyFiller}
accessible={false}
onPress={() => {
if (plainTextMode) plainBodyRef.current?.focus();
else editorRef.current?.focusEnd();
}}
/>
</ScrollView>
{/* 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',