feat(chat): add keyboard diagnostics

This commit is contained in:
2026-07-16 14:05:48 +08:00
parent 5934d48034
commit aec7cee9cf
3 changed files with 325 additions and 0 deletions
@@ -0,0 +1,119 @@
/* @vitest-environment jsdom */
import { type RefObject } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
installChatKeyboardDiagnostics,
isChatKeyboardDiagnosticsEnabled,
} from "../use-chat-keyboard-diagnostics";
vi.mock("@/utils/browser-detect", () => ({
BrowserDetector: {
getBrowserInfo: vi.fn(() => ({
name: "Chrome WebView",
version: "126.0.0.0",
inAppBrowserName: "facebook",
})),
},
}));
vi.mock("@/utils/platform-detect", () => ({
PlatformDetector: {
getPlatformInfo: vi.fn(() => ({
platform: "android",
osName: "Android",
osVersion: "15",
deviceVendor: "OnePlus",
deviceModel: "PLB110",
})),
},
}));
class MockVisualViewport extends EventTarget {
height = 460;
offsetLeft = 0;
offsetTop = 0;
scale = 1;
width = 390;
}
class MockVirtualKeyboard extends EventTarget {
boundingRect = {
x: 0,
y: 460,
width: 390,
height: 300,
};
}
describe("chat keyboard diagnostics", () => {
let consoleInfo: ReturnType<typeof vi.spyOn>;
let visualViewport: MockVisualViewport;
let virtualKeyboard: MockVirtualKeyboard;
beforeEach(() => {
visualViewport = new MockVisualViewport();
virtualKeyboard = new MockVirtualKeyboard();
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: visualViewport,
});
Object.defineProperty(navigator, "virtualKeyboard", {
configurable: true,
value: virtualKeyboard,
});
consoleInfo = vi.spyOn(window.console, "info").mockImplementation(() => {});
});
afterEach(() => {
window.__keyboardDebugCleanup?.();
vi.restoreAllMocks();
});
it("enables diagnostics only for the explicit query parameter", () => {
expect(isChatKeyboardDiagnosticsEnabled("?keyboard_debug=1")).toBe(true);
expect(isChatKeyboardDiagnosticsEnabled("?keyboard_debug=0")).toBe(false);
expect(isChatKeyboardDiagnosticsEnabled("")).toBe(false);
});
it("records viewport events and exposes manual snapshot helpers", () => {
const input = document.createElement("textarea");
input.style.setProperty("--chat-input-lift", "314px");
document.body.append(input);
input.focus();
const inputRef = { current: input } as RefObject<HTMLElement | null>;
const cleanup = installChatKeyboardDiagnostics(inputRef);
expect(window.__keyboardDebugSnapshot).toBeTypeOf("function");
expect(window.__keyboardDebugCleanup).toBe(cleanup);
expect(window.__keyboardDebugSnapshot?.()).toMatchObject({
event: "manual",
visualViewportSupported: true,
visualHeight: 460,
virtualKeyboardSupported: true,
virtualKeyboardRect: { height: 300 },
activeElement: "TEXTAREA",
activeFocused: true,
inputLift: "314px",
platform: { name: "android", deviceModel: "PLB110" },
browser: { inAppBrowserName: "facebook" },
});
visualViewport.dispatchEvent(new Event("resize"));
expect(consoleInfo).toHaveBeenLastCalledWith(
"[keyboard-debug]",
expect.objectContaining({ event: "visualViewport.resize" }),
);
const callCount = consoleInfo.mock.calls.length;
cleanup();
visualViewport.dispatchEvent(new Event("resize"));
expect(consoleInfo).toHaveBeenCalledTimes(callCount + 1);
expect(window.__keyboardDebugSnapshot).toBeUndefined();
expect(window.__keyboardDebugCleanup).toBeUndefined();
input.remove();
});
});
@@ -13,6 +13,7 @@ import {
type ChatKeyboardViewportBaseline, type ChatKeyboardViewportBaseline,
type ChatKeyboardViewportSnapshot, type ChatKeyboardViewportSnapshot,
} from "./chat-keyboard-geometry"; } from "./chat-keyboard-geometry";
import { useChatKeyboardDiagnostics } from "./use-chat-keyboard-diagnostics";
const CHAT_INPUT_LIFT_VAR = "--chat-input-lift"; const CHAT_INPUT_LIFT_VAR = "--chat-input-lift";
const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 350; const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 350;
@@ -39,6 +40,8 @@ export function useChatKeyboardAvoidance({
const activeRef = useRef(active); const activeRef = useRef(active);
const baselineRef = useRef<ChatKeyboardViewportBaseline | null>(null); const baselineRef = useRef<ChatKeyboardViewportBaseline | null>(null);
useChatKeyboardDiagnostics({ containerRef });
useEffect(() => { useEffect(() => {
activeRef.current = active; activeRef.current = active;
}, [active]); }, [active]);
@@ -0,0 +1,203 @@
"use client";
import { useEffect, type RefObject } from "react";
import { BrowserDetector } from "@/utils/browser-detect";
import { PlatformDetector } from "@/utils/platform-detect";
const CHAT_KEYBOARD_DEBUG_PARAM = "keyboard_debug";
const CHAT_INPUT_LIFT_VAR = "--chat-input-lift";
interface VirtualKeyboardLike extends EventTarget {
readonly boundingRect?: Pick<
DOMRectReadOnly,
"height" | "width" | "x" | "y"
>;
}
interface NavigatorWithVirtualKeyboard extends Navigator {
readonly virtualKeyboard?: VirtualKeyboardLike;
}
export interface ChatKeyboardDebugSnapshot {
event: string;
time: number;
windowInnerHeight: number;
documentClientHeight: number;
scrollY: number;
visualViewportSupported: boolean;
visualHeight: number | null;
visualWidth: number | null;
visualOffsetTop: number | null;
visualOffsetLeft: number | null;
visualScale: number | null;
virtualKeyboardSupported: boolean;
virtualKeyboardRect: {
x: number;
y: number;
width: number;
height: number;
} | null;
activeElement: string | null;
activeBottom: number | null;
activeFocused: boolean;
inputLift: string | null;
viewportMeta: string | null;
platform: {
name: string;
osName: string;
osVersion: string;
deviceVendor: string;
deviceModel: string;
};
browser: {
name: string;
version: string;
inAppBrowserName: string | null;
};
}
declare global {
interface Window {
__keyboardDebugCleanup?: () => void;
__keyboardDebugSnapshot?: () => ChatKeyboardDebugSnapshot;
}
}
export interface UseChatKeyboardDiagnosticsOptions {
containerRef: RefObject<HTMLElement | null>;
}
export function useChatKeyboardDiagnostics({
containerRef,
}: UseChatKeyboardDiagnosticsOptions): void {
useEffect(() => {
if (!isChatKeyboardDiagnosticsEnabled(window.location.search)) return;
return installChatKeyboardDiagnostics(containerRef);
}, [containerRef]);
}
export function isChatKeyboardDiagnosticsEnabled(search: string): boolean {
return new URLSearchParams(search).get(CHAT_KEYBOARD_DEBUG_PARAM) === "1";
}
export function installChatKeyboardDiagnostics(
containerRef: RefObject<HTMLElement | null>,
): () => void {
window.__keyboardDebugCleanup?.();
const removers: Array<() => void> = [];
let disposed = false;
const takeSnapshot = (event: string): ChatKeyboardDebugSnapshot => {
const snapshot = createChatKeyboardDebugSnapshot(event, containerRef);
window.console.info("[keyboard-debug]", snapshot);
return snapshot;
};
const listen = (
target: EventTarget | null | undefined,
eventName: string,
label = eventName,
) => {
if (!target) return;
const handler = () => takeSnapshot(label);
target.addEventListener(eventName, handler);
removers.push(() => target.removeEventListener(eventName, handler));
};
const cleanup = () => {
if (disposed) return;
disposed = true;
removers.forEach((remove) => remove());
if (window.__keyboardDebugSnapshot === manualSnapshot) {
delete window.__keyboardDebugSnapshot;
}
if (window.__keyboardDebugCleanup === cleanup) {
delete window.__keyboardDebugCleanup;
}
window.console.info("[keyboard-debug] removed");
};
const manualSnapshot = () => takeSnapshot("manual");
const virtualKeyboard = getVirtualKeyboard();
listen(window, "resize", "window.resize");
listen(window, "scroll", "window.scroll");
listen(window, "orientationchange", "orientationchange");
listen(document, "focusin", "focusin");
listen(document, "focusout", "focusout");
listen(document, "visibilitychange", "visibilitychange");
listen(window.visualViewport, "resize", "visualViewport.resize");
listen(window.visualViewport, "scroll", "visualViewport.scroll");
listen(virtualKeyboard, "geometrychange", "virtualKeyboard.geometrychange");
window.__keyboardDebugSnapshot = manualSnapshot;
window.__keyboardDebugCleanup = cleanup;
takeSnapshot("installed");
return cleanup;
}
function createChatKeyboardDebugSnapshot(
event: string,
containerRef: RefObject<HTMLElement | null>,
): ChatKeyboardDebugSnapshot {
const visualViewport = window.visualViewport;
const virtualKeyboardRect = getVirtualKeyboard()?.boundingRect ?? null;
const activeElement = document.activeElement;
const activeRect =
activeElement instanceof HTMLElement
? activeElement.getBoundingClientRect()
: null;
const platform = PlatformDetector.getPlatformInfo();
const browser = BrowserDetector.getBrowserInfo();
return {
event,
time: Math.round(performance.now()),
windowInnerHeight: window.innerHeight,
documentClientHeight: document.documentElement.clientHeight,
scrollY: window.scrollY,
visualViewportSupported: visualViewport != null,
visualHeight: visualViewport?.height ?? null,
visualWidth: visualViewport?.width ?? null,
visualOffsetTop: visualViewport?.offsetTop ?? null,
visualOffsetLeft: visualViewport?.offsetLeft ?? null,
visualScale: visualViewport?.scale ?? null,
virtualKeyboardSupported: getVirtualKeyboard() != null,
virtualKeyboardRect: virtualKeyboardRect
? {
x: virtualKeyboardRect.x,
y: virtualKeyboardRect.y,
width: virtualKeyboardRect.width,
height: virtualKeyboardRect.height,
}
: null,
activeElement: activeElement?.tagName ?? null,
activeBottom: activeRect?.bottom ?? null,
activeFocused: activeElement !== document.body,
inputLift:
containerRef.current?.style.getPropertyValue(CHAT_INPUT_LIFT_VAR) || null,
viewportMeta:
document.querySelector<HTMLMetaElement>('meta[name="viewport"]')
?.content ?? null,
platform: {
name: platform.platform,
osName: platform.osName,
osVersion: platform.osVersion,
deviceVendor: platform.deviceVendor,
deviceModel: platform.deviceModel,
},
browser: {
name: browser.name,
version: browser.version,
inAppBrowserName: browser.inAppBrowserName,
},
};
}
function getVirtualKeyboard(): VirtualKeyboardLike | null {
return (
(navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null
);
}