diff --git a/src/client/App.tsx b/src/client/App.tsx index cd96df3..9d46854 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -413,6 +413,7 @@ export default function App() { isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} /> + ) } diff --git a/src/client/__tests__/visualViewport.test.ts b/src/client/__tests__/visualViewport.test.ts index 3653ebf..b9222a1 100644 --- a/src/client/__tests__/visualViewport.test.ts +++ b/src/client/__tests__/visualViewport.test.ts @@ -14,28 +14,36 @@ function createMockClassList() { describe('visual viewport helpers', () => { test('updates keyboard inset and clears it', () => { const style = { - value: '', + values: new Map(), setProperty: (_key: string, val: string) => { - style.value = val + style.values.set(_key, val) }, removeProperty: (_key: string) => { - style.value = '' + style.values.delete(_key) }, } const classList = createMockClassList() const doc = { documentElement: { style, classList }, } as unknown as Document - const win = { innerHeight: 900 } as Window - const viewport = { height: 700 } as VisualViewport + const win = { innerHeight: 900, screen: { height: 900 } } as Window + const viewport = { height: 700, width: 800 } as VisualViewport const updated = updateKeyboardInset({ viewport, win, doc }) expect(updated).toBe(true) - expect(style.value).toBe('200px') + expect(style.values.get('--keyboard-inset')).toBe('200px') + expect(style.values.get('--viewport-offset-top')).toBe('0px') + expect(style.values.get('--viewport-offset-left')).toBe('0px') + expect(style.values.get('--visual-viewport-height')).toBe('700px') + expect(style.values.get('--visual-viewport-width')).toBe('800px') expect(classList.has('keyboard-visible')).toBe(true) clearKeyboardInset(doc) - expect(style.value).toBe('') + expect(style.values.has('--keyboard-inset')).toBe(false) + expect(style.values.has('--viewport-offset-top')).toBe(false) + expect(style.values.has('--viewport-offset-left')).toBe(false) + expect(style.values.has('--visual-viewport-height')).toBe(false) + expect(style.values.has('--visual-viewport-width')).toBe(false) expect(classList.has('keyboard-visible')).toBe(false) }) @@ -43,51 +51,76 @@ describe('visual viewport helpers', () => { const doc = { documentElement: { style: { setProperty: () => {} } }, } as unknown as Document - const win = { innerHeight: 900 } as Window + const win = { innerHeight: 900, screen: { height: 900 } } as Window expect(updateKeyboardInset({ viewport: null, win, doc })).toBe(false) }) test('clamps negative keyboard inset to zero', () => { const style = { - value: '', + values: new Map(), setProperty: (_key: string, val: string) => { - style.value = val + style.values.set(_key, val) }, } const classList = createMockClassList() const doc = { documentElement: { style, classList }, } as unknown as Document - const win = { innerHeight: 600 } as Window - const viewport = { height: 800 } as VisualViewport + const win = { innerHeight: 600, screen: { height: 600 } } as Window + const viewport = { height: 800, width: 900 } as VisualViewport const updated = updateKeyboardInset({ viewport, win, doc }) expect(updated).toBe(true) - expect(style.value).toBe('0px') + expect(style.values.get('--keyboard-inset')).toBe('0px') + expect(style.values.get('--visual-viewport-height')).toBe('800px') + expect(style.values.get('--visual-viewport-width')).toBe('900px') // Keyboard not visible when height is 0 expect(classList.has('keyboard-visible')).toBe(false) }) test('toggles keyboard-visible class based on threshold', () => { const style = { - value: '', + values: new Map(), setProperty: (_key: string, val: string) => { - style.value = val + style.values.set(_key, val) }, } const classList = createMockClassList() const doc = { documentElement: { style, classList }, } as unknown as Document - const win = { innerHeight: 900 } as Window + const win = { innerHeight: 900, screen: { height: 900 } } as Window // Below threshold (100px) - not visible - updateKeyboardInset({ viewport: { height: 850 } as VisualViewport, win, doc }) + updateKeyboardInset({ viewport: { height: 850, width: 800 } as VisualViewport, win, doc }) expect(classList.has('keyboard-visible')).toBe(false) // Above threshold - visible - updateKeyboardInset({ viewport: { height: 700 } as VisualViewport, win, doc }) + updateKeyboardInset({ viewport: { height: 700, width: 800 } as VisualViewport, win, doc }) expect(classList.has('keyboard-visible')).toBe(true) }) + + test('accounts for visual viewport offset in inset calculation', () => { + const style = { + values: new Map(), + setProperty: (_key: string, val: string) => { + style.values.set(_key, val) + }, + } + const classList = createMockClassList() + const doc = { + documentElement: { style, classList }, + } as unknown as Document + const win = { innerHeight: 900, screen: { height: 900 } } as Window + const viewport = { height: 600, width: 700, offsetTop: 100, offsetLeft: 20 } as VisualViewport + + const updated = updateKeyboardInset({ viewport, win, doc }) + expect(updated).toBe(true) + expect(style.values.get('--keyboard-inset')).toBe('200px') + expect(style.values.get('--viewport-offset-top')).toBe('100px') + expect(style.values.get('--visual-viewport-height')).toBe('600px') + expect(style.values.get('--viewport-offset-left')).toBe('20px') + expect(style.values.get('--visual-viewport-width')).toBe('700px') + }) }) diff --git a/src/client/__tests__/visualViewportHook.test.tsx b/src/client/__tests__/visualViewportHook.test.tsx index 2115ea7..7bc7ee3 100644 --- a/src/client/__tests__/visualViewportHook.test.tsx +++ b/src/client/__tests__/visualViewportHook.test.tsx @@ -34,18 +34,19 @@ describe('useVisualViewport', () => { const events = new Map() const removed: string[] = [] const style = { - value: '', + values: new Map(), setProperty: (_key: string, val: string) => { - style.value = val + style.values.set(_key, val) }, removeProperty: (_key: string) => { - style.value = '' + style.values.delete(_key) }, } const classList = createMockClassList() const viewport = { height: 700, + width: 800, addEventListener: (event: string, handler: EventListener) => { events.set(event, handler) }, @@ -56,6 +57,7 @@ describe('useVisualViewport', () => { globalAny.window = { innerHeight: 900, + screen: { height: 900 }, visualViewport: viewport, } as unknown as Window & typeof globalThis @@ -69,18 +71,26 @@ describe('useVisualViewport', () => { renderer = TestRenderer.create() }) - expect(style.value).toBe('200px') + expect(style.values.get('--keyboard-inset')).toBe('200px') + expect(style.values.get('--viewport-offset-top')).toBe('0px') + expect(style.values.get('--viewport-offset-left')).toBe('0px') + expect(style.values.get('--visual-viewport-height')).toBe('700px') + expect(style.values.get('--visual-viewport-width')).toBe('800px') expect(events.has('resize')).toBe(true) expect(events.has('scroll')).toBe(true) events.get('resize')?.({} as Event) - expect(style.value).toBe('200px') + expect(style.values.get('--keyboard-inset')).toBe('200px') act(() => { renderer.unmount() }) expect(removed).toEqual(['resize', 'scroll']) - expect(style.value).toBe('') + expect(style.values.has('--keyboard-inset')).toBe(false) + expect(style.values.has('--viewport-offset-top')).toBe(false) + expect(style.values.has('--viewport-offset-left')).toBe(false) + expect(style.values.has('--visual-viewport-height')).toBe(false) + expect(style.values.has('--visual-viewport-width')).toBe(false) }) }) diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index 466623e..f2b77d8 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -837,10 +837,27 @@ export default function Terminal({ }, [session, sendMessage, containerRef, inTmuxCopyModeRef, setTmuxCopyMode]) const isKeyboardVisible = useCallback(() => { + if (typeof document === 'undefined') return false const container = containerRef.current if (!container) return false const textarea = container.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null - return textarea ? document.activeElement === textarea : false + if (textarea && document.activeElement === textarea) { + return true + } + + const activeElement = document.activeElement + const inputActive = + typeof HTMLInputElement !== 'undefined' && activeElement instanceof HTMLInputElement + const textareaActive = + typeof HTMLTextAreaElement !== 'undefined' && activeElement instanceof HTMLTextAreaElement + if (inputActive || textareaActive) { + return false + } + if (activeElement && (activeElement as HTMLElement).isContentEditable) { + return false + } + + return !!document.documentElement?.classList?.contains('keyboard-visible') }, [containerRef]) useEffect(() => { diff --git a/src/client/hooks/useVisualViewport.ts b/src/client/hooks/useVisualViewport.ts index 971fb7a..ded90c1 100644 --- a/src/client/hooks/useVisualViewport.ts +++ b/src/client/hooks/useVisualViewport.ts @@ -22,14 +22,35 @@ export function updateKeyboardInset({ return false } - const keyboardHeight = win.innerHeight - viewport.height + const offsetTop = Math.max(0, viewport.offsetTop || 0) + const offsetLeft = Math.max(0, viewport.offsetLeft || 0) + const keyboardHeight = win.innerHeight - (viewport.height + offsetTop) + const clampedKeyboardHeight = Math.max(0, keyboardHeight) + const viewportHeight = Math.max(0, viewport.height || 0) + const viewportWidth = Math.max(0, viewport.width || 0) doc.documentElement.style.setProperty( '--keyboard-inset', - `${Math.max(0, keyboardHeight)}px` + `${clampedKeyboardHeight}px` + ) + doc.documentElement.style.setProperty( + '--viewport-offset-top', + `${offsetTop}px` + ) + doc.documentElement.style.setProperty( + '--viewport-offset-left', + `${offsetLeft}px` + ) + doc.documentElement.style.setProperty( + '--visual-viewport-height', + `${viewportHeight}px` + ) + doc.documentElement.style.setProperty( + '--visual-viewport-width', + `${viewportWidth}px` ) // Toggle class for CSS-based safe area handling - if (keyboardHeight > KEYBOARD_THRESHOLD) { + if (clampedKeyboardHeight > KEYBOARD_THRESHOLD) { doc.documentElement.classList.add('keyboard-visible') } else { doc.documentElement.classList.remove('keyboard-visible') @@ -40,6 +61,10 @@ export function updateKeyboardInset({ export function clearKeyboardInset(doc: Document) { doc.documentElement.style.removeProperty('--keyboard-inset') + doc.documentElement.style.removeProperty('--viewport-offset-top') + doc.documentElement.style.removeProperty('--viewport-offset-left') + doc.documentElement.style.removeProperty('--visual-viewport-height') + doc.documentElement.style.removeProperty('--visual-viewport-width') doc.documentElement.classList.remove('keyboard-visible') } @@ -48,20 +73,120 @@ export function useVisualViewport() { const viewport = window.visualViewport if (!viewport) return + let rafId: number | null = null + let pollTimer: number | null = null + + const isTextInputActive = () => { + const active = document.activeElement + if (!active) return false + const tagName = (active as HTMLElement).tagName?.toLowerCase() + if (tagName === 'input' || tagName === 'textarea') { + return true + } + return Boolean((active as HTMLElement).isContentEditable) + } + const updateViewport = () => { updateKeyboardInset({ viewport, win: window, doc: document }) } + const startRafBurst = () => { + if (typeof window.requestAnimationFrame !== 'function') { + updateViewport() + return + } + if (rafId !== null) { + window.cancelAnimationFrame(rafId) + } + const start = performance.now() + const tick = () => { + updateViewport() + if (performance.now() - start < 1500) { + rafId = window.requestAnimationFrame(tick) + } else { + rafId = null + } + } + rafId = window.requestAnimationFrame(tick) + } + + const pollTick = () => { + if (!isTextInputActive()) { + stopPolling() + return + } + updateViewport() + } + + const startPolling = () => { + if (pollTimer !== null || typeof window.setInterval !== 'function') return + pollTimer = window.setInterval(pollTick, 250) + pollTick() + } + + const stopPolling = () => { + if (pollTimer === null || typeof window.clearInterval !== 'function') return + window.clearInterval(pollTimer) + pollTimer = null + } + + const syncActiveState = () => { + if (isTextInputActive()) { + startRafBurst() + startPolling() + updateViewport() + } else { + stopPolling() + } + } + + const handleFocusIn = () => { + syncActiveState() + } + + const handleFocusOut = () => { + syncActiveState() + } + + const handleOrientationChange = () => { + startRafBurst() + } + // Initial update updateViewport() + syncActiveState() // Listen for viewport changes (keyboard show/hide, zoom, scroll) viewport.addEventListener('resize', updateViewport) viewport.addEventListener('scroll', updateViewport) + if (typeof window.addEventListener === 'function') { + window.addEventListener('resize', updateViewport) + window.addEventListener('orientationchange', handleOrientationChange) + } + if (typeof document.addEventListener === 'function') { + document.addEventListener('focusin', handleFocusIn) + document.addEventListener('focusout', handleFocusOut) + document.addEventListener('focus', handleFocusIn, true) + document.addEventListener('blur', handleFocusOut, true) + } return () => { viewport.removeEventListener('resize', updateViewport) viewport.removeEventListener('scroll', updateViewport) + if (typeof window.removeEventListener === 'function') { + window.removeEventListener('resize', updateViewport) + window.removeEventListener('orientationchange', handleOrientationChange) + } + if (typeof document.removeEventListener === 'function') { + document.removeEventListener('focusin', handleFocusIn) + document.removeEventListener('focusout', handleFocusOut) + document.removeEventListener('focus', handleFocusIn, true) + document.removeEventListener('blur', handleFocusOut, true) + } + if (rafId !== null && typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(rafId) + } + stopPolling() clearKeyboardInset(document) } }, []) diff --git a/src/client/styles/index.css b/src/client/styles/index.css index 6f0adf3..b603b61 100644 --- a/src/client/styles/index.css +++ b/src/client/styles/index.css @@ -147,9 +147,27 @@ body { overflow: hidden; } +/* Align app root to the visual viewport on iOS PWA to keep hit-testing accurate */ +@media (max-width: 767px) { + .ios #root { + position: fixed; + top: 0; + left: 0; + width: var(--visual-viewport-width, 100vw); + height: var(--visual-viewport-height, 100dvh); + transform: translate3d(var(--viewport-offset-left, 0px), var(--viewport-offset-top, 0px), 0); + transform-origin: top left; + } +} + + /* Keyboard inset variable (set by useVisualViewport hook) */ :root { --keyboard-inset: 0px; + --viewport-offset-top: 0px; + --viewport-offset-left: 0px; + --visual-viewport-height: 100dvh; + --visual-viewport-width: 100vw; } ::selection { @@ -327,6 +345,8 @@ body { caret-color: transparent; width: 1px; height: 1px; + font-size: 16px; + line-height: 16px; pointer-events: none; -webkit-user-select: text; user-select: text; @@ -406,16 +426,13 @@ body { /* Mobile terminal overlay */ @media (max-width: 767px) { .terminal-mobile-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: var(--keyboard-inset, 0px); + position: relative; + height: 100%; z-index: 30; display: flex; flex-direction: column; background: var(--bg-base); - transition: bottom 0.1s ease-out; + transition: height 0.1s ease-out; } } @@ -471,6 +488,8 @@ body { /* Terminal control strip styling */ .terminal-controls { flex-shrink: 0; + position: relative; + z-index: 40; } .terminal-key { @@ -489,11 +508,15 @@ body { } .ios .terminal-mobile-overlay { - bottom: var(--keyboard-inset, 0px); + height: var(--visual-viewport-height, 100dvh); +} + +.ios-pwa .terminal-mobile-overlay { + height: 100%; } .ios:not(.keyboard-visible) .terminal-mobile-overlay { - bottom: calc(var(--keyboard-inset, 0px) + env(safe-area-inset-bottom, 0px)); + padding-bottom: env(safe-area-inset-bottom, 0px); } /* Hide scrollbar but allow scrolling */