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} /> )}