fix: keep mobile composer above soft keyboard

This commit is contained in:
chengyongru
2026-06-15 02:54:37 +08:00
committed by Xubin Ren
parent 946ed6690a
commit 052e7f1559
4 changed files with 235 additions and 7 deletions
+67 -3
View File
@@ -50,6 +50,7 @@ const NEAR_BOTTOM_PX = 48;
const NEAR_TOP_PX = 96;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
@@ -66,6 +67,35 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
return messages.slice(start);
}
function isKeyboardEditableElement(element: Element | null): element is HTMLElement {
if (!(element instanceof HTMLElement)) return false;
if (element.isContentEditable) return true;
if (element instanceof HTMLTextAreaElement) return true;
if (!(element instanceof HTMLInputElement)) return false;
return ![
"button",
"checkbox",
"color",
"file",
"hidden",
"image",
"radio",
"range",
"reset",
"submit",
].includes(element.type);
}
function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
const viewport = window.visualViewport;
if (!viewport) return 0;
const active = document.activeElement;
if (!isKeyboardEditableElement(active) || !container?.contains(active)) return 0;
const layoutHeight = window.innerHeight || document.documentElement.clientHeight;
const inset = layoutHeight - viewport.height - viewport.offsetTop;
return inset >= SOFT_KEYBOARD_MIN_INSET_PX ? Math.ceil(inset) : 0;
}
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
isStreaming,
@@ -99,6 +129,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const userReadingHistoryRef = useRef(false);
const [atBottom, setAtBottom] = useState(true);
const [composerDockHeight, setComposerDockHeight] = useState(0);
const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0);
const [visibleMessageCount, setVisibleMessageCount] =
useState(INITIAL_HISTORY_WINDOW);
const hasMessages = messages.length > 0;
@@ -116,9 +147,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
? forkBoundaryMessageCount - hiddenMessageCount
: null;
const scrollButtonBottom = composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX;
const scrollButtonBottom =
keyboardInsetBottom
+ (composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
const scrollViewportStyle =
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
const cancelScheduledBottomScroll = useCallback(() => {
for (const id of scrollFrameIdsRef.current) {
@@ -216,6 +251,29 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
);
}, []);
useLayoutEffect(() => {
const updateKeyboardInset = () => {
const next = readSoftKeyboardInsetBottom(scrollRef.current);
setKeyboardInsetBottom((current) =>
Math.abs(current - next) < 1 ? current : next,
);
};
updateKeyboardInset();
const viewport = window.visualViewport;
viewport?.addEventListener("resize", updateKeyboardInset);
viewport?.addEventListener("scroll", updateKeyboardInset);
window.addEventListener("resize", updateKeyboardInset);
document.addEventListener("focusin", updateKeyboardInset);
document.addEventListener("focusout", updateKeyboardInset);
return () => {
viewport?.removeEventListener("resize", updateKeyboardInset);
viewport?.removeEventListener("scroll", updateKeyboardInset);
window.removeEventListener("resize", updateKeyboardInset);
document.removeEventListener("focusin", updateKeyboardInset);
document.removeEventListener("focusout", updateKeyboardInset);
};
}, []);
useEffect(() => {
if (!atBottom) return;
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
@@ -223,6 +281,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
scrollToBottom(false);
}, [messages, atBottom, scrollToBottom]);
useLayoutEffect(() => {
if (userReadingHistoryRef.current) return;
scrollToBottom(false, 4);
}, [keyboardInsetBottom, scrollToBottom]);
useEffect(() => {
if (scrollToBottomSignal <= 0) return;
userReadingHistoryRef.current = false;
@@ -332,6 +395,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
"[&::-webkit-scrollbar-track]:bg-transparent",
)}
style={scrollViewportStyle}
>
{hasMessages ? (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">