Browse Source

fix(web): size diagram previews to image content

yudshj 1 month ago
parent
commit
4e0a405a9b

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md
+2026-09-09-content-sized-diagram-previews.md: ceb29238eec09f2dde312835dbf376855998a507
+2026-09-09-content-sized-diagram-previews.zh.md: 2689602bfe3469f53e532367877f7e49d52d3d5d

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md

@@ -0,0 +1,25 @@
+# Agent Note: Content-sized diagram previews
+
+Status: implemented
+
+English | [中文](2026-09-09-content-sized-diagram-previews.zh.md)
+
+## Problem
+
+Short Graphviz and SVG images leave large empty regions when displayed inside 400px frames. A 59px Graphviz diagram occupies a 400px preview even though its rendered dimensions are already available to the browser.
+
+## Decision
+
+Mermaid, Graphviz, and SVG share the inert image canvas in `SourcePreview`. The browser derives its height from the image, preserves aspect ratio when shrinking to the available width, and adds 16px padding. Source switching retains the image and copying reads the original code. Theme changes regenerate Graphviz colors without introducing a separate sizing observer.
+
+This replaces the diagram-frame choice in [Static Markdown fence previews](../feature/2026-09-09-markdown-static-previews.md); that note continues to own HTML isolation, rendering lifecycle, and license obligations. HTML retains its opaque, script-free, 400px frame. SVG image mode disables scripts, link interaction, and external resource loading without inserting source-controlled markup into the application document.
+
+## Alternatives considered
+
+**Set iframe height to auto.** An iframe does not derive its outer height from its inner document. This still reserves an unrelated viewport.
+
+**Add a frame measurement script or same-origin access.** A diagram already has image dimensions. Extra execution or origin permissions and resize messaging are unnecessary for that content.
+
+## Consequences
+
+Short diagrams occupy only their rendered height and canvas padding. The browser geometry regression compares image and container height in light and dark modes, then verifies proportional scaling in a narrow viewport. Browser security checks include SVG scripts, event handlers, nested HTML, and external images; copy and source toggling remain covered. HTML automatic height remains outside this change because arbitrary document layout has different measurement and isolation requirements.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 按内容定高的图表预览
+
+Status: implemented
+
+[English](2026-09-09-content-sized-diagram-previews.md) | 中文
+
+## Problem
+
+较矮的 Graphviz 与 SVG 图片显示在 400px iframe 中时,会留下大块空白。浏览器已经知道图片的渲染尺寸,但一张 59px 高的 Graphviz 图表仍占据 400px 的预览区域。
+
+## Decision
+
+Mermaid、Graphviz 与 SVG 共享 `SourcePreview` 的不可执行图片画布。浏览器根据图片决定高度,在可用宽度不足时按比例缩小,并添加 16px 内边距。源码切换保留图片,复制读取原始代码。主题变化重新生成 Graphviz 配色,无需单独的尺寸观察器。
+
+这替代了[静态 Markdown fence 预览](../feature/2026-09-09-markdown-static-previews.zh.md)中的图表 iframe 选择;该记录继续维护 HTML 隔离、渲染生命周期与许可证义务。HTML 保留不透明来源、禁用脚本的 400px iframe。SVG 图片模式禁用脚本、链接交互与外部资源加载,无需把源码控制的标记插入应用文档。
+
+## Alternatives considered
+
+**把 iframe 高度设为 auto。** iframe 不会根据内部文档决定外部高度,仍然会保留与内容无关的视口。
+
+**添加 iframe 测量脚本或同源访问。** 图表已经具备图片尺寸,不需要额外执行权限、来源权限或尺寸消息协议。
+
+## Consequences
+
+较矮图表只占据渲染高度与画布内边距。浏览器几何回归用例在浅色和深色下比较图片与容器高度,再验证窄视口中的等比例缩放。浏览器安全检查包含 SVG 脚本、事件处理器、嵌套 HTML 与外部图片,并保留复制和源码切换覆盖。HTML 自动高度不属于本次改动,因为任意文档布局具有不同的测量与隔离要求。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md
-2026-09-09-markdown-static-previews.md: a5fa79fcc8c192dbcd3949debbabbccf85cb3b62
-2026-09-09-markdown-static-previews.zh.md: 30efe897702377c180c9e1650fda93ae42f6e580
+2026-09-09-markdown-static-previews.md: f3a0363e3d7c4cb19ca67dfc5011e936f7c024cf
+2026-09-09-markdown-static-previews.zh.md: 24f87cd73504c1fbd274e1420a3692b3ca55ed5e

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md

