refactor(Mermaid): render mermaid in shadow dom (#9187)

* refactor(Mermaid): render mermaid in shadow dom

* refactor: pass style overrides to renderSvgInShadowHost

* refactor(MermaidPreview): separate measurement from rendering

* refactor: rename hostCss to customCss

* refactor: use custom properties in shadow host

* test: update snapshots

* fix: remove svg max-width

* refactor: add viewBox to svg (experimental)

* Revert "refactor: add viewBox to svg (experimental)"

This reverts commit 8a265fa8a4.
This commit is contained in:
one 2025-08-17 13:22:59 +08:00 committed by GitHub
parent 535dcf4778
commit ded941b7b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 84 additions and 44 deletions

View File

@ -1,9 +1,9 @@
import { AsyncInitializer } from '@renderer/utils/asyncInitializer' import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
import React, { memo, useCallback } from 'react' import React, { memo, useCallback } from 'react'
import styled from 'styled-components'
import { useDebouncedRender } from './hooks/useDebouncedRender' import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout' import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowWhiteContainer } from './styles'
import { BasicPreviewHandles, BasicPreviewProps } from './types' import { BasicPreviewHandles, BasicPreviewProps } from './types'
import { renderSvgInShadowHost } from './utils' import { renderSvgInShadowHost } from './utils'
@ -13,8 +13,10 @@ const vizInitializer = new AsyncInitializer(async () => {
return await module.instance() return await module.instance()
}) })
/** Graphviz /**
* 使 usePreviewRenderer hook * Graphviz
* - 使 useDebouncedRender
* - 使 shadow dom SVG
*/ */
const GraphvizPreview = ({ const GraphvizPreview = ({
children, children,
@ -41,16 +43,9 @@ const GraphvizPreview = ({
ref={ref} ref={ref}
imageRef={containerRef} imageRef={containerRef}
source="graphviz"> source="graphviz">
<StyledGraphviz ref={containerRef} className="graphviz special-preview" /> <ShadowWhiteContainer ref={containerRef} className="graphviz special-preview" />
</ImagePreviewLayout> </ImagePreviewLayout>
) )
} }
const StyledGraphviz = styled.div`
overflow: auto;
position: relative;
width: 100%;
height: 100%;
`
export default memo(GraphvizPreview) export default memo(GraphvizPreview)

View File

@ -1,15 +1,17 @@
import { nanoid } from '@reduxjs/toolkit' import { nanoid } from '@reduxjs/toolkit'
import { useMermaid } from '@renderer/hooks/useMermaid' import { useMermaid } from '@renderer/hooks/useMermaid'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react' import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import { useDebouncedRender } from './hooks/useDebouncedRender' import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout' import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowTransparentContainer } from './styles'
import { BasicPreviewHandles, BasicPreviewProps } from './types' import { BasicPreviewHandles, BasicPreviewProps } from './types'
import { renderSvgInShadowHost } from './utils'
/** Mermaid /**
* 使 usePreviewRenderer hook * Mermaid
* FIXME: 等将来 mermaid-js * - 使 useDebouncedRender
* - 使 shadow dom SVG
*/ */
const MermaidPreview = ({ const MermaidPreview = ({
children, children,
@ -20,17 +22,39 @@ const MermaidPreview = ({
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
const [isVisible, setIsVisible] = useState(true) const [isVisible, setIsVisible] = useState(true)
// 定义渲染函数 /**
* shadow dom
* 退 innerHTML
*/
const renderMermaid = useCallback( const renderMermaid = useCallback(
async (content: string, container: HTMLDivElement) => { async (content: string, container: HTMLDivElement) => {
// 验证语法,提前抛出异常 // 验证语法,提前抛出异常
await mermaid.parse(content) await mermaid.parse(content)
const { svg } = await mermaid.render(diagramId, content, container) // 获取容器宽度
const { width } = container.getBoundingClientRect()
if (width === 0) return
// 避免不可见时产生 undefined 和 NaN // 创建临时的 div 用于 mermaid 测量
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)') const measureEl = document.createElement('div')
container.innerHTML = fixedSvg measureEl.style.position = 'absolute'
measureEl.style.left = '-9999px'
measureEl.style.top = '-9999px'
measureEl.style.width = `${width}px`
document.body.appendChild(measureEl)
try {
const { svg } = await mermaid.render(diagramId, content, measureEl)
// 避免不可见时产生 undefined 和 NaN
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
// 有问题可以回退到 innerHTML
renderSvgInShadowHost(fixedSvg, container)
// container.innerHTML = fixedSvg
} finally {
document.body.removeChild(measureEl)
}
}, },
[diagramId, mermaid] [diagramId, mermaid]
) )
@ -63,7 +87,7 @@ const MermaidPreview = ({
const element = containerRef.current const element = containerRef.current
if (!element) return if (!element) return
const currentlyVisible = element.offsetParent !== null const currentlyVisible = element.offsetParent !== null && element.offsetWidth > 0 && element.offsetHeight > 0
setIsVisible(currentlyVisible) setIsVisible(currentlyVisible)
} }
@ -105,16 +129,9 @@ const MermaidPreview = ({
ref={ref} ref={ref}
imageRef={containerRef} imageRef={containerRef}
source="mermaid"> source="mermaid">
<StyledMermaid ref={containerRef} className="mermaid special-preview" /> <ShadowTransparentContainer ref={containerRef} className="mermaid special-preview" />
</ImagePreviewLayout> </ImagePreviewLayout>
) )
} }
const StyledMermaid = styled.div`
overflow: auto;
position: relative;
width: 100%;
height: 100%;
`
export default memo(MermaidPreview) export default memo(MermaidPreview)

View File

@ -4,6 +4,7 @@ import React, { memo, useCallback, useEffect } from 'react'
import { useDebouncedRender } from './hooks/useDebouncedRender' import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout' import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowWhiteContainer } from './styles'
import { BasicPreviewHandles, BasicPreviewProps } from './types' import { BasicPreviewHandles, BasicPreviewProps } from './types'
import { renderSvgInShadowHost } from './utils' import { renderSvgInShadowHost } from './utils'
@ -128,7 +129,7 @@ const PlantUmlPreview = ({
ref={ref} ref={ref}
imageRef={containerRef} imageRef={containerRef}
source="plantuml"> source="plantuml">
<div ref={containerRef} className="plantuml-preview special-preview" /> <ShadowWhiteContainer ref={containerRef} className="plantuml-preview special-preview" />
</ImagePreviewLayout> </ImagePreviewLayout>
) )
} }

View File

@ -2,6 +2,7 @@ import { memo, useCallback } from 'react'
import { useDebouncedRender } from './hooks/useDebouncedRender' import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout' import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowWhiteContainer } from './styles'
import { BasicPreviewHandles } from './types' import { BasicPreviewHandles } from './types'
import { renderSvgInShadowHost } from './utils' import { renderSvgInShadowHost } from './utils'
@ -34,7 +35,7 @@ const SvgPreview = ({ children, enableToolbar = false, className, ref }: SvgPrev
ref={ref} ref={ref}
imageRef={containerRef} imageRef={containerRef}
source="svg"> source="svg">
<div ref={containerRef} className={className ?? 'svg-preview special-preview'}></div> <ShadowWhiteContainer ref={containerRef} className={className ?? 'svg-preview special-preview'} />
</ImagePreviewLayout> </ImagePreviewLayout>
) )
} }

