diff --git a/apps/ios/ios/App/App.xcodeproj/project.pbxproj b/apps/ios/ios/App/App.xcodeproj/project.pbxproj index 92308bccca..a372460065 100644 --- a/apps/ios/ios/App/App.xcodeproj/project.pbxproj +++ b/apps/ios/ios/App/App.xcodeproj/project.pbxproj @@ -318,6 +318,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = M96WTV3CKX; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index b8a845e4e7..0ef152f82b 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -732,6 +732,25 @@ -webkit-text-size-adjust: 100%; } +/* Prevent iOS input zoom - font-size < 16px causes auto-zoom on focus */ +.capacitor-ios input, +.capacitor-ios textarea, +.capacitor-ios select, +.capacitor-ios [contenteditable] { + font-size: 16px !important; +} + +/* Smooth keyboard padding transition for elements that adapt to keyboard */ +.capacitor-ios .keyboard-animate { + transition: padding-bottom 0.25s ease-out; +} + +/* Utility for elements that need keyboard-aware bottom positioning */ +.capacitor-ios .keyboard-aware-bottom { + padding-bottom: var(--keyboard-height, 0px); + transition: padding-bottom 0.25s ease-out; +} + @media print { /* Hide ALL UI chrome - comprehensive selectors */ header, diff --git a/apps/web/src/components/ai/chat/input/ChatInput.tsx b/apps/web/src/components/ai/chat/input/ChatInput.tsx index 9f6384eb51..3801e33fa1 100644 --- a/apps/web/src/components/ai/chat/input/ChatInput.tsx +++ b/apps/web/src/components/ai/chat/input/ChatInput.tsx @@ -7,6 +7,7 @@ import { InputActions } from './InputActions'; import { InputFooter } from '@/components/ui/floating-input'; import { useAssistantSettingsStore } from '@/stores/useAssistantSettingsStore'; import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'; +import { useMobileKeyboard } from '@/hooks/useMobileKeyboard'; export interface ChatInputProps { /** Current input value */ @@ -135,6 +136,18 @@ export const ChatInput = forwardRef( }, }); + // Mobile keyboard management + const keyboard = useMobileKeyboard(); + const prevStreamingRef = useRef(isStreaming); + + // Dismiss keyboard when streaming starts + useEffect(() => { + if (isStreaming && !prevStreamingRef.current) { + keyboard.dismiss(); + } + prevStreamingRef.current = isStreaming; + }, [isStreaming, keyboard]); + useImperativeHandle(ref, () => ({ focus: () => textareaRef.current?.focus(), clear: () => textareaRef.current?.clear(), @@ -142,6 +155,7 @@ export const ChatInput = forwardRef( const handleSend = () => { if (value.trim() && !disabled) { + keyboard.dismiss(); onSend(); } }; diff --git a/apps/web/src/components/ai/chat/input/ChatTextarea.tsx b/apps/web/src/components/ai/chat/input/ChatTextarea.tsx index 958d1eb574..4544ad758b 100644 --- a/apps/web/src/components/ai/chat/input/ChatTextarea.tsx +++ b/apps/web/src/components/ai/chat/input/ChatTextarea.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { forwardRef, useImperativeHandle, useRef } from 'react'; +import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'; import { useSuggestion } from '@/hooks/useSuggestion'; import { Textarea } from '@/components/ui/textarea'; import SuggestionPopup from '@/components/mentions/SuggestionPopup'; @@ -59,6 +59,8 @@ const ChatTextareaInner = forwardRef( ) => { const textareaRef = useRef(null); const context = useSuggestionContext(); + // Track IME composition state to prevent accidental sends during predictive text + const [isComposing, setIsComposing] = useState(false); const suggestion = useSuggestion({ inputRef: textareaRef as React.RefObject, @@ -81,7 +83,12 @@ const ChatTextareaInner = forwardRef( suggestion.handleKeyDown(e); // Send on Enter (without Shift) when suggestions are closed + // Also check for IME composition to prevent sends during predictive text selection if (!context.isOpen && e.key === 'Enter' && !e.shiftKey) { + // Don't send during IME composition (predictive text, etc.) + if (isComposing || e.nativeEvent.isComposing) { + return; + } e.preventDefault(); if (value.trim() && !disabled) { onSend(); @@ -96,6 +103,8 @@ const ChatTextareaInner = forwardRef( value={value} onChange={(e) => suggestion.handleValueChange(e.target.value)} onKeyDown={handleKeyDown} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} placeholder={placeholder} disabled={disabled} className={cn( diff --git a/apps/web/src/components/ai/shared/chat/ChatInputArea.tsx b/apps/web/src/components/ai/shared/chat/ChatInputArea.tsx index 9a1788719e..33de017ed6 100644 --- a/apps/web/src/components/ai/shared/chat/ChatInputArea.tsx +++ b/apps/web/src/components/ai/shared/chat/ChatInputArea.tsx @@ -3,12 +3,13 @@ * Used by both Agent engine and Global Assistant engine */ -import React, { useRef, forwardRef, useImperativeHandle } from 'react'; +import React, { useRef, forwardRef, useImperativeHandle, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Send, StopCircle } from 'lucide-react'; import AiInput from './AiInput'; import { ChatInputRef } from '@/components/messages/ChatInput'; import { getAIErrorMessage } from '@/lib/ai/shared/error-messages'; +import { useMobileKeyboard } from '@/hooks/useMobileKeyboard'; interface ChatInputAreaProps { /** Current input value */ @@ -76,6 +77,18 @@ export const ChatInputArea = forwardRef( ) => { const chatInputRef = useRef(null); + // Mobile keyboard management + const keyboard = useMobileKeyboard(); + const prevStreamingRef = useRef(isStreaming); + + // Dismiss keyboard when streaming starts + useEffect(() => { + if (isStreaming && !prevStreamingRef.current) { + keyboard.dismiss(); + } + prevStreamingRef.current = isStreaming; + }, [isStreaming, keyboard]); + // Expose methods to parent useImperativeHandle(ref, () => ({ focus: () => chatInputRef.current?.focus?.(), @@ -85,6 +98,7 @@ export const ChatInputArea = forwardRef( // Handle send const handleSend = () => { if (value.trim() && !disabled && !isLoading && !isReadOnly) { + keyboard.dismiss(); onSend(); } }; diff --git a/apps/web/src/components/layout/Layout.tsx b/apps/web/src/components/layout/Layout.tsx index ad723f47d3..c2690571f2 100644 --- a/apps/web/src/components/layout/Layout.tsx +++ b/apps/web/src/components/layout/Layout.tsx @@ -14,7 +14,8 @@ import { DebugPanel } from "./DebugPanel"; import { useLayoutStore } from "@/stores/useLayoutStore"; import { useHasHydrated } from "@/hooks/useHasHydrated"; import { usePerformanceMonitor } from "@/hooks/usePerformanceMonitor"; -import { useRouter } from "next/navigation"; +import { useRouter, usePathname } from "next/navigation"; +import { isCapacitorApp } from "@/lib/capacitor-bridge"; import { useCallback, useEffect, useState } from "react"; import { Sheet, @@ -31,6 +32,7 @@ interface LayoutProps { function Layout({ children }: LayoutProps) { const { isLoading, isAuthenticated } = useAuth(); const router = useRouter(); + const pathname = usePathname(); const isSheetBreakpoint = useBreakpoint("(max-width: 1023px)"); // Use selective Zustand subscriptions to prevent re-renders when unrelated store values change @@ -60,6 +62,15 @@ function Layout({ children }: LayoutProps) { } }, [isSheetBreakpoint]); + // Auto-close sheets on navigation (Capacitor only) + // This fixes the issue where tapping a sidebar item navigates but leaves the sheet open + useEffect(() => { + if (isCapacitorApp() && isSheetBreakpoint) { + setLeftSheetOpen(false); + setRightSheetOpen(false); + } + }, [pathname, isSheetBreakpoint]); + // Handle authentication redirect with Next.js router for faster navigation useEffect(() => { if (hasHydrated && !isLoading && !isAuthenticated) { diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx index c1eb8bd840..66c92d3a8e 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx @@ -242,7 +242,8 @@ export function PageTreeItem({ e.stopPropagation()} - className="flex-1 min-w-0 ml-1.5 truncate text-sm font-medium text-gray-900 dark:text-gray-100 hover:underline cursor-pointer" + onTouchEnd={(e) => e.stopPropagation()} + className="flex-1 min-w-0 ml-1.5 truncate text-sm font-medium text-gray-900 dark:text-gray-100 hover:underline cursor-pointer touch-manipulation" > {item.title} diff --git a/apps/web/src/components/layout/middle-content/page-views/sheet/FloatingCellEditor.tsx b/apps/web/src/components/layout/middle-content/page-views/sheet/FloatingCellEditor.tsx index aa6419856f..457477c9b9 100644 --- a/apps/web/src/components/layout/middle-content/page-views/sheet/FloatingCellEditor.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/sheet/FloatingCellEditor.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'; import { useSuggestion } from '@/hooks/useSuggestion'; import { useSuggestionContext } from '@/components/providers/SuggestionProvider'; import SuggestionPopup from '@/components/mentions/SuggestionPopup'; +import { useMobileKeyboard } from '@/hooks/useMobileKeyboard'; interface FloatingCellEditorProps { value: string; @@ -32,6 +33,9 @@ export const FloatingCellEditor: React.FC = ({ const inputRef = useRef(null); const [mounted, setMounted] = useState(false); const suggestionContext = useSuggestionContext(); + const { height: keyboardHeight } = useMobileKeyboard(); + // Track IME composition state to prevent accidental commits during predictive text + const [isComposing, setIsComposing] = useState(false); // Sheet-specific trigger pattern: allows @ after formula operators and whitespace // Allows: ( = + - * / , < > ! and whitespace characters, or at start of string @@ -63,6 +67,10 @@ export const FloatingCellEditor: React.FC = ({ switch (event.key) { case 'Enter': + // Don't commit during IME composition (predictive text, etc.) + if (isComposing || event.nativeEvent.isComposing) { + return; + } if (!event.shiftKey) { event.preventDefault(); onCommit(value); @@ -87,7 +95,7 @@ export const FloatingCellEditor: React.FC = ({ break; } }, - [value, onCommit, onCancel, suggestion, suggestionContext.isOpen] + [value, onCommit, onCancel, suggestion, suggestionContext.isOpen, isComposing] ); const handleBlur = useCallback( @@ -149,10 +157,26 @@ export const FloatingCellEditor: React.FC = ({ const minWidth = isMobile ? 100 : 120; const minHeight = isMobile ? 36 : cellRect.height; + // Calculate position, accounting for keyboard on iOS + const viewportHeight = typeof window !== 'undefined' + ? (window.visualViewport?.height ?? window.innerHeight) + : 0; + const availableHeight = viewportHeight - keyboardHeight; + const cellBottom = cellRect.top + Math.max(cellRect.height, minHeight); + + // If cell would be hidden behind keyboard, move it up + let adjustedTop = cellRect.top; + if (keyboardHeight > 0 && cellBottom > availableHeight - 20) { + // Move editor to be visible above keyboard with some padding + adjustedTop = availableHeight - Math.max(cellRect.height, minHeight) - 20; + // Don't go above the viewport + adjustedTop = Math.max(20, adjustedTop); + } + const style: React.CSSProperties = { position: 'fixed', left: cellRect.left, - top: cellRect.top, + top: adjustedTop, width: Math.max(cellRect.width, minWidth), height: Math.max(cellRect.height, minHeight), zIndex: 1000, @@ -172,6 +196,8 @@ export const FloatingCellEditor: React.FC = ({ onChange={(e) => suggestion.handleValueChange(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleBlur} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} className={cn( 'h-full w-full rounded-none border-2 border-primary bg-background px-2 py-1.5 text-sm', 'sm:px-3 sm:py-2', diff --git a/apps/web/src/components/messages/ChatInput.tsx b/apps/web/src/components/messages/ChatInput.tsx index 32616cdd2d..e219c5e454 100644 --- a/apps/web/src/components/messages/ChatInput.tsx +++ b/apps/web/src/components/messages/ChatInput.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { forwardRef, useImperativeHandle, useRef } from 'react'; +import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'; import { useSuggestion } from '@/hooks/useSuggestion'; import { Textarea } from '@/components/ui/textarea'; import SuggestionPopup from '@/components/mentions/SuggestionPopup'; @@ -30,6 +30,8 @@ const ChatInputWithProvider = forwardRef(({ }, ref) => { const textareaRef = useRef(null); const context = useSuggestionContext(); + // Track IME composition state to prevent accidental sends during predictive text + const [isComposing, setIsComposing] = useState(false); const suggestion = useSuggestion({ inputRef: textareaRef as React.RefObject, @@ -53,8 +55,12 @@ const ChatInputWithProvider = forwardRef(({ const handleKeyDown = (e: React.KeyboardEvent) => { suggestion.handleKeyDown(e); - + if (!context.isOpen && e.key === 'Enter' && !e.shiftKey) { + // Don't send during IME composition (predictive text, etc.) + if (isComposing || e.nativeEvent.isComposing) { + return; + } e.preventDefault(); if (value.trim()) { onSendMessage(); @@ -69,6 +75,8 @@ const ChatInputWithProvider = forwardRef(({ value={value} onChange={(e) => suggestion.handleValueChange(e.target.value)} onKeyDown={handleKeyDown} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} placeholder={placeholder} className="min-h-[40px] max-h-[120px] w-full" /> diff --git a/apps/web/src/components/ui/floating-input/InputPositioner.tsx b/apps/web/src/components/ui/floating-input/InputPositioner.tsx index ac6344c017..738259b843 100644 --- a/apps/web/src/components/ui/floating-input/InputPositioner.tsx +++ b/apps/web/src/components/ui/floating-input/InputPositioner.tsx @@ -78,6 +78,9 @@ export function InputPositioner({ )} style={{ maxWidth: isCentered ? centeredMaxWidth : undefined, + // Add keyboard-aware padding when docked (uses CSS variable set by iOS Capacitor) + // Using paddingBottom instead of animating bottom to avoid Framer Motion conflicts + paddingBottom: !isCentered ? 'calc(1rem + var(--keyboard-height, 0px))' : undefined, }} initial={false} animate={position} diff --git a/apps/web/src/hooks/__tests__/useMobileKeyboard.test.ts b/apps/web/src/hooks/__tests__/useMobileKeyboard.test.ts new file mode 100644 index 0000000000..e558b256f1 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useMobileKeyboard.test.ts @@ -0,0 +1,304 @@ +/** + * useMobileKeyboard Hook Tests + * Tests for mobile keyboard state tracking and interaction utilities + * + * These tests validate observable behavior: + * - Hook returns correct keyboard state from DOM + * - dismiss() triggers blur on iOS Capacitor + * - Helper functions work correctly + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +type Platform = 'ios' | 'android' | 'web'; + +// Create hoisted mocks +const { mockIsCapacitorApp, mockGetPlatform } = vi.hoisted(() => { + return { + mockIsCapacitorApp: vi.fn<() => boolean>(() => false), + mockGetPlatform: vi.fn<() => Platform>(() => 'web'), + }; +}); + +// Mock useCapacitor module +vi.mock('../useCapacitor', () => ({ + isCapacitorApp: () => mockIsCapacitorApp(), + getPlatform: () => mockGetPlatform(), +})); + +// Import after mocks +import { + useMobileKeyboard, + dismissKeyboard, + getKeyboardHeight, + isKeyboardOpen, +} from '../useMobileKeyboard'; + +describe('useMobileKeyboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Reset mocks to default values + mockIsCapacitorApp.mockReturnValue(false); + mockGetPlatform.mockReturnValue('web'); + + // Clear any keyboard-related classes and styles + document.body.classList.remove('keyboard-open'); + document.body.style.removeProperty('--keyboard-height'); + }); + + afterEach(() => { + vi.clearAllMocks(); + document.body.classList.remove('keyboard-open'); + document.body.style.removeProperty('--keyboard-height'); + }); + + describe('initial state', () => { + it('given no keyboard class on body, should return isOpen=false', () => { + const { result } = renderHook(() => useMobileKeyboard()); + + expect(result.current.isOpen).toBe(false); + }); + + it('given no --keyboard-height CSS variable, should return height=0', () => { + const { result } = renderHook(() => useMobileKeyboard()); + + expect(result.current.height).toBe(0); + }); + + it('given keyboard-open class on body, should return isOpen=true', () => { + document.body.classList.add('keyboard-open'); + + const { result } = renderHook(() => useMobileKeyboard()); + + expect(result.current.isOpen).toBe(true); + }); + + it('given --keyboard-height CSS variable, should return height value', () => { + document.body.style.setProperty('--keyboard-height', '300px'); + + const { result } = renderHook(() => useMobileKeyboard()); + + expect(result.current.height).toBe(300); + }); + }); + + describe('state updates via MutationObserver', () => { + it('given class added to body, should update isOpen', async () => { + const { result } = renderHook(() => useMobileKeyboard()); + + expect(result.current.isOpen).toBe(false); + + await act(async () => { + document.body.classList.add('keyboard-open'); + // Wait for MutationObserver to trigger + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect(result.current.isOpen).toBe(true); + }); + + it('given style change on body, should update height', async () => { + const { result } = renderHook(() => useMobileKeyboard()); + + expect(result.current.height).toBe(0); + + await act(async () => { + document.body.style.setProperty('--keyboard-height', '250px'); + // Wait for MutationObserver to trigger + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect(result.current.height).toBe(250); + }); + }); + + describe('dismiss', () => { + it('given not on iOS Capacitor, should not blur active element', () => { + mockIsCapacitorApp.mockReturnValue(false); + mockGetPlatform.mockReturnValue('web'); + + // Create a focusable element + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + const blurSpy = vi.spyOn(input, 'blur'); + + const { result } = renderHook(() => useMobileKeyboard()); + + act(() => { + result.current.dismiss(); + }); + + expect(blurSpy).not.toHaveBeenCalled(); + + // Cleanup + document.body.removeChild(input); + }); + + it('given on iOS Capacitor, should blur active element', () => { + mockIsCapacitorApp.mockReturnValue(true); + mockGetPlatform.mockReturnValue('ios'); + + // Create a focusable element + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + const blurSpy = vi.spyOn(input, 'blur'); + + const { result } = renderHook(() => useMobileKeyboard()); + + act(() => { + result.current.dismiss(); + }); + + expect(blurSpy).toHaveBeenCalled(); + + // Cleanup + document.body.removeChild(input); + }); + + it('given on Android Capacitor, should not blur active element', () => { + mockIsCapacitorApp.mockReturnValue(true); + mockGetPlatform.mockReturnValue('android'); + + // Create a focusable element + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + const blurSpy = vi.spyOn(input, 'blur'); + + const { result } = renderHook(() => useMobileKeyboard()); + + act(() => { + result.current.dismiss(); + }); + + expect(blurSpy).not.toHaveBeenCalled(); + + // Cleanup + document.body.removeChild(input); + }); + }); + + describe('scrollInputIntoView', () => { + it('given keyboard not open, should not call scrollIntoView', () => { + const { result } = renderHook(() => useMobileKeyboard()); + + const element = document.createElement('input'); + element.scrollIntoView = vi.fn(); + + act(() => { + result.current.scrollInputIntoView(element); + }); + + expect(element.scrollIntoView).not.toHaveBeenCalled(); + }); + + it('given keyboard open with height, should call scrollIntoView', async () => { + document.body.classList.add('keyboard-open'); + document.body.style.setProperty('--keyboard-height', '300px'); + + const { result } = renderHook(() => useMobileKeyboard()); + + const element = document.createElement('input'); + element.scrollIntoView = vi.fn(); + + act(() => { + result.current.scrollInputIntoView(element); + }); + + expect(element.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }); + }); + }); +}); + +describe('dismissKeyboard (standalone function)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsCapacitorApp.mockReturnValue(false); + mockGetPlatform.mockReturnValue('web'); + }); + + it('given not on iOS Capacitor, should not blur active element', () => { + mockIsCapacitorApp.mockReturnValue(false); + mockGetPlatform.mockReturnValue('web'); + + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + const blurSpy = vi.spyOn(input, 'blur'); + + dismissKeyboard(); + + expect(blurSpy).not.toHaveBeenCalled(); + + document.body.removeChild(input); + }); + + it('given on iOS Capacitor, should blur active element', () => { + mockIsCapacitorApp.mockReturnValue(true); + mockGetPlatform.mockReturnValue('ios'); + + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + const blurSpy = vi.spyOn(input, 'blur'); + + dismissKeyboard(); + + expect(blurSpy).toHaveBeenCalled(); + + document.body.removeChild(input); + }); +}); + +describe('getKeyboardHeight (standalone function)', () => { + beforeEach(() => { + document.body.style.removeProperty('--keyboard-height'); + }); + + afterEach(() => { + document.body.style.removeProperty('--keyboard-height'); + }); + + it('given no --keyboard-height CSS variable, should return 0', () => { + expect(getKeyboardHeight()).toBe(0); + }); + + it('given --keyboard-height CSS variable, should return height value', () => { + document.body.style.setProperty('--keyboard-height', '320px'); + + expect(getKeyboardHeight()).toBe(320); + }); + + it('given invalid --keyboard-height value, should return 0', () => { + document.body.style.setProperty('--keyboard-height', 'invalid'); + + expect(getKeyboardHeight()).toBe(0); + }); +}); + +describe('isKeyboardOpen (standalone function)', () => { + beforeEach(() => { + document.body.classList.remove('keyboard-open'); + }); + + afterEach(() => { + document.body.classList.remove('keyboard-open'); + }); + + it('given no keyboard-open class, should return false', () => { + expect(isKeyboardOpen()).toBe(false); + }); + + it('given keyboard-open class present, should return true', () => { + document.body.classList.add('keyboard-open'); + + expect(isKeyboardOpen()).toBe(true); + }); +}); diff --git a/apps/web/src/hooks/useMobileKeyboard.ts b/apps/web/src/hooks/useMobileKeyboard.ts new file mode 100644 index 0000000000..1b0c8a7c6a --- /dev/null +++ b/apps/web/src/hooks/useMobileKeyboard.ts @@ -0,0 +1,153 @@ +'use client'; + +import { useEffect, useState, useCallback, useRef } from 'react'; +import { isCapacitorApp, getPlatform } from './useCapacitor'; + +export interface UseMobileKeyboardReturn { + /** Whether the keyboard is currently open */ + isOpen: boolean; + /** Current keyboard height in pixels */ + height: number; + /** Dismiss the keyboard (iOS only, no-op elsewhere) */ + dismiss: () => void; + /** Scroll an element into view above the keyboard */ + scrollInputIntoView: (element: HTMLElement) => void; +} + +/** + * Hook to manage mobile keyboard interactions. + * + * Provides: + * - Keyboard open/close state tracking + * - Keyboard height for layout adjustments + * - Dismiss function to programmatically close keyboard + * - Helper to scroll inputs into view above keyboard + * + * Safe to call on all platforms - returns sensible defaults on desktop/web. + * + * @example + * ```tsx + * const { isOpen, height, dismiss } = useMobileKeyboard(); + * + * // Dismiss keyboard after sending a message + * const handleSend = () => { + * sendMessage(); + * dismiss(); + * }; + * ``` + */ +export function useMobileKeyboard(): UseMobileKeyboardReturn { + const [isOpen, setIsOpen] = useState(false); + const [height, setHeight] = useState(0); + const observerRef = useRef(null); + + // Track keyboard state from CSS class changes + useEffect(() => { + if (typeof window === 'undefined') return; + + // Initial state check + const checkState = () => { + const hasClass = document.body.classList.contains('keyboard-open'); + setIsOpen(hasClass); + + const cssHeight = getComputedStyle(document.body).getPropertyValue('--keyboard-height'); + const heightValue = parseInt(cssHeight, 10) || 0; + setHeight(heightValue); + }; + + checkState(); + + // Watch for class changes on body + observerRef.current = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + checkState(); + } + if (mutation.type === 'attributes' && mutation.attributeName === 'style') { + // Also check style changes for --keyboard-height + const cssHeight = getComputedStyle(document.body).getPropertyValue('--keyboard-height'); + const heightValue = parseInt(cssHeight, 10) || 0; + setHeight(heightValue); + } + } + }); + + observerRef.current.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + + return () => { + observerRef.current?.disconnect(); + }; + }, []); + + // Dismiss keyboard - only works on iOS Capacitor + const dismiss = useCallback(() => { + if (typeof window === 'undefined') return; + + // Only attempt dismiss on iOS Capacitor + if (!isCapacitorApp() || getPlatform() !== 'ios') { + return; + } + + // Blur active element as a reliable cross-platform way to dismiss keyboard + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }, []); + + // Scroll an input element into view above the keyboard + const scrollInputIntoView = useCallback((element: HTMLElement) => { + if (!isOpen || height === 0) return; + + // Use scrollIntoView with a bottom margin for the keyboard + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }, [isOpen, height]); + + return { + isOpen, + height, + dismiss, + scrollInputIntoView, + }; +} + +/** + * Non-hook function to dismiss keyboard. + * Use when you need to dismiss outside of React components. + */ +export function dismissKeyboard(): void { + if (typeof window === 'undefined') return; + + if (!isCapacitorApp() || getPlatform() !== 'ios') { + return; + } + + // Blur active element as a reliable cross-platform way to dismiss keyboard + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } +} + +/** + * Get current keyboard height synchronously. + * Returns 0 if not on iOS or keyboard is closed. + */ +export function getKeyboardHeight(): number { + if (typeof window === 'undefined') return 0; + + const cssHeight = getComputedStyle(document.body).getPropertyValue('--keyboard-height'); + return parseInt(cssHeight, 10) || 0; +} + +/** + * Check if keyboard is currently open. + */ +export function isKeyboardOpen(): boolean { + if (typeof window === 'undefined') return false; + return document.body.classList.contains('keyboard-open'); +} diff --git a/apps/web/src/services/__tests__/positioningService.test.ts b/apps/web/src/services/__tests__/positioningService.test.ts new file mode 100644 index 0000000000..6eac74ba67 --- /dev/null +++ b/apps/web/src/services/__tests__/positioningService.test.ts @@ -0,0 +1,279 @@ +/** + * positioningService Tests + * Tests for positioning helper functions used in mention popups and overlays + * + * These tests validate observable behavior: + * - getViewportHeight() returns correct viewport dimensions + * - getKeyboardOffset() caches and returns keyboard height + * - calculateInlinePosition() accounts for keyboard offset + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + getViewportHeight, + getKeyboardOffset, + clearKeyboardOffsetCache, + positioningService, +} from '../positioningService'; + +describe('getViewportHeight', () => { + const originalVisualViewport = window.visualViewport; + const originalInnerHeight = window.innerHeight; + + afterEach(() => { + // Restore original values + Object.defineProperty(window, 'visualViewport', { + value: originalVisualViewport, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'innerHeight', { + value: originalInnerHeight, + writable: true, + configurable: true, + }); + }); + + it('given visualViewport available, should return visualViewport.height', () => { + Object.defineProperty(window, 'visualViewport', { + value: { height: 600, width: 400 }, + writable: true, + configurable: true, + }); + + expect(getViewportHeight()).toBe(600); + }); + + it('given visualViewport not available, should return innerHeight', () => { + Object.defineProperty(window, 'visualViewport', { + value: null, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'innerHeight', { + value: 800, + writable: true, + configurable: true, + }); + + expect(getViewportHeight()).toBe(800); + }); +}); + +describe('getKeyboardOffset', () => { + beforeEach(() => { + clearKeyboardOffsetCache(); + document.body.style.removeProperty('--keyboard-height'); + }); + + afterEach(() => { + clearKeyboardOffsetCache(); + document.body.style.removeProperty('--keyboard-height'); + }); + + it('given no --keyboard-height CSS variable, should return 0', () => { + expect(getKeyboardOffset()).toBe(0); + }); + + it('given --keyboard-height CSS variable, should return height value', () => { + document.body.style.setProperty('--keyboard-height', '280px'); + + expect(getKeyboardOffset()).toBe(280); + }); + + it('given cached value within TTL, should return cached value without recomputing', () => { + document.body.style.setProperty('--keyboard-height', '300px'); + + // First call - populates cache + const firstResult = getKeyboardOffset(); + expect(firstResult).toBe(300); + + // Change the CSS variable + document.body.style.setProperty('--keyboard-height', '400px'); + + // Second call within TTL - should return cached value + const secondResult = getKeyboardOffset(); + expect(secondResult).toBe(300); + }); + + it('given cache expired, should read fresh value', async () => { + document.body.style.setProperty('--keyboard-height', '300px'); + + // First call - populates cache + const firstResult = getKeyboardOffset(); + expect(firstResult).toBe(300); + + // Change the CSS variable + document.body.style.setProperty('--keyboard-height', '400px'); + + // Wait for cache to expire (TTL is 100ms) + await new Promise((resolve) => setTimeout(resolve, 110)); + + // Third call after TTL - should return new value + const thirdResult = getKeyboardOffset(); + expect(thirdResult).toBe(400); + }); + + it('given clearKeyboardOffsetCache called, should read fresh value', () => { + document.body.style.setProperty('--keyboard-height', '300px'); + + // First call - populates cache + const firstResult = getKeyboardOffset(); + expect(firstResult).toBe(300); + + // Change the CSS variable + document.body.style.setProperty('--keyboard-height', '500px'); + + // Clear cache + clearKeyboardOffsetCache(); + + // Next call should return new value + const freshResult = getKeyboardOffset(); + expect(freshResult).toBe(500); + }); +}); + +describe('positioningService.calculateInlinePosition', () => { + const originalGetSelection = window.getSelection; + const originalVisualViewport = window.visualViewport; + + beforeEach(() => { + clearKeyboardOffsetCache(); + document.body.style.removeProperty('--keyboard-height'); + + // Mock visualViewport + Object.defineProperty(window, 'visualViewport', { + value: { height: 800, width: 400 }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + clearKeyboardOffsetCache(); + document.body.style.removeProperty('--keyboard-height'); + window.getSelection = originalGetSelection; + Object.defineProperty(window, 'visualViewport', { + value: originalVisualViewport, + writable: true, + configurable: true, + }); + }); + + it('given no selection, should fallback to element positioning', () => { + window.getSelection = vi.fn(() => null); + + const element = document.createElement('div'); + element.getBoundingClientRect = vi.fn(() => ({ + top: 100, + left: 50, + bottom: 120, + right: 200, + width: 150, + height: 20, + x: 50, + y: 100, + toJSON: () => ({}), + })); + + const result = positioningService.calculateInlinePosition({ element }); + + expect(result.top).toBe(130); // rect.top + 30 + expect(result.left).toBe(70); // rect.left + 20 + expect(result.width).toBe(240); + }); + + it('given selection with enough space below, should position below cursor', () => { + const mockRange = { + getBoundingClientRect: vi.fn(() => ({ + top: 100, + bottom: 120, + left: 50, + right: 60, + width: 10, + height: 20, + x: 50, + y: 100, + toJSON: () => ({}), + })), + }; + + window.getSelection = vi.fn(() => ({ + rangeCount: 1, + getRangeAt: vi.fn(() => mockRange), + })) as unknown as typeof window.getSelection; + + const element = document.createElement('div'); + + const result = positioningService.calculateInlinePosition({ element }); + + // Popup should be below cursor: cursorRect.bottom + gap + expect(result.top).toBe(126); // 120 + 6 + expect(result.left).toBe(50); + expect(result.width).toBe(240); + }); + + it('given keyboard open reducing available height, should position above cursor', () => { + // Set keyboard height + document.body.style.setProperty('--keyboard-height', '300px'); + clearKeyboardOffsetCache(); + + // Cursor near the bottom of available space + const mockRange = { + getBoundingClientRect: vi.fn(() => ({ + top: 450, + bottom: 470, + left: 50, + right: 60, + width: 10, + height: 20, + x: 50, + y: 450, + toJSON: () => ({}), + })), + }; + + window.getSelection = vi.fn(() => ({ + rangeCount: 1, + getRangeAt: vi.fn(() => mockRange), + })) as unknown as typeof window.getSelection; + + const element = document.createElement('div'); + + const result = positioningService.calculateInlinePosition({ element }); + + // Available height = 800 - 300 = 500 + // Below position would be 470 + 6 = 476 + // 476 + 240 = 716 > 500 - 20 = 480, so should flip above + // Above position: cursorRect.top - popupHeight - gap = 450 - 240 - 6 = 204 + expect(result.top).toBe(204); + }); + + it('given cursor near right edge, should adjust left position', () => { + const mockRange = { + getBoundingClientRect: vi.fn(() => ({ + top: 100, + bottom: 120, + left: 350, // Near right edge (viewport is 400px wide) + right: 360, + width: 10, + height: 20, + x: 350, + y: 100, + toJSON: () => ({}), + })), + }; + + window.getSelection = vi.fn(() => ({ + rangeCount: 1, + getRangeAt: vi.fn(() => mockRange), + })) as unknown as typeof window.getSelection; + + const element = document.createElement('div'); + + const result = positioningService.calculateInlinePosition({ element }); + + // Left should be adjusted: viewportWidth - popupWidth - 20 = 400 - 240 - 20 = 140 + expect(result.left).toBe(140); + }); +}); diff --git a/apps/web/src/services/positioningService.ts b/apps/web/src/services/positioningService.ts index 76b4b2a7fb..c151de57e6 100644 --- a/apps/web/src/services/positioningService.ts +++ b/apps/web/src/services/positioningService.ts @@ -21,16 +21,57 @@ export interface InlinePositionParams { preferredWidth?: number; } +/** + * Get the effective viewport height, accounting for iOS keyboard. + * Uses Visual Viewport API when available (modern browsers, iOS Safari). + */ +export function getViewportHeight(): number { + return window.visualViewport?.height ?? window.innerHeight; +} + +// Keyboard height cache to reduce getComputedStyle calls during rapid position updates +let cachedKeyboardHeight: number | null = null; +let cacheTimestamp = 0; +const CACHE_TTL_MS = 100; // Invalidate after 100ms + +/** + * Get the current keyboard height from CSS variable. + * Uses caching to reduce getComputedStyle calls during cursor movement/typing. + */ +export function getKeyboardOffset(): number { + if (typeof window === 'undefined') return 0; + + const now = Date.now(); + if (cachedKeyboardHeight !== null && (now - cacheTimestamp) < CACHE_TTL_MS) { + return cachedKeyboardHeight; + } + + const cssHeight = getComputedStyle(document.body).getPropertyValue('--keyboard-height'); + cachedKeyboardHeight = parseInt(cssHeight, 10) || 0; + cacheTimestamp = now; + return cachedKeyboardHeight; +} + +/** + * Clear the keyboard height cache. Useful for testing. + */ +export function clearKeyboardOffsetCache(): void { + cachedKeyboardHeight = null; + cacheTimestamp = 0; +} + export const positioningService = { calculateTextareaPosition: ( params: TextareaPositionParams ): Position => { const { element } = params; const rect = element.getBoundingClientRect(); - + const viewportHeight = getViewportHeight(); + // Anchor to the bottom of the viewport, with a gap above the textarea + // Account for keyboard height on iOS return { - bottom: window.innerHeight - rect.top + 8, + bottom: viewportHeight - rect.top + 8, left: rect.left, width: rect.width, }; @@ -57,7 +98,10 @@ export const positioningService = { ): Position => { // Use browser's selection API for precise cursor positioning const selection = window.getSelection(); - + const viewportHeight = getViewportHeight(); + const viewportWidth = window.visualViewport?.width ?? window.innerWidth; + const keyboardOffset = getKeyboardOffset(); + if (!selection || selection.rangeCount === 0) { // Fallback to element positioning if no selection const { element } = params; @@ -68,34 +112,36 @@ export const positioningService = { width: 240, }; } - + // Get exact cursor position from browser const range = selection.getRangeAt(0); const cursorRect = range.getBoundingClientRect(); - + // Calculate popup dimensions const popupWidth = 240; const popupHeight = 240; const gap = 6; - + // Primary position: Below cursor let top = cursorRect.bottom + gap; let left = cursorRect.left; - + // Handle vertical overflow - position above if not enough space below - if (top + popupHeight > window.innerHeight - 20) { + // Account for keyboard height on iOS + const availableHeight = viewportHeight - keyboardOffset; + if (top + popupHeight > availableHeight - 20) { top = cursorRect.top - popupHeight - gap; } - + // Handle horizontal overflow - if (left + popupWidth > window.innerWidth - 20) { - left = window.innerWidth - popupWidth - 20; + if (left + popupWidth > viewportWidth - 20) { + left = viewportWidth - popupWidth - 20; } - + // Ensure popup doesn't go off edges left = Math.max(20, left); top = Math.max(20, top); - + return { top, left,