feat(mermaid): Markdown 预览安全渲染 Mermaid 图表(strict 清洗 + 懒加载 chunk + 点击放大) (#164)

* feat(mermaid): 新增 mermaid 懒加载 chunk(构建链四处同步 + mermaid 依赖)

tsdown CHUNKS / bundle-route CHUNK_NAMES / chunk-loader ChunkName /
package.json files 同步注册 mermaid chunk;mermaid ^11.16.1 进依赖与
lockfile;chunk 构建注入 uuid 浏览器入口别名,绕开 node:crypto 纯度门。

* feat(mermaid): Markdown 预览安全渲染 mermaid 图表 + 点击放大弹窗

- splitMermaidBlocks 纯函数按 fence 切块,无 mermaid 文件零行为变化;
- mermaid.initialize 走 securityLevel strict + htmlLabels:false,不应用
  bindFunctions(静态图);渲染 SVG 经 mermaid-sanitize 二次清洗:
  删 foreignObject/script/外来 HTML 元素、剥 @*/on* 属性、移除全部
  href/xlink:href,仅接受 SVG 根,解析失败返回空串;
- 点击图表弹出放大弹窗(滚轮缩放/拖拽平移/快捷键,借鉴 PR #75 交互,
  基于已清洗 SVG 克隆重写,不新增攻击面);
- 深浅色跟随重渲、失败回退错误条 + 原码、CodeBlock 同款 chrome。

* test(mermaid): splitter/sanitize 单测 + chunk 清单同步 + 挂载冒烟 diagram.md

- mermaid-blocks.spec 10 例 fence 切块语义;
- mermaid-sanitize.spec 8 例 XSS(foreignObject/on*/@*/href/xlink:href/
  script/外来元素/畸形 XML/非 SVG 根);
- bundle-route / chunk-artifact / manifest-consistency 清单加入 mermaid
  (chunk-artifact 补 addEventListener stub);
- 挂载冒烟新增 diagram.md:client-mermaid.js 往返、SVG 节点文字、
  点击弹窗 Esc 关闭、preview 模式编辑器隐藏。

* docs(mermaid): 安全渲染设计文档 + lazy-chunks 表 + README 双语更新

设计文档记录基线决策(PR #99 架构 + PR #75 点击放大)、安全模型
(strict/htmlLabels/清洗/href 剥离/无 bindFunctions)、验证与取舍。

* fix(mermaid): sanitizer 属性名大小写归一,堵住混合大小写属性绕过(CR P1)

XML 属性名保留大小写,而输出会经 HTML 解析归一化(dangerouslySetInnerHTML),
oNload/HREF/xlink:HREF 这类混合大小写属性此前会漏过清洗。比较前先
toLowerCase(),按原名删除;新增混合大小写事件/链接属性回归用例。

* fix(mermaid): sanitizer 元素黑名单大小写归一,堵住混合大小写元素绕过(CR P1)

XML 元素名保留大小写,querySelectorAll('script') 等选择器匹配不到
<sCrIpT>/<foreignobject>,而输出经 HTML 解析会被归一化为 SVG
script/foreignObject(可执行/可注入 HTML)。改为遍历全部元素按
localName.toLowerCase() 命中小写黑名单后整节点删除;新增混合大小写
script/foreignObject/外来 HTML 元素回归用例(jsdom 实证:旧选择器
命中 0,新逻辑命中 2)。

* fix(mermaid): 单次 Markdown 解析 + 占位交换(CR P1)+ CommonMark fence 规则(CR P2)

P1(跨 fence 语义):弃用逐块 MarkdownText 拆分,改为整篇文档单次
MarkdownText 渲染,布局后把 language-mermaid 代码块交换为 MermaidDiagram
(React 管理的 .md-code-block 宿主保留在树中、display:contents 抑制盒子,
协调安全;块不再 mermaid 时还原原 children)。新增
tests/mermaid-markdown.spec.tsx 与 e2e 跨 fence 引用链接断言。

P2(fence 匹配):支持 3+ 反引号与波浪号开 fence;闭 fence 须同字符且
长度不小于开 fence;反引号 info string 含反引号不视为开 fence;修复
带缩进开 fence 的 info 提取。blocks 单测 +3 例。
This commit is contained in:
Atlas Zeng
2026-08-18 01:22:28 +08:00
committed by GitHub
parent 9bea6c5539
commit 5e416eccef
23 changed files with 2131 additions and 25 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['terminal', 'editor'] as const
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
export type ChunkName = (typeof CHUNK_NAMES)[number]
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
+35 -7
View File
@@ -13,7 +13,8 @@
* the FileViewerProps toolbar callbacks so the host's path-input header
* renders the controls instead.
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ComponentType } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
@@ -27,6 +28,8 @@ import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
import { t } from './locales.ts'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
@@ -41,6 +44,16 @@ interface SelectionPopup {
top: number
}
/**
* The chunk-resident markdown preview renderer (mermaid lazy chunk): one
* MarkdownText pass over the whole source, with rendered mermaid fences
* swapped for diagrams. Module-level `pick` keeps the load effect stable.
*/
const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
'mermaid',
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
)
/**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
@@ -237,6 +250,19 @@ export function TextEditor(props: FileViewerProps) {
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
/** The markdown source the preview renders (draft wins over saved content). */
const mdText = draft ?? content ?? ''
/** md/mermaid block split for the preview (mermaid fences lift out). Split
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
const mdBlocks = useMemo(
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
[markdown, mode, mdText],
)
const hasMermaid = useMemo(
() => mdBlocks.some(block => block.kind === 'mermaid'),
[mdBlocks],
)
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
/**
* Selection popup for the markdown preview: a mouse-up inside the preview
@@ -263,7 +289,7 @@ export function TextEditor(props: FileViewerProps) {
return
}
const rect = sel.getRangeAt(0).getBoundingClientRect()
const lines = linesOfSelection(draft ?? content ?? '', text)
const lines = linesOfSelection(mdText, text)
showPopup(
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
rect.left + rect.width / 2,
@@ -361,11 +387,13 @@ export function TextEditor(props: FileViewerProps) {
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
fall back to hardcoded Chinese otherwise (same pattern as the
chat's AssistantMarkdown). Render-time t() keeps them following
the active locale on live switches. */}
<MarkdownText
text={draft ?? content ?? ''}
codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}
/>
the active locale on live switches. Mermaid fences hand the
whole document to the mermaid lazy chunk (single markdown
parse; cross-fence references/footnotes stay intact); files
without one render exactly as before. */}
{hasMermaid
? <LazyMermaidMarkdown text={mdText} codeLabels={codeLabels} />
: <MarkdownText text={mdText} codeLabels={codeLabels} />}
</div>
)}
{html && mode === 'preview' && (
+1 -1
View File
@@ -44,7 +44,7 @@
* open. Chunk-only source edits still need a manual page refresh (the HMR
* poll watches only client.js).
*/
export type ChunkName = 'terminal' | 'editor'
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
/** The module exports a chunk factory provides (namespace-ish record). */
export type ChunkExports = Record<string, unknown>
+10
View File
@@ -0,0 +1,10 @@
/**
* Lazy chunk entry: the mermaid diagram renderer for the markdown preview
* (mermaid + its d3/dagre/cytoscape graph deps). Built as
* lib/client-mermaid.js and registered under the `mermaid` global chunk
* slot — fetched only when a previewed markdown file contains a mermaid
* fence (see chunk-loader.ts and the mermaid markdown preview design doc).
* Never import this module from the core bundle: it pulls mermaid into the
* startup path.
*/
export { MermaidMarkdown } from '../mermaid.tsx'
+10
View File
@@ -49,6 +49,11 @@ export const zh = {
terminalDepsProfile: '(检测到 profile:{profile})',
preview: '预览',
edit: '编辑',
mermaidError: 'Mermaid 渲染失败',
mermaidZoomIn: '放大',
mermaidZoomOut: '缩小',
mermaidZoomReset: '重置',
mermaidZoomHint: '滚轮缩放 · 拖拽平移 · Esc 关闭',
refresh: '刷新',
save: '保存',
saved: '已保存',
@@ -291,6 +296,11 @@ export const en: Record<keyof typeof zh, string> = {
terminalDepsProfile: ' (detected profile: {profile})',
preview: 'Preview',
edit: 'Edit',
mermaidError: 'Mermaid render failed',
mermaidZoomIn: 'Zoom in',
mermaidZoomOut: 'Zoom out',
mermaidZoomReset: 'Reset',
mermaidZoomHint: 'Scroll to zoom · drag to pan · Esc to close',
refresh: 'Refresh',
save: 'Save',
saved: 'Saved',
+110
View File
@@ -0,0 +1,110 @@
/**
* Markdown/mermaid fence detection for the markdown preview. The preview
* renders the WHOLE document through one DSH `MarkdownText` pass (so
* cross-fence semantics — reference-style links, footnotes, list
* continuity — stay intact) and the mermaid lazy chunk then swaps the
* rendered `language-mermaid` code blocks for diagrams. This module's pure
* splitter exists to detect whether the source contains a mermaid fence at
* all, so the mermaid chunk is only fetched when needed (unit-tested in
* tests/mermaid-blocks.spec.ts).
*/
/** One fenced mermaid diagram lifted out of the markdown source. */
export interface MermaidBlock {
kind: 'mermaid'
/** The raw diagram source between the fences (info string stripped). */
code: string
}
/** A span of plain markdown source (may itself contain non-mermaid fences). */
export interface MarkdownBlock {
kind: 'markdown'
text: string
}
export type MdBlock = MarkdownBlock | MermaidBlock
/** Props of the chunk-resident `MermaidMarkdown` component (shared contract). */
export interface MermaidMarkdownProps {
/** The full markdown source (rendered in a single MarkdownText pass). */
text: string
codeLabels: { copyLabel: string; copiedLabel: string }
}
/** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
/** A closing-fence line: 0-3 spaces indent + 3+ backticks/tildes + trailing spaces only. */
const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
/** Parse the info string from the line tail after the fence run; null when invalid. */
function fenceInfo(rest: string, fence: string): string | null {
const info = rest.trimStart().split(/\s+/)[0] ?? ''
// CommonMark: a backtick fence's info string may not contain backticks —
// such a line is not an opening fence at all.
if (fence.charAt(0) === '`' && info.includes('`')) return null
return info
}
/** True when the fence info string names mermaid (bare or `mermaid{...}`). */
function isMermaidInfo(info: string): boolean {
const word = info.toLowerCase()
return word === 'mermaid' || word.startsWith('mermaid{')
}
/**
* Split markdown source into md/mermaid blocks for detection: only fences
* whose info string names mermaid are lifted; every other line stays in the
* markdown stream untouched. CommonMark fence rules are honored — opening
* fences of 3+ backticks OR tildes, and a closing fence must use the same
* character with at least as many characters as the opening fence. An
* unterminated mermaid fence swallows the rest of the file (the same
* recovery CommonMark applies to open fences).
*/
export function splitMermaidBlocks(text: string): MdBlock[] {
if (text === '') return []
const lines = text.split('\n')
const blocks: MdBlock[] = []
let markdown: string[] = []
let index = 0
const flushMarkdown = (): void => {
if (markdown.length === 0) return
blocks.push({ kind: 'markdown', text: markdown.join('\n') })
markdown = []
}
while (index < lines.length) {
const line = lines[index] ?? ''
const fenceMatch = OPEN_FENCE_RE.exec(line)
if (fenceMatch === null) {
markdown.push(line)
index += 1
continue
}
const fence = fenceMatch[1]!
// The line tail after the whole matched prefix (indent + fence run).
const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
const info = fenceInfo(rest, fence)
if (info === null || !isMermaidInfo(info)) {
markdown.push(line)
index += 1
continue
}
flushMarkdown()
const char = fence.charAt(0)
const length = fence.length
const code: string[] = []
index += 1
while (index < lines.length) {
const candidate = lines[index] ?? ''
const close = CLOSE_FENCE_RE.exec(candidate)
if (close !== null && close[1]!.charAt(0) === char && close[1]!.length >= length) break
code.push(candidate)
index += 1
}
// Consume the closing fence (or run off the end on an open fence).
index += 1
blocks.push({ kind: 'mermaid', code: code.join('\n') })
}
flushMarkdown()
return blocks
}
+93
View File
@@ -0,0 +1,93 @@
/**
* SVG sanitization for mermaid-rendered diagrams (markdown preview). The
* diagrams come from untrusted markdown sources, so the emitted SVG is
* re-sanitized before it reaches dangerouslySetInnerHTML — defense in depth
* on top of mermaid's own `securityLevel: 'strict'` (labels escaped, click
* directives inert) and `htmlLabels: false` (labels as real SVG <text>).
*
* What is stripped, and why:
* - `foreignObject`: the only channel that can carry raw HTML inside an
* SVG document — with it gone, a hostile label cannot smuggle an
* <img onerror> / <iframe> through the XML parse (any non-SVG element
* outside foreignObject makes the XML parse fail and the whole diagram
* is rejected).
* - `script` and foreign HTML elements (`img`/`iframe`/`object`/`embed`/
* `video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`): belt and
* braces — real browsers reject these outside foreignObject at XML parse
* time (→ '') while lenient parsers keep them, so they are stripped
* explicitly instead of relying on the parser alone. Element names are
* matched case-insensitively (XML preserves their case; the output is
* re-parsed as HTML whose parser normalizes tag casing, so `<sCrIpT>` /
* `<foreignobject>` would otherwise come back as script/foreignObject).
* - `@*` / `on*` attributes: event-handler channels (Vue-style directives
* and native listeners), matched case-insensitively — the output is
* re-parsed as HTML whose parser normalizes attribute casing, so
* `oNload`/`OnClick` must not survive either.
* - ALL `href` / `xlink:href` attributes: the diagrams are static (no
* bindFunctions, no click navigation), so links carry no value — and an
* attacker-controlled URL (even an http(s) one) could otherwise navigate
* the whole GUI away from the sidebar. Removing every href keeps the
* surface deterministic.
*
* Parsing happens with `image/svg+xml`, so a malformed SVG fails the XML
* parse and the function returns '' (the caller surfaces the error
* fallback) instead of ever passing the raw string through; only a document
* whose root is an `<svg>` element is accepted.
*/
/** Element local names stripped case-insensitively (all lowercase). */
const STRIP_ELEMENTS = new Set([
'foreignobject',
'script',
'img',
'iframe',
'object',
'embed',
'video',
'audio',
'input',
'button',
'form',
'link',
'meta',
'base',
])
/** A parse failure keeps nothing of the input: the caller shows the error. */
export function sanitizeSvg(svg: string): string {
if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') return ''
let doc: Document
try {
doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
} catch {
return ''
}
if (doc.querySelector('parsererror') !== null) return ''
if (doc.documentElement === null || doc.documentElement.localName !== 'svg') return ''
doc.querySelectorAll('*').forEach((node) => {
// XML element names preserve case; the string is later re-parsed as HTML
// whose parser normalizes tag casing — so mixed-case spellings of the
// strip list (e.g. `<sCrIpT>`, `<foreignobject>`) must be caught the
// same way as their canonical forms.
if (STRIP_ELEMENTS.has(node.localName.toLowerCase())) {
node.remove()
return
}
for (const attribute of [...node.attributes]) {
const name = attribute.name
// XML attribute names preserve case; the string is later re-parsed as
// HTML (dangerouslySetInnerHTML), whose parser normalizes attribute
// casing — so a mixed-case `oNload`/`HREF` must be caught the same way
// as its lowercase form. Compare on the lowercased name, remove with
// the original.
const normalized = name.toLowerCase()
if (normalized.startsWith('@') || normalized.startsWith('on')) {
node.removeAttribute(name)
continue
}
if (normalized === 'href' || normalized === 'xlink:href') {
node.removeAttribute(name)
}
}
})
return new XMLSerializer().serializeToString(doc.documentElement)
}
+401
View File
@@ -0,0 +1,401 @@
/**
* Mermaid diagram renderer for the markdown preview, resident in the
* `mermaid` lazy chunk (src/client/chunks/mermaid.tsx): the mermaid library
* and its transitive graph deps (d3/dagre/cytoscape) are inlined into
* lib/client-mermaid.js and fetched only when a previewed markdown file
* actually contains a mermaid fence (see mermaid-blocks.ts).
*
* Rendering architecture: the whole document is rendered ONCE through the
* DSH `MarkdownText` (so cross-fence semantics — reference-style links,
* footnotes, ordered-list continuity — stay intact), then a layout effect
* swaps every rendered `language-mermaid` CodeBlock for a diagram. The
* swap keeps the React-managed `.md-code-block` host node in the tree and
* only replaces its children (display:contents suppresses the code-block
* chrome), so React reconciliation never loses the host; when a swapped
* block stops being a mermaid fence, the original children are restored.
*
* Security: mermaid.render → sanitized SVG injected into the block.
* `bindFunctions` is intentionally NOT applied (static diagrams; no
* flowchart click handlers), `securityLevel` stays 'strict' (labels are
* escaped, no raw-HTML foreignObject), `htmlLabels: false` keeps node text
* as real SVG <text>, and the emitted SVG is re-sanitized (see
* mermaid-sanitize.ts) before it reaches dangerouslySetInnerHTML. Clicking
* a rendered diagram opens a zoom/pan modal (borrowed interaction design
* from the mermaid PR #75, reimplemented on top of the sanitized SVG —
* the clone carries no event surface, so the modal adds no attack surface).
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import { createPortal } from 'react-dom'
import { createRoot, type Root } from 'react-dom/client'
import mermaid from 'mermaid'
import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { t } from './locales.ts'
import { sanitizeSvg } from './mermaid-sanitize.ts'
import type { MermaidMarkdownProps } from './mermaid-blocks.ts'
import css from './sidebar.module.css'
/** Monotonic id seed: every render call gets a fresh, document-unique id. */
let mermaidSeq = 0
/** Configure mermaid for the current color scheme (idempotent). */
function configureMermaid(): void {
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
// Labels as real SVG <text>: mermaid's default htmlLabels renders node
// text inside <foreignObject>, which the sanitizer strips wholesale —
// forcing pure SVG text keeps labels visible and the HTML label channel
// closed (strict already escapes label content).
htmlLabels: false,
theme: isDarkScheme() ? 'dark' : 'default',
})
}
/** First lines of a mermaid error (its dumps are huge; the head explains). */
function summarizeError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return message.split('\n').slice(0, 6).join('\n')
}
/** The zoom/pan modal for one rendered diagram (click-to-enlarge). */
function MermaidZoomModal({ svg, onClose }: { svg: SVGSVGElement; onClose: () => void }): React.ReactNode {
const overlayRef = useRef<HTMLDivElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement | null>(null)
const dragRef = useRef({ active: false, startX: 0, startY: 0 })
const zoomRef = useRef({ scale: 1, tx: 0, ty: 0 })
const applyTransform = (): void => {
const node = svgRef.current
if (node === null) return
const { scale, tx, ty } = zoomRef.current
node.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
}
/** Zoom by `delta` keeping the stage point (centerX/centerY) fixed. */
const zoom = useCallback((delta: number, centerX?: number, centerY?: number): void => {
const stage = stageRef.current
if (stage === null) return
const rect = stage.getBoundingClientRect()
const cx = centerX ?? rect.width / 2
const cy = centerY ?? rect.height / 2
const current = zoomRef.current
const newScale = Math.min(8, Math.max(0.2, current.scale * delta))
// The svg is flex-centered in the stage, so its center sits at
// (rect.width/2, rect.height/2). Solve for the translate that keeps the
// mouse point stationary in stage coordinates across the scale change.
const sx = rect.width / 2
const sy = rect.height / 2
const ratio = newScale / current.scale
current.tx = cx - sx - (cx - sx - current.tx) * ratio
current.ty = cy - sy - (cy - sy - current.ty) * ratio
current.scale = newScale
applyTransform()
}, [])
const reset = useCallback((): void => {
zoomRef.current = { scale: 1, tx: 0, ty: 0 }
applyTransform()
}, [])
const close = useCallback((): void => { onClose() }, [onClose])
// Mount the caller-provided (sanitized) svg clone imperatively: React
// types don't accept a raw DOM node as a child, and the modal owns the
// node's lifetime (removed on unmount; the preview copy is untouched).
useEffect(() => {
const stage = stageRef.current
if (stage === null) return
svgRef.current = svg
stage.appendChild(svg)
return () => {
svg.remove()
svgRef.current = null
}
}, [svg])
useEffect(() => {
const stage = stageRef.current
const node = svgRef.current
const overlay = overlayRef.current
if (stage === null || node === null || overlay === null) return
const onWheel = (event: WheelEvent): void => {
event.preventDefault()
const rect = stage.getBoundingClientRect()
zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX - rect.left, event.clientY - rect.top)
}
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') { close(); return }
if (event.key === '+' || event.key === '=') zoom(1.2)
else if (event.key === '-') zoom(1 / 1.2)
else if (event.key === '0') reset()
}
const onMouseDown = (event: MouseEvent): void => {
event.preventDefault()
dragRef.current = {
active: true,
startX: event.clientX - zoomRef.current.tx,
startY: event.clientY - zoomRef.current.ty,
}
}
const onMouseMove = (event: MouseEvent): void => {
if (!dragRef.current.active) return
zoomRef.current.tx = event.clientX - dragRef.current.startX
zoomRef.current.ty = event.clientY - dragRef.current.startY
applyTransform()
}
const onMouseUp = (): void => { dragRef.current.active = false }
const onOverlayClick = (event: MouseEvent): void => {
if (event.target === overlay) close()
}
// React's synthetic wheel is passive; a native listener is required to
// preventDefault (the page must not scroll while zooming the modal).
stage.addEventListener('wheel', onWheel, { passive: false })
node.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('keydown', onKey)
overlay.addEventListener('click', onOverlayClick)
return () => {
stage.removeEventListener('wheel', onWheel)
node.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('keydown', onKey)
overlay.removeEventListener('click', onOverlayClick)
}
}, [zoom, reset, close])
return createPortal(
<div className={css.mermaidModal} data-mermaid-modal ref={overlayRef}>
<div className={css.mermaidModalToolbar}>
<button
type="button"
className={css.mermaidModalButton}
title={t('mermaidZoomOut')}
onClick={() => zoom(1 / 1.2)}
>
−
</button>
<button
type="button"
className={css.mermaidModalButton}
title={t('mermaidZoomIn')}
onClick={() => zoom(1.2)}
>
+
</button>
<button
type="button"
className={css.mermaidModalButton}
title={t('mermaidZoomReset')}
onClick={reset}
>
⟳
</button>
<button
type="button"
className={css.mermaidModalButton}
title={t('close')}
onClick={close}
>
✕
</button>
</div>
<div className={css.mermaidModalStage} ref={stageRef} />
<div className={css.mermaidModalHint}>{t('mermaidZoomHint')}</div>
</div>,
document.body,
)
}
/** One rendered mermaid fence: header chrome + diagram (or error + source). */
function MermaidDiagram({ code }: { code: string }): React.ReactNode {
const [svg, setSvg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
/** Scheme state: a flip re-renders the diagram with the matching theme. */
const [dark, setDark] = useState(() => isDarkScheme())
/** The cloned svg shown in the zoom modal (null = closed). */
const [zoomSvg, setZoomSvg] = useState<SVGSVGElement | null>(null)
const copyTimer = useRef<number | undefined>(undefined)
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
useEffect(() => {
let cancelled = false
setSvg(null)
setError(null)
if (code.trim() === '') return () => { cancelled = true }
configureMermaid()
const id = `dsh-md-mermaid-${mermaidSeq += 1}`
mermaid.render(id, code)
.then(({ svg: rendered }) => {
if (cancelled) return
const clean = sanitizeSvg(rendered)
if (clean === '') {
// Parse/sanitize rejection: never pass the raw string through.
setError(t('mermaidError'))
return
}
setSvg(clean)
})
.catch((reason: unknown) => {
if (cancelled) return
setError(summarizeError(reason))
})
return () => { cancelled = true }
}, [code, dark])
const onCopy = useCallback(() => {
if (copied) return
writeClipboard(code).then((ok) => {
if (!ok) return
setCopied(true)
window.clearTimeout(copyTimer.current)
copyTimer.current = window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [code, copied])
/** Clicking the diagram opens the zoom modal with a sanitized clone. */
const onBodyClick = (event: ReactMouseEvent<HTMLDivElement>): void => {
const svgEl = (event.target as Element).closest('svg')
if (svgEl === null) return
const clone = svgEl.cloneNode(true) as SVGSVGElement
// The modal owns sizing/transform; drop the preview's inline geometry.
clone.removeAttribute('style')
clone.removeAttribute('width')
clone.removeAttribute('height')
setZoomSvg(clone)
}
return (
<div className={css.mermaidWrap}>
<div className={css.mermaidHeader}>
<span className={css.mermaidInfo}>mermaid</span>
<button
type="button"
className={css.mermaidCopy}
onClick={onCopy}
aria-label={t('copy')}
title={t('copy')}
>
<IconCopyOutline16 />
<span>{copied ? t('copied') : t('copy')}</span>
</button>
</div>
{error !== null && <div className={css.mermaidError} title={error}>{t('mermaidError')}</div>}
{svg !== null && (
<div
className={css.mermaidBody}
data-mermaid-diagram
onClick={onBodyClick}
dangerouslySetInnerHTML={{ __html: svg }}
/>
)}
{error !== null && <pre className={css.mermaidCode}><code>{code}</code></pre>}
{zoomSvg !== null && <MermaidZoomModal svg={zoomSvg} onClose={() => { setZoomSvg(null) }} />}
</div>
)
}
/** One swapped mount: the diagram root + the CodeBlock children it displaced. */
interface MermaidMount {
root: Root
source: string
removed: ChildNode[]
}
/**
* True when a rendered CodeBlock is a mermaid fence. The DSH CodeBlock has
* two bodies: the shiki path carries the `language-*` class on generated
* <code> elements, while the plain path (which is always the one mermaid
* takes — no shiki grammar) only shows the language in the banner
* infostring (first element of the banner row; CSS-module classes are
* hashed, so that match is structural).
*/
function isMermaidBlock(block: HTMLElement): boolean {
const code = block.querySelector('code')
if (code !== null && [...code.classList].some(c => c.startsWith('language-mermaid'))) return true
const infostring = block.firstElementChild?.firstElementChild?.firstElementChild
return infostring !== null
&& infostring !== undefined
&& (infostring.textContent ?? '').trim() === 'mermaid'
}
/**
* The chunk-resident markdown preview renderer: ONE MarkdownText pass over
* the full source (cross-fence reference/footnote/list semantics intact),
* then every rendered `language-mermaid` code block is swapped for a
* `MermaidDiagram`. The `.md-code-block` host stays in the React tree —
* only its children are replaced — so reconciliation never loses the host;
* a block that stops being a mermaid fence gets its original children back.
* Only mounted when the source contains at least one mermaid fence (see
* TextEditor.tsx).
*/
export function MermaidMarkdown({ text, codeLabels }: MermaidMarkdownProps): React.ReactNode {
const containerRef = useRef<HTMLDivElement>(null)
const mountsRef = useRef(new Map<HTMLElement, MermaidMount>())
useLayoutEffect(() => {
const container = containerRef.current
if (container === null) return
const mounts = mountsRef.current
const seen = new Set<HTMLElement>()
for (const block of container.querySelectorAll<HTMLElement>('.md-code-block')) {
const mount = mounts.get(block)
const isMermaid = isMermaidBlock(block)
if (!isMermaid) {
// A previously swapped block that is no longer a mermaid fence:
// restore the CodeBlock children React still manages, so the plain
// fence renders normally again.
if (mount !== undefined) {
mount.root.unmount()
block.replaceChildren(...mount.removed)
block.removeAttribute('data-mermaid-processed')
mounts.delete(block)
}
continue
}
seen.add(block)
// The plain body always carries the fence source in <code>.
const source = block.querySelector('code')?.textContent ?? ''
if (mount !== undefined && mount.source === source) continue
if (mount === undefined) {
const host = document.createElement('div')
const removed = [...block.childNodes]
block.replaceChildren(host)
block.setAttribute('data-mermaid-processed', 'true')
const root = createRoot(host)
mounts.set(block, { root, source, removed })
root.render(<MermaidDiagram code={source} />)
} else {
mount.source = source
mount.root.render(<MermaidDiagram code={source} />)
}
}
// Drop mounts whose code block left the tree (fence removed, document
// restructured): the host node is gone with it.
for (const [block, mount] of mounts) {
if (seen.has(block)) continue
mount.root.unmount()
mounts.delete(block)
}
}, [text])
useEffect(() => () => {
for (const { root } of mountsRef.current.values()) root.unmount()
mountsRef.current.clear()
}, [])
return (
<div className={css.mermaidMarkdown} ref={containerRef}>
<MarkdownText text={text} codeLabels={codeLabels} />
</div>
)
}
+168
View File
@@ -1290,6 +1290,174 @@
font: var(--dsw-font-xs-13);
}
/* ── Mermaid diagram block (markdown preview, mermaid lazy chunk) ────────
Mirrors the md-code-block chrome the DSH CodeBlock renders, so a diagram
reads as a peer of the surrounding fences: hairline border, layer-2
header with the info string + copy action, layer-1 body. The SVG body is
static (no bindFunctions) and sanitized (no foreignObject/script/@attrs/
hrefs), so it carries no event surface beyond the copy button; the body
click opens the zoom modal on a sanitized clone. */
.mermaidWrap {
margin: 6px 0;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
overflow: hidden;
}
.mermaidHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-2);
}
.mermaidInfo {
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
}
.mermaidCopy {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 6px;
height: 20px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-11);
cursor: pointer;
}
.mermaidCopy:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.mermaidBody {
display: flex;
justify-content: center;
padding: 10px;
overflow: auto;
cursor: zoom-in;
}
.mermaidBody :global(svg) {
max-width: 100%;
height: auto;
}
.mermaidError {
padding: 6px 10px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-state-error-primary);
font: var(--dsw-font-xxxs-11);
}
/* The diagram source shown under a render error (helps fixing the fence). */
.mermaidCode {
margin: 0;
padding: 8px 10px;
overflow: auto;
font: var(--dsw-font-xxxs-11);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* The mermaid markdown container (single MarkdownText pass): a swapped
code block keeps its React-managed .md-code-block wrapper in the tree
(reconciliation safety), but the wrapper's own box is suppressed so only
the diagram chrome renders; the swapped-in host div flows as a block. */
.mermaidMarkdown :global(.md-code-block[data-mermaid-processed]) {
display: contents;
}
/* ── Mermaid zoom modal (click-to-enlarge; portalled to document.body) ──
Fixed overlay above the DSH overlay stack (1000+), token-driven surface.
The stage centers the (sanitized, cloned) svg and the modal owns the
transform; the drag/zoom listeners live in the component, nothing here
targets global class names. */
.mermaidModal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: blur(2px);
}
.mermaidModalToolbar {
position: absolute;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
z-index: 10;
}
.mermaidModalButton {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xs-strong-13);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mermaidModalButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.mermaidModalStage {
width: 90vw;
height: 80vh;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.mermaidModalStage :global(svg) {
max-width: none;
max-height: none;
cursor: grab;
transform-origin: center center;
user-select: none;
-webkit-user-drag: none;
background: var(--dsw-alias-bg-layer-1);
padding: 16px;
border-radius: 12px;
}
.mermaidModalStage :global(svg):active {
cursor: grabbing;
}
.mermaidModalHint {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
pointer-events: none;
}
/* The floating "add to conversation" button anchored above a text
selection in the preview/code viewers. Portalled to document.body and
position:fixed, so the editor's overflow clips cannot crop it; flat