From 8ce53816afe3d6266850fa4eddf5105e6ebca84d Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:47:38 +0200 Subject: [PATCH] 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) --- src/components/RichTextEditor.tsx | 19 +++++- src/lib/__tests__/editor-html.test.ts | 84 +++++++++++++++++++++++++++ src/lib/editor-html.ts | 29 ++++++++- src/screens/ComposeScreen.tsx | 15 +++++ 4 files changed, 144 insertions(+), 3 deletions(-) diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index ce416986..b81ce012 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -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(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(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(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(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; + } } }; diff --git a/src/lib/__tests__/editor-html.test.ts b/src/lib/__tests__/editor-html.test.ts index e7373922..9e4a3265 100644 --- a/src/lib/__tests__/editor-html.test.ts +++ b/src/lib/__tests__/editor-html.test.ts @@ -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 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: '


', 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(); + }); +}); diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts index 46db006e..7dd7e30e 100644 --- a/src/lib/editor-html.ts +++ b/src/lib/editor-html.ts @@ -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); diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 3ec09ce2..93950715 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -680,6 +680,17 @@ export default function ComposeScreen({ route, navigation }: Props) { }); const editorRef = React.useRef(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(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) { { bodyScrollY.current = e.nativeEvent.contentOffset.y; }} + scrollEventThrottle={16} > {t('email_composer.from', 'From')} @@ -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} /> )}