20 KiB
Mobile keyboard covers plugin text boxes (Android) — investigation log
Status: CSS top-anchor confirmed working; residual tall-popup cases fixed in Attempt 4b, awaiting
device confirmation. After three JS approaches (all keyed off visualViewport) produced no
observable change, the JS helper was removed entirely and replaced with a CSS-only top-anchor (see
Attempt 4 below). The reporter confirmed the top-anchor fixed the simple popups; two tall-popup cases
(change-passphrase fields, Paste submit button) were then addressed in Attempt 4b. Corroborating evidence drove the
pivot: (a) the reporter confirmed another plugin with the same popup-style text boxes hits the
identical bug, establishing this as Obsidian-mobile platform behavior rather than a defect in our
code; (b) the official Obsidian docs expose no plugin-facing keyboard/viewport/inset API and confirm
Obsidian mobile is Capacitor-based (Platform.isMobileApp = "running the capacitor-js mobile app"),
which is consistent with Hypothesis A (keyboard overlays without resizing the viewport, so no JS
signal fires). The CSS approach is signal-independent: it does not depend on visualViewport or any
Capacitor event. This document records everything tried so the history is not lost.
Problem statement
On Android, when a text field managed by the plugin receives focus, the on-screen software keyboard slides up and covers the text field. The field does not move out of the way, so the user cannot see what they are typing. The expected behavior (seen in Obsidian core UIs and in other plugins) is that the focused field scrolls/repositions above the keyboard.
Reporter platform: Android, Obsidian mobile. Desktop and iOS are not affected (the helper
early-returns on !Platform.isMobile, and iOS WebKit resizes the layout viewport when the
keyboard opens, which makes naive scrollIntoView work there).
Test matrix
The reporter exercises these flows, positioning each field low enough that the keyboard would overlap it:
| # | Flow | Surface | Container | Last reported result |
|---|---|---|---|---|
| 1 | Quick controls (bottom-right hamburger) → ReWrite → tap Unlock | Passphrase unlock prompt | PassphraseModal (popup) |
FAIL |
| 2 | Quick controls → ReWrite → Paste → tap textarea | Paste tab textarea | ReWriteModal (popup) |
FAIL |
| 3 | Sidebar → Settings gear → ReWrite (Voice Notes) → scroll to Change passphrase → tap both fields | Change-passphrase prompt | PassphraseModal (popup, requireConfirm) |
FAIL |
| 4 | Sidebar → Settings gear → ReWrite (Voice Notes) → tap a field (transcription API key, assistant name, attachments folder, etc.) | Settings tab field | ReWriteSettingTab (settings panel) |
PASS (confirmed still passing) |
Disambiguation (confirmed by reporter): test 4 still succeeds; tests 1-3 still fail. So the split is stable: scrollable settings surface works, fixed-position popups do not.
Critical caveat — this does NOT prove our code runs. Test 4 passing is most likely native
browser / Obsidian-core behavior, not our helper. Chrome on Android natively scrolls a focused
input into view within its nearest scrollable ancestor, and is keyboard-aware when doing so. The
settings panel is exactly that: a tall scrollable surface. A fixed-position centered popup is not,
so native focus-scroll cannot move it and the field stays covered. In other words, the test 4 /
tests 1-3 split is fully explained by the browser, with our helper contributing nothing in any of
the four cases. Hypothesis A (our code is a silent no-op because visualViewport never changes)
remains fully alive. We still have zero positive evidence that installMobileKeyboardScrollFix
mutates anything on the device.
The reporter confirmed the build was deployed cleanly: they deleted the plugin's main.js,
manifest.json, and styles.css and recreated them (keeping data.json and
secrets.json.nosync), so stale-artifact caching is ruled out.
The code under test
All attempts live in one helper: installMobileKeyboardScrollFix(root) in
src/platform.ts. It is attached (via focusin, which bubbles) to:
- src/ui/modal.ts line 31 — main modal
contentEl - src/settings/tab.ts line 69 — settings
containerEl - src/ui/passphrase-modal.ts line 29 — passphrase modal
contentEl(added this session) - src/insert.ts line 121 —
RenamePromptModalcontentEl(added this session)
The helper early-returns unless Platform.isMobile.
Chronology of attempts
Attempt 0 — pre-existing baseline (before this investigation)
- Mechanism:
focusin→setTimeout(300ms)→target.scrollIntoView({ block: 'center', behavior: 'smooth' }). - Wired into: main modal + settings tab only.
- Theory: scroll the focused input to the center of its scroll container after the keyboard animation starts.
- Why it can't work on Android:
scrollIntoView({ block: 'center' })centers the element in the layout viewport (the full screen). Android (unlike iOS) does not shrink the layout viewport when the keyboard opens, so "center of the full screen" is still behind the keyboard's top edge for low fields.
Attempt 1 — visualViewport + scroll nearest scrollable ancestor
- Change: Rewrote the helper to read
window.visualViewport. Onfocusin: if the visual viewport is already shrunk act immediately, else wait for a one-shotvisualViewportresize(600 ms safety timeout). Then computevisibleBottom = vv.offsetTop + vv.height, and if the input'srect.bottomis belowvisibleBottom - 16, scroll the nearest scrollable ancestor by the delta (fallbackwindow.scrollBy). Kept ascrollIntoViewfallback for whenvisualViewportis unavailable. - Also: wired the helper into
PassphraseModalandRenamePromptModal. - Theory:
visualViewportreflects the real post-keyboard visible region on Android even though the layout viewport does not, so we can scroll precisely. - Result: Reporter: test 4 (settings) works; tests 1-3 (popups) fail.
Attempt 2 — translate the .modal box via CSS transform
- Change: Added a branch: when no scrollable ancestor is found, walk up to the
.modalelement and apply a composedtransform: <computed> translateY(-delta)to lift the modal box, restoring onblur. Composition read the current computed transform so as not to wipe Obsidian's centering transform. - Theory: short popups have no scroll room, so the scroll path did nothing; lift the whole fixed-position modal instead.
- Result: Reporter: still broken for tests 1-3.
Attempt 3 — shrink .modal-container so flex re-centers the popup
- Change (current code): Two-step in
liftAboveKeyboard: (1) scroll the nearest scrollable ancestor and re-measure; (2) if the input is still below the visible region, walk up to.modal-containerand set inlinetop = vv.offsetTopandheight = vv.height, shrinking the fixed-position container to the visible region so Obsidian's flex centering re-positions.modalabove the keyboard. Restore onblur; re-fit on subsequentvisualViewportresize; fully restore whenvv.heightreturns to nearwindow.innerHeight. - Theory:
.modal-contentactually hasoverflow: auto, so Attempt 2's scroll path was being taken and the transform branch never ran. Scrolling inside.modal-contentshifts the input within the modal but cannot move the fixed, centered modal itself; shrinking the container makes the existing flex centering do the work. - Result: Reporter: "No change for any of the tests."
Attempt 4 — CSS-only top-anchor; JS helper removed (current)
- Change: Deleted
installMobileKeyboardScrollFixand its four call sites from src/platform.ts, src/ui/modal.ts, src/settings/tab.ts, src/ui/passphrase-modal.ts, and src/insert.ts. Added a CSS rule in styles.css: under.is-mobile,.rewrite-modal(main + passphrase) and.rewrite-rename-modalgetalign-self: flex-start; margin-top: 8px; margin-bottom: auto; max-height: calc(100% - 16px). - Theory: This is alternative #3b made concrete. The one surface that always worked (the settings tab) is a tall scrollable container where Chromium's native keyboard-aware focus-scroll does the job; the failing surfaces are short fixed-position popups centered by Obsidian's flex. Rather than fight the keyboard with JS that never had a signal to fire on, pin the popup to the top of the screen so the keyboard (which opens from the bottom) cannot cover it. The reporter confirmed other plugins use exactly this workaround.
- Why CSS over JS: Hypothesis A (the keyboard overlays without resizing the viewport) means no
JS signal is reliable. CSS positioning needs no signal.
align-self: flex-startmoves only our modal to the top of the flex container;margin-bottom: autois a fallback for non-default flex configurations; scoping to our classes leaves core Obsidian modals untouched. - Caveat for the main modal: the Paste textarea sits below the template selector and tab row, so top-anchoring lifts it into the upper half of the screen but not to the very top. For the passphrase and rename modals the input is the first field, so they clear fully.
- Result: Confirmed working on device for the simple popups (unlock prompt, rename prompt, Paste textarea itself). Two residual cases remained, both instances of "something pushes the focused element low within a tall popup," fixed in Attempt 4b below.
Attempt 4b — companion tweaks for tall-popup cases (current)
Top-anchoring fixes popups whose input sits near the top, but two surfaces kept the focused element low:
- Change passphrase (settings → Change passphrase): the two passphrase fields render below the
"Picking a strong passphrase" tips block, so even a top-anchored modal put them in the keyboard
zone. Fix: the tips block is now a
<details>(src/ui/passphrase-modal.tsrenderPassphraseTips), expanded by default on every platform (opt-out security guidance) but auto-collapsing on mobile when a passphrase field is focused (collapseTipsOnMobile), so the guidance is seen on open yet the fields rise under the title once the user taps in. The first field'sautofocusis disabled on mobile so the collapse coincides with the user's tap, not a premature programmatic focus. - Paste tab submit button: the textarea was fine but the "Clean up" button below it was covered.
Fixes: the textarea renders at
rows = 4on mobile (vs 10 on desktop, src/ui/modal.ts) with the desktop 160pxmin-heightfloor dropped to 80px under.is-mobile; and the reporter's observation that Obsidian leaves an empty band above the title is addressed bypadding-top: 8pxon.modal-content+margin-top: 0on theh2(mobile-scoped), shifting the whole popup up.
All of these are in styles.css plus the two small JS changes noted above. Still
signal-independent; no visualViewport or Capacitor dependency.
Removed JS helper (Attempts 1-3, verbatim summary — no longer in the codebase)
This describes the installMobileKeyboardScrollFix / liftAboveKeyboard logic that Attempt 4
deleted. Retained for history only; none of this runs anymore.
liftAboveKeyboard(target, vv):
visibleBottom = vv.offsetTop + vv.height. Ifrect.bottom <= visibleBottom - 16, return (do nothing) — we believe the input is already visible.- Scroll nearest scrollable ancestor by delta; re-measure; return if now visible.
- Else shrink
.modal-container(top/height), restore on blur. - Else
window.scrollBy.
The entry path waits for visualViewport.resize (or acts immediately if vv.height < window.innerHeight - 100), with a 600 ms fallback timer.
Unverified assumptions (the heart of the problem)
We have been writing code against a mental model that has never been confirmed on the actual device. Every one of these is a guess:
- That the
focusinlistener fires at all. Never confirmed. IfPlatform.isMobileis somehow false, or the listener is attached to the wrong element, nothing runs. - That
window.visualViewportshrinks when the Android keyboard opens. This is the load-bearing assumption for all three attempts, and it is the most suspect (see hypothesis A). - That
window.innerHeightdoes / doesn't change when the keyboard opens (drives the "already up" check and the restore condition). - The DOM structure and class names of Obsidian mobile modals (
.modal-container>.modal>.modal-content), and that.modal-contenthasoverflow: auto, and that.modal-containercenters via flex. Never inspected on device. - That step 1's early-return condition is ever false on the device. If
visualViewportdoes not shrink,rect.bottom <= visibleBottom - 16is always true and the function is a guaranteed no-op — which would perfectly explain "no change for any approach." - Whether test 4 "working" is our code or Obsidian's own behavior. If Obsidian core scrolls its own settings panel on focus, test 4 would "pass" regardless of our helper, and we have zero evidence our code does anything anywhere.
Leading hypotheses (ranked)
A. visualViewport does not reflect the keyboard in Obsidian's WebView (most likely)
Obsidian mobile is a Capacitor app. The Capacitor Keyboard plugin has a resize mode
(native / body / ionic / none). If Obsidian uses resize: 'none' (keyboard overlays
content), then neither window.innerHeight nor window.visualViewport.height changes when the
keyboard opens — the keyboard simply floats on top. In that case:
- The entry "already up" check (
vv.height < innerHeight - 100) is never true. - The
visualViewportresizeevent may never fire, so we fall to the 600 ms timer. - When
liftAboveKeyboardfinally runs,visibleBottomequals the full-screen bottom, sorect.bottom <= visibleBottom - 16is true and we return immediately, doing nothing.
This single hypothesis explains why three completely different mutation strategies all produced no visible change: none of them ever executed their mutation. This is the first thing to verify.
If true, the correct signal is the Capacitor keyboard event, not visualViewport. Capacitor
dispatches DOM events on window: keyboardWillShow / keyboardDidShow (with
event.keyboardHeight) and keyboardWillHide / keyboardDidHide. Obsidian is known to surface
these. The fix would key off keyboardHeight instead of visualViewport.
B. The listener never fires / wrong element
Less likely given the helper is attached at four call sites, but unproven. Would also explain "no change anywhere."
C. visualViewport works, but our DOM model is wrong
If visualViewport does shrink (so step 1 does not early-return) but .modal-container is not the
real class, or it does not center via flex, or top/height are overridden by !important rules,
then step 3 silently fails. This is plausible only if hypothesis A is false.
Recommended next step: instrument before fixing
Stop guessing. Add a temporary on-screen diagnostic (a Notice, or a fixed debug <div>) that
fires on focusin and again ~400 ms later, dumping the real numbers. Because remote DevTools on
Android Obsidian is fiddly, an on-screen readout is the lowest-friction way to get ground truth.
Capture, at focus time and after a delay:
Platform.isMobilewindow.innerHeightbefore vs. after keyboardwindow.visualViewport?.height,.offsetTop,.pageTopbefore vs. after- whether a
visualViewportresizeevent fired (counter) - whether any
keyboardDidShowwindow event fired, and itskeyboardHeightif present - the focused element's
getBoundingClientRect().bottom - the chain of ancestor class names from the input up to
body(to confirm.modal-container/.modal/.modal-contentand which one, if any, is scrollable)
The single most important data point: does visualViewport.height (or innerHeight) actually
decrease when the keyboard appears? If no, hypothesis A is confirmed and we pivot to Capacitor
keyboard events. If yes, hypothesis A is dead and we debug the DOM model (hypothesis C).
If feasible, also enable remote debugging: connect the Android device over USB, open
chrome://inspect in desktop Chrome, inspect the Obsidian WebView, and watch the DOM/visualViewport
live while focusing a field. This is the gold-standard confirmation.
Alternative fix approaches not yet tried
Pursue these only after instrumentation tells us which signal is real.
- Capacitor keyboard events (most promising if hypothesis A holds). Listen on
windowforkeyboardWillShow/keyboardDidShowand readevent.keyboardHeight. Apply a bottom inset (e.g.padding-bottomon the scrollable region, or shrink.modal-containerbykeyboardHeight) and reverse it onkeyboardWillHide/keyboardDidHide. This does not depend onvisualViewportupdating. - CSS environment inset. Newer WebViews expose
env(keyboard-inset-height)/env(keyboard-inset-bottom)when the page opts in via theinteractive-widgetviewport meta — but a plugin cannot set the viewport meta, so this likely requires Obsidian-level support. Worth testing whether the env vars are non-zero on the device anyway. - Pure CSS positioning of popups. Anchor
.rewrite-modal/.rewrite-passphrase-modalto the top of the screen on mobile (e.g..is-mobile .modal { top: 8px; transform: none; }) so even a non-moving modal sits above where the keyboard appears. Crude, but independent of any JS signal, and a useful diagnostic: if a top-anchored modal is still covered, the keyboard is taller than assumed or our CSS is being overridden. 3b. Make popups behave like the settings surface (highest-leverage, given the confirmed split). The one thing that demonstrably works is a tall scrollable container (settings tab) where native keyboard-aware focus-scroll does the job. Mimic that for popups: on mobile, top-anchor the popup and give its content a scrollable region that can extend below the fold (.is-mobile .rewrite-modal { top: 0; transform: none; max-height: 100%; }plus anoverflow-y: autocontent area with enough height that the focused field can scroll up). If the browser then scrolls popup fields into view the same way it does settings fields, the bug is fixed without relying onvisualViewportor Capacitor events at all. This is CSS-first and signal-independent, and it directly transplants the working case onto the failing cases — try it before the JS-signal approaches. - Ask whether Obsidian already exposes a hook. Check Obsidian's mobile behavior and any documented API for keyboard insets before reinventing it; mirror whatever the core settings panel does (since test 4 historically worked, the core mechanism is worth copying directly).
- Confirm the
webspeechremoval / unrelated churn isn't interfering. Unlikely, but the working tree has broad uncommitted changes; verify the helper is actually the code shipping inmain.js(search the built bundle forvisualViewport/modal-container).
Key references
- Helper: src/platform.ts
installMobileKeyboardScrollFix/liftAboveKeyboard - Call sites: src/ui/modal.ts:31, src/settings/tab.ts:69, src/ui/passphrase-modal.ts:29, src/insert.ts:121
- Digital Garden plugin (referenced by the reporter as a working example): its Appearance modal is a
bare
new Modal(app)with no keyboard-handling code — its good behavior comes from Obsidian core, not plugin code. So "copy Digital Garden" reduces to "copy Obsidian core's modal behavior." - Gotcha entry summarizing the helper: CLAUDE.md (search "Mobile keyboard scroll").