diff --git a/src/renderer/src/components/editor/MermaidBlock.tsx b/src/renderer/src/components/editor/MermaidBlock.tsx index 03903b63616..6a5c1c8bc5d 100644 --- a/src/renderer/src/components/editor/MermaidBlock.tsx +++ b/src/renderer/src/components/editor/MermaidBlock.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useId, useState } from 'react' import type mermaidNamespace from 'mermaid' import DOMPurify from 'dompurify' import { getMermaidConfig } from './mermaid-config' +import { readMermaidThemeColors } from './mermaid-theme-colors' import { translate } from '@/i18n/i18n' type MermaidApi = typeof mermaidNamespace @@ -76,7 +77,9 @@ export default function MermaidBlock({ // MermaidBlock cannot overwrite htmlLabels/theme between initialize() // and render(), which would make markdown preview fall back to the // broken foreignObject label path again. - mermaid.initialize(getMermaidConfig(isDark, htmlLabels)) + mermaid.initialize( + getMermaidConfig(isDark, htmlLabels, isDark ? readMermaidThemeColors() : undefined) + ) const { svg } = await mermaid.render(`mermaid-${id}`, content) if (!cancelled) { // Why: although mermaid uses DOMPurify internally, we add an explicit diff --git a/src/renderer/src/components/editor/mermaid-config.test.ts b/src/renderer/src/components/editor/mermaid-config.test.ts index b4883f6948e..951f80d6523 100644 --- a/src/renderer/src/components/editor/mermaid-config.test.ts +++ b/src/renderer/src/components/editor/mermaid-config.test.ts @@ -1,7 +1,44 @@ +// @vitest-environment happy-dom import { describe, expect, it } from 'vitest' +import mermaid from 'mermaid' import { getMermaidConfig } from './mermaid-config' +const darkColors = { + background: '#0a0a0a', + foreground: '#fafafa', + primary: '#193cb8', + secondary: '#1447e6', + tertiary: '#155dfc', + border: '#8ec5ff' +} + +function luminance(color: string | boolean | undefined): number { + if (typeof color !== 'string') { + throw new Error('Expected a resolved Mermaid color') + } + const hex = color === 'lightgrey' ? '#d3d3d3' : color === 'white' ? '#ffffff' : color + if (!/^#[\da-f]{6}$/i.test(hex)) { + throw new Error(`Unsupported contrast-test color: ${color}`) + } + const channels = [1, 3, 5].map((offset) => { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255 + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 + }) + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722 +} + +function resolvedDarkTheme(): NonNullable< + ReturnType['themeVariables'] +> { + mermaid.initialize(getMermaidConfig(true, false, darkColors)) + const variables = mermaid.mermaidAPI.getConfig().themeVariables + if (!variables) { + throw new Error('Mermaid did not resolve the theme') + } + return variables +} + describe('getMermaidConfig', () => { it('uses strict Mermaid rendering defaults', () => { expect(getMermaidConfig(false)).toMatchObject({ @@ -21,10 +58,41 @@ describe('getMermaidConfig', () => { }) }) - it('switches to the dark mermaid theme when the preview is dark', () => { - expect(getMermaidConfig(true)).toMatchObject({ - theme: 'dark', + it('uses the colored dark palette when the preview is dark', () => { + const colors = darkColors + expect(getMermaidConfig(true, false, colors)).toMatchObject({ + theme: 'base', + darkMode: true, + themeVariables: { + darkMode: true, + primaryColor: colors.primary, + tertiaryColor: colors.tertiary, + primaryTextColor: colors.foreground, + tertiaryTextColor: colors.foreground, + primaryBorderColor: colors.border, + lineColor: colors.foreground, + edgeLabelBackground: colors.background + }, htmlLabels: false }) }) + + it('keeps Gantt labels readable on completed and active tasks and alternate sections', () => { + const variables = resolvedDarkTheme() + for (const [text, background] of [ + ['taskTextDarkColor', 'doneTaskBkgColor'], + ['taskTextDarkColor', 'activeTaskBkgColor'], + ['taskTextOutsideColor', 'altSectionBkgColor'] + ]) { + const first = luminance(variables[text]) + const second = luminance(variables[background]) + const contrast = (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05) + expect(contrast, `${text} on ${background}`).toBeGreaterThanOrEqual(4.5) + } + }) + + it('gives the first three pie categories distinct resolved colors', () => { + const variables = resolvedDarkTheme() + expect(new Set([variables.pie1, variables.pie2, variables.pie3]).size).toBe(3) + }) }) diff --git a/src/renderer/src/components/editor/mermaid-config.ts b/src/renderer/src/components/editor/mermaid-config.ts index bc3cd179d95..3e5a24dcbbb 100644 --- a/src/renderer/src/components/editor/mermaid-config.ts +++ b/src/renderer/src/components/editor/mermaid-config.ts @@ -1,14 +1,52 @@ import type mermaid from 'mermaid' +export type MermaidThemeColors = { + background: string + foreground: string + primary: string + secondary: string + tertiary: string + border: string +} + export function getMermaidConfig( isDark: boolean, - htmlLabels = false + htmlLabels = false, + colors?: MermaidThemeColors ): Parameters[0] { return { startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true, - theme: isDark ? 'dark' : 'default', + // The legacy dark theme removes color from flowcharts. + theme: isDark ? (colors ? 'base' : 'dark') : 'default', + darkMode: isDark, + ...(isDark && colors + ? { + themeVariables: { + darkMode: true, + background: colors.background, + primaryColor: colors.primary, + secondaryColor: colors.secondary, + tertiaryColor: colors.tertiary, + primaryTextColor: colors.foreground, + secondaryTextColor: colors.foreground, + tertiaryTextColor: colors.foreground, + primaryBorderColor: colors.border, + secondaryBorderColor: colors.border, + tertiaryBorderColor: colors.border, + lineColor: colors.foreground, + textColor: colors.foreground, + edgeLabelBackground: colors.background, + // Base defaults to light Gantt surfaces even when darkMode is enabled. + altSectionBkgColor: colors.background, + excludeBkgColor: colors.background, + doneTaskBkgColor: colors.background, + doneTaskBorderColor: colors.border, + activeTaskBkgColor: colors.secondary + } + } + : {}), htmlLabels } } diff --git a/src/renderer/src/components/editor/mermaid-theme-colors.ts b/src/renderer/src/components/editor/mermaid-theme-colors.ts new file mode 100644 index 00000000000..e988a11caf7 --- /dev/null +++ b/src/renderer/src/components/editor/mermaid-theme-colors.ts @@ -0,0 +1,27 @@ +import type { MermaidThemeColors } from './mermaid-config' + +export function readMermaidThemeColors(): MermaidThemeColors | undefined { + const canvas = document.createElement('canvas') + canvas.width = canvas.height = 1 + const context = canvas.getContext('2d') + if (!context) { + return undefined + } + const styles = getComputedStyle(document.documentElement) + const readColor = (token: string): string => { + // Mermaid needs hex colors; chart tokens can resolve to oklch(). + context.clearRect(0, 0, 1, 1) + context.fillStyle = styles.getPropertyValue(token).trim() + context.fillRect(0, 0, 1, 1) + const pixel = context.getImageData(0, 0, 1, 1).data + return `#${Array.from(pixel.slice(0, 3), (channel) => channel.toString(16).padStart(2, '0')).join('')}` + } + return { + background: readColor('--background'), + foreground: readColor('--foreground'), + primary: readColor('--chart-5'), + secondary: readColor('--chart-4'), + tertiary: readColor('--chart-3'), + border: readColor('--chart-1') + } +} diff --git a/src/renderer/src/mermaid.d.ts b/src/renderer/src/mermaid.d.ts index 2cb73d30462..19f1a48e2cf 100644 --- a/src/renderer/src/mermaid.d.ts +++ b/src/renderer/src/mermaid.d.ts @@ -1,9 +1,11 @@ declare module 'mermaid' { - type MermaidTheme = 'default' | 'dark' + type MermaidTheme = 'default' | 'dark' | 'base' type MermaidInitializeOptions = { startOnLoad?: boolean theme?: MermaidTheme + darkMode?: boolean + themeVariables?: Record htmlLabels?: boolean securityLevel?: 'strict' | 'loose' | 'antiscript' | 'sandbox' suppressErrorRendering?: boolean @@ -16,6 +18,7 @@ declare module 'mermaid' { type MermaidApi = { initialize: (options: MermaidInitializeOptions) => void + mermaidAPI: { getConfig: () => MermaidInitializeOptions } render: (id: string, text: string) => Promise }