1type VirtualKeyboard = EventTarget & {
2 readonly boundingRect: Pick<DOMRectReadOnly, "height">;
3 readonly overlaysContent: boolean;
4};
5
6type KeyboardWindow = Pick<Window, "innerHeight" | "visualViewport"> & {
7 readonly navigator: Navigator & { readonly virtualKeyboard?: VirtualKeyboard };
8};
9
10const KEYBOARD_THRESHOLD = 150;
11
12function viewportSnapshot(viewport: VisualViewport) {
13 return {
14 width: Math.round(viewport.width),
15 height: Math.round(viewport.height),
16 scale: viewport.scale,
17 };
18}
19
20export function createSoftwareKeyboardStore(win: KeyboardWindow | undefined, isDesktop: boolean) {
21 const listeners = new Set<() => void>();
22 let viewportOpen = false;
23 const viewport = isDesktop ? undefined : win?.visualViewport;
24 const keyboard = isDesktop ? undefined : win?.navigator.virtualKeyboard;
25 let previous = viewport ? viewportSnapshot(viewport) : undefined;
26
27 const getSnapshot = () => {
28 // Chromium can expose the API while withholding geometry outside overlay mode.
29 // Keep the browser's automatic viewport resizing instead of enabling overlays.
30 if (keyboard && (keyboard.overlaysContent || keyboard.boundingRect.height > 0)) {
31 return keyboard.boundingRect.height > 0;
32 }
33 return viewportOpen;
34 };
35
36 const notify = () => {
37 for (const listener of listeners) listener();
38 };
39
40 const onViewportResize = () => {
41 if (!viewport || !win) return;
42 const current = viewportSnapshot(viewport);
43 // Adapted from Ionic's keyboard assist, MIT, Copyright (c) 2015-present Drifty Co.
44 // https://github.com/ionic-team/ionic-framework/blob/main/core/src/utils/keyboard/keyboard.ts
45 const scaledHeightDifference = previous
46 ? (previous.height - current.height) * current.scale
47 : 0;
48 if (Math.abs(current.height - win.innerHeight / current.scale) <= 1) {
49 viewportOpen = false;
50 } else if (
51 !viewportOpen &&
52 previous?.width === current.width &&
53 scaledHeightDifference > KEYBOARD_THRESHOLD
54 ) {
55 viewportOpen = true;
56 }
57 previous = current;
58 notify();
59 };
60
61 const subscribe = (listener: () => void) => {
62 if (isDesktop || !win) return () => {};
63 if (listeners.size === 0) {
64 if (viewport) {
65 previous = viewportSnapshot(viewport);
66 // A composer can mount after the keyboard has already opened.
67 viewportOpen = win.innerHeight - previous.height * previous.scale > KEYBOARD_THRESHOLD;
68 viewport.addEventListener("resize", onViewportResize);
69 }
70 keyboard?.addEventListener("geometrychange", notify);
71 }
72 listeners.add(listener);
73 return () => {
74 listeners.delete(listener);
75 if (listeners.size === 0) {
76 viewport?.removeEventListener("resize", onViewportResize);
77 keyboard?.removeEventListener("geometrychange", notify);
78 }
79 };
80 };
81
82 return { getSnapshot, subscribe };
83}