2026-08-02 16:25:16 +03:00
|
|
|
/**
|
|
|
|
|
* "Is a hardware keyboard attached?" — the input the mobile layout uses to
|
|
|
|
|
* decide whether a soft keyboard will ever eat the screen.
|
|
|
|
|
*
|
|
|
|
|
* Two sources, in priority order:
|
|
|
|
|
*
|
|
|
|
|
* 1. The native answer. On iOS the shell reads `GCKeyboard` and stamps
|
|
|
|
|
* `window.__OPENCHAMBER_HARDWARE_KEYBOARD__` at document start, then keeps it
|
|
|
|
|
* live via `oc:hardware-keyboard` (see BridgeViewController). This is
|
|
|
|
|
* authoritative and — crucially — known BEFORE the user focuses anything, so
|
|
|
|
|
* the draft screen and composer start in the right shape instead of
|
|
|
|
|
* re-laying-out after the first focus.
|
|
|
|
|
* 2. Inference, for runtimes with no native answer (Android, hosted mobile).
|
|
|
|
|
* A `keyboardWillShow` with a real height means there IS a soft keyboard; a
|
|
|
|
|
* tiny height means only iOS' shortcut strip; focus with no event at all
|
|
|
|
|
* within a short window means nothing was presented. Inference is ignored
|
|
|
|
|
* entirely once the native source has spoken.
|
|
|
|
|
*
|
|
|
|
|
* Everything else stays `false`, which is the safe default: the layout then
|
|
|
|
|
* behaves exactly as it does on a phone.
|
|
|
|
|
*
|
|
|
|
|
* In memory only — a keyboard can be attached and detached while the app runs,
|
|
|
|
|
* and both sources re-answer the question continuously.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import React from 'react';
|
|
|
|
|
|
|
|
|
|
/** Below this the "keyboard" is only iOS' shortcut bar, not a real keyboard. */
|
|
|
|
|
const SOFTWARE_KEYBOARD_MIN_HEIGHT_PX = 120;
|
|
|
|
|
/** iOS starts its keyboard animation well inside this window after focus. */
|
|
|
|
|
const KEYBOARD_EVENT_GRACE_MS = 600;
|
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
interface Window {
|
|
|
|
|
__OPENCHAMBER_HARDWARE_KEYBOARD__?: boolean;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Read at module init, not just from the bridge effect: the stamp exists from
|
|
|
|
|
// document start, and the very first render of the draft screen / composer must
|
|
|
|
|
// already see it — otherwise the layout still settles one frame late.
|
|
|
|
|
const initialNativeAnswer = typeof window !== 'undefined'
|
|
|
|
|
&& typeof window.__OPENCHAMBER_HARDWARE_KEYBOARD__ === 'boolean'
|
|
|
|
|
? window.__OPENCHAMBER_HARDWARE_KEYBOARD__
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
let hardwareKeyboardAttached = initialNativeAnswer === true;
|
|
|
|
|
let hasNativeAnswer = initialNativeAnswer !== null;
|
|
|
|
|
let focusProbeTimer: number | null = null;
|
|
|
|
|
let bridgeStarted = false;
|
|
|
|
|
const subscribers = new Set<() => void>();
|
|
|
|
|
|
|
|
|
|
if (hardwareKeyboardAttached && typeof document !== 'undefined') {
|
|
|
|
|
document.documentElement.classList.add('oc-hardware-keyboard');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const clearFocusProbe = (): void => {
|
|
|
|
|
if (focusProbeTimer === null) return;
|
|
|
|
|
window.clearTimeout(focusProbeTimer);
|
|
|
|
|
focusProbeTimer = null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setHardwareKeyboardAttached = (value: boolean): void => {
|
|
|
|
|
if (hardwareKeyboardAttached === value) return;
|
|
|
|
|
hardwareKeyboardAttached = value;
|
|
|
|
|
if (typeof document !== 'undefined') {
|
|
|
|
|
document.documentElement.classList.toggle('oc-hardware-keyboard', value);
|
|
|
|
|
}
|
|
|
|
|
for (const listener of subscribers) listener();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Adopt the native shell's answer and stop inferring. Idempotent; safe to call
|
|
|
|
|
* before the shell has stamped anything (then it is a no-op and inference
|
|
|
|
|
* stays in charge).
|
|
|
|
|
*/
|
|
|
|
|
export const startHardwareKeyboardBridge = (): (() => void) => {
|
|
|
|
|
if (typeof window === 'undefined') return () => {};
|
|
|
|
|
|
|
|
|
|
const adopt = (value: boolean) => {
|
|
|
|
|
hasNativeAnswer = true;
|
|
|
|
|
clearFocusProbe();
|
|
|
|
|
setHardwareKeyboardAttached(value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (typeof window.__OPENCHAMBER_HARDWARE_KEYBOARD__ === 'boolean') {
|
|
|
|
|
adopt(window.__OPENCHAMBER_HARDWARE_KEYBOARD__);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (bridgeStarted) return () => {};
|
|
|
|
|
bridgeStarted = true;
|
|
|
|
|
|
|
|
|
|
const handleNativeChange = (event: Event) => {
|
|
|
|
|
const detail = (event as CustomEvent<{ attached?: boolean }>).detail;
|
|
|
|
|
adopt(detail?.attached === true);
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener('oc:hardware-keyboard', handleNativeChange);
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('oc:hardware-keyboard', handleNativeChange);
|
|
|
|
|
bridgeStarted = false;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Feed a native `keyboardWillShow` height in. Called by the Capacitor keyboard
|
|
|
|
|
* bridge (see `mobileNativeChrome`) on both platforms. An arriving event always
|
|
|
|
|
* settles the question, so it cancels any pending focus probe.
|
|
|
|
|
*/
|
|
|
|
|
export const observeNativeKeyboardHeight = (heightPx: number): void => {
|
|
|
|
|
if (hasNativeAnswer || !Number.isFinite(heightPx)) return;
|
|
|
|
|
clearFocusProbe();
|
|
|
|
|
setHardwareKeyboardAttached(heightPx > 0 && heightPx < SOFTWARE_KEYBOARD_MIN_HEIGHT_PX);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Report that an editor just took focus. If no keyboard event follows, nothing
|
|
|
|
|
* was presented — which means a hardware keyboard is attached.
|
|
|
|
|
*
|
|
|
|
|
* Deliberately one-directional within the window: only the SILENCE concludes
|
|
|
|
|
* "hardware". A real `keyboardWillShow` cancels the probe above, so a slow
|
|
|
|
|
* keyboard can never be misread.
|
|
|
|
|
*/
|
|
|
|
|
export const observeEditorFocus = (): void => {
|
|
|
|
|
if (hasNativeAnswer || typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
|
|
|
// A soft keyboard already up sends no second `keyboardWillShow` — a refocus
|
|
|
|
|
// through it (the overlay-close keyboard restore) would look like silence.
|
|
|
|
|
if (document.documentElement.classList.contains('oc-keyboard-open')) return;
|
|
|
|
|
clearFocusProbe();
|
|
|
|
|
focusProbeTimer = window.setTimeout(() => {
|
|
|
|
|
focusProbeTimer = null;
|
|
|
|
|
setHardwareKeyboardAttached(true);
|
|
|
|
|
}, KEYBOARD_EVENT_GRACE_MS);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Drop the inferred state when the native bridge tears down. */
|
|
|
|
|
export const resetHardwareKeyboardDetection = (): void => {
|
|
|
|
|
clearFocusProbe();
|
|
|
|
|
if (hasNativeAnswer) return;
|
|
|
|
|
setHardwareKeyboardAttached(false);
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-13 15:30:54 +03:00
|
|
|
const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached;
|
2026-08-02 16:25:16 +03:00
|
|
|
|
2026-08-13 15:30:54 +03:00
|
|
|
const subscribeHardwareKeyboard = (listener: () => void): (() => void) => {
|
2026-08-02 16:25:16 +03:00
|
|
|
subscribers.add(listener);
|
|
|
|
|
return () => {
|
|
|
|
|
subscribers.delete(listener);
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function useHardwareKeyboard(): boolean {
|
|
|
|
|
return React.useSyncExternalStore(
|
|
|
|
|
subscribeHardwareKeyboard,
|
|
|
|
|
isHardwareKeyboardAttached,
|
|
|
|
|
() => false,
|
|
|
|
|
);
|
|
|
|
|
}
|