View File

@ -2,10 +2,9 @@
exports[`GraphvizPreview > basic rendering > should match snapshot 1`] = ` exports[`GraphvizPreview > basic rendering > should match snapshot 1`] = `
.c0 { .c0 {
overflow: auto; --shadow-host-background-color: white;
position: relative; --shadow-host-border: 0.5px solid var(--color-code-background);
width: 100%; --shadow-host-border-radius: 8px;
height: 100%;
} }
<div> <div>

View File

@ -2,10 +2,9 @@
exports[`MermaidPreview > basic rendering > should match snapshot 1`] = ` exports[`MermaidPreview > basic rendering > should match snapshot 1`] = `
.c0 { .c0 {
overflow: auto; --shadow-host-background-color: transparent;
position: relative; --shadow-host-border: unset;
width: 100%; --shadow-host-border-radius: unset;
height: 100%;
} }
<div> <div>

View File

@ -1,6 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = ` exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
.c0 {
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
}
<div> <div>
<div <div
data-source="plantuml" data-source="plantuml"
@ -15,7 +21,7 @@ exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
data-testid="preview-content" data-testid="preview-content"
> >
<div <div
class="plantuml-preview special-preview" class="c0 plantuml-preview special-preview"
/> />
</div> </div>
</div> </div>

View File

@ -1,6 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SvgPreview > basic rendering > should match snapshot 1`] = ` exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
.c0 {
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
}
<div> <div>
<div <div
data-source="svg" data-source="svg"
@ -15,7 +21,7 @@ exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
data-testid="preview-content" data-testid="preview-content"
> >
<div <div
class="svg-preview special-preview" class="c0 svg-preview special-preview"
/> />
</div> </div>
</div> </div>

View File

@ -33,3 +33,15 @@ export const PreviewContainer = styled(Flex).attrs({ role: 'alert' })`
} }
} }
` `
export const ShadowWhiteContainer = styled.div`
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
`
export const ShadowTransparentContainer = styled.div`
--shadow-host-background-color: transparent;
--shadow-host-border: unset;
--shadow-host-border-radius: unset;
`

View File

@ -28,11 +28,15 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
const style = document.createElement('style') const style = document.createElement('style')
style.textContent = ` style.textContent = `
:host { :host {
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
background-color: var(--shadow-host-background-color);
border: var(--shadow-host-border);
border-radius: var(--shadow-host-border-radius);
padding: 1em; padding: 1em;
background-color: white;
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */ overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
border: 0.5px solid var(--color-code-background);
border-radius: 8px;
display: block; display: block;
position: relative; position: relative;
width: 100%; width: 100%;