fix(core): restore Mermaid preview labels and theme-aware contrast (#15073)

fix #14979 
[Bug]: mermaid transparent text in light theme

## Summary

Mermaid diagram preview in code blocks showed shapes and connectors but
no node or edge labels, with poor contrast in dark mode. This change
fixes rendering, sanitization, and display so labels are visible in both
light and dark themes.

## Root cause

1. **Mermaid 11 config** — `flowchart.htmlLabels: false` is ignored;
only root-level `htmlLabels` applies. Labels were still emitted in
`<foreignObject>`.
2. **SVG sanitization** — `sanitizeSvg()` removed all `foreignObject`
elements (and did not allow `<use>`), stripping most label content.
3. **Theme mismatch** — Preview always used Mermaid’s light `default`
theme while the preview panel follows AFFiNE light/dark, causing dark
text on dark backgrounds for edge and title text.
4. **Embedded CSS** — Mermaid’s inline SVG styles often do not apply
after sanitization, leaving text without a visible `fill`.

## Changes

### Classic renderer (`classic-mermaid.ts`)

- Set root-level `htmlLabels: false` (Mermaid 11+).
- Map `dark` theme to Mermaid’s built-in `dark` palette.

### Sanitization (`bridge.ts`)

- Allow `<use>` and `xlink:href` / `href` for label references.
- Allow `class`, `style`, and `id` on SVG nodes.
- **Sanitize** `foreignObject` inner HTML with DOMPurify instead of
deleting it.

### Preview UI (`mermaid-preview.ts`)

- Sync render theme with app `data-theme` (`default` / `dark`) and
re-render on theme change.
- Add CSS overrides so `text` / `tspan` and HTML inside `foreignObject`
use AFFiNE `text/primary`.

### Native / mobile (`preview.rs`)

- Map `dark` and `modern` themes to the modern renderer options (light
uses `default`).

### Types & tests

- Extend `MermaidRenderTheme` with `'dark'`.
- Update unit tests for sanitization and classic config.
- Add integration test (skips when the test environment cannot lay out
Mermaid).

## Test plan

- [ ] Hard refresh or restart `yarn dev`.
- [ ] Create a `mermaid` code block: `graph TD; A-->B` → enable
**Preview**.
- [ ] Confirm labels **A** and **B** appear inside nodes and on the
edge.
- [ ] Toggle AFFiNE **light** / **dark** theme; confirm preview updates
and text stays readable.
- [ ] Run unit tests:
  ```bash
yarn vitest run
packages/frontend/core/src/modules/code-block-preview-renderer/
  ```
- [ ] (Optional) With **Enable Native Mermaid Renderer** enabled in
experimental settings, repeat the manual check.

## Notes for reviewers

- Security: `foreignObject` content is sanitized with the HTML profile;
scripts are stripped.
- The integration test intentionally skips when Mermaid produces an
empty diagram (e.g. happy-dom without full browser layout).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Mermaid diagrams now adapt to the app's dark or light theme and update
in real time.

* **Improvements**
* SVG sanitization now preserves diagram labels and foreignObject text
while removing unsafe content.
* Classic Mermaid rendering adjusted to keep text labels intact for
previews.

* **Tests**
* Added unit and integration tests covering Mermaid rendering and SVG
sanitization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ahsan Khaleeq
2026-06-03 13:20:34 +05:00
committed by GitHub
parent 81760fd45c
commit 37ffef76a4
9 changed files with 208 additions and 15 deletions
@@ -1,4 +1,5 @@
import { renderMermaidSvg } from '@affine/core/modules/code-block-preview-renderer/bridge';
import type { MermaidRenderTheme } from '@affine/core/modules/mermaid/renderer';
import { CodeBlockPreviewExtension } from '@blocksuite/affine/blocks/code';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { CodeBlockModel } from '@blocksuite/affine/model';
@@ -86,6 +87,18 @@ export class MermaidPreview extends SignalWatcher(
transform-origin: center;
}
/* Mermaid embeds theme CSS that may not apply after sanitization. */
.mermaid-preview-svg svg text,
.mermaid-preview-svg svg tspan {
fill: ${unsafeCSSVarV2('text/primary')} !important;
}
.mermaid-preview-svg foreignObject div,
.mermaid-preview-svg foreignObject span,
.mermaid-preview-svg foreignObject p {
color: ${unsafeCSSVarV2('text/primary')} !important;
}
.mermaid-controls {
position: absolute;
top: 8px;
@@ -157,6 +170,7 @@ export class MermaidPreview extends SignalWatcher(
private readonly maxRetries = 3;
private renderTimeout: ReturnType<typeof setTimeout> | null = null;
private isRendering = false;
private pendingRender = false;
// zoom and pan
private scale = 1;
@@ -165,8 +179,11 @@ export class MermaidPreview extends SignalWatcher(
private isDragging = false;
private lastMouseX = 0;
private lastMouseY = 0;
private mermaidTheme: MermaidRenderTheme = 'default';
override firstUpdated(_changedProperties: PropertyValues): void {
this._syncMermaidTheme();
this._observeAppTheme();
this._scheduleRender();
this._setupEventListeners();
@@ -198,6 +215,44 @@ export class MermaidPreview extends SignalWatcher(
return this.model?.props.text.toString() ?? this.mermaidCode;
}
private _resolveMermaidTheme(): MermaidRenderTheme {
const themedElement =
this.closest('[data-theme]') ??
document.querySelector('[data-theme]') ??
document.documentElement;
return (themedElement as HTMLElement).dataset.theme === 'dark'
? 'dark'
: 'default';
}
private _syncMermaidTheme() {
this.mermaidTheme = this._resolveMermaidTheme();
}
private _observeAppTheme() {
const targets = new Set<Element>([
document.documentElement,
...document.querySelectorAll('[data-theme]'),
]);
const observer = new MutationObserver(() => {
const nextTheme = this._resolveMermaidTheme();
if (nextTheme === this.mermaidTheme) {
return;
}
this.mermaidTheme = nextTheme;
this._scheduleRender();
});
targets.forEach(target => {
observer.observe(target, {
attributes: true,
attributeFilter: ['data-theme'],
});
});
this.disposables.add(() => observer.disconnect());
}
private _scheduleRender() {
// clear previous timeout
if (this.renderTimeout) {
@@ -305,13 +360,23 @@ export class MermaidPreview extends SignalWatcher(
);
}
private _finishRendering() {
this.isRendering = false;
if (!this.pendingRender) {
return;
}
this.pendingRender = false;
this._scheduleRender();
}
private async _render() {
// prevent duplicate rendering
if (this.isRendering) {
this.pendingRender = true;
return;
}
this.isRendering = true;
this.pendingRender = false;
this.state = 'loading';
const code = this.normalizedMermaidCode?.trim();
@@ -319,7 +384,7 @@ export class MermaidPreview extends SignalWatcher(
if (!code) {
this.svgContent = '';
this.state = 'fallback';
this.isRendering = false;
this._finishRendering();
return;
}
@@ -329,7 +394,7 @@ export class MermaidPreview extends SignalWatcher(
options: {
fastText: true,
svgOnly: true,
theme: 'default',
theme: this.mermaidTheme,
fontFamily: 'IBM Plex Mono',
},
});
@@ -361,7 +426,7 @@ export class MermaidPreview extends SignalWatcher(
this.state = 'error';
this.retryCount = 0; // reset retry count
} finally {
this.isRendering = false;
this._finishRendering();
}
}
@@ -28,7 +28,7 @@ vi.mock('dompurify', () => ({
},
}));
import { renderMermaidSvg, renderTypstSvg } from './bridge';
import { renderMermaidSvg, renderTypstSvg, sanitizeSvg } from './bridge';
describe('preview render bridge', () => {
beforeEach(() => {
@@ -62,6 +62,45 @@ describe('preview render bridge', () => {
);
});
test('sanitizeSvg keeps svg text nodes', () => {
if (typeof DOMParser === 'undefined') {
return;
}
const sanitized = sanitizeSvg(
'<svg xmlns="http://www.w3.org/2000/svg"><text>A</text></svg>'
);
expect(sanitized).toContain('>A<');
});
test('sanitizeSvg keeps use elements for mermaid label references', () => {
if (typeof DOMParser === 'undefined') {
return;
}
const sanitized = sanitizeSvg(
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><text id="lbl">A</text></defs><use xlink:href="#lbl"/></svg>'
);
expect(sanitized).toMatch(/<use[\s>]/i);
expect(sanitized).toContain('#lbl');
});
test('sanitizeSvg keeps sanitized foreignObject label text', () => {
if (typeof DOMParser === 'undefined') {
return;
}
const sanitized = sanitizeSvg(
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="10" height="10"><div xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script>A</div></foreignObject></svg>'
);
expect(sanitized).toMatch(/foreignObject/i);
expect(sanitized).toContain('>A<');
expect(sanitized).not.toContain('<script');
});
test('throws when sanitized svg is empty', async () => {
mermaidRender.mockResolvedValue({
svg: '<div><text>invalid</text></div>',
@@ -10,12 +10,27 @@ import type {
TypstRenderRequest,
TypstRenderResult,
} from '@affine/core/modules/typst/renderer';
import type { Config } from 'dompurify';
import DOMPurify from 'dompurify';
function removeForeignObject(root: ParentNode) {
root
.querySelectorAll('foreignObject, foreignobject')
.forEach(element => element.remove());
/** Mermaid SVG uses `<use>`, `<style>`, and sometimes `<foreignObject>` for labels. */
const MERMAID_SVG_SANITIZE_CONFIG: Config = {
USE_PROFILES: { svg: true },
ADD_TAGS: ['use'],
ADD_ATTR: ['href', 'xlink:href', 'class', 'style', 'id'],
};
const FOREIGN_OBJECT_HTML_SANITIZE_CONFIG: Config = {
USE_PROFILES: { html: true },
};
function sanitizeForeignObjects(root: ParentNode) {
root.querySelectorAll('foreignObject, foreignobject').forEach(element => {
element.innerHTML = DOMPurify.sanitize(
element.innerHTML,
FOREIGN_OBJECT_HTML_SANITIZE_CONFIG
);
});
}
export function sanitizeSvg(svg: string): string {
@@ -23,7 +38,7 @@ export function sanitizeSvg(svg: string): string {
typeof DOMParser === 'undefined' ||
typeof XMLSerializer === 'undefined'
) {
const sanitized = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
const sanitized = DOMPurify.sanitize(svg, MERMAID_SVG_SANITIZE_CONFIG);
if (typeof sanitized !== 'string' || !/^\s*<svg[\s>]/i.test(sanitized)) {
return '';
}
@@ -35,7 +50,7 @@ export function sanitizeSvg(svg: string): string {
const root = parsed.documentElement;
if (!root || root.tagName.toLowerCase() !== 'svg') return '';
const sanitized = DOMPurify.sanitize(root, { USE_PROFILES: { svg: true } });
const sanitized = DOMPurify.sanitize(root, MERMAID_SVG_SANITIZE_CONFIG);
if (typeof sanitized !== 'string') return '';
const sanitizedDoc = parser.parseFromString(sanitized, 'image/svg+xml');
@@ -43,7 +58,7 @@ export function sanitizeSvg(svg: string): string {
if (!sanitizedRoot || sanitizedRoot.tagName.toLowerCase() !== 'svg')
return '';
removeForeignObject(sanitizedRoot);
sanitizeForeignObjects(sanitizedRoot);
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
}
@@ -65,4 +65,29 @@ describe('renderClassicMermaidSvg', () => {
'render:second:start',
]);
});
test('maps dark theme to mermaid dark palette', async () => {
render.mockResolvedValue({ svg: '<svg>ok</svg>' });
await renderClassicMermaidSvg({
code: 'flowchart TD;A-->B',
options: { theme: 'dark' },
});
expect(initialize).toHaveBeenCalledWith(
expect.objectContaining({ theme: 'dark' })
);
});
test('uses svg text labels so preview sanitization keeps diagram text', async () => {
render.mockResolvedValue({ svg: '<svg>ok</svg>' });
await renderClassicMermaidSvg({ code: 'flowchart TD;A-->B' });
expect(initialize).toHaveBeenCalledWith(
expect.objectContaining({
htmlLabels: false,
})
);
});
});
@@ -11,7 +11,14 @@ let mermaidPromise: Promise<Mermaid> | null = null;
let mermaidRenderQueue: Promise<void> = Promise.resolve();
function toTheme(theme: MermaidRenderTheme | undefined) {
return theme === 'modern' ? ('base' as const) : ('default' as const);
switch (theme) {
case 'modern':
return 'base' as const;
case 'dark':
return 'dark' as const;
default:
return 'default' as const;
}
}
function createClassicMermaidConfig(options?: MermaidRenderOptions) {
@@ -19,8 +26,9 @@ function createClassicMermaidConfig(options?: MermaidRenderOptions) {
startOnLoad: false,
theme: toTheme(options?.theme),
securityLevel: 'strict' as const,
htmlLabels: false,
fontFamily: options?.fontFamily ?? 'IBM Plex Mono',
flowchart: { useMaxWidth: true, htmlLabels: true },
flowchart: { useMaxWidth: true },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
pie: { useMaxWidth: true },
@@ -0,0 +1,39 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, test } from 'vitest';
import { sanitizeSvg } from './bridge';
import { renderClassicMermaidSvg } from './classic-mermaid';
const canRunDomIntegration =
typeof document !== 'undefined' &&
typeof DOMParser !== 'undefined' &&
typeof XMLSerializer !== 'undefined';
describe.skipIf(!canRunDomIntegration)('mermaid preview integration', () => {
test('flowchart labels survive classic render and svg sanitization', async () => {
const { svg: raw } = await renderClassicMermaidSvg({
code: 'flowchart TD; A-->B',
options: { theme: 'default' },
});
expect(raw).toMatch(/<svg[\s>]/i);
const sanitized = sanitizeSvg(raw);
expect(sanitized).toMatch(/<svg[\s>]/i);
// happy-dom cannot lay out Mermaid (CSSStyleSheet); skip empty output.
if (!/<(?:rect|path|circle|polygon)\b/i.test(sanitized)) {
return;
}
const hasLabelText =
/>\s*A\s*</i.test(sanitized) ||
/>\s*B\s*</i.test(sanitized) ||
/foreignObject[\s\S]*>\s*A\s*</i.test(sanitized) ||
/<tspan[^>]*>\s*A\s*</i.test(sanitized);
expect(hasLabelText).toBe(true);
}, 30_000);
});
@@ -6,7 +6,7 @@ export type MermaidTextMetrics = {
space: number;
};
export type MermaidRenderTheme = 'modern' | 'default';
export type MermaidRenderTheme = 'modern' | 'default' | 'dark';
export type MermaidRenderOptions = {
fastText?: boolean;
@@ -47,6 +47,7 @@ fn resolve_mermaid_render_options(
) -> RenderOptions {
let mut render_options = match theme.as_deref() {
Some("default") => RenderOptions::mermaid_default(),
Some("dark") | Some("modern") => RenderOptions::modern(),
_ => RenderOptions::modern(),
};
+1
View File
@@ -27,6 +27,7 @@ pub struct MermaidRenderResult {
fn resolve_mermaid_render_options(options: Option<MermaidRenderOptions>) -> RenderOptions {
let mut render_options = match options.as_ref().and_then(|options| options.theme.as_deref()) {
Some("default") => RenderOptions::mermaid_default(),
Some("dark") | Some("modern") => RenderOptions::modern(),
_ => RenderOptions::modern(),
};