fix: keep mobile composer above soft keyboard
This commit is contained in:
@@ -419,9 +419,20 @@ function suppressNativeDragPreview(dataTransfer: DataTransfer): void {
|
||||
window.setTimeout(() => ghost.remove(), 0);
|
||||
}
|
||||
|
||||
function visualViewportBounds(): { top: number; bottom: number; height: number } {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) {
|
||||
return { top: 0, bottom: window.innerHeight, height: window.innerHeight };
|
||||
}
|
||||
const top = Math.max(0, viewport.offsetTop);
|
||||
const height = Math.max(0, viewport.height);
|
||||
return { top, bottom: top + height, height };
|
||||
}
|
||||
|
||||
function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } {
|
||||
let top = 0;
|
||||
let bottom = window.innerHeight;
|
||||
const viewport = visualViewportBounds();
|
||||
let top = viewport.top;
|
||||
let bottom = viewport.bottom;
|
||||
let parent = el.parentElement;
|
||||
|
||||
while (parent) {
|
||||
@@ -455,11 +466,12 @@ const GOAL_PANEL_MIN_HEIGHT_PX = 112;
|
||||
const GOAL_PANEL_MAX_VIEWPORT_RATIO = 0.62;
|
||||
|
||||
function measureGoalPanelMaxCssHeight(stripTopY: number): number {
|
||||
const viewport = visualViewportBounds();
|
||||
const spaceAboveStrip =
|
||||
stripTopY - GOAL_PANEL_VIEWPORT_TOP_PAD - GOAL_PANEL_GAP_ABOVE_STRIP_PX;
|
||||
stripTopY - viewport.top - GOAL_PANEL_VIEWPORT_TOP_PAD - GOAL_PANEL_GAP_ABOVE_STRIP_PX;
|
||||
return Math.min(
|
||||
Math.max(spaceAboveStrip, GOAL_PANEL_MIN_HEIGHT_PX),
|
||||
Math.floor(window.innerHeight * GOAL_PANEL_MAX_VIEWPORT_RATIO),
|
||||
Math.floor(viewport.height * GOAL_PANEL_MAX_VIEWPORT_RATIO),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -593,10 +605,15 @@ function RunElapsedStrip({
|
||||
if (stripWrapperRef.current && ro) {
|
||||
ro.observe(stripWrapperRef.current);
|
||||
}
|
||||
const viewport = window.visualViewport;
|
||||
viewport?.addEventListener("resize", relayout);
|
||||
viewport?.addEventListener("scroll", relayout);
|
||||
window.addEventListener("resize", relayout);
|
||||
window.addEventListener("scroll", relayout, true);
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
viewport?.removeEventListener("resize", relayout);
|
||||
viewport?.removeEventListener("scroll", relayout);
|
||||
window.removeEventListener("resize", relayout);
|
||||
window.removeEventListener("scroll", relayout, true);
|
||||
};
|
||||
@@ -1111,9 +1128,14 @@ export function ThreadComposer({
|
||||
};
|
||||
|
||||
updateLayout();
|
||||
const viewport = window.visualViewport;
|
||||
viewport?.addEventListener("resize", updateLayout);
|
||||
viewport?.addEventListener("scroll", updateLayout);
|
||||
window.addEventListener("resize", updateLayout);
|
||||
document.addEventListener("scroll", updateLayout, true);
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", updateLayout);
|
||||
viewport?.removeEventListener("scroll", updateLayout);
|
||||
window.removeEventListener("resize", updateLayout);
|
||||
document.removeEventListener("scroll", updateLayout, true);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user