Skip to content

Commit 0b60596

Browse files
Stvadclaude
andcommitted
feat(mobile): show editing toolbar whenever a soft keyboard is up, not just on phones
The edit bar was gated purely on viewport width (useIsMobile, max-width 767px), so a wide iPad with NO hardware keyboard — where the soft keyboard does appear — never got the toolbar. Add a width-independent arm: show when editing AND (narrow OR a soft keyboard is up). - keyboardViewport: add pan-invariant `softKeyboardPresent(layoutHeight, vvHeight)` = `layoutHeight - vvHeight >= SOFT_KEYBOARD_MIN_HEIGHT` (150px). It deliberately omits offsetTop (so it stays true while the page pans/scrolls with the keyboard up, unlike the positioning overlap) and sits well above the URL-bar band so a collapsing URL bar can't read as a keyboard. ~0 on Chromium/Firefox resizes-content (layout shrinks with the keyboard) — fine, those phones are already covered by the width gate. - MobileKeyboardToolbar: gate on `isEditing && (isMobile || softKeyboardPresent)`; detect the keyboard only when `!isMobile` so phones don't pay for it. Extract the shared subscribe-and-derive pattern (inset + presence) into one generic `useKeyboardViewportValue` hook. With a hardware keyboard connected no soft keyboard appears, so the bar stays hidden — which is the desired behavior. Unit-tested; on-device confirm pending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b6fed79 commit 0b60596

3 files changed

Lines changed: 110 additions & 29 deletions

File tree

src/plugins/mobile-keyboard-toolbar/MobileKeyboardToolbar.tsx

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,45 +7,53 @@ import { ActionContextTypes } from '@/shortcuts/types.js'
77
import { withEditModeKeepalive } from '@/components/editModeKeepalive.js'
88
import {
99
getLayoutViewportKeyboardOverlap,
10+
getSoftKeyboardPresent,
1011
setEditingToolbarHeight,
1112
subscribeKeyboardViewport,
1213
} from '@/utils/keyboardViewport.js'
1314
import { EXIT_EDIT_ACTION_ID, mobileKeyboardToolbarItemsFacet } from './facet.ts'
1415

15-
/** The mobile editing toolbar's `bottom` inset — lifts the `position: fixed`
16-
* toolbar just above the on-screen keyboard while editing. The value is the
17-
* layout-viewport keyboard overlap (see `getLayoutViewportKeyboardOverlap` for
18-
* the formula and the iOS clientHeight-vs-innerHeight + pan rationale); this
19-
* hook tracks it live while the toolbar is active (`isMobile && isEditing`)
20-
* and re-renders on change. */
21-
const useKeyboardInset = (active: boolean): number => {
22-
const [inset, setInset] = useState(0)
16+
/** Track a value derived from viewport geometry, recomputed on every relevant
17+
* change via the shared keyboard-viewport subscription (the same listener set
18+
* keyboardAwareScroll uses), and re-rendering only when the mapped value
19+
* changes. `read` must be a stable module-level reader. Only subscribes while
20+
* `active`, so an app with no active editor carries no listeners.
21+
*
22+
* Accepted transient: during a rapid keyboard open/close iOS emits a burst of
23+
* events while clientHeight / vv.height / offsetTop settle independently, so a
24+
* derived value can be briefly off for a frame; the next event recomputes it.
25+
* Not rAF-coalesced — that would add a frame of latency to the common
26+
* smooth-scroll case for a rare, self-correcting blip. */
27+
const useKeyboardViewportValue = <T,>(active: boolean, read: () => T, initial: T): T => {
28+
const [value, setValue] = useState(initial)
2329

2430
useEffect(() => {
2531
if (!active || typeof window === 'undefined') return
26-
// Recompute on every viewport change (keyboard open/close, the iOS pan on
27-
// scroll, rotation) via the shared subscription — the same listener set
28-
// keyboardAwareScroll uses. Its extra fire on toolbar-height changes is a
29-
// no-op here: the inset doesn't depend on that, and the equality guard
30-
// drops the redundant recompute.
31-
//
32-
// Accepted transient: during a rapid keyboard open/close iOS emits a burst
33-
// of events while clientHeight / vv.height / offsetTop settle
34-
// independently, so the inset can be briefly off for a frame; the next
35-
// event recomputes it correctly. Not rAF-coalesced — that would add a frame
36-
// of latency to the common smooth-scroll case for a rare, self-correcting
37-
// blip.
38-
const update = () => {
39-
const next = getLayoutViewportKeyboardOverlap()
40-
setInset(prev => (prev === next ? prev : next))
41-
}
32+
const update = () =>
33+
setValue(prev => {
34+
const next = read()
35+
return prev === next ? prev : next
36+
})
4237
update()
4338
return subscribeKeyboardViewport(update)
44-
}, [active])
39+
}, [active, read])
4540

