diff --git a/src/app/chat/__tests__/chat-scroll-session.test.ts b/src/app/chat/__tests__/chat-scroll-session.test.ts new file mode 100644 index 00000000..da835a3c --- /dev/null +++ b/src/app/chat/__tests__/chat-scroll-session.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { ChatScrollSessionStorage } from "@/lib/chat/chat_scroll_session_storage"; + +import { + consumeChatScrollSnapshot, + getChatScrollRestoreTop, + saveChatScrollSnapshot, +} from "../chat-scroll-session"; + +afterEach(async () => { + await consumeChatScrollSnapshot(); + await ChatScrollSessionStorage.clearSnapshot(); + document.body.innerHTML = ""; +}); + +describe("chat scroll session", () => { + it("saves image viewer scroll sessions by message anchor", async () => { + const { anchorNode, scrollNode } = createScrollFixture({ + anchorTop: 220, + scrollTop: 120, + }); + + saveChatScrollSnapshot(scrollNode, { anchorMessageId: "msg-image-1" }); + + const snapshot = await consumeChatScrollSnapshot(); + expect(snapshot).toEqual({ + version: 2, + reason: "image-viewer", + anchorMessageId: "msg-image-1", + anchorOffsetTop: 120, + fallbackScrollTop: 120, + savedAt: expect.any(Number), + }); + expect(anchorNode.dataset.chatMessageId).toBe("msg-image-1"); + }); + + it("restores scroll top by keeping the anchor at the saved visual offset", () => { + const { scrollNode } = createScrollFixture({ + anchorTop: 250, + scrollTop: 200, + }); + + expect( + getChatScrollRestoreTop(scrollNode, { + version: 2, + reason: "image-viewer", + anchorMessageId: "msg-image-1", + anchorOffsetTop: 120, + fallbackScrollTop: 120, + savedAt: Date.now(), + }), + ).toBe(230); + }); + + it("falls back to the saved scroll top when the anchor is missing", () => { + const { scrollNode } = createScrollFixture({ + anchorTop: 250, + scrollTop: 200, + }); + + expect( + getChatScrollRestoreTop(scrollNode, { + version: 2, + reason: "image-viewer", + anchorMessageId: "missing-message", + anchorOffsetTop: 120, + fallbackScrollTop: 180, + savedAt: Date.now(), + }), + ).toBe(180); + }); +}); + +function createScrollFixture(input: { + anchorTop: number; + scrollTop: number; +}): { + anchorNode: HTMLElement; + scrollNode: HTMLElement; +} { + const scrollNode = document.createElement("div"); + Object.defineProperties(scrollNode, { + clientHeight: { configurable: true, value: 400 }, + scrollHeight: { configurable: true, value: 1000 }, + scrollTop: { + configurable: true, + value: input.scrollTop, + writable: true, + }, + }); + scrollNode.getBoundingClientRect = () => makeRect(100); + + const anchorNode = document.createElement("div"); + anchorNode.dataset.chatMessageId = "msg-image-1"; + anchorNode.getBoundingClientRect = () => makeRect(input.anchorTop); + + scrollNode.appendChild(anchorNode); + document.body.appendChild(scrollNode); + + return { anchorNode, scrollNode }; +} + +function makeRect(top: number): DOMRect { + return { + bottom: top + 40, + height: 40, + left: 0, + right: 100, + toJSON: () => ({}), + top, + width: 100, + x: 0, + y: top, + }; +} diff --git a/src/app/chat/chat-scroll-session.ts b/src/app/chat/chat-scroll-session.ts index 6f09facc..a7054e53 100644 --- a/src/app/chat/chat-scroll-session.ts +++ b/src/app/chat/chat-scroll-session.ts @@ -8,35 +8,57 @@ import { Result } from "@/utils"; const CHAT_SCROLL_SAVE_EVENT = "cozsweet:chat:save-scroll"; const CHAT_SCROLL_MAX_AGE_MS = 10 * 60 * 1000; -const CHAT_SCROLL_BOTTOM_THRESHOLD = 80; let memoryScrollSession: ChatScrollSession | null = null; -export function requestChatScrollSnapshotSave(): void { +interface ChatScrollSnapshotSaveDetail { + anchorMessageId: string; +} + +export function requestChatScrollSnapshotSave(anchorMessageId: string): void { if (typeof window === "undefined") return; - window.dispatchEvent(new Event(CHAT_SCROLL_SAVE_EVENT)); + if (anchorMessageId.trim().length === 0) return; + window.dispatchEvent( + new CustomEvent(CHAT_SCROLL_SAVE_EVENT, { + detail: { anchorMessageId }, + }), + ); } export function listenChatScrollSnapshotSave( - listener: () => void, + listener: (detail: ChatScrollSnapshotSaveDetail) => void, ): () => void { if (typeof window === "undefined") return () => {}; - window.addEventListener(CHAT_SCROLL_SAVE_EVENT, listener); - return () => window.removeEventListener(CHAT_SCROLL_SAVE_EVENT, listener); + const handler = (event: Event) => { + if (!(event instanceof CustomEvent)) return; + const detail = event.detail as Partial; + if (typeof detail.anchorMessageId !== "string") return; + listener({ anchorMessageId: detail.anchorMessageId }); + }; + window.addEventListener(CHAT_SCROLL_SAVE_EVENT, handler); + return () => window.removeEventListener(CHAT_SCROLL_SAVE_EVENT, handler); } export function saveChatScrollSnapshot( - scrollNode: Pick, + scrollNode: HTMLElement, + detail: ChatScrollSnapshotSaveDetail, ): void { if (typeof window === "undefined") return; - const { scrollTop, scrollHeight, clientHeight } = scrollNode; + const { scrollTop } = scrollNode; if (!Number.isFinite(scrollTop) || scrollTop < 0) return; + const anchorNode = findChatMessageElement( + scrollNode, + detail.anchorMessageId, + ); + if (!anchorNode) return; + const payload: ChatScrollSession = { - scrollTop, - scrollHeight, - clientHeight, - distanceFromBottom: Math.max(0, scrollHeight - scrollTop - clientHeight), + version: 2, + reason: "image-viewer", + anchorMessageId: detail.anchorMessageId, + anchorOffsetTop: getElementOffsetTop(scrollNode, anchorNode), + fallbackScrollTop: scrollTop, savedAt: Date.now(), }; memoryScrollSession = payload; @@ -60,14 +82,22 @@ export async function consumeChatScrollSnapshot(): Promise, + scrollNode: HTMLElement, snapshot: ChatScrollSession, ): number { - const maxScrollTop = Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight); - if (snapshot.distanceFromBottom <= CHAT_SCROLL_BOTTOM_THRESHOLD) { - return maxScrollTop; + const anchorNode = findChatMessageElement( + scrollNode, + snapshot.anchorMessageId, + ); + if (anchorNode) { + const currentOffsetTop = getElementOffsetTop(scrollNode, anchorNode); + return clampScrollTop( + scrollNode, + scrollNode.scrollTop + currentOffsetTop - snapshot.anchorOffsetTop, + ); } - return Math.min(Math.max(0, snapshot.scrollTop), maxScrollTop); + + return clampScrollTop(scrollNode, snapshot.fallbackScrollTop); } function readValidChatScrollSession(value: unknown): ChatScrollSession | null { @@ -75,14 +105,14 @@ function readValidChatScrollSession(value: unknown): ChatScrollSession | null { const payload = value as Partial; 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) || + payload.version !== 2 || + payload.reason !== "image-viewer" || + typeof payload.anchorMessageId !== "string" || + payload.anchorMessageId.length === 0 || + typeof payload.anchorOffsetTop !== "number" || + !Number.isFinite(payload.anchorOffsetTop) || + typeof payload.fallbackScrollTop !== "number" || + !Number.isFinite(payload.fallbackScrollTop) || typeof payload.savedAt !== "number" || !Number.isFinite(payload.savedAt) ) { @@ -94,10 +124,40 @@ function readValidChatScrollSession(value: unknown): ChatScrollSession | 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), + version: 2, + reason: "image-viewer", + anchorMessageId: payload.anchorMessageId, + anchorOffsetTop: payload.anchorOffsetTop, + fallbackScrollTop: Math.max(0, payload.fallbackScrollTop), savedAt: payload.savedAt, }; } + +function findChatMessageElement( + scrollNode: HTMLElement, + messageId: string, +): HTMLElement | null { + return ( + Array.from( + scrollNode.querySelectorAll("[data-chat-message-id]"), + ).find((element) => element.dataset.chatMessageId === messageId) ?? null + ); +} + +function getElementOffsetTop( + scrollNode: HTMLElement, + element: HTMLElement, +): number { + return ( + element.getBoundingClientRect().top - + scrollNode.getBoundingClientRect().top + ); +} + +function clampScrollTop(scrollNode: HTMLElement, value: number): number { + const maxScrollTop = Math.max( + 0, + scrollNode.scrollHeight - scrollNode.clientHeight, + ); + return Math.min(Math.max(0, value), maxScrollTop); +} diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 3dd34ddc..5fbb66b0 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -28,6 +28,8 @@ import { LottieMessageBubble } from "./lottie-message-bubble"; import { MessageBubble } from "./message-bubble"; import styles from "./chat-area.module.css"; +const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96; + export interface ChatAreaProps { messages: readonly UiMessage[]; isReplyingAI: boolean; @@ -49,30 +51,31 @@ export function ChatArea({ const scrollRef = useRef(null); const prevLengthRef = useRef(messages.length); const restoredScrollRef = useRef(false); - const saveFrameRef = useRef(null); + const restoreStateRef = useRef<"idle" | "checking" | "restoring" | "done">( + "idle", + ); + const wasNearBottomRef = useRef(true); - const saveCurrentScrollSnapshotNow = () => { + const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => { const scrollNode = scrollRef.current; if (!scrollNode) return; - saveChatScrollSnapshot(scrollNode); - }; - - const saveCurrentScrollSnapshot = () => { - if (saveFrameRef.current !== null) return; - - saveFrameRef.current = window.requestAnimationFrame(() => { - saveFrameRef.current = null; - saveCurrentScrollSnapshotNow(); - }); + saveChatScrollSnapshot(scrollNode, { anchorMessageId }); }; // 新消息 → 滚到底部 useEffect(() => { if (messages.length !== prevLengthRef.current) { - scrollRef.current?.scrollTo({ - top: scrollRef.current.scrollHeight, - behavior: "smooth", - }); + const scrollNode = scrollRef.current; + const shouldSkipAutoScroll = + restoreStateRef.current === "checking" || + restoreStateRef.current === "restoring"; + + if (scrollNode && !shouldSkipAutoScroll && wasNearBottomRef.current) { + scrollNode.scrollTo({ + top: scrollNode.scrollHeight, + behavior: "smooth", + }); + } prevLengthRef.current = messages.length; } }, [messages.length]); @@ -88,13 +91,28 @@ export function ChatArea({ let timer: number | null = null; let observerTimer: number | null = null; let lateRestoreTimer: number | null = null; + let finishRestoreTimer: number | null = null; let resizeObserver: ResizeObserver | null = null; + restoreStateRef.current = "checking"; + const restore = async () => { const snapshot = await consumeChatScrollSnapshot(); - if (cancelled || snapshot === null) return; + if (cancelled) return; + + if (snapshot === null) { + restoredScrollRef.current = true; + restoreStateRef.current = "done"; + scrollNode.scrollTo({ + top: scrollNode.scrollHeight, + behavior: "auto", + }); + return; + } restoredScrollRef.current = true; + restoreStateRef.current = "restoring"; + prevLengthRef.current = messages.length; const restoreScroll = () => { scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot); @@ -114,6 +132,10 @@ export function ChatArea({ resizeObserver?.disconnect(); }, 800); lateRestoreTimer = window.setTimeout(restoreScroll, 600); + finishRestoreTimer = window.setTimeout(() => { + restoreStateRef.current = "done"; + wasNearBottomRef.current = isNearBottom(scrollNode); + }, 850); }; void restore(); @@ -124,24 +146,18 @@ export function ChatArea({ if (timer !== null) window.clearTimeout(timer); if (observerTimer !== null) window.clearTimeout(observerTimer); if (lateRestoreTimer !== null) window.clearTimeout(lateRestoreTimer); + if (finishRestoreTimer !== null) window.clearTimeout(finishRestoreTimer); resizeObserver?.disconnect(); }; }, [messages.length]); useEffect(() => { - const scrollNode = scrollRef.current; - const stopListening = listenChatScrollSnapshotSave( - saveCurrentScrollSnapshotNow, - ); + const stopListening = listenChatScrollSnapshotSave(({ anchorMessageId }) => { + saveCurrentScrollSnapshotNow(anchorMessageId); + }); return () => { stopListening(); - if (saveFrameRef.current !== null) { - window.cancelAnimationFrame(saveFrameRef.current); - saveFrameRef.current = null; - } - if (!scrollNode) return; - saveChatScrollSnapshot(scrollNode); }; }, []); @@ -150,7 +166,9 @@ export function ChatArea({ ref={scrollRef} className={styles.area} aria-label="Chat messages" - onScroll={saveCurrentScrollSnapshot} + onScroll={(event) => { + wasNearBottomRef.current = isNearBottom(event.currentTarget); + }} > @@ -167,6 +185,12 @@ export function ChatArea({ ); } +function isNearBottom(scrollNode: HTMLElement): boolean { + const distanceFromBottom = + scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight; + return distanceFromBottom <= CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD; +} + /** 渲染消息列表(按日期分组插入分隔条) */ function renderMessagesWithDateHeaders( messages: readonly UiMessage[], diff --git a/src/app/chat/components/image-bubble.tsx b/src/app/chat/components/image-bubble.tsx index 506e1ba7..335fb457 100644 --- a/src/app/chat/components/image-bubble.tsx +++ b/src/app/chat/components/image-bubble.tsx @@ -57,7 +57,7 @@ export function ImageBubble({ const openImage = () => { if (!messageId) return; - requestChatScrollSnapshotSave(); + requestChatScrollSnapshotSave(messageId); router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false }); }; diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 12ec04a5..c09c2afb 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -49,7 +49,11 @@ export function MessageBubble({ if (isFromAI) { return ( -
+
+
{/* 占位 */}