feat(editor): migrate typst mermaid to native (#14499)

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

* **New Features**
* Native/WASM Mermaid and Typst SVG preview rendering on desktop and
mobile, plus cross-platform Preview plugin integrations.

* **Improvements**
* Centralized, sanitized rendering bridge with automatic Typst
font-directory handling and configurable native renderer selection.
* More consistent and robust error serialization and worker-backed
preview flows for improved stability and performance.

* **Tests**
* Extensive unit and integration tests for preview rendering, font
discovery, sanitization, and error serialization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-20 04:04:40 +08:00
committed by GitHub
parent 16a8f17717
commit 7ac8b14b65
85 changed files with 4926 additions and 835 deletions
@@ -1,3 +1,4 @@
import { renderMermaidSvg } from '@affine/core/modules/code-block-preview-renderer/bridge';
import { CodeBlockPreviewExtension } from '@blocksuite/affine/blocks/code';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { CodeBlockModel } from '@blocksuite/affine/model';
@@ -7,7 +8,6 @@ import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { Mermaid } from 'mermaid';
export const CodeBlockMermaidPreview = CodeBlockPreviewExtension(
'mermaid',
@@ -154,7 +154,6 @@ export class MermaidPreview extends SignalWatcher(
@query('.mermaid-preview-container')
accessor container!: HTMLDivElement;
private mermaid: Mermaid | null = null;
private retryCount = 0;
private readonly maxRetries = 3;
private renderTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -169,9 +168,6 @@ export class MermaidPreview extends SignalWatcher(
private lastMouseY = 0;
override firstUpdated(_changedProperties: PropertyValues): void {
this._loadMermaid().catch(error => {
console.error('Failed to load mermaid in firstUpdated:', error);
});
this._scheduleRender();
this._setupEventListeners();
@@ -271,7 +267,8 @@ export class MermaidPreview extends SignalWatcher(
event.preventDefault();
const delta = event.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.1, Math.min(5, this.scale * delta));
const previousScale = this.scale;
const newScale = Math.max(0.1, Math.min(5, previousScale * delta));
// calculate mouse position relative to container
const rect = this.container.getBoundingClientRect();
@@ -284,8 +281,8 @@ export class MermaidPreview extends SignalWatcher(
// update transform
this.scale = newScale;
this.translateX = mouseX - scaleCenterX * (newScale / this.scale);
this.translateY = mouseY - scaleCenterY * (newScale / this.scale);
this.translateX = mouseX - scaleCenterX * (newScale / previousScale);
this.translateY = mouseY - scaleCenterY * (newScale / previousScale);
this._updateTransform();
};
@@ -309,44 +306,6 @@ export class MermaidPreview extends SignalWatcher(
);
}
private async _loadMermaid() {
try {
// dynamic load mermaid
const mermaidModule = await import('mermaid');
this.mermaid = mermaidModule.default;
// initialize mermaid
this.mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'strict',
fontFamily: 'IBM Plex Mono',
flowchart: {
useMaxWidth: true,
htmlLabels: true,
},
sequence: {
useMaxWidth: true,
},
gantt: {
useMaxWidth: true,
},
pie: {
useMaxWidth: true,
},
journey: {
useMaxWidth: true,
},
gitGraph: {
useMaxWidth: true,
},
});
} catch (error) {
console.error('Failed to load mermaid:', error);
this.state = 'error';
}
}
private async _render() {
// prevent duplicate rendering
if (this.isRendering) {
@@ -356,28 +315,25 @@ export class MermaidPreview extends SignalWatcher(
this.isRendering = true;
this.state = 'loading';
if (!this.normalizedMermaidCode) {
const code = this.normalizedMermaidCode?.trim();
if (!code) {
this.svgContent = '';
this.state = 'fallback';
this.isRendering = false;
return;
}
if (!this.mermaid) {
await this._loadMermaid();
}
if (!this.mermaid) {
return;
}
try {
// generate unique ID
const diagramId = `mermaid-diagram-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// generate SVG
const { svg } = await this.mermaid.render(
diagramId,
this.normalizedMermaidCode
);
const { svg } = await renderMermaidSvg({
code,
options: {
fastText: true,
svgOnly: true,
theme: 'default',
fontFamily: 'IBM Plex Mono',
},
});
// update SVG content
this.svgContent = svg;
@@ -1,3 +1,4 @@
import { renderTypstSvg } from '@affine/core/modules/code-block-preview-renderer/bridge';
import { CodeBlockPreviewExtension } from '@blocksuite/affine/blocks/code';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { CodeBlockModel } from '@blocksuite/affine/model';
@@ -8,8 +9,6 @@ import { property, query, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ensureTypstReady, getTypst } from './typst';
const RENDER_DEBOUNCE_MS = 200;
export const CodeBlockTypstPreview = CodeBlockPreviewExtension(
@@ -378,9 +377,7 @@ ${this.errorMessage}</pre
}
try {
await ensureTypstReady();
const typst = await getTypst();
const svg = await typst.svg({ mainContent: code });
const { svg } = await renderTypstSvg({ code });
this.svgContent = svg;
this.state = 'finish';
this._resetView();
@@ -1,57 +0,0 @@
import { $typst, type BeforeBuildFn, loadFonts } from '@myriaddreamin/typst.ts';
const FONT_CDN_URLS = [
'https://cdn.affine.pro/fonts/Inter-Regular.woff',
'https://cdn.affine.pro/fonts/Inter-SemiBold.woff',
'https://cdn.affine.pro/fonts/Inter-Italic.woff',
'https://cdn.affine.pro/fonts/Inter-SemiBoldItalic.woff',
'https://cdn.affine.pro/fonts/SarasaGothicCL-Regular.ttf',
] as const;
const getBeforeBuildHooks = (): BeforeBuildFn[] => [
loadFonts([...FONT_CDN_URLS]),
];
const compilerWasmUrl = new URL(
'@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm',
import.meta.url
).toString();
const rendererWasmUrl = new URL(
'@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm',
import.meta.url
).toString();
let typstInitPromise: Promise<void> | null = null;
export async function ensureTypstReady() {
if (typstInitPromise) {
return typstInitPromise;
}
typstInitPromise = Promise.resolve()
.then(() => {
$typst.setCompilerInitOptions({
beforeBuild: getBeforeBuildHooks(),
getModule: () => compilerWasmUrl,
});
$typst.setRendererInitOptions({
beforeBuild: getBeforeBuildHooks(),
getModule: () => rendererWasmUrl,
});
})
.catch(error => {
typstInitPromise = null;
throw error;
});
return typstInitPromise;
}
export async function getTypst() {
await ensureTypstReady();
return $typst;
}
export const TYPST_FONT_URLS = FONT_CDN_URLS;