fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android

Android's WebView shrinks the editor page's visual viewport to the part of
the view that is on screen and pans it to reveal the caret while typing.
The editor is an auto-height WebView inside the composer's ScrollView, so
nothing ever resets that pan: scrolled back to the top, the body is still
drawn shifted up by the pan offset and its first lines sit hidden under the
header fields until a scroll to the very bottom clamps it away.

The page now undoes every pan as it happens (scrolling the page top into
view, since window.scrollTo only moves the layout viewport) and reports the
offset as a `viewportShift` message; the composer scrolls its ScrollView by
the same amount so the caret stays visible. Android only; iOS and the other
RichTextEditor hosts keep their behaviour (hosts without the callback still
get the reset).

Reproduced and verified on a Pixel 4 (Android 13, WebView 153): before,
visualViewport.offsetTop stayed at ~18 px at the top of the composer and
cut the first line; after, it stays 0 and the caret still follows typing
above the keyboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucletoffe
2026-10-14 08:49:57 +02:00
co-authored by Claude Opus 5.5
parent e19885dc72
commit 8ce53816af
4 changed files with 144 additions and 3 deletions
+17 -2
View File
@@ -76,10 +76,16 @@ interface Props {
onSelectionChange?: (state: RichTextSelectionState) => void;
onFocus?: () => void;
onBlur?: () => void;
/**
* Android: the page undid a caret-reveal pan of `dy` px (dp) inside the
* WebView. The host should scroll its ScrollView down by `dy` so the caret
* stays visible (see `pinVisualViewport` in editor-html).
*/
onViewportShift?: (dy: number) => void;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
ref,
) {
const c = useColors();
@@ -90,6 +96,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
const onBlurRef = React.useRef(onBlur);
const onViewportShiftRef = React.useRef(onViewportShift);
onViewportShiftRef.current = onViewportShift;
onChangeRef.current = onChange;
onSelectionRef.current = onSelectionChange;
onFocusRef.current = onFocus;
@@ -98,7 +106,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
// Build the source HTML once. Theming changes won't auto-rebuild the page,
// which is fine: themes change rarely and the user can recompose if needed.
const html = React.useMemo(
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
() => buildEditorHtml({
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
}),
// c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -219,6 +229,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
case 'blur':
onBlurRef.current?.();
break;
case 'viewportShift': {
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
break;
}
}
};
+84
View File
@@ -196,3 +196,87 @@ describe('editor page messages', () => {
expect((page.selections()[1].payload as { bold: boolean }).bold).toBe(true);
});
});
// Android pans the editor page's visual viewport to reveal the caret and the
// pan outlived the moment: back at the top of the composer the body's first
// lines stayed hidden under To/Cc/Subject. The page undoes each pan and hands
// the offset to RN instead.
describe('editor visual viewport pinning', () => {
function boot(pinVisualViewport: boolean | undefined, withVisualViewport = true) {
const posted: Array<{ type: string; payload: unknown }> = [];
const scrolledIntoView: unknown[] = [];
const vvListeners: Record<string, Array<() => void>> = {};
const visualViewport = {
offsetTop: 0,
addEventListener: (name: string, fn: () => void) => { (vvListeners[name] ??= []).push(fn); },
};
const editor = {
innerHTML: '',
get textContent() { return this.innerHTML.replace(/<[^>]*>/g, ''); },
scrollHeight: 100,
querySelector: () => null,
setAttribute: () => undefined,
focus: () => undefined,
addEventListener: () => undefined,
// Resetting the pan is what scrolling the page top into view does.
scrollIntoView: (opts: unknown) => { scrolledIntoView.push(opts); visualViewport.offsetTop = 0; },
};
const document = {
activeElement: editor as unknown,
getElementById: () => editor,
addEventListener: () => undefined,
queryCommandState: () => false,
};
const window = {
ReactNativeWebView: { postMessage: (msg: string) => posted.push(JSON.parse(msg)) },
addEventListener: () => undefined,
getSelection: () => null,
visualViewport: withVisualViewport ? visualViewport : undefined,
};
const script = extractScript(buildEditorHtml({
initialHtml: '<p><br></p>', placeholder: '', c: LIGHT_COLORS, pinVisualViewport,
}));
new Function('window', 'document', script)(window, document);
const pan = (offsetTop: number) => {
visualViewport.offsetTop = offsetTop;
(vvListeners.scroll ?? []).forEach((fn) => fn());
};
const shifts = () => posted.filter((m) => m.type === 'viewportShift').map((m) => m.payload);
return { pan, shifts, scrolledIntoView, visualViewport, vvListeners };
}
it('undoes a caret-reveal pan and reports its offset to RN', () => {
const page = boot(true);
page.pan(18.5);
expect(page.visualViewport.offsetTop).toBe(0);
expect(page.scrolledIntoView).toEqual([{ block: 'start', inline: 'nearest' }]);
expect(page.shifts()).toEqual([18.5]);
});
it('ignores the scroll event its own reset fires', () => {
const page = boot(true);
page.pan(40);
page.pan(0);
page.pan(0.2);
expect(page.shifts()).toEqual([40]);
expect(page.scrolledIntoView).toHaveLength(1);
});
it('reports each later pan separately', () => {
const page = boot(true);
page.pan(12);
page.pan(30);
expect(page.shifts()).toEqual([12, 30]);
});
it('leaves the viewport alone unless asked (iOS, settings editors)', () => {
const off = boot(false);
expect(off.vvListeners.scroll).toBeUndefined();
const unset = boot(undefined);
expect(unset.vvListeners.scroll).toBeUndefined();
});
it('survives a WebView without visualViewport', () => {
expect(() => boot(true, false)).not.toThrow();
});
});
+28 -1
View File
@@ -70,8 +70,14 @@ export function buildEditorHtml(opts: {
c: ThemePalette;
/** Block remote images and fonts (see `buildEditorCsp`). */
blockRemoteImages?: boolean;
/**
* Keep the page's visual viewport at the top and report every pan to RN as
* a `viewportShift` (CSS px = dp) instead (see the page script). Android
* only: its WebView pans the visual viewport to reveal the caret.
*/
pinVisualViewport?: boolean;
}): string {
const { initialHtml, placeholder, c, blockRemoteImages = false } = opts;
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false } = opts;
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body).
const initialJson = JSON.stringify(initialHtml);
@@ -362,6 +368,27 @@ export function buildEditorHtml(opts: {
reportHeight();
reportChange();
// ── Visual viewport pinning (Android) ──────────────────────────────
// The WebView is as tall as its content and sits in RN's ScrollView, so
// the page itself never scrolls. But Android's WebView shrinks the visual
// viewport to the part of the view that is on screen (above the keyboard,
// below the header fields) and pans it to reveal the caret while typing.
// The pan outlives the moment: once the composer is scrolled back to the
// top the body is still drawn shifted up by that offset, so its first
// lines hide under To/Cc/Subject until a scroll to the very bottom clamps
// it away. Undo every pan at once (window.scrollTo only moves the layout
// viewport; scrolling the top of the page into view resets both) and hand
// the offset to RN, which scrolls the composer by as much so the caret
// stays where the pan had put it.
if (${pinVisualViewport ? 'true' : 'false'} && window.visualViewport) {
window.visualViewport.addEventListener('scroll', function () {
var shift = window.visualViewport.offsetTop;
if (Math.abs(shift) < 0.5) return;
try { editor.scrollIntoView({ block: 'start', inline: 'nearest' }); } catch (e) {}
post('viewportShift', shift);
});
}
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
+15
View File
@@ -680,6 +680,17 @@ export default function ComposeScreen({ route, navigation }: Props) {
});
const editorRef = React.useRef<RichTextEditorHandle>(null);
// The composer's ScrollView and its live offset. On Android the editor page
// hands back the caret-reveal pans it undoes (see `pinVisualViewport` in
// editor-html); scrolling the composer by the same amount keeps the caret
// where the pan had put it, without leaving the body drawn shifted up.
const bodyScrollRef = React.useRef<ScrollView>(null);
const bodyScrollY = React.useRef(0);
const onEditorViewportShift = React.useCallback((dy: number) => {
const y = Math.max(0, bodyScrollY.current + dy);
bodyScrollY.current = y;
bodyScrollRef.current?.scrollTo({ y, animated: false });
}, []);
const isPickingSuggestion = React.useRef(false);
// Track inline-image placeholders that haven't yet been rewritten to cid:
// until send time. Maps cid → blobId/type/name/size.
@@ -2349,9 +2360,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
<ScrollView
ref={bodyScrollRef}
style={styles.flex}
keyboardShouldPersistTaps="always"
contentContainerStyle={styles.scrollContent}
onScroll={(e) => { bodyScrollY.current = e.nativeEvent.contentOffset.y; }}
scrollEventThrottle={16}
>
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>{t('email_composer.from', 'From')}</Text>
@@ -2427,6 +2441,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
onChange={setBodyHtml}
onSelectionChange={setSelState}
onViewportShift={onEditorViewportShift}
/>
)}
</ScrollView>