refactor(chat): show images in page overlay

This commit is contained in:
2026-07-09 10:11:25 +08:00
parent cb7791dd8d
commit fc92c3a5d5
23 changed files with 253 additions and 627 deletions
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
buildChatImageOverlayUrl,
buildChatWithoutImageOverlayUrl,
getChatImageOverlayMessageId,
} from "../chat-image-overlay-url";
describe("chat image overlay url helpers", () => {
it("reads a non-empty image message id from search params", () => {
expect(
getChatImageOverlayMessageId(new URLSearchParams("image=msg_1")),
).toBe("msg_1");
});
it("ignores missing or blank image message ids", () => {
expect(getChatImageOverlayMessageId(new URLSearchParams(""))).toBeNull();
expect(
getChatImageOverlayMessageId(new URLSearchParams("image=%20")),
).toBeNull();
});
it("builds the chat image overlay url", () => {
expect(buildChatImageOverlayUrl("msg 1")).toBe("/chat?image=msg+1");
});
it("removes only the image param when closing the overlay", () => {
expect(
buildChatWithoutImageOverlayUrl(
new URLSearchParams("image=msg_1&foo=bar"),
),
).toBe("/chat?foo=bar");
expect(
buildChatWithoutImageOverlayUrl(new URLSearchParams("image=msg_1")),
).toBe("/chat");
});
});
@@ -1,116 +0,0 @@
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,
};
}