mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
fix: keep mobile composer above soft keyboard
This commit is contained in:
parent
946ed6690a
commit
052e7f1559
@ -419,9 +419,20 @@ function suppressNativeDragPreview(dataTransfer: DataTransfer): void {
|
|||||||
window.setTimeout(() => ghost.remove(), 0);
|
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 } {
|
function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } {
|
||||||
let top = 0;
|
const viewport = visualViewportBounds();
|
||||||
let bottom = window.innerHeight;
|
let top = viewport.top;
|
||||||
|
let bottom = viewport.bottom;
|
||||||
let parent = el.parentElement;
|
let parent = el.parentElement;
|
||||||
|
|
||||||
while (parent) {
|
while (parent) {
|
||||||
@ -455,11 +466,12 @@ const GOAL_PANEL_MIN_HEIGHT_PX = 112;
|
|||||||
const GOAL_PANEL_MAX_VIEWPORT_RATIO = 0.62;
|
const GOAL_PANEL_MAX_VIEWPORT_RATIO = 0.62;
|
||||||
|
|
||||||
function measureGoalPanelMaxCssHeight(stripTopY: number): number {
|
function measureGoalPanelMaxCssHeight(stripTopY: number): number {
|
||||||
|
const viewport = visualViewportBounds();
|
||||||
const spaceAboveStrip =
|
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(
|
return Math.min(
|
||||||
Math.max(spaceAboveStrip, GOAL_PANEL_MIN_HEIGHT_PX),
|
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) {
|
if (stripWrapperRef.current && ro) {
|
||||||
ro.observe(stripWrapperRef.current);
|
ro.observe(stripWrapperRef.current);
|
||||||
}
|
}
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
viewport?.addEventListener("resize", relayout);
|
||||||
|
viewport?.addEventListener("scroll", relayout);
|
||||||
window.addEventListener("resize", relayout);
|
window.addEventListener("resize", relayout);
|
||||||
window.addEventListener("scroll", relayout, true);
|
window.addEventListener("scroll", relayout, true);
|
||||||
return () => {
|
return () => {
|
||||||
ro?.disconnect();
|
ro?.disconnect();
|
||||||
|
viewport?.removeEventListener("resize", relayout);
|
||||||
|
viewport?.removeEventListener("scroll", relayout);
|
||||||
window.removeEventListener("resize", relayout);
|
window.removeEventListener("resize", relayout);
|
||||||
window.removeEventListener("scroll", relayout, true);
|
window.removeEventListener("scroll", relayout, true);
|
||||||
};
|
};
|
||||||
@ -1111,9 +1128,14 @@ export function ThreadComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
updateLayout();
|
updateLayout();
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
viewport?.addEventListener("resize", updateLayout);
|
||||||
|
viewport?.addEventListener("scroll", updateLayout);
|
||||||
window.addEventListener("resize", updateLayout);
|
window.addEventListener("resize", updateLayout);
|
||||||
document.addEventListener("scroll", updateLayout, true);
|
document.addEventListener("scroll", updateLayout, true);
|
||||||
return () => {
|
return () => {
|
||||||
|
viewport?.removeEventListener("resize", updateLayout);
|
||||||
|
viewport?.removeEventListener("scroll", updateLayout);
|
||||||
window.removeEventListener("resize", updateLayout);
|
window.removeEventListener("resize", updateLayout);
|
||||||
document.removeEventListener("scroll", updateLayout, true);
|
document.removeEventListener("scroll", updateLayout, true);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -50,6 +50,7 @@ const NEAR_BOTTOM_PX = 48;
|
|||||||
const NEAR_TOP_PX = 96;
|
const NEAR_TOP_PX = 96;
|
||||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||||
|
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||||
export const INITIAL_HISTORY_WINDOW = 160;
|
export const INITIAL_HISTORY_WINDOW = 160;
|
||||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||||
|
|
||||||
@ -66,6 +67,35 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
|
|||||||
return messages.slice(start);
|
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({
|
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@ -99,6 +129,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const userReadingHistoryRef = useRef(false);
|
const userReadingHistoryRef = useRef(false);
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||||
|
const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0);
|
||||||
const [visibleMessageCount, setVisibleMessageCount] =
|
const [visibleMessageCount, setVisibleMessageCount] =
|
||||||
useState(INITIAL_HISTORY_WINDOW);
|
useState(INITIAL_HISTORY_WINDOW);
|
||||||
const hasMessages = messages.length > 0;
|
const hasMessages = messages.length > 0;
|
||||||
@ -116,9 +147,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||||
? forkBoundaryMessageCount - hiddenMessageCount
|
? forkBoundaryMessageCount - hiddenMessageCount
|
||||||
: null;
|
: null;
|
||||||
const scrollButtonBottom = composerDockHeight > 0
|
const scrollButtonBottom =
|
||||||
|
keyboardInsetBottom
|
||||||
|
+ (composerDockHeight > 0
|
||||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX;
|
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
|
||||||
|
const scrollViewportStyle =
|
||||||
|
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||||
|
|
||||||
const cancelScheduledBottomScroll = useCallback(() => {
|
const cancelScheduledBottomScroll = useCallback(() => {
|
||||||
for (const id of scrollFrameIdsRef.current) {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!atBottom) return;
|
if (!atBottom) return;
|
||||||
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
||||||
@ -223,6 +281,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
scrollToBottom(false);
|
scrollToBottom(false);
|
||||||
}, [messages, atBottom, scrollToBottom]);
|
}, [messages, atBottom, scrollToBottom]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (userReadingHistoryRef.current) return;
|
||||||
|
scrollToBottom(false, 4);
|
||||||
|
}, [keyboardInsetBottom, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollToBottomSignal <= 0) return;
|
if (scrollToBottomSignal <= 0) return;
|
||||||
userReadingHistoryRef.current = false;
|
userReadingHistoryRef.current = false;
|
||||||
@ -332,6 +395,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
|
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
|
||||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||||
)}
|
)}
|
||||||
|
style={scrollViewportStyle}
|
||||||
>
|
>
|
||||||
{hasMessages ? (
|
{hasMessages ? (
|
||||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||||
|
|||||||
@ -134,6 +134,28 @@ function mockBlobUrls() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stubVisualViewport({
|
||||||
|
height,
|
||||||
|
offsetTop = 0,
|
||||||
|
}: {
|
||||||
|
height: number;
|
||||||
|
offsetTop?: number;
|
||||||
|
}) {
|
||||||
|
const target = new EventTarget();
|
||||||
|
vi.stubGlobal("visualViewport", {
|
||||||
|
width: 390,
|
||||||
|
height,
|
||||||
|
offsetTop,
|
||||||
|
offsetLeft: 0,
|
||||||
|
pageTop: offsetTop,
|
||||||
|
pageLeft: 0,
|
||||||
|
scale: 1,
|
||||||
|
addEventListener: target.addEventListener.bind(target),
|
||||||
|
removeEventListener: target.removeEventListener.bind(target),
|
||||||
|
dispatchEvent: target.dispatchEvent.bind(target),
|
||||||
|
} as unknown as VisualViewport);
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@ -1124,6 +1146,33 @@ describe("ThreadComposer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the slash command palette above a keyboard-constrained visual viewport", async () => {
|
||||||
|
vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue(
|
||||||
|
rect({ top: 120, bottom: 220, width: 390, height: 100 }),
|
||||||
|
);
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
value: 800,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
stubVisualViewport({ height: 300 });
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Ask anything..."
|
||||||
|
slashCommands={COMMANDS}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "/" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const palette = screen.getByRole("listbox", { name: "Slash commands" });
|
||||||
|
expect(palette.className).toContain("bottom-full");
|
||||||
|
expect(palette).toHaveStyle({ maxHeight: "112px" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("dismisses the slash command palette on outside click", () => {
|
it("dismisses the slash command palette on outside click", () => {
|
||||||
render(
|
render(
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -29,6 +29,59 @@ interface ResizeObserverInstance {
|
|||||||
disconnect: ReturnType<typeof vi.fn>;
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stubVisualViewport({
|
||||||
|
height,
|
||||||
|
innerHeight,
|
||||||
|
offsetTop = 0,
|
||||||
|
}: {
|
||||||
|
height: number;
|
||||||
|
innerHeight: number;
|
||||||
|
offsetTop?: number;
|
||||||
|
}) {
|
||||||
|
const originalInnerHeight = window.innerHeight;
|
||||||
|
const originalVisualViewport = window.visualViewport;
|
||||||
|
const target = new EventTarget();
|
||||||
|
const viewport = {
|
||||||
|
width: 390,
|
||||||
|
height,
|
||||||
|
offsetTop,
|
||||||
|
offsetLeft: 0,
|
||||||
|
pageTop: offsetTop,
|
||||||
|
pageLeft: 0,
|
||||||
|
scale: 1,
|
||||||
|
addEventListener: target.addEventListener.bind(target),
|
||||||
|
removeEventListener: target.removeEventListener.bind(target),
|
||||||
|
dispatchEvent: target.dispatchEvent.bind(target),
|
||||||
|
} as unknown as VisualViewport;
|
||||||
|
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: innerHeight,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: viewport,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewport,
|
||||||
|
restore: () => {
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalInnerHeight,
|
||||||
|
});
|
||||||
|
if (originalVisualViewport) {
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalVisualViewport,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(window, "visualViewport");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function makeLongMessages(count: number): UIMessage[] {
|
function makeLongMessages(count: number): UIMessage[] {
|
||||||
return Array.from({ length: count }, (_, index) => ({
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
id: `m${index}`,
|
id: `m${index}`,
|
||||||
@ -129,6 +182,46 @@ describe("ThreadViewport", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the thread scrollport above a mobile soft keyboard", async () => {
|
||||||
|
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
|
||||||
|
try {
|
||||||
|
const { container } = render(
|
||||||
|
<ThreadViewport
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={false}
|
||||||
|
composer={<textarea aria-label="Message input" />}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||||
|
Object.defineProperties(scroller, {
|
||||||
|
scrollHeight: { configurable: true, value: 2400 },
|
||||||
|
clientHeight: { configurable: true, value: 600 },
|
||||||
|
scrollTop: { configurable: true, value: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
act(() => {
|
||||||
|
input.focus();
|
||||||
|
fireEvent.focusIn(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(scroller).toHaveStyle({ bottom: "320px" }));
|
||||||
|
const button = screen.getByRole("button", { name: "Scroll to bottom" });
|
||||||
|
expect(button.parentElement).toHaveStyle({ bottom: "512px" });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
visualViewport.viewport.dispatchEvent(new Event("resize"));
|
||||||
|
});
|
||||||
|
expect(scroller).toHaveStyle({ bottom: "320px" });
|
||||||
|
} finally {
|
||||||
|
visualViewport.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
|
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ThreadViewport
|
<ThreadViewport
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user