fix: File editor contents not always visible on mobile (#400)
This commit is contained in:
@@ -208,6 +208,11 @@ export const MainLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null;
|
const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null;
|
||||||
if (markedTarget) {
|
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;
|
return markedTarget;
|
||||||
}
|
}
|
||||||
if (active.classList.contains('overlay-scrollbar-container')) {
|
if (active.classList.contains('overlay-scrollbar-container')) {
|
||||||
@@ -335,17 +340,21 @@ export const MainLayout: React.FC = () => {
|
|||||||
viewport?.addEventListener('scroll', updateVisualViewport);
|
viewport?.addEventListener('scroll', updateVisualViewport);
|
||||||
window.addEventListener('resize', updateVisualViewport);
|
window.addEventListener('resize', updateVisualViewport);
|
||||||
window.addEventListener('orientationchange', 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.
|
// Reset ignoreOpenUntilZero when focus moves to a text input.
|
||||||
// This allows keyboard detection to work when user taps input quickly
|
// This allows keyboard detection to work when user taps input quickly
|
||||||
// while keyboard is still closing (common on Android).
|
// while keyboard is still closing (common on Android).
|
||||||
const handleFocusIn = (event: FocusEvent) => {
|
const handleFocusIn = (event: FocusEvent) => {
|
||||||
const target = event.target as HTMLElement | null;
|
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) {
|
|
||||||
ignoreOpenUntilZero = false;
|
ignoreOpenUntilZero = false;
|
||||||
}
|
}
|
||||||
updateVisualViewport();
|
updateVisualViewport();
|
||||||
@@ -354,22 +363,40 @@ export const MainLayout: React.FC = () => {
|
|||||||
|
|
||||||
const handleFocusOut = (event: FocusEvent) => {
|
const handleFocusOut = (event: FocusEvent) => {
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
if (!target) {
|
if (!isTextInputTarget(target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagName = target.tagName;
|
// Check if focus is moving to another input - if so, don't close keyboard
|
||||||
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
const related = event.relatedTarget as HTMLElement | null;
|
||||||
if (isInput || target.isContentEditable) {
|
if (isTextInputTarget(related)) {
|
||||||
// Check if focus is moving to another input - if so, don't close keyboard
|
return;
|
||||||
const related = event.relatedTarget as HTMLElement | null;
|
}
|
||||||
const relatedTag = related?.tagName;
|
|
||||||
const relatedIsInput = relatedTag === 'INPUT' || relatedTag === 'TEXTAREA' || relatedTag === 'SELECT' || related?.isContentEditable;
|
// On mobile contenteditable editors (CodeMirror), focus can momentarily
|
||||||
if (relatedIsInput) {
|
// 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;
|
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();
|
forceKeyboardClosed();
|
||||||
}
|
updateVisualViewport();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('focusout', handleFocusOut, true);
|
document.addEventListener('focusout', handleFocusOut, true);
|
||||||
|
|||||||
@@ -619,6 +619,7 @@ export const FilesView: React.FC = () => {
|
|||||||
const skipDirtyOnceRef = React.useRef(false);
|
const skipDirtyOnceRef = React.useRef(false);
|
||||||
const copiedContentTimeoutRef = React.useRef<number | null>(null);
|
const copiedContentTimeoutRef = React.useRef<number | null>(null);
|
||||||
const copiedPathTimeoutRef = React.useRef<number | null>(null);
|
const copiedPathTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
const editorViewRef = React.useRef<EditorView | null>(null);
|
||||||
|
|
||||||
const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(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);
|
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 canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
|
||||||
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
|
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(() => {
|
const editorExtensions = React.useMemo(() => {
|
||||||
if (!selectedFile?.path) {
|
if (!selectedFile?.path) {
|
||||||
return [createFlexokiCodeMirrorTheme(currentTheme)];
|
return [createFlexokiCodeMirrorTheme(currentTheme)];
|
||||||
@@ -1639,8 +1697,22 @@ export const FilesView: React.FC = () => {
|
|||||||
if (wrapLines) {
|
if (wrapLines) {
|
||||||
extensions.push(EditorView.lineWrapping);
|
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;
|
return extensions;
|
||||||
}, [currentTheme, selectedFile?.path, wrapLines]);
|
}, [currentTheme, selectedFile?.path, wrapLines, isMobile, nudgeEditorSelectionAboveKeyboard]);
|
||||||
|
|
||||||
const imageSrc = selectedFile?.path && isSelectedImage
|
const imageSrc = selectedFile?.path && isSelectedImage
|
||||||
? (runtime.isDesktop
|
? (runtime.isDesktop
|
||||||
@@ -2172,15 +2244,27 @@ export const FilesView: React.FC = () => {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full">
|
<div
|
||||||
|
className="h-full"
|
||||||
|
data-keyboard-avoid="none"
|
||||||
|
style={isMobile ? { height: 'calc(100% - var(--oc-keyboard-inset, 0px))' } : undefined}
|
||||||
|
>
|
||||||
<CodeMirrorEditor
|
<CodeMirrorEditor
|
||||||
value={draftContent}
|
value={draftContent}
|
||||||
onChange={setDraftContent}
|
onChange={setDraftContent}
|
||||||
extensions={editorExtensions}
|
extensions={editorExtensions}
|
||||||
className={cn(
|
className="h-full"
|
||||||
"h-full",
|
onViewReady={(view) => {
|
||||||
isMobile && "[&_.cm-scroller]:pb-[var(--oc-keyboard-inset,0px)]"
|
editorViewRef.current = view;
|
||||||
)}
|
window.requestAnimationFrame(() => {
|
||||||
|
nudgeEditorSelectionAboveKeyboard(view);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onViewDestroy={() => {
|
||||||
|
if (editorViewRef.current) {
|
||||||
|
editorViewRef.current = null;
|
||||||
|
}
|
||||||
|
}}
|
||||||
enableSearch
|
enableSearch
|
||||||
searchOpen={isSearchOpen}
|
searchOpen={isSearchOpen}
|
||||||
onSearchOpenChange={setIsSearchOpen}
|
onSearchOpenChange={setIsSearchOpen}
|
||||||
@@ -2543,6 +2627,17 @@ export const FilesView: React.FC = () => {
|
|||||||
onChange={setDraftContent}
|
onChange={setDraftContent}
|
||||||
extensions={editorExtensions}
|
extensions={editorExtensions}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
onViewReady={(view) => {
|
||||||
|
editorViewRef.current = view;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
nudgeEditorSelectionAboveKeyboard(view);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onViewDestroy={() => {
|
||||||
|
if (editorViewRef.current) {
|
||||||
|
editorViewRef.current = null;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user