diff --git a/src/components/ai-elements/html-code-preview.test.tsx b/src/components/ai-elements/html-code-preview.test.tsx
new file mode 100644
index 0000000000..1f803ab107
--- /dev/null
+++ b/src/components/ai-elements/html-code-preview.test.tsx
@@ -0,0 +1,132 @@
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
+import { NextIntlClientProvider } from "next-intl"
+import type { ComponentType, ReactNode } from "react"
+import { beforeEach, describe, expect, it } from "vitest"
+import enMessages from "@/i18n/messages/en.json"
+import { resetHtmlPreviewCachesForTests } from "./html-code-preview"
+import { messageBlockComponents, JsonRenderScope } from "./json-render-pre"
+import { MessageResponse } from "./message"
+
+const Pre = messageBlockComponents.pre as ComponentType<{
+ children?: ReactNode
+}>
+
+beforeEach(() => {
+ localStorage.clear()
+ localStorage.setItem("workspace:chat-html-rendering", "true")
+ resetHtmlPreviewCachesForTests()
+})
+
+function reply(source: string, language = "html") {
+ return (
+
+
+ {source}
+ Hello
\n```"}
First
")) + await screen.findByTitle("HTML preview") + rerender(reply("Second
")) + await waitFor(() => + expect( + screen.getByTitle("HTML preview").getAttribute("srcdoc") + ).toContain("Second
") + ) + }) + + it("leaves non-HTML fences unchanged", () => { + render(reply("const x = 1", "javascript")) + expect(screen.queryByRole("button", { name: "Preview" })).toBeNull() + expect(screen.getByText("const x = 1")).toHaveAttribute( + "data-block", + "true" + ) + }) + + it("does not offer preview outside assistant replies", () => { + render( +
+ {"User content
"}
+
+ )
+ expect(screen.queryByRole("button", { name: "Preview" })).toBeNull()
+ })
+
+ it.each(["HTML", "htm"])("supports the %s language label", (language) => {
+ render(reply("Hello
", language)) + expect(screen.getByRole("button", { name: "Preview" })).toBeInTheDocument() + }) + + it("keeps the view switch clear of the code block's own buttons", async () => { + render(reply("Kept
")) + await screen.findByTitle("HTML preview") + first.unmount() + // Mounted again from the cache, as when a reply stops streaming. + render(reply("Kept
")) + const frame = screen.getByTitle("HTML preview") as HTMLIFrameElement + // Enough other previews to push its entry out of the cache. + for (let i = 0; i < 40; i++) { + const other = render(reply(`Other ${i}
`)) + await waitFor(() => + expect(other.container.querySelector("iframe")).not.toBeNull() + ) + other.unmount() + } + act(() => + window.dispatchEvent( + new MessageEvent("message", { + source: frame.contentWindow, + data: { type: "codeg-visualize:size", height: 200 }, + }) + ) + ) + expect(screen.queryByRole("status")).toBeNull() + expect(screen.getByTitle("HTML preview").getAttribute("srcdoc")).toContain( + "Kept
" + ) + }) +}) diff --git a/src/components/ai-elements/html-code-preview.tsx b/src/components/ai-elements/html-code-preview.tsx new file mode 100644 index 0000000000..914eb34631 --- /dev/null +++ b/src/components/ai-elements/html-code-preview.tsx @@ -0,0 +1,219 @@ +"use client" + +import { useEffect, useRef, useState, type ReactNode } from "react" +import { useTranslations } from "next-intl" +import { useTheme } from "next-themes" +import { Button } from "@/components/ui/button" +import { buildChatHtmlDocument } from "@/lib/chat-html-document" +import { + HTML_PREVIEW_SIZE_MESSAGE_TYPE, + readHtmlPreviewTheme, +} from "@/lib/html-preview-frame" +import { cn, randomUUID } from "@/lib/utils" + +interface FrameEntry { + /** `null` when the build failed: the fence stays code. */ + doc: string | null + /** The theme tokens it was built with; other tokens rebuild it. */ + theme: string + /** What the frame last reported, while it reported anything. */ + height?: number +} + +interface View { + preview: boolean + expanded: boolean +} + +const DEFAULT_VIEW: View = { preview: true, expanded: false } + +/** + * The documents previews built, by theme and source, the height each + * measured, and the view the reader picked, by source. Streamdown draws a + * finished reply as a new tree, so every preview in a reply mounts again the + * moment it stops streaming; these bring it back as it was, instead of + * "Generating", then growing from the default height, back in its first view. + */ +const frames = new MapFirst
', true) + ) + expect(screen.queryByTitle("HTML preview")).toBeNull() + expect(screen.getByRole("status")).toBeInTheDocument() + const complete = 'First
First
Safe
+ {t("settingsDescription")} +
+ +A
" + expect(createChatHtmlProcessor()(source, false)).toContain( + "```codeg-html\n" + source + ) + }) + + it("同一原始 HTML 块中的相邻根元素都被保留在预览内", () => { + const html = + '\nB\n\nMore **markdown** here.\n\n## Heading", + ], + [ + "闭合标签落在段落中间", + "
x
\n```", true)).toContain( + "codeg-html-pending" + ) + expect(process("```html\nx
\n```\n", true)).toContain( + "```codeg-html\nx
\n```" + ) + expect(createChatHtmlProcessor()("```html\nHello { + const source = + "```html\nHellox
\r\n", true) + expect(process("```html\r\nx
\r\n```\r\n\r\nNext", true)).toContain( + "```codeg-html\n" + ) + }) + + it("逐字流式的结果在结束时与一次性处理完全相同", () => { + const documents = [ + 'Intro **text** with `MapFence
\n```\n\n| 1 |
one
\n\ntwo
\nCard
\n\nold", true) + const edited = "Card
\n\n