From cece52643339545d5388abcb6b61b1c5efcf398d Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 19 Aug 2026 02:40:07 +0000 Subject: [PATCH] fix(webview): adapt Mermaid diagrams to IDE themes --- .../src/components/common/MermaidBlock.tsx | 107 +++--------- .../common/__tests__/MermaidBlock.spec.tsx | 89 ++++++++++ .../common/__tests__/MermaidBlock.visual.tsx | 100 ++++++++++++ .../common/__tests__/mermaidTheme.spec.ts | 89 ++++++++++ .../src/components/common/mermaidTheme.ts | 153 ++++++++++++++++++ 5 files changed, 451 insertions(+), 87 deletions(-) create mode 100644 webview-ui/src/components/common/__tests__/MermaidBlock.spec.tsx create mode 100644 webview-ui/src/components/common/__tests__/MermaidBlock.visual.tsx create mode 100644 webview-ui/src/components/common/__tests__/mermaidTheme.spec.ts create mode 100644 webview-ui/src/components/common/mermaidTheme.ts diff --git a/webview-ui/src/components/common/MermaidBlock.tsx b/webview-ui/src/components/common/MermaidBlock.tsx index 111919c649..346fe3c471 100644 --- a/webview-ui/src/components/common/MermaidBlock.tsx +++ b/webview-ui/src/components/common/MermaidBlock.tsx @@ -7,82 +7,7 @@ import { useAppTranslation } from "@src/i18n/TranslationContext" import { useCopyToClipboard } from "@src/utils/clipboard" import CodeBlock from "./CodeBlock" import { MermaidButton } from "@/components/common/MermaidButton" - -// Removed previous attempts at static imports for individual diagram types -// as the paths were incorrect for Mermaid v11.4.1 and caused errors. -// The primary strategy will now rely on Vite's bundling configuration. - -const MERMAID_THEME = { - background: "#1e1e1e", // VS Code dark theme background - textColor: "#ffffff", // Main text color - mainBkg: "#2d2d2d", // Background for nodes - nodeBorder: "#888888", // Border color for nodes - lineColor: "#cccccc", // Lines connecting nodes - primaryColor: "#3c3c3c", // Primary color for highlights - primaryTextColor: "#ffffff", // Text in primary colored elements - primaryBorderColor: "#888888", - secondaryColor: "#2d2d2d", // Secondary color for alternate elements - tertiaryColor: "#454545", // Third color for special elements - - // Class diagram specific - classText: "#ffffff", - - // State diagram specific - labelColor: "#ffffff", - - // Sequence diagram specific - actorLineColor: "#cccccc", - actorBkg: "#2d2d2d", - actorBorder: "#888888", - actorTextColor: "#ffffff", - - // Flow diagram specific - fillType0: "#2d2d2d", - fillType1: "#3c3c3c", - fillType2: "#454545", -} - -mermaid.initialize({ - startOnLoad: false, - // "strict" is required: mermaid renders LLM-generated source, and looser modes allow HTML injection through diagram labels. - securityLevel: "strict", - theme: "dark", - suppressErrorRendering: true, - themeVariables: { - ...MERMAID_THEME, - fontSize: "16px", - fontFamily: "var(--vscode-font-family, 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif)", - - // Additional styling - noteTextColor: "#ffffff", - noteBkgColor: "#454545", - noteBorderColor: "#888888", - - // Improve contrast for special elements - critBorderColor: "#ff9580", - critBkgColor: "#803d36", - - // Task diagram specific - taskTextColor: "#ffffff", - taskTextOutsideColor: "#ffffff", - taskTextLightColor: "#ffffff", - - // Numbers/sections - sectionBkgColor: "#2d2d2d", - sectionBkgColor2: "#3c3c3c", - - // Alt sections in sequence diagrams - altBackground: "#2d2d2d", - - // Links - linkColor: "#6cb6ff", - - // Borders and lines - compositeBackground: "#2d2d2d", - compositeBorder: "#888888", - titleColor: "#ffffff", - }, -}) +import { getMermaidBackgroundColor, getMermaidConfig, useMermaidTheme } from "./mermaidTheme" interface MermaidBlockProps { code: string @@ -93,22 +18,26 @@ export default function MermaidBlock({ code }: MermaidBlockProps) { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [isErrorExpanded, setIsErrorExpanded] = useState(false) + const renderVersionRef = useRef(0) + const theme = useMermaidTheme() const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard() const { t } = useAppTranslation() - // 1) Whenever `code` changes, mark that we need to re-render a new chart + // Whenever the source or host theme changes, invalidate in-flight rendering. useEffect(() => { + renderVersionRef.current += 1 setIsLoading(true) setError(null) - }, [code]) + }, [code, theme.signature]) - // 2) Debounce the actual parse/render useDebounceEffect( () => { + const renderVersion = renderVersionRef.current if (containerRef.current) { containerRef.current.innerHTML = "" } + mermaid.initialize(getMermaidConfig(theme.kind)) mermaid .parse(code) .then(() => { @@ -116,20 +45,24 @@ export default function MermaidBlock({ code }: MermaidBlockProps) { return mermaid.render(id, code) }) .then(({ svg }) => { - if (containerRef.current) { + if (containerRef.current && renderVersion === renderVersionRef.current) { containerRef.current.innerHTML = svg } }) .catch((err) => { - console.warn("Mermaid parse/render failed:", err) - setError(err.message || "Failed to render Mermaid diagram") + if (renderVersion === renderVersionRef.current) { + console.warn("Mermaid parse/render failed:", err) + setError(err.message || "Failed to render Mermaid diagram") + } }) .finally(() => { - setIsLoading(false) + if (renderVersion === renderVersionRef.current) { + setIsLoading(false) + } }) }, - 500, // Delay 500ms - [code], // Dependencies for scheduling + 500, + [code, theme.signature], ) /** @@ -225,6 +158,7 @@ export default function MermaidBlock({ code }: MermaidBlockProps) { } async function svgToPng(svgEl: SVGElement): Promise { + const backgroundColor = getMermaidBackgroundColor() // Clone the SVG to avoid modifying the original const svgClone = svgEl.cloneNode(true) as SVGElement @@ -266,8 +200,7 @@ async function svgToPng(svgEl: SVGElement): Promise { const ctx = canvas.getContext("2d") if (!ctx) return reject("Canvas context not available") - // Fill background with Mermaid's dark theme background color - ctx.fillStyle = MERMAID_THEME.background + ctx.fillStyle = backgroundColor ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.imageSmoothingEnabled = true diff --git a/webview-ui/src/components/common/__tests__/MermaidBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MermaidBlock.spec.tsx new file mode 100644 index 0000000000..428e3ae306 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/MermaidBlock.spec.tsx @@ -0,0 +1,89 @@ +import { act, render, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import MermaidBlock from "../MermaidBlock" + +const mermaidMocks = vi.hoisted(() => ({ + initialize: vi.fn(), + parse: vi.fn(), + renderDiagram: vi.fn(), +})) + +vi.mock("mermaid", () => ({ + default: { + initialize: mermaidMocks.initialize, + parse: mermaidMocks.parse, + render: mermaidMocks.renderDiagram, + }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@src/utils/clipboard", () => ({ + useCopyToClipboard: () => ({ showCopyFeedback: false, copyWithFeedback: vi.fn() }), +})) + +vi.mock("@/components/common/MermaidButton", () => ({ + MermaidButton: ({ children }: { children: React.ReactNode }) => children, +})) + +describe("MermaidBlock", () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.className = "vscode-light" + document.body.dataset.vscodeThemeId = "Default Light Modern" + document.body.style.setProperty("--vscode-editor-background", "#ffffff") + document.body.style.setProperty("--vscode-editor-foreground", "#333333") + document.body.style.setProperty("--vscode-input-background", "#f3f3f3") + document.body.style.setProperty("--vscode-input-border", "#717171") + mermaidMocks.parse.mockResolvedValue({ diagramType: "flowchart-v2" }) + mermaidMocks.renderDiagram.mockResolvedValue({ svg: '' }) + }) + + it("rerenders an existing diagram when the host theme changes", async () => { + const { getByTestId } = render() + + await waitFor(() => expect(mermaidMocks.renderDiagram).toHaveBeenCalledTimes(1), { timeout: 1_500 }) + expect(getByTestId("rendered-diagram")).toBeInTheDocument() + expect(mermaidMocks.initialize).toHaveBeenLastCalledWith( + expect.objectContaining({ themeVariables: expect.objectContaining({ darkMode: false }) }), + ) + + act(() => { + document.body.className = "vscode-dark" + document.body.dataset.vscodeThemeId = "Default Dark Modern" + document.body.style.setProperty("--vscode-editor-background", "#1e1e1e") + document.body.style.setProperty("--vscode-editor-foreground", "#d4d4d4") + }) + + await waitFor(() => expect(mermaidMocks.renderDiagram).toHaveBeenCalledTimes(2), { timeout: 1_500 }) + expect(mermaidMocks.initialize).toHaveBeenLastCalledWith( + expect.objectContaining({ themeVariables: expect.objectContaining({ darkMode: true }) }), + ) + }) + + it("does not replace the current theme with a stale render", async () => { + let resolveFirstRender!: (value: { svg: string }) => void + mermaidMocks.renderDiagram + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirstRender = resolve))) + .mockResolvedValueOnce({ svg: '' }) + const { queryByTestId } = render() + + await waitFor(() => expect(mermaidMocks.renderDiagram).toHaveBeenCalledTimes(1), { timeout: 1_500 }) + act(() => { + document.body.className = "vscode-dark" + document.body.dataset.vscodeThemeId = "Default Dark Modern" + document.body.style.setProperty("--vscode-editor-background", "#1e1e1e") + document.body.style.setProperty("--vscode-editor-foreground", "#d4d4d4") + }) + + await waitFor(() => expect(mermaidMocks.renderDiagram).toHaveBeenCalledTimes(2), { timeout: 1_500 }) + await waitFor(() => expect(queryByTestId("dark-diagram")).toBeInTheDocument()) + await act(async () => resolveFirstRender({ svg: '' })) + + expect(queryByTestId("dark-diagram")).toBeInTheDocument() + expect(queryByTestId("stale-light-diagram")).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/common/__tests__/MermaidBlock.visual.tsx b/webview-ui/src/components/common/__tests__/MermaidBlock.visual.tsx new file mode 100644 index 0000000000..17aa2abc3c --- /dev/null +++ b/webview-ui/src/components/common/__tests__/MermaidBlock.visual.tsx @@ -0,0 +1,100 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import MermaidBlock from "../MermaidBlock" + +const diagram = `gantt + title Project plan + dateFormat YYYY-MM-DD + section Planning + Define scope :done, scope, 2026-08-01, 3d + section Delivery + Ship release :active, release, after scope, 3d` + +const themes = [ + { + name: "dark", + bodyClass: "vscode-dark", + themeId: "Default Dark Modern", + colors: {}, + }, + { + name: "light", + bodyClass: "vscode-light", + themeId: "Default Light Modern", + colors: {}, + }, + { + name: "high-contrast", + bodyClass: "vscode-high-contrast", + themeId: "Default High Contrast", + colors: { + "--vscode-editor-background": "#000000", + "--vscode-editor-foreground": "#ffffff", + "--vscode-input-background": "#000000", + "--vscode-input-border": "#ffffff", + "--vscode-textLink-foreground": "#6fc3df", + }, + }, + { + name: "high-contrast-light", + bodyClass: "vscode-high-contrast-light", + themeId: "Default High Contrast Light", + colors: { + "--vscode-editor-background": "#ffffff", + "--vscode-editor-foreground": "#000000", + "--vscode-input-background": "#ffffff", + "--vscode-input-border": "#000000", + "--vscode-textLink-foreground": "#0044cc", + }, + }, +] as const + +for (const theme of themes) { + test(`renders Mermaid sections in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + await page.evaluate(({ bodyClass, themeId, colors }) => { + document.documentElement.className = bodyClass + document.body.className = bodyClass + document.body.dataset.vscodeThemeId = themeId + for (const [property, value] of Object.entries(colors)) { + document.body.style.setProperty(property, value) + } + }, theme) + + const component = await mount() + await expect(component.locator("svg")).toBeVisible({ timeout: 10_000 }) + + const contrastRatio = await component.locator("svg").evaluate((svg) => { + const section = svg.querySelector(".section0")! + const title = svg.querySelector(".sectionTitle0")! + const sectionStyles = getComputedStyle(section) + const titleStyles = getComputedStyle(title) + const fill = sectionStyles.fill + .match(/\d+(?:\.\d+)?/g)! + .slice(0, 3) + .map(Number) + const foreground = titleStyles.fill + .match(/\d+(?:\.\d+)?/g)! + .slice(0, 3) + .map(Number) + const body = getComputedStyle(document.body) + .backgroundColor.match(/\d+(?:\.\d+)?/g)! + .slice(0, 3) + .map(Number) + const alpha = Number(sectionStyles.opacity) * Number(sectionStyles.fillOpacity) + const background = fill.map((channel, index) => Math.round(channel * alpha + body[index] * (1 - alpha))) + const luminance = (rgb: number[]) => { + const channels = rgb.map((channel) => { + const value = channel / 255 + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] + } + const lighter = Math.max(luminance(background), luminance(foreground)) + const darker = Math.min(luminance(background), luminance(foreground)) + return (lighter + 0.05) / (darker + 0.05) + }) + + expect(contrastRatio).toBeGreaterThanOrEqual(4.5) + }) +} diff --git a/webview-ui/src/components/common/__tests__/mermaidTheme.spec.ts b/webview-ui/src/components/common/__tests__/mermaidTheme.spec.ts new file mode 100644 index 0000000000..654efc9deb --- /dev/null +++ b/webview-ui/src/components/common/__tests__/mermaidTheme.spec.ts @@ -0,0 +1,89 @@ +import { act, renderHook, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { getMermaidBackgroundColor, getMermaidConfig, useMermaidTheme } from "../mermaidTheme" + +function applyTheme( + className: string, + colors: { background: string; foreground: string; surface: string; border: string; link: string }, +) { + document.body.className = className + document.body.style.setProperty("--vscode-editor-background", colors.background) + document.body.style.setProperty("--vscode-editor-foreground", colors.foreground) + document.body.style.setProperty("--vscode-input-background", colors.surface) + document.body.style.setProperty("--vscode-input-border", colors.border) + document.body.style.setProperty("--vscode-textLink-foreground", colors.link) +} + +describe("Mermaid theme", () => { + beforeEach(() => { + document.body.dataset.vscodeThemeId = "Default Light Modern" + applyTheme("vscode-light", { + background: "#ffffff", + foreground: "#333333", + surface: "#f3f3f3", + border: "#717171", + link: "#006ab1", + }) + }) + + afterEach(() => { + document.body.className = "" + document.body.removeAttribute("style") + delete document.body.dataset.vscodeThemeId + delete document.body.dataset.vscodeThemeKind + }) + + it("builds a light base theme from VS Code colors", () => { + const config = getMermaidConfig("light") + + expect(config).toMatchObject({ + securityLevel: "strict", + theme: "base", + themeVariables: { + darkMode: false, + background: "#ffffff", + primaryColor: "#f3f3f3", + primaryTextColor: "#333333", + primaryBorderColor: "#717171", + linkColor: "#006ab1", + }, + }) + expect(getMermaidBackgroundColor()).toBe("#ffffff") + }) + + it("builds a dark theme and normalizes translucent colors", () => { + applyTheme("vscode-dark", { + background: "rgb(30, 30, 30)", + foreground: "rgb(212, 212, 212)", + surface: "rgba(60, 60, 60, 0.5)", + border: "#8888", + link: "#3794ff", + }) + + const config = getMermaidConfig("dark") + + expect(config.themeVariables).toMatchObject({ + darkMode: true, + background: "#1e1e1e", + primaryColor: "#2d2d2d", + primaryTextColor: "#d4d4d4", + primaryBorderColor: "#575757", + }) + }) + + it("updates when the host switches themes", async () => { + const { result } = renderHook(() => useMermaidTheme()) + expect(result.current.kind).toBe("light") + + act(() => { + document.body.className = "vscode-high-contrast" + document.body.dataset.vscodeThemeId = "Default High Contrast" + document.body.style.setProperty("--vscode-editor-background", "#000000") + document.body.style.setProperty("--vscode-editor-foreground", "#ffffff") + }) + + await waitFor(() => expect(result.current.kind).toBe("high-contrast")) + expect(result.current.signature).toContain("Default High Contrast") + }) +}) diff --git a/webview-ui/src/components/common/mermaidTheme.ts b/webview-ui/src/components/common/mermaidTheme.ts new file mode 100644 index 0000000000..8f06500e73 --- /dev/null +++ b/webview-ui/src/components/common/mermaidTheme.ts @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react" + +export type MermaidThemeKind = "dark" | "light" | "high-contrast" | "high-contrast-light" + +interface MermaidThemeState { + kind: MermaidThemeKind + signature: string +} + +const DARK_FALLBACK = { + background: "#1e1e1e", + foreground: "#d4d4d4", + surface: "#3c3c3c", + border: "#888888", + link: "#3794ff", +} + +const LIGHT_FALLBACK = { + background: "#ffffff", + foreground: "#333333", + surface: "#ffffff", + border: "#717171", + link: "#006ab1", +} + +function parseColor(value: string): [number, number, number, number] | undefined { + const color = value.trim() + const hex = color.match(/^#([\da-f]{3,8})$/i)?.[1] + + if (hex) { + const expanded = hex.length === 3 || hex.length === 4 ? [...hex].map((digit) => digit + digit).join("") : hex + if (expanded.length === 6 || expanded.length === 8) { + return [ + parseInt(expanded.slice(0, 2), 16), + parseInt(expanded.slice(2, 4), 16), + parseInt(expanded.slice(4, 6), 16), + expanded.length === 8 ? parseInt(expanded.slice(6, 8), 16) / 255 : 1, + ] + } + } + + if (/^rgba?\(/i.test(color)) { + const channels = color.match(/[\d.]+/g)?.map(Number) + if (channels && channels.length >= 3) { + return [channels[0], channels[1], channels[2], channels[3] ?? 1] + } + } + + return undefined +} + +function toHex(value: string, fallback: string, background = fallback): string { + const fallbackColor = parseColor(fallback) ?? [0, 0, 0, 1] + const backgroundColor = parseColor(background) ?? fallbackColor + const [red, green, blue, alpha] = parseColor(value) ?? fallbackColor + const channels = [red, green, blue].map((channel, index) => + Math.round(channel * alpha + backgroundColor[index] * (1 - alpha)), + ) + + return `#${channels.map((channel) => Math.max(0, Math.min(255, channel)).toString(16).padStart(2, "0")).join("")}` +} + +function getThemeKind(): MermaidThemeKind { + const body = document.body + const className = body.className + const themeKind = body.dataset.vscodeThemeKind ?? "" + + if (/vscode-high-contrast-light/i.test(`${className} ${themeKind}`)) return "high-contrast-light" + if (/vscode-high-contrast/i.test(`${className} ${themeKind}`)) return "high-contrast" + if (/vscode-light/i.test(className)) return "light" + return "dark" +} + +function getThemeState(): MermaidThemeState { + const styles = getComputedStyle(document.body) + const signature = [ + document.body.dataset.vscodeThemeId, + document.body.dataset.vscodeThemeKind, + document.body.className, + styles.getPropertyValue("--vscode-editor-background"), + styles.getPropertyValue("--vscode-editor-foreground"), + styles.getPropertyValue("--vscode-input-background"), + styles.getPropertyValue("--vscode-input-border"), + styles.getPropertyValue("--vscode-textLink-foreground"), + ].join("|") + + return { kind: getThemeKind(), signature } +} + +export function useMermaidTheme(): MermaidThemeState { + const [theme, setTheme] = useState(getThemeState) + + useEffect(() => { + const updateTheme = () => { + const nextTheme = getThemeState() + setTheme((currentTheme) => (currentTheme.signature === nextTheme.signature ? currentTheme : nextTheme)) + } + const observer = new MutationObserver(updateTheme) + const options: MutationObserverInit = { + attributes: true, + attributeFilter: ["class", "style", "data-vscode-theme-id", "data-vscode-theme-kind"], + } + + observer.observe(document.documentElement, options) + observer.observe(document.body, options) + return () => observer.disconnect() + }, []) + + return theme +} + +export function getMermaidConfig(kind: MermaidThemeKind) { + const isDark = kind === "dark" || kind === "high-contrast" + const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK + const styles = getComputedStyle(document.body) + const background = toHex(styles.getPropertyValue("--vscode-editor-background"), fallback.background) + const foreground = toHex(styles.getPropertyValue("--vscode-editor-foreground"), fallback.foreground, background) + const surface = toHex(styles.getPropertyValue("--vscode-input-background"), fallback.surface, background) + const border = toHex(styles.getPropertyValue("--vscode-input-border"), fallback.border, background) + const link = toHex(styles.getPropertyValue("--vscode-textLink-foreground"), fallback.link, background) + + return { + startOnLoad: false, + securityLevel: "strict" as const, + theme: "base" as const, + suppressErrorRendering: true, + themeVariables: { + darkMode: isDark, + background, + primaryColor: surface, + primaryTextColor: foreground, + primaryBorderColor: border, + secondaryColor: background, + secondaryTextColor: foreground, + secondaryBorderColor: border, + tertiaryColor: surface, + tertiaryTextColor: foreground, + tertiaryBorderColor: border, + lineColor: foreground, + textColor: foreground, + noteBkgColor: surface, + noteTextColor: foreground, + noteBorderColor: border, + linkColor: link, + fontSize: "16px", + fontFamily: "var(--vscode-font-family, 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif)", + }, + } +} + +export function getMermaidBackgroundColor(): string { + return getMermaidConfig(getThemeKind()).themeVariables.background +}