From 8fe4435c68d2f4828db778469adc632af07f7a8a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 11 Feb 2026 23:49:10 -0800 Subject: [PATCH] fix: File editor contents not always visible on mobile (#400) --- .../ui/src/components/layout/MainLayout.tsx | 59 +++++++--- .../ui/src/components/views/FilesView.tsx | 107 +++++++++++++++++- 2 files changed, 144 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index b6e4ac15..677d6b1a 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -208,6 +208,11 @@ export const MainLayout: React.FC = () => { } const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null; if (markedTarget) { + // data-keyboard-avoid="none" opts out of translateY avoidance entirely. + // Used by components with their own scroll (e.g. CodeMirror). + if (markedTarget.getAttribute('data-keyboard-avoid') === 'none') { + return null; + } return markedTarget; } if (active.classList.contains('overlay-scrollbar-container')) { @@ -335,17 +340,21 @@ export const MainLayout: React.FC = () => { viewport?.addEventListener('scroll', updateVisualViewport); window.addEventListener('resize', updateVisualViewport); window.addEventListener('orientationchange', updateVisualViewport); + const isTextInputTarget = (element: HTMLElement | null) => { + if (!element) { + return false; + } + const tagName = element.tagName; + const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'; + return isInput || element.isContentEditable; + }; + // Reset ignoreOpenUntilZero when focus moves to a text input. // This allows keyboard detection to work when user taps input quickly // while keyboard is still closing (common on Android). const handleFocusIn = (event: FocusEvent) => { const target = event.target as HTMLElement | null; - if (!target) { - return; - } - const tagName = target.tagName; - const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'; - if (isInput || target.isContentEditable) { + if (isTextInputTarget(target)) { ignoreOpenUntilZero = false; } updateVisualViewport(); @@ -354,22 +363,40 @@ export const MainLayout: React.FC = () => { const handleFocusOut = (event: FocusEvent) => { const target = event.target as HTMLElement | null; - if (!target) { + if (!isTextInputTarget(target)) { return; } - const tagName = target.tagName; - const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'; - if (isInput || target.isContentEditable) { - // Check if focus is moving to another input - if so, don't close keyboard - const related = event.relatedTarget as HTMLElement | null; - const relatedTag = related?.tagName; - const relatedIsInput = relatedTag === 'INPUT' || relatedTag === 'TEXTAREA' || relatedTag === 'SELECT' || related?.isContentEditable; - if (relatedIsInput) { + // Check if focus is moving to another input - if so, don't close keyboard + const related = event.relatedTarget as HTMLElement | null; + if (isTextInputTarget(related)) { + return; + } + + // On mobile contenteditable editors (CodeMirror), focus can momentarily + // leave and return during drag-selection handles. Defer closing until the + // next frame and only close when no text target is focused and the + // visual viewport inset is actually zero. + window.requestAnimationFrame(() => { + if (isTextInputTarget(document.activeElement as HTMLElement | null)) { return; } + + const currentViewport = window.visualViewport; + const height = currentViewport ? Math.round(currentViewport.height) : window.innerHeight; + const offsetTop = currentViewport ? Math.max(0, Math.round(currentViewport.offsetTop)) : 0; + const layoutHeight = Math.round(root.clientHeight || window.innerHeight); + const viewportSum = height + offsetTop; + const rawInset = Math.max(0, layoutHeight - viewportSum); + + if (rawInset > 0) { + updateVisualViewport(); + return; + } + forceKeyboardClosed(); - } + updateVisualViewport(); + }); }; document.addEventListener('focusout', handleFocusOut, true); diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 1867fd59..6545ca63 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -619,6 +619,7 @@ export const FilesView: React.FC = () => { const skipDirtyOnceRef = React.useRef(false); const copiedContentTimeoutRef = React.useRef(null); const copiedPathTimeoutRef = React.useRef(null); + const editorViewRef = React.useRef(null); const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null); const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null); @@ -1626,6 +1627,63 @@ export const FilesView: React.FC = () => { const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); + const nudgeEditorSelectionAboveKeyboard = React.useCallback((view: EditorView | null) => { + if (!isMobile || !view || !view.hasFocus || typeof window === 'undefined') { + return; + } + + const viewport = window.visualViewport; + if (!viewport) { + return; + } + + const rootStyles = getComputedStyle(document.documentElement); + const keyboardInset = Number.parseFloat(rootStyles.getPropertyValue('--oc-keyboard-inset')) || 0; + const keyboardHomeIndicator = Number.parseFloat(rootStyles.getPropertyValue('--oc-keyboard-home-indicator')) || 0; + const occludedBottom = keyboardInset + keyboardHomeIndicator; + if (occludedBottom <= 0) { + return; + } + + const head = view.state.selection.main.head; + const cursorRect = view.coordsAtPos(head); + if (!cursorRect) { + return; + } + + const visibleBottom = Math.round(viewport.offsetTop + viewport.height); + const clearance = 20; + const overlap = cursorRect.bottom + clearance - visibleBottom; + if (overlap <= 0) { + return; + } + + view.scrollDOM.scrollTop += overlap; + }, [isMobile]); + + React.useEffect(() => { + if (!isMobile || typeof window === 'undefined') { + return; + } + + const runNudge = () => { + window.requestAnimationFrame(() => { + nudgeEditorSelectionAboveKeyboard(editorViewRef.current); + }); + }; + + const viewport = window.visualViewport; + viewport?.addEventListener('resize', runNudge); + viewport?.addEventListener('scroll', runNudge); + document.addEventListener('selectionchange', runNudge); + + return () => { + viewport?.removeEventListener('resize', runNudge); + viewport?.removeEventListener('scroll', runNudge); + document.removeEventListener('selectionchange', runNudge); + }; + }, [isMobile, nudgeEditorSelectionAboveKeyboard]); + const editorExtensions = React.useMemo(() => { if (!selectedFile?.path) { return [createFlexokiCodeMirrorTheme(currentTheme)]; @@ -1639,8 +1697,22 @@ export const FilesView: React.FC = () => { if (wrapLines) { extensions.push(EditorView.lineWrapping); } + if (isMobile) { + extensions.push(EditorView.updateListener.of((update) => { + if (!update.view.hasFocus) { + return; + } + if (!(update.selectionSet || update.focusChanged || update.viewportChanged || update.geometryChanged)) { + return; + } + + window.requestAnimationFrame(() => { + nudgeEditorSelectionAboveKeyboard(update.view); + }); + })); + } return extensions; - }, [currentTheme, selectedFile?.path, wrapLines]); + }, [currentTheme, selectedFile?.path, wrapLines, isMobile, nudgeEditorSelectionAboveKeyboard]); const imageSrc = selectedFile?.path && isSelectedImage ? (runtime.isDesktop @@ -2172,15 +2244,27 @@ export const FilesView: React.FC = () => { ) : ( -
+
{ + editorViewRef.current = view; + window.requestAnimationFrame(() => { + nudgeEditorSelectionAboveKeyboard(view); + }); + }} + onViewDestroy={() => { + if (editorViewRef.current) { + editorViewRef.current = null; + } + }} enableSearch searchOpen={isSearchOpen} onSearchOpenChange={setIsSearchOpen} @@ -2543,6 +2627,17 @@ export const FilesView: React.FC = () => { onChange={setDraftContent} extensions={editorExtensions} className="h-full" + onViewReady={(view) => { + editorViewRef.current = view; + window.requestAnimationFrame(() => { + nudgeEditorSelectionAboveKeyboard(view); + }); + }} + onViewDestroy={() => { + if (editorViewRef.current) { + editorViewRef.current = null; + } + }} />
)}