@@ -18,7 +18,7 @@ The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, `svg`,
 
 Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Mounted previews observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT, SVG, and HTML colors remain intact.
 
-HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and placed in an image inside the frame, so SVG scripts and links never become active. Frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts.
+HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and displayed with Graphviz output as inert images in a [content-sized canvas](../bug-fix/2026-09-09-content-sized-diagram-previews.md), so SVG scripts and links never become active. HTML frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts.
 
 Graphviz uses the pinned, unmodified `@viz-js/viz` 3.30.0 WebAssembly distribution with the `dot` layout engine. Its npm MIT declaration covers the wrapper; its build provenance identifies Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 (MIT/NCSA). Distribution retains these component terms and the exact Graphviz source download under EPL-2.0 section 3.1. [Full preview notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) also retain Mermaid's MIT text and select Apache-2.0 for DOMPurify. The UI primitives package ships the notices and the Web build emits the same bytes alongside its assets. License review covers embedded payloads separately from the general permissive npm-metadata policy.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。已挂载的预览观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT、SVG 和 HTML 中明确指定的颜色保持原样。
 
-HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后作为图片放进 iframe,因此 SVG 脚本与链接不会激活。iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。
+HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示在[按内容定高的画布](../bug-fix/2026-09-09-content-sized-diagram-previews.zh.md)中,因此 SVG 脚本与链接不会激活。HTML iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。
 
 Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建来源记录标明 Graphviz 16.0.0(EPL-2.0)、Expat 2.8.4(MIT)与 Emscripten 5.0.7(MIT/NCSA)。分发保留各组件条款,并按 EPL-2.0 第 3.1 节提供准确的 Graphviz 源码下载地址。[完整预览声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)还保留 Mermaid 的 MIT 文本,并为 DOMPurify 选择 Apache-2.0。UI primitives 包携带该声明,Web 构建在资源旁输出相同字节。内嵌产物的许可证检查独立于通用的宽松 npm 元数据策略。
 

+ 2 - 2
apps/web/tests/expected/markdown-mermaid/ui.expected.md

@@ -15,10 +15,10 @@
 - img "Mermaid diagram"
 - button "Source"
 - button "Copy"
-- iframe
+- img "Graphviz diagram"
 - button "Source"
 - button "Copy"
-- iframe
+- img "SVG preview"
 - button "Source"
 - button "Copy"
 - iframe

+ 2 - 2
apps/web/tests/expected/markdown-mermaid/zh.expected.md

@@ -14,10 +14,10 @@
 - img "Mermaid 图表"
 - button "源码"
 - button "复制"
-- iframe
+- img "Graphviz 图表"
 - button "源码"
 - button "复制"
-- iframe
+- img "SVG 预览"
 - button "源码"
 - button "复制"
 - iframe

+ 77 - 15
apps/web/tests/markdown-mermaid.e2e.ts

@@ -27,7 +27,12 @@ const UNTRUSTED = [
 ].join('\n')
 
 const DOT = 'digraph { rankdir=LR; Input -> Preview }'