46-
return inset
41+
return value
4742
}
4843

44+
/** The toolbar's `bottom` inset — the live layout-viewport keyboard overlap
45+
* that lifts the `position: fixed` toolbar just above the on-screen keyboard
46+
* (see `getLayoutViewportKeyboardOverlap` for the iOS clientHeight/pan
47+
* rationale). ~0 on Chromium/Firefox, nonzero on iOS Safari. */
48+
const useKeyboardInset = (active: boolean): number =>
49+
useKeyboardViewportValue(active, getLayoutViewportKeyboardOverlap, 0)
50+
51+
/** Whether a soft keyboard is currently up (pan-invariant — see
52+
* `getSoftKeyboardPresent`). Lets the toolbar show on a wide iPad with no
53+
* hardware keyboard, where the `useIsMobile` width gate is off. */
54+
const useSoftKeyboardPresent = (active: boolean): boolean =>
55+
useKeyboardViewportValue(active, getSoftKeyboardPresent, false)
56+
4957
/** Mobile-only toolbar that sits above the on-screen keyboard while a
5058
* block is being edited. Its buttons are facet contributions
5159
* (`mobileKeyboardToolbarItemsFacet`): the structural/reference set comes
@@ -69,10 +77,18 @@ export function MobileKeyboardToolbar() {
6977
// presentation lives on the action. The toolbar only shows in edit mode, so
7078
// unqualified items resolve against EDIT_MODE_CM.
7179
const resolved = useActionRefItems(mobileKeyboardToolbarItemsFacet, ActionContextTypes.EDIT_MODE_CM)
80+
// Show while editing on a narrow (phone) viewport, OR — regardless of width —
81+
// whenever a soft keyboard is up. The soft-keyboard arm covers a wide iPad
82+
// with no hardware keyboard: the keyboard appears, so the toolbar should too;
83+
// with a hardware keyboard connected no soft keyboard shows and the toolbar
84+
// stays hidden. Only detect the keyboard when the width gate wouldn't already
85+
// show the bar (`!isMobile`), so phones don't pay for the extra subscription.
86+
const softKeyboardPresent = useSoftKeyboardPresent(isEditing && !isMobile)
87+
const showToolbar = isEditing && (isMobile || softKeyboardPresent)
7288
// Hooks above the early-return must run on every render. Pass the
7389
// activation flag in so the sentinel only mounts/listens while the
7490
// toolbar is on screen.
75-
const keyboardInset = useKeyboardInset(isMobile && isEditing)
91+
const keyboardInset = useKeyboardInset(showToolbar)
7692

7793
// Publish the toolbar's rendered height so keyboardAwareScroll can keep
7894
// the caret above the toolbar, not just above the keyboard. Measured
@@ -93,9 +109,9 @@ export function MobileKeyboardToolbar() {
93109
observer.disconnect()
94110
setEditingToolbarHeight(0)
95111
}
96-
}, [isMobile, isEditing])
112+
}, [showToolbar])
97113

98-
if (!isMobile || !isEditing) return null
114+
if (!showToolbar) return null
99115

100116
// Prevent the editor from blurring when a button is pressed — losing
101117
// focus would dismiss the on-screen keyboard mid-tap and tear down

src/utils/keyboardViewport.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,35 @@ export const getLayoutViewportKeyboardOverlap = (): number => {
7272
export const getVisualViewportHeight = (): number =>
7373
typeof window === 'undefined' ? 0 : Math.round(window.visualViewport?.height ?? 0)
7474

75+
/** Minimum layout-vs-visual height delta (CSS px) that counts as a soft
76+
* keyboard being up. Deliberately well above the URL-bar band (~60–100px on
77+
* mobile Safari): the delta is used width-independently to decide whether to
78+
* show the editing toolbar on a wide iPad (no hardware keyboard ⇒ soft
79+
* keyboard ⇒ toolbar), so a collapsing URL bar must NOT read as a keyboard.
80+
* A real soft keyboard is ≥ ~200px; this sits between. */
81+
export const SOFT_KEYBOARD_MIN_HEIGHT = 150
82+
83+
/** Pure: is a soft keyboard up, judged by how much the visual viewport has
84+
* shrunk below the layout viewport? Uses ONLY the height delta (no offsetTop)
85+
* so it's invariant to the iOS pan — unlike the positioning overlap, this must
86+
* stay true while the page scrolls with the keyboard up. On Chromium/Firefox
87+
* (interactive-widget=resizes-content) the layout viewport shrinks WITH the
88+
* keyboard, so the delta is ~0 and this reads false — fine, because those are
89+
* the phones already covered by the width (`useIsMobile`) gate; the delta is
90+
* the extra signal for wide iOS where the layout viewport stays full. */
91+
export const softKeyboardPresent = (layoutHeight: number, visualViewportHeight: number): boolean =>
92+
layoutHeight - visualViewportHeight >= SOFT_KEYBOARD_MIN_HEIGHT
93+
94+
/** Live {@link softKeyboardPresent} read from the DOM. Layout height is
95+
* `documentElement.clientHeight` (stays full on iOS while the keyboard is up),
96+
* matching {@link getLayoutViewportKeyboardOverlap}'s source. */
97+
export const getSoftKeyboardPresent = (): boolean => {
98+
if (typeof document === 'undefined') return false
99+
const vv = typeof window === 'undefined' ? undefined : window.visualViewport
100+
if (!vv) return false
101+
return softKeyboardPresent(document.documentElement.clientHeight, vv.height)
102+
}
103+
75104
const listeners = new CallbackSet('keyboard-viewport')
76105
let attached = false
77106

src/utils/test/keyboardViewport.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getKeyboardOverlap,
55
layoutViewportKeyboardOverlap,
66
setEditingToolbarHeight,
7+
softKeyboardPresent,
78
subscribeKeyboardViewport,
89
} from '@/utils/keyboardViewport'
910

@@ -104,6 +105,41 @@ describe('layoutViewportKeyboardOverlap', () => {
104105
})
105106
})
106107

108+
describe('softKeyboardPresent', () => {
109+
// Width-independent "is a soft keyboard up" signal for showing the editing
110+
// toolbar on a wide iPad. Uses only the layout-vs-visual height delta (no
111+
// offsetTop), so unlike the positioning overlap it stays true as the page
112+
// pans/scrolls with the keyboard up.
113+
it('is true when the visual viewport is shrunk by a keyboard (iOS)', () => {
114+
// Real iPad: clientHeight 650, keyboard shrinks vv to 314.
115+
expect(softKeyboardPresent(650, 314)).toBe(true)
116+
})
117+
118+
it('stays true while scrolled — the same shrunk vv.height, no offsetTop term', () => {
119+
// Scrolled case where the positioning overlap drops to 59; presence must
120+
// NOT, because the keyboard is still up (vv.height still 314).
121+
expect(softKeyboardPresent(650, 314)).toBe(true)
122+
})
123+
124+
it('is false with no keyboard (visual viewport fills the layout viewport)', () => {
125+
expect(softKeyboardPresent(650, 650)).toBe(false)
126+
})
127+
128+
it('does NOT treat a collapsing URL bar as a keyboard', () => {
129+
// A mobile-Safari URL bar shrinks the visual viewport by ~60px — well below
130+
// the threshold, so it must not switch the toolbar on.
131+
expect(softKeyboardPresent(650, 600)).toBe(false)
132+
})
133+
134+
it('is false on Chromium resizes-content (layout shrinks with the keyboard)', () => {
135+
expect(softKeyboardPresent(433, 433)).toBe(false)
136+
})
137+
138+
it('is true exactly at the 150px threshold', () => {
139+
expect(softKeyboardPresent(650, 500)).toBe(true)
140+
})
141+
})
142+
107143
describe('subscribeKeyboardViewport', () => {
108144
it('attaches on first subscriber and detaches once the last leaves', () => {
109145
const vv = installViewport({height: 500})

0 commit comments

Comments
 (0)