From ded941b7b92e1266084dbaec01facb465768a905 Mon Sep 17 00:00:00 2001 From: one Date: Sun, 17 Aug 2025 13:22:59 +0800 Subject: [PATCH] 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 8a265fa8a48d34b098fc050c516686ae5029a4e9. --- .../components/Preview/GraphvizPreview.tsx | 17 +++--- .../src/components/Preview/MermaidPreview.tsx | 53 ++++++++++++------- .../components/Preview/PlantUmlPreview.tsx | 3 +- .../src/components/Preview/SvgPreview.tsx | 3 +- .../GraphvizPreview.test.tsx.snap | 7 ++- .../MermaidPreview.test.tsx.snap | 7 ++- .../PlantUmlPreview.test.tsx.snap | 8 ++- .../__snapshots__/SvgPreview.test.tsx.snap | 8 ++- src/renderer/src/components/Preview/styles.ts | 12 +++++ src/renderer/src/components/Preview/utils.ts | 10 ++-- 10 files changed, 84 insertions(+), 44 deletions(-) diff --git a/src/renderer/src/components/Preview/GraphvizPreview.tsx b/src/renderer/src/components/Preview/GraphvizPreview.tsx index c3c5c641a2..578a35450b 100644 --- a/src/renderer/src/components/Preview/GraphvizPreview.tsx +++ b/src/renderer/src/components/Preview/GraphvizPreview.tsx @@ -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"> - + ) } -const StyledGraphviz = styled.div` - overflow: auto; - position: relative; - width: 100%; - height: 100%; -` - export default memo(GraphvizPreview) diff --git a/src/renderer/src/components/Preview/MermaidPreview.tsx b/src/renderer/src/components/Preview/MermaidPreview.tsx index b4bd1b148e..86e0339c2f 100644 --- a/src/renderer/src/components/Preview/MermaidPreview.tsx +++ b/src/renderer/src/components/Preview/MermaidPreview.tsx @@ -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(`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"> - + ) } -const StyledMermaid = styled.div` - overflow: auto; - position: relative; - width: 100%; - height: 100%; -` - export default memo(MermaidPreview) diff --git a/src/renderer/src/components/Preview/PlantUmlPreview.tsx b/src/renderer/src/components/Preview/PlantUmlPreview.tsx index b94b87e187..f29a0c1ffc 100644 --- a/src/renderer/src/components/Preview/PlantUmlPreview.tsx +++ b/src/renderer/src/components/Preview/PlantUmlPreview.tsx @@ -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"> -
+ ) } diff --git a/src/renderer/src/components/Preview/SvgPreview.tsx b/src/renderer/src/components/Preview/SvgPreview.tsx index d9a4689fcd..2122ebe091 100644 --- a/src/renderer/src/components/Preview/SvgPreview.tsx +++ b/src/renderer/src/components/Preview/SvgPreview.tsx @@ -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"> -
+ ) } diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap index 923f35b9e7..5ac34bfadd 100644 --- a/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap +++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap @@ -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; }
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap index 01e98a63cd..1f87a151a8 100644 --- a/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap +++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap @@ -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; }
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap index 34d7840cb4..eb398f4cf5 100644 --- a/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap +++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap @@ -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; +} +
basic rendering > should match snapshot 1`] = ` data-testid="preview-content" >
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap index 00ee51b0e6..b96c8def5d 100644 --- a/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap +++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap @@ -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; +} +
basic rendering > should match snapshot 1`] = ` data-testid="preview-content" >
diff --git a/src/renderer/src/components/Preview/styles.ts b/src/renderer/src/components/Preview/styles.ts index aa8718b34e..521b96e225 100644 --- a/src/renderer/src/components/Preview/styles.ts +++ b/src/renderer/src/components/Preview/styles.ts @@ -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; +` diff --git a/src/renderer/src/components/Preview/utils.ts b/src/renderer/src/components/Preview/utils.ts index e400062911..476a3b913a 100644 --- a/src/renderer/src/components/Preview/utils.ts +++ b/src/renderer/src/components/Preview/utils.ts @@ -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%;