-const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="220" height="100"><rect width="220" height="100" fill="lightblue"/><text x="20" y="55">SVG preview</text></svg>'
+const SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="100" onload="parent.document.body.dataset.previewEscaped='yes'">
+<rect width="460" height="100" fill="lightblue"/><text x="20" y="55">SVG preview</text>
+<script>parent.document.body.dataset.previewEscaped='yes';alert('unsafe SVG')</script>
+<image href="https://preview.invalid/svg-image" width="1" height="1"/>
+<foreignObject width="1" height="1"><div xmlns="http://www.w3.org/1999/xhtml"><script>alert('unsafe HTML')</script></div></foreignObject>
+</svg>`
 const HTML = `<style>h2 { color: green }</style><h2>Static HTML</h2>
 <script>parent.document.body.dataset.previewEscaped = 'yes'; alert('unsafe')</script>
 <meta http-equiv="refresh" content="0;url=https://preview.invalid/refresh">
@@ -88,6 +93,52 @@ describe('web e2e: Mermaid chat previews', () => {
     await scaffold?.close()
   })
 
+  it.skipIf(MODE === 'record').each(['light', 'dark'] as const)(
+    'tightly wraps short diagram previews in %s mode', async (scheme) => {
+      const page = await newEnglishPage(browser)
+      try {
+        await page.emulateMedia({ colorScheme: scheme })
+        await openConversation(page, scaffold)
+        await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe(scheme)
+
+        const mermaid = page.getByRole('img', { name: 'Mermaid diagram' }).first()
+        await mermaid.evaluate(async (node: HTMLImageElement) => { await node.decode() })
+        const mermaidHeight = await mermaid.evaluate(node => node.getBoundingClientRect().height)
+        const canvasHeight = await mermaid.evaluate(node => node.parentElement!.getBoundingClientRect().height)
+        // Compact diagrams need at most the Mermaid canvas's 16px padding on each side.
+        expect(canvasHeight - mermaidHeight).toBeLessThanOrEqual(32)
+
+        for (const title of ['Graphviz diagram', 'SVG preview']) {
+          const image = page.getByRole('img', { name: title, exact: true })
+          await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
+          const imageHeight = await image.evaluate(node => node.getBoundingClientRect().height)
+          const containerHeight = await image.evaluate(node => node.parentElement!.getBoundingClientRect().height)
+          expect(imageHeight).toBeGreaterThan(0)
+          expect(containerHeight).toBeGreaterThanOrEqual(imageHeight)
+          expect.soft(containerHeight - imageHeight,
+            `${scheme} ${title}: ${containerHeight}px container around ${imageHeight}px content`,
+          ).toBeLessThanOrEqual(32)
+        }
+        await page.setViewportSize({ width: 360, height: 800 })
+        const svg = page.getByRole('img', { name: 'SVG preview', exact: true })
+        await expect.poll(() => svg.evaluate((node) => {
+          const { width, height } = node.getBoundingClientRect()
+          return width > 0 && height > 0 && width < 460
+        })).toBe(true)
+        const compact = await svg.evaluate((node: HTMLImageElement) => ({
+          width: node.getBoundingClientRect().width,
+          height: node.getBoundingClientRect().height,
+          canvasHeight: node.parentElement!.getBoundingClientRect().height,
+          ratio: node.naturalWidth / node.naturalHeight,
+        }))
+        expect(compact.width / compact.height).toBeCloseTo(compact.ratio, 1)
+        expect(compact.canvasHeight - compact.height).toBeLessThanOrEqual(32)
+      } finally {
+        await page.close()
+      }
+    },
+  )
+
   it.skipIf(MODE === 'record')('updates existing diagrams when the document switches between light and dark', async () => {
     const page = await newEnglishPage(browser)
     try {
@@ -102,10 +153,19 @@ describe('web e2e: Mermaid chat previews', () => {
         }, scheme)
         await expect.poll(() => image.getAttribute('src')).not.toBe(previous)
         await expect.poll(() => image.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
-        const colors = await image.evaluate(node => ({
-          canvas: getComputedStyle(node.parentElement!).backgroundColor,
-          expected: getComputedStyle(document.body).getPropertyValue('--dsw-alias-markdown-code-block').trim(),
-        }))
+        const colors = await image.evaluate((node) => {
+          const reference = document.createElement('span')
+          reference.style.backgroundColor = 'var(--dsw-alias-markdown-code-block)'
+          document.body.append(reference)
+          try {
+            return {
+              canvas: getComputedStyle(node.parentElement!).backgroundColor,
+              expected: getComputedStyle(reference).backgroundColor,
+            }
+          } finally {
+            reference.remove()
+          }
+        })
         expect(colors.canvas).toBe(colors.expected)
         await expect.poll(() => page.getByTitle('HTML preview', { exact: true }).getAttribute('srcdoc'))
           .toContain(`color-scheme:${scheme}`)
@@ -186,7 +246,7 @@ describe('web e2e: Mermaid chat previews', () => {
     await page.close()
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('previews DOT, SVG, and static HTML inside isolated frames', async () => {
+  it.skipIf(MODE === 'record')('previews inert DOT and SVG images and isolates static HTML', async () => {
     const page = await newEnglishPage(browser)
     const requests: string[] = []
     const dialogs: string[] = []
@@ -198,27 +258,29 @@ describe('web e2e: Mermaid chat previews', () => {
     page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() })
     await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
     await openConversation(page, scaffold)
-    for (const [title, code] of [['Graphviz diagram', DOT], ['SVG preview', SVG], ['HTML preview', HTML]]) {
-      const frame = page.locator(`iframe[title="${title}"]`)
-      await frame.waitFor()
-      expect(await frame.getAttribute('sandbox')).toBe('')
-      const body = frame.contentFrame()
+    for (const [title, code] of [['Graphviz diagram', DOT], ['SVG preview', SVG], ['HTML preview', HTML]] as const) {
+      const preview = title === 'HTML preview'
+        ? page.getByTitle(title, { exact: true })
+        : page.getByRole('img', { name: title, exact: true, includeHidden: true })
+      await preview.waitFor()
       if (title === 'HTML preview') {
+        expect(await preview.getAttribute('sandbox')).toBe('')
+        const body = preview.contentFrame()
         await body.getByRole('heading', { name: 'Static HTML' }).waitFor()
         expect(await body.getByRole('heading').evaluate(node => getComputedStyle(node).color)).toBe('rgb(0, 128, 0)')
         await body.getByText('Disabled navigation').click()
-        expect(await frame.evaluate(node => (node as HTMLIFrameElement).contentDocument)).toBeNull()
+        expect(await preview.evaluate(node => (node as HTMLIFrameElement).contentDocument)).toBeNull()
       } else {
-        await expect.poll(() => body.locator('img').evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
+        await expect.poll(() => preview.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
       }
-      const block = page.locator('.md-code-block').filter({ has: frame })
+      const block = page.locator('.md-code-block').filter({ has: preview })
       await block.hover()
       await block.getByRole('button', { name: 'Copy', exact: true }).click()
       expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(code)
       await block.getByRole('button', { name: 'Source', exact: true }).click()
       expect(await block.locator('pre code').textContent()).toBe(code)
       await block.getByRole('button', { name: 'Preview', exact: true }).click()
-      expect(await frame.isVisible()).toBe(true)
+      expect(await preview.isVisible()).toBe(true)
     }
     expect(await page.locator('body').getAttribute('data-preview-escaped')).toBeNull()
     expect(dialogs).toEqual([])

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 8bdd2b7f90ebe4a998be8158351e49f815343c78
-README.zh.md: b861f9e17ff73f20717aed63b5b087358daeae10
+README.md: e6f63196375a4f96416ea05efc03cb72e8182823
+README.zh.md: 323a09e396c9b40b1401909a81998bf35af0ae6f

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -77,7 +77,7 @@ Supply `MarkdownLabels.preview` to enable settled `mermaid`, `graphviz`/`dot`, `
 
 `CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible below the diagram whenever a touchscreen is available, including with a mouse attached. Switching to source keeps the preview mounted, so returning reuses its result; copying always retains the source.
 
-Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images inside opaque sandbox iframes; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. Frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input.
+Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images in the same content-sized canvas as Mermaid; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. HTML frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Diagram canvases follow the rendered image height with 16px padding and shrink images proportionally to fit the available width. HTML frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input.
 
 Internal render functions accept optional native `MermaidConfig` and Viz.js `RenderOptions` for developer customization. Attribute and theme-variable overrides merge with document defaults; Mermaid preview restrictions and Graphviz SVG output remain fixed. These parameters have no settings UI.
 

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -77,7 +77,7 @@ kind: "package-library"
 
 `CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。
 
-每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片放入不透明来源的 sandbox iframe;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。
+每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片,使用与 Mermaid 相同的按内容定高画布;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。HTML iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。图表画布随渲染后的图片高度收缩,保留 16px 内边距,并在可用宽度不足时等比例缩小图片。HTML iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。
 
 内部渲染函数接受可选的原生 `MermaidConfig` 与 Viz.js `RenderOptions`,供开发者定制。属性与主题变量覆盖项和文档默认值合并;Mermaid 预览限制与 Graphviz SVG 输出保持固定。这些参数没有设置界面。
 

+ 1 - 1
packages/client/ui-primitives/src/markdown/graphviz.ts

@@ -11,7 +11,7 @@ let runtime: ReturnType<typeof instance> | undefined
  * @param code - Complete Graphviz DOT source.
  * @param signal - Cancels work waiting for the runtime; synchronous layout cannot be interrupted.
  * @param options - Native overrides; attribute maps merge over theme defaults and output remains SVG regardless of format.
- * @returns A srcdoc document. Import, layout, invalid source, and cancellation failures reject.
+ * @returns An SVG image data URL. Import, layout, invalid source, and cancellation failures reject.
  */
 export async function renderGraphviz(code: string, signal: AbortSignal, options?: RenderOptions): Promise<string> {
   runtime ??= import('@viz-js/viz').then(module => module.instance()).catch((error: unknown) => {

+ 4 - 6
packages/client/ui-primitives/src/markdown/preview-document.ts

@@ -1,4 +1,4 @@
-/** Static preview documents: opaque sandbox frames with no scripts, navigation, or remote resources. */
+/** Static HTML documents and SVG image URLs without executable source markup. */
 
 import DOMPurify from 'dompurify'
 import { readPreviewTheme } from './preview-theme.ts'
@@ -28,10 +28,10 @@ export function renderHtml(code: string, signal: AbortSignal): string {
 }
 
 /**
- * Prepare an SVG image inside an isolated document, without activating SVG scripts or links.
+ * Prepare an SVG image URL without activating SVG scripts, links, or external resources.
  * @param code - Complete SVG XML; malformed XML or a non-SVG root throws.
  * @param signal - Prevents preparation after the preview owner is cancelled.
- * @returns A srcdoc document containing a responsive inert SVG image.
+ * @returns A data URL for an image whose intrinsic dimensions determine the preview height.
  */
 export function renderSvg(code: string, signal: AbortSignal): string {
   signal.throwIfAborted()
@@ -40,7 +40,5 @@ export function renderSvg(code: string, signal: AbortSignal): string {
     || parsed.documentElement.namespaceURI !== 'http://www.w3.org/2000/svg') {
     throw new Error('Invalid SVG document')
   }
-  // Double-quoted attributes keep encodeURIComponent's literal apostrophes inert.
-  const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(code)}`
-  return documentOf(`<img style="display:block;max-width:100%;height:auto;margin:auto" src="${url}" alt="">`)
+  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(code)}`
 }

+ 1 - 1
packages/client/ui-primitives/src/markdown/render.tsx

@@ -421,7 +421,7 @@ function fencePreview(lang: string | undefined, code: string, labels: MarkdownLa
   const render = { mermaid: renderMermaid, graphviz: renderGraphviz, svg: renderSvg, html: renderHtml }[kind]
   return {
     content: (
-      <SourcePreview key={kind} code={code} labels={labels.preview[kind]} render={render} document={kind !== 'mermaid'} />
+      <SourcePreview key={kind} code={code} labels={labels.preview[kind]} render={render} document={kind === 'html'} />
     ),
     previewLabel: labels.preview.preview,
     sourceLabel: labels.preview.source,

+ 19 - 12
packages/client/ui-primitives/tests/fence-previews.client.spec.tsx

@@ -35,22 +35,29 @@ describe('Markdown fence previews', () => {
     expect(view.container.querySelector('iframe')).toBeNull()
     expect(view.container.querySelector('pre code')?.textContent).toBe(code)
     view.rerender(<MarkdownText {...props} />)
-    const frame = await screen.findByTitle(title)
-    expect(frame.getAttribute('sandbox')).toBe('')
-    expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer')
-    expect(frame.getAttribute('srcdoc')).toContain('Content-Security-Policy')
+    const findPreview = async () => lang === 'html' ? await screen.findByTitle(title) : await screen.findByRole('img', { name: title })
+    const element = await findPreview()
+    if (lang === 'html') {
+      expect(element.getAttribute('sandbox')).toBe('')
+      expect(element.getAttribute('referrerpolicy')).toBe('no-referrer')
+      expect(element.getAttribute('srcdoc')).toContain('Content-Security-Policy')
+    } else {
+      expect(element.getAttribute('src')).toMatch(/^data:image\/svg\+xml/)
+      expect(view.container.querySelector('iframe')).toBeNull()
+      expect(element.parentElement!.querySelector('svg')).toBeNull()
+    }
     expect(view.container.querySelector('pre code')).toBeNull()
     fireEvent.click(screen.getByRole('button', { name: 'Source' }))
     expect(view.container.querySelector('pre code')?.textContent).toBe(code)
-    expect(frame.closest('[hidden]')).not.toBeNull()
+    expect(element.closest('[hidden]')).not.toBeNull()
     fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
     await waitFor(() => { expect(writeText).toHaveBeenCalledWith(code) })
     fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
-    expect(screen.getByTitle(title)).toBe(frame)
-    expect(frame.closest('[hidden]')).toBeNull()
+    expect(await findPreview()).toBe(element)
+    expect(element.closest('[hidden]')).toBeNull()
     view.unmount()
     render(<MarkdownText {...props} />)
-    await screen.findByTitle(title)
+    await findPreview()
     fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
     await waitFor(() => { expect(writeText).toHaveBeenCalledTimes(2) })
   })
@@ -60,7 +67,7 @@ describe('Markdown fence previews', () => {
     await screen.findByText(status.error)
     expect(view.container.querySelector('pre code')?.textContent).toBe('broken')
     view.rerender(<MarkdownText text={`\`\`\`svg\n${svg}\n\`\`\``} labels={{ ...markdownLabels, preview }} />)
-    await screen.findByTitle(preview.svg.diagram)
+    await screen.findByRole('img', { name: preview.svg.diagram })
     expect(screen.queryByText(status.error)).toBeNull()
   })
 
@@ -90,9 +97,9 @@ describe('static preview documents', () => {
 
   it('encodes SVG as an image rather than executable document markup', () => {
     const code = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert("test")</script></svg>'
-    const parsed = new DOMParser().parseFromString(renderSvg(code, new AbortController().signal), 'text/html')
-    expect(parsed.querySelector('svg, script')).toBeNull()
-    expect(decodeURIComponent(parsed.querySelector('img')!.getAttribute('src')!)).toContain(code)
+    const url = renderSvg(code, new AbortController().signal)
+    expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/)
+    expect(decodeURIComponent(url.slice(url.indexOf(',') + 1))).toBe(code)
   })
 
   it.each(['<html/>', '<svg/>', '<svg xmlns="http://www.w3.org/2000/svg"><g></svg>'])('rejects non-SVG or malformed XML: %s', (code) => {