Skip to content

feat(mobile): native editor toolbar pinned to the keyboard - #2144

Merged
h4yfans merged 6 commits into
mainfrom
mobile-native-editor-toolbar
Sep 11, 2026
Merged

feat(mobile): native editor toolbar pinned to the keyboard#2144
h4yfans merged 6 commits into
mainfrom
mobile-native-editor-toolbar

Conversation

@h4yfans

@h4yfans h4yfans commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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 visualViewport chases 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 (keyboardWillChangeFrame against the host's bottom edge in window coordinates) and the frame animates once per keyboard event through the same LayoutAnimation KeyboardAvoidingView 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 in packages/contracts/src/webview-bridge.ts (toolbar-action host to guest, toolbar-selection and editor-focus guest to host); the guest's DOM toolbar, visual-viewport.ts, the toolbar CSS, keyboard-visibility and cfg.keyboardHeight are 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 (_inputViewBoundsForViewportCalculations in WKWebViewIOS.mm), which shrank and panned the visual viewport once the document was scrolled near its end. The guest's viewport meta now declares interactive-widget=overlays-content, which is the switch that makes WebKit ignore the keyboard there. (WebKit's own FIXME notes the scroll-view contentInset path has been dead since iOS 11, so a contentInset resend does nothing.)

Reconciled with #2129 after rebasing: the block-actions panel stays a guest sheet, now hung off #editor-chrome like the date sheet (editor-web/src/block-actions-panel.ts), reporting through editor-panel-visibility so the native toolbar and the note footer step aside; the native ••• reaches it through toolbar-action { kind: 'open-block-actions' }. The paste-link menu no longer measures a toolbar shell. NoteFooter visibility 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 test 72 files passed, 718 tests passed; mobile typecheck (app and test configs) and guest tsc clean; pnpm --filter @memry/mobile editor:check current at c7435177afbaf628; pnpm check:contracts passed; pnpm ipc:check invoke map and RPC bindings up to date; mobile lint 0 errors (13 pre-existing warnings, none in touched files); git diff --check clean.

Simulator, iPhone 17 Pro against staging, on a private clone of the device (the shared one was being reinstalled by other sessions mid-run):

  • Scroll with the keyboard up: four swipe bursts at 250 to 2500 ms, 45 frames each; the document moved in 21, 23, 21 and 17 frames while the toolbar's plus glyph stayed at the same pixel rows (1514 to 1554) in all 180 frames, at rest and after. Guest probe over the same swipes: 245 of 246 samples reported innerHeight 376 equal to visualViewport.height with offsetTop 0; the one exception was offsetTop 1 at the document-end bounce. Scrolling to the document end three times: 47 of 47 samples equal, no pan.
  • Selection: a selection on the line directly above the toolbar with the keyboard up shows 0 highlight rows in the toolbar band. Not driven: extending a selection across two lines down to the WebView's bottom edge, because maestro's synthesised swipes never grabbed the selection handle and WebKit's contenteditable edit menu has no Select All.
  • Panels: block picker, table picker (caret in the Istanbul table), style panel and link prompt each took the keyboard's place; tapping the note closed the panel and brought the keyboard back; a picked card (Bulleted list) applied and returned focus, then Undo restored it; Align centre applied and was reverted; the link prompt kept the keyboard for its own field and Cancel returned to the formatting row.
  • Guest sheets: find-in-note came up with no native toolbar. Not driven: the date sheet, because no note in the staging vault carries a date mention; it takes the same editor-panel-visibility path as find-in-note and the block-actions sheet, and the controller test covers the suppression.
  • Title field: keyboard up, the host measured overlap 335, no editor focus, no toolbar, guest innerHeight 432 (767 minus 335).

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.
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

React Doctor found 13 new issues in 11 files · 1 error & 12 warnings · score 80 / 100 (Needs work) · 12 fixed · vs main

Errors

12 warnings

src/app/(vault)/(tabs)/notes/[id].tsx

  • ⚠️ L202 React function has high control-flow complexity no-high-complexity-react-function

src/editor/editor-host.tsx

  • ⚠️ L17 JS-thread animation instead of Reanimated rn-prefer-reanimated

src/editor/editor-view.tsx

  • ⚠️ L300 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L334 Manual memoization in compiler-managed code react-compiler-no-manual-memoization

src/renderer/src/components/folder-view/grouped-table.tsx

  • ⚠️ L312 React function has high control-flow complexity no-high-complexity-react-function

src/renderer/src/components/note/content-area/ContentArea.tsx

  • ⚠️ L298 React function has high control-flow complexity no-high-complexity-react-function

src/renderer/src/components/ui/picker/picker-item.tsx

  • ⚠️ L23 React function has high control-flow complexity no-high-complexity-react-function

src/renderer/src/components/virtualized-notes-tree.tsx

  • ⚠️ L309 React function has high control-flow complexity no-high-complexity-react-function

src/renderer/src/pages/folder-view.tsx

  • ⚠️ L134 React function has high control-flow complexity no-high-complexity-react-function

src/renderer/src/pages/inbox/inbox-list-view.tsx

  • ⚠️ L61 React function has high control-flow complexity no-high-complexity-react-function

src/renderer/src/pages/journal.tsx

  • ⚠️ L131 React function has high control-flow complexity no-high-complexity-react-function

src/renderer/src/pages/note.tsx

  • ⚠️ L174 React function has high control-flow complexity no-high-complexity-react-function

Reviewed by React Doctor for commit 6ca4034. See inline comments for fixes.

@github-actions github-actions Bot added enhancement New feature or request test labels Sep 10, 2026
@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@h4yfans
h4yfans marked this pull request as ready for review September 11, 2026 18:13
@h4yfans
h4yfans merged commit 96dd463 into main Sep 11, 2026
21 checks passed
@h4yfans
h4yfans deleted the mobile-native-editor-toolbar branch September 11, 2026 18:13
I18nManager,
KeyboardAvoidingView,
Keyboard,
LayoutAnimation,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Docs

@@ -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(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Docs

}, [bridge, guestCfg, mounted])
}, [bridge, cfg, mounted])

const handleGuestMsg = useCallback(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Docs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant