import { useEffect, useState } from "react"; const IOS_FALLBACK_MIN_GAP_PX = 30; const IOS_FALLBACK_MIN_FOCUSED_GAP_PX = 16; const IOS_VIEWPORT_SHRINK_MIN_PX = 16; /** Whether the current device is likely mobile (touch-primary, small viewport). */ function isMobileDevice(): boolean { if (typeof window === "undefined") return false; const hasTouchScreen = "ontouchstart" in window || navigator.maxTouchPoints > 0; const isNarrow = window.innerWidth <= 768; return hasTouchScreen && isNarrow; } /** * Baseline viewport height captured while keyboard is likely closed. * Kept as max-observed value to recover if first sample was keyboard-open. */ let _baselineViewportHeight: number | null = null; function getBaselineViewportHeight(): number { if (_baselineViewportHeight === null) { _baselineViewportHeight = window.visualViewport?.height ?? window.innerHeight; } return _baselineViewportHeight; } function updateBaselineViewportHeight(nextHeight: number): void { const current = getBaselineViewportHeight(); if (nextHeight > current) { _baselineViewportHeight = nextHeight; } } function isKeyboardFocusableElement(el: Element | null): boolean { if (!el) return false; if (el instanceof HTMLTextAreaElement) return true; if (el instanceof HTMLInputElement) { const nonTextTypes = new Set(["checkbox", "radio", "button", "submit", "reset", "file", "range", "color", "hidden"]); return !nonTextTypes.has(el.type); } return el instanceof HTMLElement && el.isContentEditable; } interface KeyboardMetrics { overlap: number; open: boolean; vvHeight: number | null; vvOffsetTop: number; } function getKeyboardMetrics(): KeyboardMetrics { if (typeof window === "undefined" || !window.visualViewport) { return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 }; } const vv = window.visualViewport; const focused = isKeyboardFocusableElement(document.activeElement); const offsetTop = vv.offsetTop; // Only refresh baseline while keyboard is likely closed. if (!focused) { updateBaselineViewportHeight(vv.height); } // Android/Chrome style overlap. Only treat as open while an input is // actually focused — without this, the (often slow) visualViewport // dismissal animation keeps reporting overlap > 0 for hundreds of ms // after the user has tapped Done, which leaves App-level layout (mobile // nav bar visibility, project-content padding) stuck in keyboard-up // mode and makes downstream components (ChatView) jump on settle. const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height); if (chromeOverlap > 0 && focused) { return { overlap: chromeOverlap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop }; } // iOS fallback (window.innerHeight shrinks with keyboard). Same focused // requirement as above — the dismissal animation otherwise leaves the // gap > the open-threshold for the duration of the slide. const baselineHeight = getBaselineViewportHeight(); const gap = Math.max(0, baselineHeight - vv.offsetTop - vv.height); if (gap >= IOS_FALLBACK_MIN_GAP_PX && focused) { return { overlap: gap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop }; } if (gap >= IOS_FALLBACK_MIN_FOCUSED_GAP_PX && focused) { return { overlap: gap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop }; } // Last-resort signal: focused input + meaningful viewport shrink. const viewportShrink = Math.max(0, baselineHeight - vv.height); if (focused && viewportShrink >= IOS_VIEWPORT_SHRINK_MIN_PX) { return { overlap: 0, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop }; } return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 }; } /** Reset cached viewport baseline. Exported for tests only. */ export function _resetInitialViewportHeight(): void { _baselineViewportHeight = null; } interface UseMobileKeyboardOptions { enabled?: boolean; } export function useMobileKeyboard( { enabled = true }: UseMobileKeyboardOptions = {}, ): { keyboardOverlap: number; viewportHeight: number | null; viewportOffsetTop: number; keyboardOpen: boolean } { const [keyboardOverlap, setKeyboardOverlap] = useState(0); const [viewportHeight, setViewportHeight] = useState(null); const [viewportOffsetTop, setViewportOffsetTop] = useState(0); const [keyboardOpen, setKeyboardOpen] = useState(false); useEffect(() => { if (!enabled || !isMobileDevice()) { setKeyboardOverlap(0); setViewportHeight(null); setViewportOffsetTop(0); setKeyboardOpen(false); return; } const vv = window.visualViewport; if (!vv) { setKeyboardOverlap(0); setViewportHeight(null); setViewportOffsetTop(0); setKeyboardOpen(false); return; } const update = () => { const metrics = getKeyboardMetrics(); setKeyboardOverlap(metrics.overlap); setViewportHeight(metrics.vvHeight); setViewportOffsetTop(metrics.vvOffsetTop); setKeyboardOpen(metrics.open); }; update(); vv.addEventListener("resize", update); vv.addEventListener("scroll", update); document.addEventListener("focusin", update); document.addEventListener("focusout", update); return () => { vv.removeEventListener("resize", update); vv.removeEventListener("scroll", update); document.removeEventListener("focusin", update); document.removeEventListener("focusout", update); setKeyboardOverlap(0); setViewportHeight(null); setViewportOffsetTop(0); setKeyboardOpen(false); }; }, [enabled]); return { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen }; }