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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
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
data-source="plantuml"
@ -15,7 +21,7 @@ exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
data-testid="preview-content"
>
<div
class="plantuml-preview special-preview"
class="c0 plantuml-preview special-preview"
/>
</div>
</div>

View File

@ -1,6 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
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
data-source="svg"
@ -15,7 +21,7 @@ exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
data-testid="preview-content"
>
<div
class="svg-preview special-preview"
class="c0 svg-preview special-preview"
/>
</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')
style.textContent = `
: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;
background-color: white;
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
border: 0.5px solid var(--color-code-background);
border-radius: 8px;
display: block;
position: relative;
width: 100%;