fix(chat): restore scroll after image viewer return

This commit is contained in:
2026-07-01 17:15:38 +08:00
parent 5f9df58dbe
commit fa5b380577
4 changed files with 205 additions and 4 deletions
+117
View File
@@ -0,0 +1,117 @@
"use client";
const CHAT_SCROLL_STORAGE_KEY = "cozsweet:chat:scroll";
const CHAT_SCROLL_SAVE_EVENT = "cozsweet:chat:save-scroll";
const CHAT_SCROLL_MAX_AGE_MS = 10 * 60 * 1000;
const CHAT_SCROLL_BOTTOM_THRESHOLD = 80;
interface ChatScrollSession {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
distanceFromBottom: number;
savedAt: number;
}
let memoryScrollSession: ChatScrollSession | null = null;
export function requestChatScrollSnapshotSave(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event(CHAT_SCROLL_SAVE_EVENT));
}
export function listenChatScrollSnapshotSave(
listener: () => void,
): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(CHAT_SCROLL_SAVE_EVENT, listener);
return () => window.removeEventListener(CHAT_SCROLL_SAVE_EVENT, listener);
}
export function saveChatScrollSnapshot(
scrollNode: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">,
): void {
if (typeof window === "undefined") return;
const { scrollTop, scrollHeight, clientHeight } = scrollNode;
if (!Number.isFinite(scrollTop) || scrollTop < 0) return;
const payload: ChatScrollSession = {
scrollTop,
scrollHeight,
clientHeight,
distanceFromBottom: Math.max(0, scrollHeight - scrollTop - clientHeight),
savedAt: Date.now(),
};
memoryScrollSession = payload;
try {
window.sessionStorage.setItem(
CHAT_SCROLL_STORAGE_KEY,
JSON.stringify(payload),
);
} catch {
// Scroll restoration is best-effort; storage failures should not affect chat.
}
}
export function consumeChatScrollSnapshot(): ChatScrollSession | null {
if (typeof window === "undefined") return null;
try {
const payload =
readValidChatScrollSession(memoryScrollSession) ??
readValidChatScrollSession(
JSON.parse(window.sessionStorage.getItem(CHAT_SCROLL_STORAGE_KEY) ?? "null"),
);
memoryScrollSession = null;
window.sessionStorage.removeItem(CHAT_SCROLL_STORAGE_KEY);
return payload;
} catch {
memoryScrollSession = null;
return null;
}
}
export function getChatScrollRestoreTop(
scrollNode: Pick<HTMLElement, "scrollHeight" | "clientHeight">,
snapshot: ChatScrollSession,
): number {
const maxScrollTop = Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight);
if (snapshot.distanceFromBottom <= CHAT_SCROLL_BOTTOM_THRESHOLD) {
return maxScrollTop;
}
return Math.min(Math.max(0, snapshot.scrollTop), maxScrollTop);
}
function readValidChatScrollSession(value: unknown): ChatScrollSession | null {
if (typeof value !== "object" || value === null) return null;
const payload = value as Partial<ChatScrollSession>;
if (
typeof payload.scrollTop !== "number" ||
!Number.isFinite(payload.scrollTop) ||
typeof payload.scrollHeight !== "number" ||
!Number.isFinite(payload.scrollHeight) ||
typeof payload.clientHeight !== "number" ||
!Number.isFinite(payload.clientHeight) ||
typeof payload.distanceFromBottom !== "number" ||
!Number.isFinite(payload.distanceFromBottom) ||
typeof payload.savedAt !== "number" ||
!Number.isFinite(payload.savedAt)
) {
return null;
}
if (Date.now() - payload.savedAt > CHAT_SCROLL_MAX_AGE_MS) {
return null;
}
return {
scrollTop: Math.max(0, payload.scrollTop),
scrollHeight: Math.max(0, payload.scrollHeight),
clientHeight: Math.max(0, payload.clientHeight),
distanceFromBottom: Math.max(0, payload.distanceFromBottom),
savedAt: payload.savedAt,
};
}
+84 -2
View File
@@ -12,10 +12,16 @@
* - 新消息到达时自动滚到底部
* - 用户向上滚动时不强制拉回(保持阅读上下文)
*/
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import type { UiMessage } from "@/data/dto/chat";
import {
consumeChatScrollSnapshot,
getChatScrollRestoreTop,
listenChatScrollSnapshotSave,
saveChatScrollSnapshot,
} from "../chat-scroll-session";
import { AiDisclosureBanner } from "./ai-disclosure-banner";
import { DateHeader } from "./date-header";
import { LottieMessageBubble } from "./lottie-message-bubble";
@@ -42,6 +48,23 @@ export function ChatArea({
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length);
const restoredScrollRef = useRef(false);
const saveFrameRef = useRef<number | null>(null);
const saveCurrentScrollSnapshotNow = () => {
const scrollNode = scrollRef.current;
if (!scrollNode) return;
saveChatScrollSnapshot(scrollNode);
};
const saveCurrentScrollSnapshot = () => {
if (saveFrameRef.current !== null) return;
saveFrameRef.current = window.requestAnimationFrame(() => {
saveFrameRef.current = null;
saveCurrentScrollSnapshotNow();
});
};
// 新消息 → 滚到底部
useEffect(() => {
@@ -54,8 +77,67 @@ export function ChatArea({
}
}, [messages.length]);
useLayoutEffect(() => {
if (restoredScrollRef.current || messages.length === 0) return;
const scrollNode = scrollRef.current;
const snapshot = consumeChatScrollSnapshot();
if (!scrollNode || snapshot === null) return;
restoredScrollRef.current = true;
const restoreScroll = () => {
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
};
restoreScroll();
const frame = window.requestAnimationFrame(restoreScroll);
const timer = window.setTimeout(restoreScroll, 120);
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(restoreScroll);
Array.from(scrollNode.children).forEach((child) => {
resizeObserver?.observe(child);
});
const observerTimer = window.setTimeout(() => {
resizeObserver?.disconnect();
}, 800);
const lateRestoreTimer = window.setTimeout(restoreScroll, 600);
return () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(timer);
window.clearTimeout(observerTimer);
window.clearTimeout(lateRestoreTimer);
resizeObserver?.disconnect();
};
}, [messages.length]);
useEffect(() => {
const scrollNode = scrollRef.current;
const stopListening = listenChatScrollSnapshotSave(
saveCurrentScrollSnapshotNow,
);
return () => {
stopListening();
if (saveFrameRef.current !== null) {
window.cancelAnimationFrame(saveFrameRef.current);
saveFrameRef.current = null;
}
if (!scrollNode) return;
saveChatScrollSnapshot(scrollNode);
};
}, []);
return (
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
<main
ref={scrollRef}
className={styles.area}
aria-label="Chat messages"
onScroll={saveCurrentScrollSnapshot}
>
<AiDisclosureBanner />
{renderMessagesWithDateHeaders(
+3 -1
View File
@@ -19,6 +19,7 @@ import {
} from "@/lib/chat/chat_media_url";
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
import { requestChatScrollSnapshotSave } from "../chat-scroll-session";
import styles from "./image-bubble.module.css";
export interface ImageBubbleProps {
@@ -46,7 +47,8 @@ export function ImageBubble({
const openImage = () => {
if (!messageId) return;
router.push(ROUTE_BUILDERS.chatImage(messageId));
requestChatScrollSnapshotSave();
router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false });
};
return (
@@ -40,7 +40,7 @@ export function ChatImageViewerScreen({
);
const handleClose = () => {
router.push(ROUTES.chat);
router.push(ROUTES.chat, { scroll: false });
};
const handleUnlockImagePaywall = () => {