refactor(web): unify markdown preview components and notes

This commit is contained in:
yudshj
2026-09-09 17:39:04 +08:00
committed by Yudong Han
parent a8f9d81f3a
commit fb84576ea7
21 changed files with 144 additions and 203 deletions
@@ -1,6 +0,0 @@
# 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/feature/2026-09-07-web-mermaid-preview.md
2026-09-07-web-mermaid-preview.md: 246b38e764e1e5812427f935c4a41c28f0fa24a0
2026-09-07-web-mermaid-preview.zh.md: c1dd25c1b6dc21c36d92e0d7c1477fcea9ae99c2
@@ -1,35 +0,0 @@
# Agent Note: Mermaid previews in Chat code fences
Status: implemented
English | [中文](2026-09-07-web-mermaid-preview.zh.md)
## Problem
Assistant replies can describe diagrams in Mermaid code fences, but readers must interpret the source or copy it to another renderer. Rendering must handle incomplete streaming input and untrusted diagram content while remaining reusable outside Chat.
## Decision
Chat enables settled fence previews through `MarkdownLabels.mermaid`. The shared Markdown renderer uses the parsed fence language; streaming fences and consumers without those labels retain code display. The static [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `MermaidPreview`, which accepts source and localized labels without Session, file, or Cordis dependencies. `CodeBlock.preview` owns view switching and source copying. Previews omit the language banner and expose compact icon actions on hover or keyboard focus; devices with any touch input keep those actions visible below the diagram. Switching to source hides the mounted preview, so returning preserves completed rendering and pending work. Source and preview share the same focused toggle button, and copying always reads the source prop.
Mermaid loads on demand. Its public render API serializes diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the neutral theme, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing diagram links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas stays light in both application themes.
Source replacement and unmounting cancel result publication. Cancellation before runtime loading completes prevents rendering; an active Mermaid render finishes and releases its DOM but cannot publish to a cancelled component. Failures show a localized error and the original source. Replacing invalid source with a valid diagram recovers the preview.
## Alternatives considered
**Keep the code banner above diagrams.** A language label and permanent text controls distract from the diagram. Overlay actions retain source access without reserving a title row.
**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. The existing message-settlement boundary provides a complete source value.
**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md).
**Insert interactive SVG into the message.** The requested preview needs diagram display and source access. Displaying the result as an image keeps diagram links and event handlers inactive.
The [static fence preview decision](2026-09-09-markdown-static-previews.md) owns Graphviz, SVG, and HTML sandboxing and shared preview license distribution.
## Consequences
The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. Mermaid adds lazily loaded browser assets. Rendering still runs on the browser thread, and work already handed to the library cannot be interrupted. The initial feature has no editing, export, zoom controls, or interactive diagram links.
Component tests cover delayed completion, stale success and failure, unmounting, source fallback, and copying. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) exercises the assembled Chat with Chinese flowcharts, sequence diagrams, malformed source, configuration overrides, and English/Chinese UI snapshots.
@@ -1,35 +0,0 @@
# Agent Note: Chat 代码 fence 中的 Mermaid 预览
Status: implemented
[English](2026-09-07-web-mermaid-preview.md) | 中文
## 问题
Assistant 回复可以在 Mermaid 代码 fence 中描述图表,但读者必须理解源码或把它复制到其他渲染器。渲染需要处理不完整的流式输入与不可信图表内容,同时保持在 Chat 之外的可复用性。
## 决策
Chat 通过 `MarkdownLabels.mermaid` 启用已定稿 fence 预览。共享 Markdown 渲染器使用解析后的 fence 语言;流式 fence 和未传入这些 label 的调用方保留代码显示。静态 [UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `MermaidPreview`,它接收源码与本地化 label,不依赖 Session、文件或 Cordis。`CodeBlock.preview` 拥有视图切换与源码复制。预览省略语言标题栏,悬停或键盘聚焦时显示紧凑的图标操作;具备任何触控输入的设备均在图表下方保持操作可见。切换到源码时隐藏已挂载的预览,因此返回时保留已完成的渲染与进行中的工作。源码与预览使用同一个已聚焦的切换按钮,复制始终读取源码 prop。
Mermaid 按需加载。它的公开 render API 串行执行图表工作,每次调用在 `finally` 中移除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML label、中性主题和错误渲染策略。生成的 SVG 以图片显示,不安装图内链接或脚本。固有尺寸取自 SVG viewBox;大图缩小以适应宽度,画布在两种应用主题下均保持浅色。
替换源码与卸载组件会取消结果发布。在运行时加载完成前取消可阻止渲染;已经开始的 Mermaid 渲染会完成并释放 DOM,但无法向已取消的组件发布结果。失败时显示本地化错误和原始源码。用有效图表替换非法源码可恢复预览。
## 考虑过的替代方案
**在图表上方保留代码标题栏。** 语言标记和常驻的文字操作分散了对图表的注意力。浮层操作无需占用标题行即可保留源码入口。
**渲染每个流式分片。** 不完整的图表经常无效,反复布局也会与文本流式输出竞争。已有的消息定稿边界提供完整源码值。
**把渲染放在 Chat 内或增加通用预览注册表。** 接收普通 prop 的共享原语无需另一套注册表或功能插件依赖即可满足复用。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。
**把可交互 SVG 插入消息。** 此次预览需求只需要图表显示与源码访问。将结果显示为图片后,图内链接和事件处理器不会生效。
[静态 fence 预览决策](2026-09-09-markdown-static-previews.zh.md)负责 Graphviz、SVG、HTML 的 sandbox 及共享预览许可证分发。
## 后果
功能只改变展示,不改变持久化消息、提供者请求、工具或 Host API。Mermaid 增加按需加载的浏览器资源。渲染仍在浏览器线程上执行,已交给库的工作无法中断。首版不包含编辑、导出、缩放控件或可交互图内链接。
组件测试覆盖延迟完成、过期成功与失败、卸载、源码回退和复制。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)通过完整 Chat 组合验证中文流程图、时序图、非法源码、配置覆盖以及英文/中文 UI 快照。
@@ -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: fd3772cabc2160f07e79b06dffa4374fe104cbd1
2026-09-09-markdown-static-previews.zh.md: 92f4b030879acaeb484eed33e078574750a1801e
2026-09-09-markdown-static-previews.md: a6d1802546e9e976da7ae4d1ecc024924414e9a4
2026-09-09-markdown-static-previews.zh.md: 42488547efafdbf5b1944b1300af13f359365d74
@@ -6,26 +6,38 @@ English | [中文](2026-09-09-markdown-static-previews.zh.md)
## Problem
Readers need to see DOT diagrams, SVG artwork, and HTML examples directly in Assistant replies. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations.
Readers need to see Mermaid and DOT diagrams, SVG artwork, and HTML examples directly in Assistant replies without interpreting source or copying it to another renderer. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations.
## Decision
The shared Markdown renderer enables settled `graphviz`/`dot`, `svg`, and `html` fences through localized `MarkdownLabels.preview`. `CodeBlock.preview` owns default visualization, source switching, and verbatim copying. `SourcePreview` owns loading, failure, and cancellation of stale result publication for both these documents and the [Mermaid preview](2026-09-07-web-mermaid-preview.md). Consumers without preview labels and streaming messages retain code.
The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences through localized `MarkdownLabels.preview` and the parsed fence language. The [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `SourcePreview`, which accepts a renderer, source, and labels without Session, file, or Cordis dependencies. Mermaid and Graphviz each supply a `.ts` renderer to that same component. Consumers without preview labels and streaming messages retain code.
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. Mermaid keeps its existing strict renderer and inert image presentation.
`CodeBlock.preview` owns default visualization, source switching, and verbatim copying. Previews omit the language banner and expose compact icon actions on hover or keyboard focus; devices with any touch input keep actions visible below the diagram. Switching to source hides the mounted preview, preserving completed rendering and pending work. Both views use the same focused toggle button, and copying always reads the source prop.
`SourcePreview` owns loading, failure, and cancellation of stale result publication. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview.
Mermaid loads on demand. Its public render API serializes diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the neutral theme, 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 stays light in both application themes.
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.
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.
## Alternatives considered
**Keep the code banner above diagrams.** A language label and permanent text controls distract from the diagram. Overlay actions retain source access without reserving a title row.
**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. Message settlement provides complete source.
**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md).
**Execute HTML scripts in a frame.** Static chat examples do not need script execution. An empty sandbox plus sanitization and CSP gives the preview fewer capabilities and avoids introducing a frame messaging protocol.
**Insert rendered markup into Chat.** HTML styles and executable SVG would share the application document. Iframes isolate layout and origin; SVG image mode additionally disables SVG behavior.
**Insert rendered markup into Chat.** The requested preview needs diagram display and source access; HTML styles and executable SVG would share the application document. Iframes isolate layout and origin; SVG image mode additionally disables SVG behavior.
**Treat Viz.js as MIT-only or use a remote Graphviz service.** The compiled Graphviz license still applies locally; a remote renderer would send conversation content off-device. The bundled renderer retains source availability and legal notices without network rendering.
## Consequences
No Session event, prompt, or tool changes. Inline HTML styles work, while interactive scripts, external assets, and links do not. Graphviz adds a lazy WebAssembly asset and synchronous browser-thread layout; source cancellation prevents publication but cannot preempt active layout. Mermaid's existing layout limitations remain documented in its owning note.
The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. Inline HTML styles work, while interactive scripts, external assets, and links do not. Mermaid and Graphviz add lazy browser assets and run layout on the browser thread. Cancellation cannot preempt active layout; Mermaid finishes and releases its measurement DOM even when its result cannot be published. The previews have no editing, export, zoom controls, or interactive diagram links.
Component tests cover source copying, default preview, fallback, cancellation, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies opaque frames, real image decoding, source toggling, blocked script/navigation/resource attempts, locale snapshots, and served license text. Notices checks pin the reviewed wrapper and native provenance so upgrades require renewed review.
Component tests cover source copying, default preview, delayed completion, stale success and failure, unmounting, fallback, cancellation, runtime loading failure, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies Chinese Mermaid flowcharts, sequence diagrams, malformed source, configuration overrides, opaque frames, real image decoding, source toggling, blocked script/navigation/resource attempts, English/Chinese UI snapshots, and served license text. Notices checks pin the reviewed wrapper and native provenance so upgrades require renewed review.
@@ -6,26 +6,38 @@ Status: implemented
## Problem
读者需要直接在 Assistant 回复中查看 DOT 图表、SVG 图像和 HTML 示例。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。
读者需要直接在 Assistant 回复中查看 Mermaid 和 DOT 图表、SVG 图像和 HTML 示例,而不必解读源码或复制到其他渲染器。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。
## Decision
共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 启用定稿后的 `graphviz`/`dot``svg``html` fence。`CodeBlock.preview` 负责默认可视化、源码切换与原样复制。`SourcePreview` 为这些文档及 [Mermaid 预览](2026-09-07-web-mermaid-preview.zh.md)负责加载、失败和取消过期结果发布。未提供预览文案的调用方与流式消息保留代码显示。
共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 和解析后的 fence 语言启用定稿后的 `mermaid``graphviz`/`dot``svg``html` fence。[UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `SourcePreview`,它接收渲染器、源码和文案,不依赖 Session、文件或 Cordis。Mermaid 与 Graphviz 各自向同一组件提供 `.ts` 渲染器。未提供预览文案的调用方与流式消息保留代码显示。
HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后作为图片放进 iframe,因此 SVG 脚本与链接不会激活。iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。Mermaid 保留既有的严格渲染器与不可执行图片展示
`CodeBlock.preview` 负责默认可视化、源码切换与原样复制。预览省略语言横幅,在悬停或键盘聚焦时显示紧凑的图标操作;具有任何触控输入的设备始终在图表下方显示操作。切换到源码会隐藏已挂载的预览,保留完成的渲染和待完成的工作。两种视图使用同一个保持焦点的切换按钮,复制始终读取源码属性
`SourcePreview` 负责加载、失败和取消过期结果发布。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。
Mermaid 按需加载。其公开渲染 API 串行执行图表工作,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、中性色主题和错误渲染策略。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,两种应用主题下的画布均保持浅色。
HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后作为图片放进 iframe,因此 SVG 脚本与链接不会激活。iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。
Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建来源记录标明 Graphviz 16.0.0EPL-2.0)、Expat 2.8.4MIT)与 Emscripten 5.0.7MIT/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 元数据策略。
## Alternatives considered
**在图表上方保留代码横幅。** 语言标签和常驻文字控件会分散对图表的注意力。浮层操作保留源码访问,不占用标题行。
**渲染每个流式片段。** 未完成的图表通常无效,反复布局会与文本流式输出争用资源。消息定稿可提供完整源码。
**在 Chat 内部渲染,或添加通用预览注册表。** 使用普通属性的共享基础组件即可满足复用,无需另加注册表或功能插件依赖。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。
**在 iframe 内执行 HTML 脚本。** 静态聊天示例不需要脚本执行。空 sandbox、清理与 CSP 降低预览权限,也避免引入 iframe 消息协议。
**把渲染标记插入 Chat。** HTML 样式和可执行 SVG 会与应用共享文档。iframe 隔离布局与来源,SVG 图片模式额外禁用 SVG 行为。
**把渲染标记插入 Chat。** 所需预览只需显示图表和访问源码;HTML 样式和可执行 SVG 会与应用共享文档。iframe 隔离布局与来源,SVG 图片模式额外禁用 SVG 行为。
**将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。
## Consequences
不改变 Session 事件、提示词或工具。HTML 内联样式可用,交互脚本、外部资源和链接不可用。Graphviz 增加按需加载的 WebAssembly 资源与浏览器线程同步布局;源码取消可以阻止结果发布,但无法抢占进行中的布局。Mermaid 既有布局限制仍记录在其所属决策中
该功能改变呈现,不改变持久化消息、provider 请求、工具或 Host API。HTML 内联样式可用,交互脚本、外部资源和链接不可用。Mermaid 和 Graphviz 增加按需加载的浏览器资源,并在浏览器线程上执行布局。取消无法抢占进行中的布局;即使结果无法发布,Mermaid 仍会完成并释放测量 DOM。预览不提供编辑、导出、缩放控件或交互式图表链接
组件测试覆盖源码复制、默认预览、回退、取消与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证不透明来源 iframe、真实图片解码、源码切换、脚本/导航/资源请求被阻止、本地化快照和已提供的许可证文本。声明检查固定已审查的包装层与原生构建来源,升级时须重新审查。
组件测试覆盖源码复制、默认预览、延迟完成、过期成功与失败、卸载、回退、取消、运行时加载失败与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证中文 Mermaid 流程图、时序图、无效源码、配置覆盖、不透明来源 iframe、真实图片解码、源码切换、脚本/导航/资源请求被阻止、中英文 UI 快照和已提供的许可证文本。声明检查固定已审查的包装层与原生构建来源,升级时须重新审查。
+2 -2
View File
@@ -44,8 +44,8 @@ export const en = {
'markdown.preview.loading': 'Rendering preview…',
'markdown.preview.error': 'Unable to render this preview. The source is shown below.',
'markdown.mermaid.diagram': 'Mermaid diagram',
'markdown.mermaid.preview': 'Preview',
'markdown.mermaid.source': 'Source',
'markdown.preview.preview': 'Preview',
'markdown.preview.source': 'Source',
'markdown.mermaid.loading': 'Rendering diagram…',
'markdown.mermaid.error': 'Unable to render this diagram. The source is shown below.',
'markdown.truncatedCharacters': '… truncated at {total} characters',
+2 -2
View File
@@ -42,8 +42,8 @@ export const zh = {
'markdown.preview.loading': '正在渲染预览…',
'markdown.preview.error': '无法渲染此预览,源码如下。',
'markdown.mermaid.diagram': 'Mermaid 图表',
'markdown.mermaid.preview': '预览',
'markdown.mermaid.source': '源码',
'markdown.preview.preview': '预览',
'markdown.preview.source': '源码',
'markdown.mermaid.loading': '正在渲染图表…',
'markdown.mermaid.error': '无法渲染此图表,源码如下。',
'markdown.truncatedCharacters': '… 已截断,共 {total} 字符',
@@ -9,24 +9,21 @@ import type { ChatViewSlotProps } from './contract/slots.ts'
* @returns Labels for code fences and footnotes.
*/
export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels {
const status = { loading: t('markdown.preview.loading'), error: t('markdown.preview.error') }
return {
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
footnotes: t('markdown.footnotes'),
preview: {
graphviz: t('markdown.preview.graphviz'),
svg: t('markdown.preview.svg'),
html: t('markdown.preview.html'),
preview: t('markdown.mermaid.preview'),
source: t('markdown.mermaid.source'),
loading: t('markdown.preview.loading'),
error: t('markdown.preview.error'),
},
mermaid: {
diagram: t('markdown.mermaid.diagram'),
preview: t('markdown.mermaid.preview'),
source: t('markdown.mermaid.source'),
loading: t('markdown.mermaid.loading'),
error: t('markdown.mermaid.error'),
preview: t('markdown.preview.preview'),
source: t('markdown.preview.source'),
mermaid: {
diagram: t('markdown.mermaid.diagram'),
loading: t('markdown.mermaid.loading'),
error: t('markdown.mermaid.error'),
},
graphviz: { ...status, diagram: t('markdown.preview.graphviz') },
svg: { ...status, diagram: t('markdown.preview.svg') },
html: { ...status, diagram: t('markdown.preview.html') },
},
}
}
@@ -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: a1e877c5249817c7ab2e9fa1f6a8ad9582c44a3a
README.zh.md: 81923da65cc21f794d2e7f5d822feb69c06f7499
README.md: a4fe046ba31212b2029e7689a542b55dd9eceb8b
README.zh.md: 59a3f0cae2b9253c303e0fb244e13c61e17d0417
+3 -4
View File
@@ -53,7 +53,6 @@ Check this table before writing a control in a feature package. A plugin cannot
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
| `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. |
| `MermaidPreview` | Read-only Mermaid diagram image with loading and source fallback states. |
| `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. |
| `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. |
@@ -74,11 +73,11 @@ The catalog above lists what each export is for; this section covers the behavio
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
`MermaidPreview` renders complete Mermaid source on a light canvas. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width.
Supply `MarkdownLabels.preview` to enable settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences. Each language supplies localized preview states, and consumers without these labels retain code. Mermaid renders complete source on a light canvas. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width.
Supply `MarkdownLabels.mermaid` to enable `mermaid` fence previews in a Markdown consumer; without it, fences remain code. Previews start after the message settles. `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. [The Mermaid decision](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md) records the rendering and reuse choices.
`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.
Supply `MarkdownLabels.preview` to enable `graphviz`/`dot`, `svg`, and `html` fences. 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 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.
[Preview license notices](THIRD_PARTY_PREVIEW_NOTICES.txt) ship with this package and the Web frontend. Mermaid is MIT; Viz.js is MIT but embeds Graphviz under EPL-2.0. The notices retain license texts and the exact Graphviz source download. Dependency upgrades require reviewing the embedded payload as well as npm metadata; the [preview decision](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md) records the distribution and sandbox choices.
+3 -4
View File
@@ -53,7 +53,6 @@ kind: "package-library"
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
| `JsonTree``JsonBlock` | 只读 JSON 查看。 |
| `MarkdownText``CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 |
| `MermaidPreview` | 只读 Mermaid 图表图片,包含加载状态与源码回退状态。 |
| `TerminalBlock``ReadBlock``DiffBlock``SearchBlock``WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
| `icons/*``FishLogo``BrandWordmark``ReferenceIcon``LinkIcon``DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 |
| `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 |
@@ -74,11 +73,11 @@ kind: "package-library"
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock``ReadBlock``DiffBlock``SearchBlock``WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree``JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
`MermaidPreview` 在浅色画布上渲染完整的 Mermaid 源码。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。
提供 `MarkdownLabels.preview` 可启用定稿后的 `mermaid``graphviz`/`dot``svg``html` fence。每种语言提供本地化预览状态,未提供这些文案的调用方保留代码显示。Mermaid 在浅色画布上渲染完整源码。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。
为 Markdown 调用方传入 `MarkdownLabels.mermaid` 即可启用 `mermaid` fence 预览;未传入时,fence 保持代码显示。预览在消息定稿后开始。`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。[Mermaid 决策](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md)记录了渲染与复用选择。
`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。
传入 `MarkdownLabels.preview` 可启用 `graphviz`/`dot``svg``html` fence。每个定稿 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 输出作为不可执行的图片放入不透明来源的 sandbox iframe;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。
[预览许可证声明](THIRD_PARTY_PREVIEW_NOTICES.txt)随本包与 Web 前端分发。Mermaid 使用 MITViz.js 使用 MIT,但内嵌 Graphviz 使用 EPL-2.0。声明保留完整许可证文本及准确的 Graphviz 源码下载地址。升级依赖时必须同时检查内嵌产物和 npm 元数据;[预览决策](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md)记录分发与 sandbox 选择。
@@ -64,8 +64,6 @@ export type {
} from './WebBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
export { MermaidPreview } from './markdown/MermaidPreview.tsx'
export type { MermaidPreviewLabels } from './markdown/MermaidPreview.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx'
@@ -98,31 +98,6 @@
color: var(--dsw-alias-label-primary);
}
.numbered :where(pre) code {
display: block;
counter-reset: source-line;
white-space: normal;
}
.numbered :where(pre) code > :global(.line) {
position: relative;
display: block;
min-height: 1lh;
padding-inline-start: calc(var(--dsl-code-block-line-number-width) + 12px);
counter-increment: source-line;
white-space: var(--dsl-code-block-line-white-space, pre-wrap);
}
.numbered :where(pre) code > :global(.line)::before {
position: absolute;
inset-inline-start: 0;
width: var(--dsl-code-block-line-number-width);
color: var(--dsw-alias-label-tertiary);
text-align: end;
content: counter(source-line);
user-select: none;
}
/* Alternate content is a figure; its actions overlay instead of taking a title row. */
.preview.showingPreview {
background: transparent;
@@ -205,3 +180,28 @@
padding-top: 16px;
}
}
.numbered :where(pre) code {
display: block;
counter-reset: source-line;
white-space: normal;
}
.numbered :where(pre) code > :global(.line) {
position: relative;
display: block;
min-height: 1lh;
padding-inline-start: calc(var(--dsl-code-block-line-number-width) + 12px);
counter-increment: source-line;
white-space: var(--dsl-code-block-line-white-space, pre-wrap);
}
.numbered :where(pre) code > :global(.line)::before {
position: absolute;
inset-inline-start: 0;
width: var(--dsl-code-block-line-number-width);
color: var(--dsw-alias-label-tertiary);
text-align: end;
content: counter(source-line);
user-select: none;
}
@@ -1,17 +0,0 @@
/** Mermaid image preview using the shared asynchronous source renderer. */
import { renderMermaid } from './mermaid.ts'
import { SourcePreview } from './SourcePreview.tsx'
import type { PreviewLabels } from './SourcePreview.tsx'
/** Localized Mermaid preview states; source remains verbatim. */
export type MermaidPreviewLabels = PreviewLabels
/**
* Display a complete Mermaid document as an inert SVG image.
* @param props - Source and complete localized labels.
* @returns A loading status, diagram image, or error with the original source.
*/
export function MermaidPreview({ code, labels }: { code: string; labels: MermaidPreviewLabels }) {
return <SourcePreview code={code} labels={labels} render={renderMermaid} />
}
@@ -1,7 +1,7 @@
/** Read-only diagram preview with source fallback and per-source async ownership. */
import { useEffect, useState } from 'react'
import css from './MermaidPreview.module.css'
import css from './SourcePreview.module.css'
/** Localized preview states; the diagram source remains verbatim. */
export interface PreviewLabels {
@@ -2,7 +2,7 @@
import type { Mermaid } from 'mermaid'
import clsx from 'clsx'
import css from './MermaidPreview.module.css'
import css from './SourcePreview.module.css'
let runtime: Promise<Mermaid> | undefined
let nextDiagramId = 0
@@ -26,8 +26,8 @@ import { CodeBlock } from './CodeBlock.tsx'
import { SourcePreview } from './SourcePreview.tsx'
import { renderGraphviz } from './graphviz.ts'
import { renderHtml, renderSvg } from './preview-document.ts'
import { MermaidPreview } from './MermaidPreview.tsx'
import type { MermaidPreviewLabels } from './MermaidPreview.tsx'
import { renderMermaid } from './mermaid.ts'
import type { PreviewLabels } from './SourcePreview.tsx'
import { renderTexToReact } from './katex.tsx'
import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
import type { PositionedBlock } from './incremental.ts'
@@ -45,17 +45,14 @@ export interface MarkdownCodeLabels {
export interface MarkdownLabels {
code: MarkdownCodeLabels
footnotes: string
/** Opt into settled Mermaid fence previews by supplying their complete localized labels. */
mermaid?: MermaidPreviewLabels & { preview: string; source: string }
/** Opt into settled Graphviz, SVG, and static HTML previews with complete localized labels. */
/** Opt into settled Mermaid, Graphviz, SVG, and static HTML previews with complete localized labels. */
preview?: {
graphviz: string
svg: string
html: string
mermaid: PreviewLabels
graphviz: PreviewLabels
svg: PreviewLabels
html: PreviewLabels
preview: string
source: string
loading: string
error: string
}
}
@@ -418,20 +415,13 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
/** Resolve the supported fence language without treating arbitrary HTML in Markdown as a preview. */
function fencePreview(lang: string | undefined, code: string, labels: MarkdownLabels) {
if (lang === 'mermaid' && labels.mermaid !== undefined) {
return {
content: <MermaidPreview code={code} labels={labels.mermaid} />,
previewLabel: labels.mermaid.preview,
sourceLabel: labels.mermaid.source,
}
}
if (labels.preview === undefined) return undefined
const kind = lang === 'dot' ? 'graphviz' : lang
if (kind !== 'graphviz' && kind !== 'svg' && kind !== 'html') return undefined
const render = { graphviz: renderGraphviz, svg: renderSvg, html: renderHtml }[kind]
if (kind !== 'mermaid' && kind !== 'graphviz' && kind !== 'svg' && kind !== 'html') return undefined
const render = { mermaid: renderMermaid, graphviz: renderGraphviz, svg: renderSvg, html: renderHtml }[kind]
return {
content: (
<SourcePreview key={kind} code={code} labels={{ ...labels.preview, diagram: labels.preview[kind] }} render={render} document />
<SourcePreview key={kind} code={code} labels={labels.preview[kind]} render={render} document={kind !== 'mermaid'} />
),
previewLabel: labels.preview.preview,
sourceLabel: labels.preview.source,
@@ -7,16 +7,20 @@ import { renderGraphviz } from '../src/markdown/graphviz.ts'
import { renderHtml, renderSvg } from '../src/markdown/preview-document.ts'
import { markdownLabels } from './labels.client.ts'
const status = { loading: 'Loading', error: 'Cannot preview' }
const preview = {
graphviz: 'Graphviz diagram', svg: 'SVG preview', html: 'HTML preview',
preview: 'Preview', source: 'Source', loading: 'Loading', error: 'Cannot preview',
mermaid: { ...status, diagram: 'Mermaid diagram' },
graphviz: { ...status, diagram: 'Graphviz diagram' },
svg: { ...status, diagram: 'SVG preview' },
html: { ...status, diagram: 'HTML preview' },
preview: 'Preview', source: 'Source',
}
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><text y="30">示例</text></svg>'
const cases = [
['graphviz', 'digraph { Input -> Preview }', preview.graphviz],
['dot', 'digraph { Input -> Preview }', preview.graphviz],
['svg', svg, preview.svg],
['html', '<style>h1 { color: green }</style><h1>Example</h1>', preview.html],
['graphviz', 'digraph { Input -> Preview }', preview.graphviz.diagram],
['dot', 'digraph { Input -> Preview }', preview.graphviz.diagram],
['svg', svg, preview.svg.diagram],
['html', '<style>h1 { color: green }</style><h1>Example</h1>', preview.html.diagram],
] as const
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
@@ -53,11 +57,11 @@ describe('Markdown fence previews', () => {
it.each(['svg', 'graphviz'])('retains invalid %s source and recovers after replacement', async (lang) => {
const view = render(<MarkdownText text={`\`\`\`${lang}\nbroken\n\`\`\``} labels={{ ...markdownLabels, preview }} />)
await screen.findByText(preview.error)
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)
expect(screen.queryByText(preview.error)).toBeNull()
await screen.findByTitle(preview.svg.diagram)
expect(screen.queryByText(status.error)).toBeNull()
})
it('requires an opted-in code fence and leaves raw HTML and other languages unrendered', () => {
@@ -2,7 +2,8 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MermaidPreview } from '../src/markdown/MermaidPreview.tsx'
import { SourcePreview } from '../src/markdown/SourcePreview.tsx'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { renderMermaid } from '../src/markdown/mermaid.ts'
import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
import { markdownLabels } from './labels.client.ts'
@@ -13,17 +14,21 @@ const labels = {
diagram: 'Mermaid diagram', loading: 'Rendering diagram…', error: 'Unable to render this diagram.',
preview: 'Preview', source: 'Source',
}
const preview = {
preview: labels.preview, source: labels.source,
mermaid: labels, graphviz: labels, svg: labels, html: labels,
}
const source = 'flowchart LR\n A[Input] --> B[Preview]'
const imageUrl = 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
beforeEach(() => { vi.resetAllMocks() })
describe('MermaidPreview', () => {
describe('SourcePreview', () => {
it('shows loading until rendering completes, then displays an image without inserting SVG', async () => {
const pending = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValue(pending.promise)
const view = render(<MermaidPreview code={source} labels={labels} />)
const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
expect(screen.getByRole('status').textContent).toBe(labels.loading)
expect(screen.queryByRole('img')).toBeNull()
await act(async () => { pending.resolve(imageUrl) })
@@ -34,12 +39,12 @@ describe('MermaidPreview', () => {
it('retains invalid source and recovers when the source changes', async () => {
vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('Parse error'))
const view = render(<MermaidPreview code="invalid" labels={labels} />)
const view = render(<SourcePreview render={renderMermaid} code="invalid" labels={labels} />)
expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status')
expect(view.container.querySelector('pre code')?.textContent).toBe('invalid')
const next = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValue(next.promise)
view.rerender(<MermaidPreview code={source} labels={labels} />)
view.rerender(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
expect(screen.getByRole('status').textContent).toBe(labels.loading)
expect(view.container.querySelector('pre')).toBeNull()
await act(async () => { next.resolve(imageUrl) })
@@ -50,9 +55,9 @@ describe('MermaidPreview', () => {
const old = Promise.withResolvers<string>()
const next = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise)
const view = render(<MermaidPreview code="old" labels={labels} />)
const view = render(<SourcePreview render={renderMermaid} code="old" labels={labels} />)
const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1]
view.rerender(<MermaidPreview code={source} labels={labels} />)
view.rerender(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
expect(oldSignal.aborted).toBe(true)
await act(async () => { next.resolve(imageUrl) })
await act(async () => {
@@ -66,7 +71,7 @@ describe('MermaidPreview', () => {
it('cancels the owner when unmounted while loading', async () => {
const pending = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValue(pending.promise)
const view = render(<MermaidPreview code={source} labels={labels} />)
const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
const signal = vi.mocked(renderMermaid).mock.calls[0]![1]
view.unmount()
expect(signal.aborted).toBe(true)
@@ -76,11 +81,29 @@ describe('MermaidPreview', () => {
})
describe('Markdown Mermaid fences', () => {
it('retains a preview when switching to numbered source', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const view = render(<CodeBlock code={source} lang="mermaid" lineNumbers {...markdownLabels.code}
preview={{
content: <SourcePreview render={renderMermaid} code={source} labels={labels} />,
previewLabel: labels.preview, sourceLabel: labels.source,
}} />)
const diagram = await screen.findByRole('img', { name: labels.diagram })
expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: labels.source }))
expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent)).toEqual(source.split('\n'))
expect(diagram.isConnected).toBe(true)
expect(screen.queryByRole('img')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: labels.preview }))
expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram)
expect(renderMermaid).toHaveBeenCalledOnce()
})
it('keeps streaming source literal, then offers preview, source and source copying', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } }))
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const props = { text: `\`\`\`mermaid\n${source}\n\`\`\``, labels: { ...markdownLabels, mermaid: labels } }
const props = { text: `\`\`\`mermaid\n${source}\n\`\`\``, labels: { ...markdownLabels, preview } }
const view = render(<MarkdownText {...props} streaming />)
expect(view.container.querySelector('pre code')?.textContent).toBe(source)
expect(renderMermaid).not.toHaveBeenCalled()
@@ -107,7 +130,7 @@ describe('Markdown Mermaid fences', () => {
it('leaves other languages and consumers without preview labels as code', () => {
const view = render(<MarkdownText text={`\`\`\`mermaid\n${source}\n\`\`\``} labels={markdownLabels} />)
expect(view.container.querySelector('pre code')?.textContent).toBe(source)
view.rerender(<MarkdownText text={'```text\nflowchart LR\n```'} labels={{ ...markdownLabels, mermaid: labels }} />)
view.rerender(<MarkdownText text={'```text\nflowchart LR\n```'} labels={{ ...markdownLabels, preview }} />)
expect(view.container.querySelector('pre code')?.textContent).toBe('flowchart LR')
expect(renderMermaid).not.toHaveBeenCalled()
})