feat(mobile): native editor toolbar pinned to the keyboard - #2144
Conversation
The mobile note editor's formatting toolbar moves out of the WebView into a native React Native view, so the bridge has to carry what the DOM toolbar used to read and do in-process: the inline styles, alignments, colours, block and table actions as zod schemas both halves compile against, a host-to-guest toolbar-action for every press, a guest-to-host toolbar-selection describing the caret, and editor-focus so a keyboard raised by the title field is not mistaken for the editor's. Additive only. keyboard-visibility and cfg.keyboardHeight stay until the guest stops speaking them.
The formatting toolbar that sat on the software keyboard was DOM inside the editor WebView, positioned from window.visualViewport. When the keyboard overlaps a WKWebView frame, WebKit pans its visual viewport inside the layout viewport on every scroll, so any position derived in the guest chased the keyboard: the toolbar drifted with each swipe, and a two-line selection painted its native highlight over it because there was document under the bar. The WebView is now a document surface only. The host lays it out as a column: the WebView, then a native toolbar row, then either a native panel (blocks, turn into, table, style, link prompt) sized to the last keyboard height or a spacer the height of the keyboard's overlap with the host. The frame ENDS where the toolbar begins, so no text is ever under it and the keyboard never overlaps the WebView; the visual viewport no longer diverges from the layout viewport. The keyboard is measured natively from keyboardWillChangeFrame against the host's bottom edge in window coordinates, and the frame animates with the keyboard's own duration and curve through one LayoutAnimation per event, as KeyboardAvoidingView does. Toolbar visibility is a pure reducer (toolbar/bottom-chrome.ts): visible for a focused editor under a raised keyboard or while a panel is open, hidden read-only or while a guest sheet holds the strip; opening a panel blurs the guest so the keyboard leaves and the panel takes its place, and the keyboard coming back closes any panel but the link prompt. The guest reports its caret as toolbar-selection when it changes, reports contenteditable focus, and applies toolbar-action presses through the same action object the DOM toolbar used. The note footer now hides on one signal from the host instead of two guessed by the guest.
…from the guest Nothing in the WebView reads the keyboard any more. The toolbar renderer, the visualViewport inset it was positioned by, the picker and toolbar CSS, and the wiki-menu's measurement of the toolbar shell all go; the remaining the frame, which now ends at the native toolbar's top edge. The contract loses keyboard-visibility and cfg.keyboardHeight with them, and the tests that drove the DOM builder are replaced by the reducer and dispatch tests that landed with the feature. The wire-level style and table assertions stay.
…oolbar review The guest's viewport meta now declares `interactive-widget=overlays-content`. The host already shrinks the WebView so its frame ends above the keyboard, but WebKit still subtracted the keyboard's window frame from its own viewport calculations (`_inputViewBoundsForViewportCalculations`), so once the document was scrolled near its end the visual viewport shrank and panned inside the layout viewport, which read as the document sliding into blank space under a fixed toolbar. Declaring the keyboard an overlay is the truth of this frame, and 245 of 246 probe samples across four scroll speeds then report the visual viewport equal to the layout viewport with a zero offset. A `contentInset` resend tried first is gone: WebKit stopped writing that inset in iOS 11. Review fixes on the native toolbar: formatting items flex from a 42pt basis instead of a hard floor that overflowed narrow phones; a row button pressed above an open panel closes it, so the style panel always has a way out; a button with its own fill keeps it while pressed; the style panel gets the 44pt header its five icon buttons need; the italic glyph uses the platform serif at 600 rather than a synthesised slant of Crimson Pro; the turn-into panel sizes to its content under the DOM's ceiling; the 40pt history buttons reach 44pt through hit slop.
Block capabilities typed their colour against the DOM toolbar module, which the native toolbar removed; the contract has owned `BlockColour` since the toolbar vocabulary moved there.
|
React Doctor found 13 new issues in 11 files · 1 error & 12 warnings · score 80 / 100 (Needs work) · 12 fixed · vs Errors
12 warnings
Reviewed by React Doctor for commit |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
| I18nManager, | ||
| KeyboardAvoidingView, | ||
| Keyboard, | ||
| LayoutAnimation, |
There was a problem hiding this comment.
React Doctor · react-doctor/rn-prefer-reanimated (warning)
Your users see stutter when LayoutAnimation runs on the JS thread.
Fix → Use import Animated from 'react-native-reanimated' so animations run on the UI thread instead of the JS thread, which keeps them smooth.
| @@ -319,9 +298,9 @@ export function EditorView({ | |||
|
|
|||
| /** Hand this note to the guest. Called by the host every time it becomes the mounted one. */ | |||
| const mountOnGuest = useCallback(() => { | |||
There was a problem hiding this comment.
React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)
React Compiler can cache this function automatically. Verify that removing useCallback preserves behavior before simplifying it.
Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.
| }, [bridge, guestCfg, mounted]) | ||
| }, [bridge, cfg, mounted]) | ||
|
|
||
| const handleGuestMsg = useCallback( |
There was a problem hiding this comment.
React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)
React Compiler can cache this function automatically. Verify that removing useCallback preserves behavior before simplifying it.
Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.
Summary
Two structural bugs in the mobile note editor's keyboard toolbar. When the keyboard overlaps the WebView frame, WKWebView pans its visual viewport inside the layout viewport on every scroll, so any DOM position derived from
visualViewportchases the keyboard (three patch commits on main tried to latch or negate the inset). And iOS paints text selection natively above web content, so a two-line selection drew over the DOM toolbar.The toolbar is now a native React Native view under the WebView, and the WebView's frame ends where the toolbar begins, so no text is ever under it and the keyboard never overlaps the document. Keyboard overlap is measured natively (
keyboardWillChangeFrameagainst the host's bottom edge in window coordinates) and the frame animates once per keyboard event through the sameLayoutAnimationKeyboardAvoidingView uses. What sits under the WebView is a pure reducer (apps/mobile/src/editor/toolbar/bottom-chrome.ts): hidden, a toolbar row on the keyboard spacer, or a panel (blocks, turn into, table, style, link prompt) in the keyboard's place. The bridge carries the toolbar vocabulary as zod schemas inpackages/contracts/src/webview-bridge.ts(toolbar-actionhost to guest,toolbar-selectionandeditor-focusguest to host); the guest's DOM toolbar,visual-viewport.ts, the toolbar CSS,keyboard-visibilityandcfg.keyboardHeightare deleted.One finding beyond the plan: with the frame already ending above the keyboard, WebKit still subtracts the keyboard's window frame from its viewport calculations (
_inputViewBoundsForViewportCalculationsinWKWebViewIOS.mm), which shrank and panned the visual viewport once the document was scrolled near its end. The guest's viewport meta now declaresinteractive-widget=overlays-content, which is the switch that makes WebKit ignore the keyboard there. (WebKit's own FIXME notes the scroll-viewcontentInsetpath has been dead since iOS 11, so acontentInsetresend does nothing.)Reconciled with #2129 after rebasing: the block-actions panel stays a guest sheet, now hung off
#editor-chromelike the date sheet (editor-web/src/block-actions-panel.ts), reporting througheditor-panel-visibilityso the native toolbar and the note footer step aside; the native•••reaches it throughtoolbar-action { kind: 'open-block-actions' }. The paste-link menu no longer measures a toolbar shell.NoteFootervisibility has one source of truth, the host's keyboard overlap plus panel and guest-sheet state (onBottomOccupiedChange).No docs change: mobile is unreleased and the WebView bridge is internal to the app, so the docs gate was skipped on push.
Release note
none
Test plan
Gates on HEAD:
pnpm --filter @memry/mobile test72 files passed, 718 tests passed; mobiletypecheck(app and test configs) and guesttscclean;pnpm --filter @memry/mobile editor:checkcurrent atc7435177afbaf628;pnpm check:contractspassed;pnpm ipc:checkinvoke map and RPC bindings up to date; mobile lint 0 errors (13 pre-existing warnings, none in touched files);git diff --checkclean.Simulator, iPhone 17 Pro against staging, on a private clone of the device (the shared one was being reinstalled by other sessions mid-run):
innerHeight376 equal tovisualViewport.heightwithoffsetTop0; the one exception wasoffsetTop1 at the document-end bounce. Scrolling to the document end three times: 47 of 47 samples equal, no pan.editor-panel-visibilitypath as find-in-note and the block-actions sheet, and the controller test covers the suppression.innerHeight432 (767 minus 335).