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;
}
}
};