fix(web): iPhone 键盘收起后顶栏复位,积分块与盘面芯片同高
This commit is contained in:
@@ -882,7 +882,51 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
content: "";
|
||||
}
|
||||
.chat-header-actions button > svg { width: 14px; height: 14px; }
|
||||
.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); cursor: pointer; font-size: var(--type-caption); font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; }
|
||||
/* Header chips share one size. `.chat-header-actions button` is 32px and
|
||||
more specific than `.credit-button`, which is why the credit pill looked
|
||||
shorter than 「当前盘面」(BUG-921). */
|
||||
.chat-header-actions .credit-button,
|
||||
.chat-header-rectification .rectification-board-peek {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
gap: var(--space-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
}
|
||||
.chat-header-actions .credit-button::before,
|
||||
.chat-header-rectification .rectification-board-peek::before {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
content: "";
|
||||
}
|
||||
.credit-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3);
|
||||
cursor: pointer;
|
||||
font-size: var(--type-body-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
|
||||
/* Empty state. The greeting is bottom-aligned inside the conversation row and
|
||||
@@ -1892,8 +1936,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
[data-sidebar="sidebar"][data-mobile-open="true"] { visibility: visible; transform: translateX(0); }
|
||||
.sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; }
|
||||
[data-sidebar="rail"] { display: none; }
|
||||
.chat-panel { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
|
||||
.chat-panel.is-rectification { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr); }
|
||||
.chat-panel { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
|
||||
.chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
|
||||
.chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
|
||||
.chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; }
|
||||
.conversation.is-empty {
|
||||
display: block;
|
||||
@@ -2962,15 +3007,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
display: none;
|
||||
}
|
||||
.chat-header-rectification .rectification-board-peek {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: auto;
|
||||
max-width: min(11.5rem, 42vw);
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 0 var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.chat-header-rectification .rectification-board-peek strong,
|
||||
.chat-header-rectification .rectification-board-peek span {
|
||||
@@ -2979,6 +3019,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.chat-header-rectification .rectification-board-peek span {
|
||||
|
||||
@@ -3,6 +3,7 @@ import localFont from "next/font/local";
|
||||
import Script from "next/script";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { StaleClientRecovery } from "@/components/stale-client-recovery";
|
||||
import { ViewportScrollLock } from "@/components/viewport-scroll-lock";
|
||||
import { themePreferenceBootScript } from "@/lib/theme-preference";
|
||||
|
||||
// Vendored Inter latin variable (OFL). Do not switch back to next/font/google;
|
||||
@@ -53,6 +54,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||
</head>
|
||||
<body>
|
||||
<StaleClientRecovery />
|
||||
<ViewportScrollLock />
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { installViewportScrollLock } from "@/lib/viewport-scroll-lock";
|
||||
|
||||
export function ViewportScrollLock() {
|
||||
useEffect(() => {
|
||||
return installViewportScrollLock(window as unknown as Parameters<typeof installViewportScrollLock>[0]);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* iOS Safari scrolls `window` to keep a focused input in view. `html, body
|
||||
* { overflow: hidden }` then blocks the user from dragging back, and reload
|
||||
* restores that offset. Reset window scroll only after the keyboard is gone.
|
||||
*/
|
||||
|
||||
export const KEYBOARD_OPEN_GAP_PX = 100;
|
||||
export const KEYBOARD_CLOSED_SLACK_PX = 1;
|
||||
|
||||
export function isKeyboardOpen(visualHeight: number, innerHeight: number): boolean {
|
||||
return visualHeight < innerHeight - KEYBOARD_OPEN_GAP_PX;
|
||||
}
|
||||
|
||||
export function shouldResetWindowScroll(input: {
|
||||
scrollY: number;
|
||||
innerHeight: number;
|
||||
visualViewportHeight: number | null;
|
||||
}): boolean {
|
||||
if (input.scrollY <= 0) return false;
|
||||
if (input.visualViewportHeight == null) return true;
|
||||
if (isKeyboardOpen(input.visualViewportHeight, input.innerHeight)) return false;
|
||||
return input.visualViewportHeight >= input.innerHeight - KEYBOARD_CLOSED_SLACK_PX;
|
||||
}
|
||||
|
||||
type ViewportLike = {
|
||||
height: number;
|
||||
addEventListener: (type: "resize" | "scroll", listener: () => void) => void;
|
||||
removeEventListener: (type: "resize" | "scroll", listener: () => void) => void;
|
||||
};
|
||||
|
||||
export type ViewportScrollLockHost = {
|
||||
matchMedia?: (query: string) => { matches: boolean };
|
||||
visualViewport?: ViewportLike | null;
|
||||
innerHeight: number;
|
||||
scrollY: number;
|
||||
scrollTo: (x: number, y: number) => void;
|
||||
history: { scrollRestoration: string };
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
document: {
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
};
|
||||
setTimeout?: (fn: () => void, ms: number) => unknown;
|
||||
};
|
||||
|
||||
export function installViewportScrollLock(host: ViewportScrollLockHost): () => void {
|
||||
const coarse = host.matchMedia?.("(pointer: coarse)").matches === true;
|
||||
const viewport = host.visualViewport ?? null;
|
||||
if (!coarse && !viewport) return () => undefined;
|
||||
|
||||
host.history.scrollRestoration = "manual";
|
||||
host.scrollTo(0, 0);
|
||||
|
||||
const resetIfSettled = () => {
|
||||
if (!shouldResetWindowScroll({
|
||||
scrollY: host.scrollY,
|
||||
innerHeight: host.innerHeight,
|
||||
visualViewportHeight: viewport?.height ?? null,
|
||||
})) return;
|
||||
host.scrollTo(0, 0);
|
||||
};
|
||||
|
||||
const onPageShow = () => {
|
||||
host.scrollTo(0, 0);
|
||||
};
|
||||
const onFocusOut = () => {
|
||||
const later = host.setTimeout ?? ((fn: () => void) => fn());
|
||||
later(resetIfSettled, 300);
|
||||
};
|
||||
|
||||
viewport?.addEventListener("resize", resetIfSettled);
|
||||
viewport?.addEventListener("scroll", resetIfSettled);
|
||||
host.addEventListener("pageshow", onPageShow);
|
||||
host.addEventListener("orientationchange", resetIfSettled);
|
||||
host.document.addEventListener("focusout", onFocusOut);
|
||||
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", resetIfSettled);
|
||||
viewport?.removeEventListener("scroll", resetIfSettled);
|
||||
host.removeEventListener("pageshow", onPageShow);
|
||||
host.removeEventListener("orientationchange", resetIfSettled);
|
||||
host.document.removeEventListener("focusout", onFocusOut);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user