"use client"; import { useEffect, type ReactNode } from "react"; import { BrowserDetector, Logger, PlatformDetector } from "@/utils"; const CSS_VAR_VIEWPORT_HEIGHT = "--app-viewport-height"; const CSS_VAR_VISIBLE_HEIGHT = "--app-visible-height"; const CSS_VAR_VISIBLE_OFFSET_TOP = "--app-visible-offset-top"; const CSS_VAR_SAFE_TOP = "--app-safe-top"; const CSS_VAR_SAFE_RIGHT = "--app-safe-right"; const CSS_VAR_SAFE_BOTTOM = "--app-safe-bottom"; const CSS_VAR_SAFE_LEFT = "--app-safe-left"; const IOS_KEYBOARD_VIEWPORT_DELTA_PX = 120; const log = new Logger("ProvidersViewportCssVarsProvider"); export interface ViewportCssVarsProviderProps { children: ReactNode; } /** * Keeps viewport-related CSS variables in sync with the real visual viewport. * * CSS already has safe defaults (`100dvh` and `env(safe-area-inset-*)`). This * client provider improves Safari/PWA/WebView behavior after hydration without * pushing viewport measurements into React state. */ export function ViewportCssVarsProvider({ children, }: ViewportCssVarsProviderProps) { useEffect(() => { if (typeof window === "undefined") return; let frameId: number | null = null; let stableLayoutHeight = window.innerHeight; let stableVisibleHeight = window.visualViewport?.height ?? window.innerHeight; let stableVisibleOffsetTop = window.visualViewport?.offsetTop ?? 0; let iosFacebookKeyboardSuppressed = false; const isIOSFacebookInAppBrowser = PlatformDetector.isIOS() && BrowserDetector.isFacebookInAppBrowser(); const applyVars = () => { frameId = null; const root = document.documentElement; const viewport = window.visualViewport; const layoutHeight = window.innerHeight; const layoutWidth = window.innerWidth; const visibleHeight = viewport?.height ?? layoutHeight; const visibleWidth = viewport?.width ?? layoutWidth; const visibleOffsetTop = viewport?.offsetTop ?? 0; const shouldSuppressIOSFacebookKeyboardViewport = isIOSFacebookInAppBrowser && isEditableElementFocused() && stableLayoutHeight - visibleHeight > IOS_KEYBOARD_VIEWPORT_DELTA_PX; const nextLayoutHeight = shouldSuppressIOSFacebookKeyboardViewport ? stableLayoutHeight : layoutHeight; const nextVisibleHeight = shouldSuppressIOSFacebookKeyboardViewport ? stableVisibleHeight : visibleHeight; const nextVisibleOffsetTop = shouldSuppressIOSFacebookKeyboardViewport ? stableVisibleOffsetTop : visibleOffsetTop; if (!shouldSuppressIOSFacebookKeyboardViewport) { stableLayoutHeight = layoutHeight; stableVisibleHeight = visibleHeight; stableVisibleOffsetTop = visibleOffsetTop; iosFacebookKeyboardSuppressed = false; } else if (!iosFacebookKeyboardSuppressed) { iosFacebookKeyboardSuppressed = true; log.debug("[viewport] suppress ios facebook keyboard viewport", { layoutHeight: Math.round(layoutHeight), visibleHeight: Math.round(visibleHeight), visibleOffsetTop: Math.round(visibleOffsetTop), stableLayoutHeight: Math.round(stableLayoutHeight), stableVisibleHeight: Math.round(stableVisibleHeight), stableVisibleOffsetTop: Math.round(stableVisibleOffsetTop), }); } root.style.setProperty(CSS_VAR_VIEWPORT_HEIGHT, `${nextLayoutHeight}px`); root.style.setProperty(CSS_VAR_VISIBLE_HEIGHT, `${nextVisibleHeight}px`); root.style.setProperty( CSS_VAR_VISIBLE_OFFSET_TOP, `${nextVisibleOffsetTop}px`, ); root.style.setProperty(CSS_VAR_SAFE_TOP, "env(safe-area-inset-top, 0px)"); root.style.setProperty( CSS_VAR_SAFE_RIGHT, "env(safe-area-inset-right, 0px)", ); root.style.setProperty( CSS_VAR_SAFE_BOTTOM, "env(safe-area-inset-bottom, 0px)", ); root.style.setProperty( CSS_VAR_SAFE_LEFT, "env(safe-area-inset-left, 0px)", ); root.dataset.viewportWidth = String(Math.round(visibleWidth)); root.dataset.viewportHeight = String(Math.round(nextVisibleHeight)); }; const scheduleApply = () => { if (frameId != null) return; frameId = window.requestAnimationFrame(applyVars); }; applyVars(); window.addEventListener("resize", scheduleApply); window.addEventListener("orientationchange", scheduleApply); window.visualViewport?.addEventListener("resize", scheduleApply); window.visualViewport?.addEventListener("scroll", scheduleApply); return () => { if (frameId != null) window.cancelAnimationFrame(frameId); window.removeEventListener("resize", scheduleApply); window.removeEventListener("orientationchange", scheduleApply); window.visualViewport?.removeEventListener("resize", scheduleApply); window.visualViewport?.removeEventListener("scroll", scheduleApply); }; }, []); return children; } function isEditableElementFocused(): boolean { const activeElement = document.activeElement; if (!(activeElement instanceof HTMLElement)) return false; const tagName = activeElement.tagName.toLowerCase(); return ( tagName === "input" || tagName === "textarea" || activeElement.isContentEditable ); }