From 37ffef76a44b15274e691076c1d322762ff0e170 Mon Sep 17 00:00:00 2001 From: Ahsan Khaleeq Date: Wed, 3 Jun 2026 13:20:34 +0500 Subject: [PATCH] fix(core): restore Mermaid preview labels and theme-aware contrast (#15073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``. 2. **SVG sanitization** — `sanitizeSvg()` removed all `foreignObject` elements (and did not allow ``), 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 `` 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). ## 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. --- .../code-block-preview/mermaid-preview.ts | 73 ++++++++++++++++++- .../bridge.spec.ts | 41 ++++++++++- .../code-block-preview-renderer/bridge.ts | 29 ++++++-- .../classic-mermaid.spec.ts | 25 +++++++ .../classic-mermaid.ts | 12 ++- .../mermaid-preview.integration.spec.ts | 39 ++++++++++ .../src/modules/mermaid/renderer/types.ts | 2 +- .../frontend/mobile-native/src/preview.rs | 1 + packages/frontend/native/src/preview.rs | 1 + 9 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 packages/frontend/core/src/modules/code-block-preview-renderer/mermaid-preview.integration.spec.ts diff --git a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/mermaid-preview.ts b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/mermaid-preview.ts index 34a7459888..f6bca50c1b 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/mermaid-preview.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/mermaid-preview.ts @@ -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 | 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([ + 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(); } } diff --git a/packages/frontend/core/src/modules/code-block-preview-renderer/bridge.spec.ts b/packages/frontend/core/src/modules/code-block-preview-renderer/bridge.spec.ts index d9dad6232c..4ab3aa4451 100644 --- a/packages/frontend/core/src/modules/code-block-preview-renderer/bridge.spec.ts +++ b/packages/frontend/core/src/modules/code-block-preview-renderer/bridge.spec.ts @@ -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( + 'A' + ); + + expect(sanitized).toContain('>A<'); + }); + + test('sanitizeSvg keeps use elements for mermaid label references', () => { + if (typeof DOMParser === 'undefined') { + return; + } + + const sanitized = sanitizeSvg( + 'A' + ); + + expect(sanitized).toMatch(/]/i); + expect(sanitized).toContain('#lbl'); + }); + + test('sanitizeSvg keeps sanitized foreignObject label text', () => { + if (typeof DOMParser === 'undefined') { + return; + } + + const sanitized = sanitizeSvg( + '
A
' + ); + + expect(sanitized).toMatch(/foreignObject/i); + expect(sanitized).toContain('>A<'); + expect(sanitized).not.toContain(' { mermaidRender.mockResolvedValue({ svg: '
invalid
', diff --git a/packages/frontend/core/src/modules/code-block-preview-renderer/bridge.ts b/packages/frontend/core/src/modules/code-block-preview-renderer/bridge.ts index a6b531bed4..ec7e682eb2 100644 --- a/packages/frontend/core/src/modules/code-block-preview-renderer/bridge.ts +++ b/packages/frontend/core/src/modules/code-block-preview-renderer/bridge.ts @@ -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 ``, `