feat: enhance mobile experience by adjusting layout and keyboard handling

This commit is contained in:
Bohdan Triapitsyn
2025-12-18 11:20:37 +02:00
parent db673d8dc4
commit d28c4e9da6
7 changed files with 283 additions and 17 deletions
@@ -78,6 +78,122 @@ export const MainLayout: React.FC = () => {
};
}, []);
React.useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
const root = document.documentElement;
let stickyKeyboardInset = 0;
let ignoreOpenUntilZero = false;
let previousHeight = 0;
const forceKeyboardClosed = () => {
stickyKeyboardInset = 0;
ignoreOpenUntilZero = true;
root.style.setProperty('--oc-keyboard-inset', '0px');
};
const updateVisualViewport = () => {
const viewport = window.visualViewport;
const height = viewport ? Math.round(viewport.height) : window.innerHeight;
const offsetTop = viewport ? Math.max(0, Math.round(viewport.offsetTop)) : 0;
root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`);
const active = document.activeElement as HTMLElement | null;
const tagName = active?.tagName;
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
const isTextTarget = isInput || Boolean(active?.isContentEditable);
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
const viewportSum = height + offsetTop;
const rawInset = Math.max(0, layoutHeight - viewportSum);
// Keyboard heuristic:
// - when an input is focused, smaller deltas can still be keyboard
// - when not focused, treat only big deltas as keyboard (ignore toolbars)
const openThreshold = isTextTarget ? 120 : 180;
const measuredInset = rawInset >= openThreshold ? rawInset : 0;
// Make the UI stable: treat keyboard inset as a step function.
// - When opening: take the first big inset and hold it.
// - When closing starts: immediately drop to 0 (even if keyboard animation continues).
// Closing start signals:
// - focus lost (handled via focusout)
// - visual viewport height starts increasing while inset is non-zero
if (ignoreOpenUntilZero) {
if (measuredInset === 0) {
ignoreOpenUntilZero = false;
}
stickyKeyboardInset = 0;
} else if (stickyKeyboardInset === 0) {
if (measuredInset > 0 && isTextTarget) {
stickyKeyboardInset = measuredInset;
}
} else {
const closingByHeight = height > previousHeight + 6;
if (measuredInset === 0) {
stickyKeyboardInset = 0;
} else if (closingByHeight) {
forceKeyboardClosed();
} else if (measuredInset > stickyKeyboardInset) {
stickyKeyboardInset = measuredInset;
}
}
root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`);
previousHeight = height;
// Only force-scroll lock while an input is focused.
if (isMobile && isTextTarget) {
const scroller = document.scrollingElement;
if (scroller && scroller.scrollTop !== 0) {
scroller.scrollTop = 0;
}
if (window.scrollY !== 0) {
window.scrollTo(0, 0);
}
}
};
updateVisualViewport();
const viewport = window.visualViewport;
viewport?.addEventListener('resize', updateVisualViewport);
viewport?.addEventListener('scroll', updateVisualViewport);
window.addEventListener('resize', updateVisualViewport);
window.addEventListener('orientationchange', updateVisualViewport);
document.addEventListener('focusin', updateVisualViewport, true);
const handleFocusOut = (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) {
forceKeyboardClosed();
}
};
document.addEventListener('focusout', handleFocusOut, true);
return () => {
viewport?.removeEventListener('resize', updateVisualViewport);
viewport?.removeEventListener('scroll', updateVisualViewport);
window.removeEventListener('resize', updateVisualViewport);
window.removeEventListener('orientationchange', updateVisualViewport);
document.removeEventListener('focusin', updateVisualViewport, true);
document.removeEventListener('focusout', handleFocusOut, true);
};
}, [isMobile]);
const secondaryView = React.useMemo(() => {
switch (activeMainTab) {
case 'git':
@@ -110,7 +226,10 @@ export const MainLayout: React.FC = () => {
{isMobile ? (
<>
<Header />
<div className="flex flex-1 overflow-hidden bg-background">
<div
className="flex flex-1 overflow-hidden bg-background"
style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
>
<main className="flex-1 overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>