fix(panel): the stage stays put when iOS Safari opens its keyboard
Merged
DA
5d ago+467+467+012345678901234567890123456789-3-3-0123456789
Summary
The developer reported on iOS Safari: when the keyboard opens, "the whole page slides down to accommodate for the keyboard", and the caret appears "about half a page off". He asked for the most robust, straightforward, standard fix.
Cause. When Safari opens its keyboard over a full-height page, it pans the visual viewport down to the focused field (visualViewport.offsetTop). use-visual-viewport.ts wrote that pan into --luke-viewport-top, and the stage (.app-stage, top: var(--luke-viewport-top)) followed it. So Safari slid the page up, then the stage slid back down. WebKit doesn't redraw the caret through that move, so it was drawn about one keyboard-height from the words.
Fix, in the same hook, at the page's own scale only:
- Undo the keyboard's pan with
window.scrollTo(0, 0), so the stage stays at the window's top and ends where the keyboard begins (--luke-keyboard-inset). This is how a native app behaves: the keyboard covers the bottom, and nothing above it moves. - When the keyboard rises or grows over a focused field (contenteditable, textarea or input), scroll that field's caret into view within its own column, 24 px above the keyboard. ProseMirror keeps it there while typing, because the column now ends above the keyboard.
- Dismissal without lag: when focus leaves every field the keyboard was up for, the stage returns to full height immediately. WebKit measures the keyboard gone only once it has slid away (WebKit bug 265578, with Safari's address bar at the bottom), so before this the stage stayed short through the whole slide and left the bottom half empty. A field taking the focus measures the keyboard again, and moving between fields keeps it up. Reported by the developer after trying the first commit on his phone: the caret was right, but the bottom half lagged on dismissal.
- No jump when the editor closes: dismissing the keyboard blurs, and so closes, the plan's editor. The read view came back with any picture, table or diagram above the caret drawn at its own height, so everything below jumped: 243 px below the audit's diagram, which the developer saw as "a big jump" on dismissal. As the editor closes, the handle now reports the words its caret stood on (
caretWords, the samePlacedWords#4131 uses on open), anduseWordsKeptOnCloseinPlanBodyscrolls the column so they stay where they stood. - Review fixes (Devin):
- A field focused under a keyboard that's already up also has its caret scrolled into view.
- A box or an area is kept above the keyboard by its own position, not by words selected elsewhere on the page.
- Words left by an editor that closed as another plan opened move nothing in the new plan.
- Fallback: if an engine keeps its pan anyway, the stage follows it as before.
- Unchanged:
- Pinch-zoom (#4028): the zoomed branch is untouched, and a pinch's pan is never undone.
- Android Chrome:
resizes-contentshrinks the window for the keyboard and pans nothing, so nothing is undone there. - The desktop: it has no pan.
Evidence
pnpm --filter @luke/panel typecheck,pnpm knip, and oxlint/biome on the changed files pass.pnpm exec vitest run packages/panel/src: 269 files and 2756 tests pass (first commit). After the latest commit:tasks,plan-editorand the viewport hook, 132 files and 1397 tests, pass.- Chromium with iPhone 13 touch emulation on the mobile-audit fake: a line below the diagram stands at 260 px before tapping, while editing, and after the editor closes (it jumped to 17 px before). The same holds below the picture (325 px all through).
- New tests in
use-visual-viewport.test.ts, each with a WebKit-measured fake visual viewport whosescrollToundoes the pan, as Safari's does:- The keyboard's pan is undone, so the stage stays at the top, 480 px tall, with a 320 px inset. Fails without the fix.
- A keyboard rising over a focused field scrolls its caret 24 px above the keyboard, and one over nothing focused scrolls nothing. Fails without the fix.
- A pinch's pan is never undone. This guards that the zoom behaviour stays the same.
plan-document-editor.test.ts: the caret's words stay where they stood as the editor closes, and a caret on no words scrolls nothing. Fails without the fix.placement.test.ts:caretWordsreports the caret's section, words, matching-node count and offset as the drawn text counts them.- Focus leaving the field lets the stage down at once, ahead of WebKit's late measurement, and holds it down through late events. Moving to another field keeps the keyboard inset. Fails without the fix.
- The existing tests for the fallback, pinch-zoom and Android still pass.
Notes
- The developer tested the first commit on his phone (iOS Safari): the caret was correct, and the remaining problem was the lag on dismissal, which the next commit addresses.
- Verified on iOS Safari by the developer on his phone, after all three changes: the caret lands where tapped, the page doesn't slide, and dismissing the keyboard neither lags nor jumps. He approved merging.
- Not yet verified on iOS (dismissal fix). Chromium emulation has no keyboard pan, and Simulator Safari can't be automated without a one-time setup on the developer's Mac. This is a draft until the developer tries the Preview on his phone. Two things to watch there: whether Safari honours
scrollTo(0, 0)during the keyboard's animation, and whether undoing the pan flickers. If it flickers, the smaller fallback is to keep following the pan and only fix the caret.
Made with Luke