mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mermaid): keep flowchart labels readable on custom node fills (#34452)
Mermaid draws every flowchart label in the theme's text colour, so a `style` or `classDef` fill can hide it: light text on a pale node in dark mode, dark text on a deep one in light mode. After rendering, labels that fall below 4.5:1 on their node's fill switch to whichever theme colour reads better, on desktop previews, chat and the phone. Co-authored-by: Nico Hu <haoyu.hu@lexmount.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { memo, useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
import { readableMermaidFlowchartSvg } from '../../../../src/shared/mermaid-flowchart-contrast'
|
||||
import { MERMAID_DIAGRAM_CONFIG } from './mermaid-diagram-config'
|
||||
import { loadPageMermaid } from './mermaid-page-engine'
|
||||
|
||||
@@ -37,7 +38,7 @@ export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: Mer
|
||||
return
|
||||
}
|
||||
// Only mermaid sanitizes its serialization; strict mode already made returned SVG safe to parse.
|
||||
host.innerHTML = svg
|
||||
host.innerHTML = readableMermaidFlowchartSvg(svg, colors.textPrimary, colors.bgRaised)
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
setFailedSource(source)
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
import type mermaidNamespace from 'mermaid'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { readableMermaidFlowchartSvg } from '../../../../shared/mermaid-flowchart-contrast'
|
||||
import { getMermaidConfig } from './mermaid-config'
|
||||
import { readMermaidThemeColors } from './mermaid-theme-colors'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -82,10 +83,13 @@ export default function MermaidBlock({
|
||||
const { svg } = await mermaid.render(`mermaid-${id}`, content)
|
||||
if (!cancelled) {
|
||||
// Sanitize explicitly against XSS even if Mermaid’s internal sanitization changes.
|
||||
const sanitized = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } })
|
||||
setRendered({
|
||||
content,
|
||||
result: {
|
||||
svg: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } }),
|
||||
svg: colors
|
||||
? readableMermaidFlowchartSvg(sanitized, colors.foreground, colors.background)
|
||||
: sanitized,
|
||||
background: colors?.background
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readableMermaidFlowchartSvg } from './mermaid-flowchart-contrast'
|
||||
|
||||
function flowchart(fill: string, label: string): string {
|
||||
return (
|
||||
'<svg aria-roledescription="flowchart-v2"><g class="node">' +
|
||||
`<rect class="label-container" style="fill:${fill}"></rect>` +
|
||||
`<g class="label"><text style="fill:${label}">Node</text></g>` +
|
||||
'</g></svg>'
|
||||
)
|
||||
}
|
||||
|
||||
function labelFill(svg: string): string {
|
||||
const host = document.createElement('div')
|
||||
host.innerHTML = svg
|
||||
return host.querySelector('text')!.style.getPropertyValue('fill')
|
||||
}
|
||||
|
||||
describe('readableMermaidFlowchartSvg', () => {
|
||||
it('recolours a light theme label on a pale custom fill', () => {
|
||||
const svg = readableMermaidFlowchartSvg(flowchart('#fde68a', '#e0e0e0'), '#e0e0e0', '#111111')
|
||||
expect(labelFill(svg)).toBe('#111111')
|
||||
})
|
||||
|
||||
it('reads the painted path when the label container is a group', () => {
|
||||
const stadium = flowchart('#1e3a8a', '#e0e0e0').replace(
|
||||
'<rect class="label-container" style="fill:#1e3a8a"></rect>',
|
||||
// The group inherits the diagram's text colour; only its path is painted.
|
||||
'<g class="label-container" style="fill:#e0e0e0"><path style="fill:#1e3a8a"></path></g>'
|
||||
)
|
||||
expect(labelFill(readableMermaidFlowchartSvg(stadium, '#e0e0e0', '#111111'))).toBe('#e0e0e0')
|
||||
})
|
||||
|
||||
it('leaves a label that already reads on its fill', () => {
|
||||
const svg = readableMermaidFlowchartSvg(flowchart('#1e3a8a', '#e0e0e0'), '#e0e0e0', '#111111')
|
||||
expect(labelFill(svg)).toBe('#e0e0e0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
const READABLE_CONTRAST = 4.5
|
||||
|
||||
/** WCAG relative luminance of a `#rrggbb` or opaque `rgb()` colour; null for anything else. */
|
||||
function luminance(color: string): number | null {
|
||||
const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color)
|
||||
const rgb = /^rgba?\(([^)]+)\)$/i.exec(color)
|
||||
const channels = hex
|
||||
? hex.slice(1).map((channel) => Number.parseInt(channel, 16))
|
||||
: rgb
|
||||
? rgb[1].split(/[\s,/]+/).map(Number)
|
||||
: null
|
||||
if (!channels || (channels.length === 4 && channels.pop() !== 1) || channels.length !== 3) {
|
||||
return null
|
||||
}
|
||||
const [r, g, b] = channels.map((channel) => {
|
||||
const value = channel / 255
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
function contrast(a: number, b: number): number {
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mermaid draws every flowchart label in the theme's text colour, so a custom fill from `style`
|
||||
* or `classDef` can hide it (light text on a pale node in dark mode). Recolours those labels to
|
||||
* whichever of `foreground` and `background` reads better on the fill. Browser-only: the fill
|
||||
* comes from computed styles, so `svg` is parsed into the document and must already be safe
|
||||
* (sanitized, or from mermaid's strict mode).
|
||||
*/
|
||||
export function readableMermaidFlowchartSvg(
|
||||
svg: string,
|
||||
foreground: string,
|
||||
background: string
|
||||
): string {
|
||||
const fgLum = luminance(foreground)
|
||||
const bgLum = luminance(background)
|
||||
if (!svg.includes('flowchart') || fgLum === null || bgLum === null) {
|
||||
return svg
|
||||
}
|
||||
const host = document.createElement('div')
|
||||
host.style.display = 'none'
|
||||
host.innerHTML = svg
|
||||
document.body.append(host)
|
||||
try {
|
||||
const nodes = host.querySelectorAll(
|
||||
'svg[aria-roledescription^="flowchart"] .node, svg[aria-roledescription^="flowchart"] .cluster'
|
||||
)
|
||||
for (const node of nodes) {
|
||||
// Only a node's label container sits behind its label (a brace draws beside it); stadium and
|
||||
// hand-drawn shapes make it a group around the painted shapes. A subgraph is a plain rect.
|
||||
const shape = node.classList.contains('cluster')
|
||||
? node.querySelector('rect')
|
||||
: node.querySelector('.label-container:not(g), .label-container :not(g)')
|
||||
if (!shape) {
|
||||
continue
|
||||
}
|
||||
const shapeStyle = getComputedStyle(shape)
|
||||
const fill = luminance(shapeStyle.fill)
|
||||
// A translucent fill blends with whatever is behind it, so its colour is unknown.
|
||||
if (
|
||||
fill === null ||
|
||||
Number.parseFloat(shapeStyle.fillOpacity) < 1 ||
|
||||
Number.parseFloat(shapeStyle.opacity) < 1
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const fgRatio = contrast(fgLum, fill)
|
||||
const bgRatio = contrast(bgLum, fill)
|
||||
const best =
|
||||
fgRatio >= bgRatio
|
||||
? { color: foreground, ratio: fgRatio }
|
||||
: { color: background, ratio: bgRatio }
|
||||
for (const label of node.querySelectorAll<SVGElement | HTMLElement>(
|
||||
'text, tspan, .nodeLabel, .nodeLabel *'
|
||||
)) {
|
||||
if (label.closest('.node, .cluster') !== node) {
|
||||
continue
|
||||
}
|
||||
const svgText = label.localName === 'text' || label.localName === 'tspan'
|
||||
const labelStyle = getComputedStyle(label)
|
||||
// HTML that paints its own background (`<mark>`) was styled against that, not the fill.
|
||||
if (!svgText && luminance(labelStyle.backgroundColor) !== null) {
|
||||
continue
|
||||
}
|
||||
const current = luminance(svgText ? labelStyle.fill : labelStyle.color)
|
||||
const ratio = current === null ? 0 : contrast(current, fill)
|
||||
if (ratio < READABLE_CONTRAST && best.ratio > ratio) {
|
||||
label.style.setProperty(svgText ? 'fill' : 'color', best.color, 'important')
|
||||
}
|
||||
}
|
||||
}
|
||||
return host.innerHTML
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user