feat: integrate typst preview & fix mermaid style (#14168)

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

* **New Features**
* Typst code block preview with interactive rendering controls (zoom,
pan, reset) and user-friendly error messages

* **Style**
  * Centered Mermaid diagram rendering for improved layout

* **Tests**
  * Added end-to-end preview validation tests for Typst and Mermaid

* **Chores**
* Added WebAssembly type declarations and updated frontend packages;
removed a build debug configuration entry

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-12-28 04:55:22 +08:00
committed by GitHub
parent 1b532d5c6c
commit 4f1d57ade5
10 changed files with 608 additions and 2 deletions

View File

@@ -34,6 +34,9 @@
"@juggle/resize-observer": "^3.4.0",
"@lit/context": "^1.1.4",
"@marsidev/react-turnstile": "^1.1.0",
"@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2",
"@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2",
"@myriaddreamin/typst.ts": "^0.7.0-rc2",
"@preact/signals-core": "^1.8.0",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-context-menu": "^2.1.15",

View File

@@ -13,6 +13,10 @@ import {
CodeBlockMermaidPreview,
effects as mermaidPreviewEffects,
} from './mermaid-preview';
import {
CodeBlockTypstPreview,
effects as typstPreviewEffects,
} from './typst-preview';
const optionsSchema = z.object({
framework: z.instanceof(FrameworkProvider).optional(),
@@ -28,6 +32,7 @@ export class CodeBlockPreviewViewExtension extends ViewExtensionProvider {
htmlPreviewEffects();
mermaidPreviewEffects();
typstPreviewEffects();
}
override setup(
@@ -37,5 +42,6 @@ export class CodeBlockPreviewViewExtension extends ViewExtensionProvider {
super.setup(context, options);
context.register(CodeBlockHtmlPreview);
context.register(CodeBlockMermaidPreview);
context.register(CodeBlockTypstPreview);
}
}

View File

@@ -77,6 +77,9 @@ export class MermaidPreview extends SignalWatcher(
}
.mermaid-preview-svg > div {
display: flex;
justify-content: center;
width: 100%;
transform-origin: center;
}

View File

@@ -0,0 +1,457 @@
import { CodeBlockPreviewExtension } from '@blocksuite/affine/blocks/code';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { CodeBlockModel } from '@blocksuite/affine/model';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html, nothing } 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 { ensureTypstReady, getTypst } from './typst';
const RENDER_DEBOUNCE_MS = 200;
export const CodeBlockTypstPreview = CodeBlockPreviewExtension(
'typst',
model => html`<typst-preview .model=${model}></typst-preview>`
);
export class TypstPreview extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.typst-preview-loading {
color: ${unsafeCSSVarV2('text/placeholder')};
font-family: 'IBM Plex Mono';
font-size: 12px;
line-height: 18px;
padding: 12px;
text-align: center;
}
.typst-preview-error,
.typst-preview-fallback {
color: ${unsafeCSSVarV2('button/error')};
font-family: 'IBM Plex Mono';
font-size: 12px;
line-height: 18px;
padding: 12px;
text-align: center;
}
details.typst-error-details {
margin-top: 8px;
text-align: left;
border: 1px dashed ${unsafeCSSVarV2('layer/insideBorder/border')};
border-radius: 6px;
padding: 8px;
background: ${unsafeCSSVarV2('layer/background/secondary')};
color: ${unsafeCSSVarV2('text/secondary')};
font-family: 'IBM Plex Mono';
font-size: 12px;
line-height: 18px;
}
.typst-error-text {
white-space: pre-wrap;
word-break: break-word;
user-select: text;
}
.typst-copy-row {
display: flex;
gap: 8px;
align-items: center;
margin-top: 6px;
}
.typst-copy-button {
padding: 6px 8px;
border-radius: 4px;
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
background: ${unsafeCSSVarV2('layer/background/primary')};
color: ${unsafeCSSVarV2('text/primary')};
cursor: pointer;
font-size: 12px;
transition: background 0.2s ease;
}
.typst-copy-button:hover {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.typst-preview-container {
width: 100%;
min-height: 300px;
max-height: 600px;
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
border-radius: 8px;
background: ${unsafeCSSVarV2('layer/background/primary')};
padding: 12px;
overflow: auto;
position: relative;
cursor: grab;
}
.typst-preview-svg {
width: 100%;
transform-origin: center;
transition: transform 0.15s ease-out;
display: inline-block;
}
.typst-preview-svg > div {
display: flex;
justify-content: center;
width: 100%;
transform-origin: center;
}
.typst-controls {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
z-index: 10;
}
.typst-control-button {
width: 28px;
height: 28px;
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
border-radius: 4px;
background: ${unsafeCSSVarV2('layer/background/primary')};
color: ${unsafeCSSVarV2('text/primary')};
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s ease;
}
.typst-control-button:hover {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
border-color: ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')};
}
.typst-control-button:active {
transform: scale(0.96);
}
`;
@property({ attribute: false })
accessor model: CodeBlockModel | null = null;
@property({ attribute: false })
accessor typstCode: string | null = null;
@state()
accessor state: 'loading' | 'error' | 'finish' | 'fallback' | 'syntax-error' =
'loading';
@state()
accessor svgContent: string = '';
@state()
accessor errorMessage: string | null = null;
@state()
accessor copyState: 'idle' | 'copied' | 'failed' = 'idle';
@query('.typst-preview-container')
accessor container!: HTMLDivElement;
private renderTimeout: ReturnType<typeof setTimeout> | null = null;
private isRendering = false;
private scale = 1;
private translateX = 0;
private translateY = 0;
private isDragging = false;
private lastMouseX = 0;
private lastMouseY = 0;
private async _copyError() {
if (!this.errorMessage) return;
try {
await navigator.clipboard.writeText(this.errorMessage);
this.copyState = 'copied';
setTimeout(() => (this.copyState = 'idle'), 1500);
} catch (err) {
console.error('Failed to copy Typst error message:', err);
this.copyState = 'failed';
setTimeout(() => (this.copyState = 'idle'), 1500);
}
}
private get _errorMessageDetail() {
return this.errorMessage
? html`<details class="typst-error-details">
<summary>Error details</summary>
<pre
class="typst-error-text"
tabindex="0"
aria-label="Typst error message"
>
${this.errorMessage}</pre
>
<div class="typst-copy-row">
<button class="typst-copy-button" @click=${this._copyError}>
${this._copyButtonLabel}
</button>
</div>
</details>`
: nothing;
}
private get _errorMessageComponent() {
const lower = this.errorMessage?.toLowerCase() ?? '';
const friendlyMessage = lower.includes('no font could be found')
? 'Failed to load fonts. Please check your network or try again.'
: 'Failed to render Typst. Please check your code.';
return [friendlyMessage, this._errorMessageDetail];
}
private get _copyButtonLabel() {
if (this.copyState === 'copied') {
return 'Copied';
} else if (this.copyState === 'failed') {
return 'Copy failed';
} else {
return 'Copy';
}
}
private get _finalPreview() {
return this.state === 'finish'
? html`
<div class="typst-controls">
<button
class="typst-control-button"
@click=${this._zoomOut}
title="Zoom out"
>
</button>
<button
class="typst-control-button"
@click=${this._resetView}
title="Reset view"
>
</button>
<button
class="typst-control-button"
@click=${this._zoomIn}
title="Zoom in"
>
+
</button>
</div>
<div
class="typst-preview-svg"
style=${styleMap({
transform: `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`,
})}
>
${this.svgContent
? html`<div .innerHTML=${this.svgContent}></div>`
: nothing}
</div>
`
: nothing;
}
override firstUpdated() {
this._scheduleRender();
this._setupDragListeners();
if (this.model) {
this.disposables.add(
this.model.props.text$.subscribe(() => {
this._scheduleRender();
})
);
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
if (this.renderTimeout) {
clearTimeout(this.renderTimeout);
this.renderTimeout = null;
}
}
get normalizedCode() {
return this.model?.props.text.toString() ?? this.typstCode ?? '';
}
private _scheduleRender() {
if (this.renderTimeout) {
clearTimeout(this.renderTimeout);
}
this.renderTimeout = setTimeout(() => {
this._render().catch(error => {
console.error('Typst preview render failed:', error);
});
}, RENDER_DEBOUNCE_MS);
}
private _resetView() {
this.scale = 1;
this.translateX = 0;
this.translateY = 0;
this.requestUpdate();
}
private _zoomIn() {
this.scale = Math.min(this.scale * 1.2, 5);
this.requestUpdate();
}
private _zoomOut() {
this.scale = Math.max(this.scale / 1.2, 0.2);
this.requestUpdate();
}
private _setupDragListeners() {
if (!this.container) return;
this.disposables.addFromEvent(
this.container,
'mousedown',
(event: MouseEvent) => {
if (event.button !== 0) return;
this.isDragging = true;
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
this.container.style.cursor = 'grabbing';
}
);
this.disposables.addFromEvent(
document,
'mousemove',
(event: MouseEvent) => {
if (!this.isDragging) return;
const deltaX = event.clientX - this.lastMouseX;
const deltaY = event.clientY - this.lastMouseY;
this.translateX += deltaX;
this.translateY += deltaY;
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
this.requestUpdate();
}
);
this.disposables.addFromEvent(document, 'mouseup', () => {
this.isDragging = false;
if (this.container) {
this.container.style.cursor = 'grab';
}
});
this.disposables.addFromEvent(this.container, 'selectstart', e =>
e.preventDefault()
);
}
private async _render() {
if (this.isRendering) return;
this.isRendering = true;
this.state = 'loading';
this.errorMessage = null;
const code = this.normalizedCode.trim();
if (!code) {
this.svgContent = '';
this.state = 'fallback';
this.isRendering = false;
return;
}
try {
await ensureTypstReady();
const typst = await getTypst();
const svg = await typst.svg({ mainContent: code });
this.svgContent = svg;
this.state = 'finish';
this._resetView();
} catch (error) {
console.error('Typst preview failed:', error);
const message =
(error as Error | undefined)?.message ??
(typeof error === 'string' ? error : null);
this.errorMessage = message;
this.state =
message && message.toLowerCase().includes('syntax')
? 'syntax-error'
: 'error';
} finally {
this.isRendering = false;
}
}
override render() {
return html`
<div class="typst-preview-wrapper">
${choose(this.state, [
[
'loading',
() =>
html`<div class="typst-preview-loading">
Rendering Typst code...
</div>`,
],
[
'error',
() =>
html`<div class="typst-preview-error">
${this._errorMessageComponent}
</div>`,
],
[
'syntax-error',
() =>
html`<div class="typst-preview-error">
Typst code has errors: ${this.errorMessage ?? 'Unknown error.'}
${this._errorMessageDetail}
</div>`,
],
[
'fallback',
() =>
html`<div class="typst-preview-fallback">
Enter Typst code to preview.
</div>`,
],
])}
<div
class="typst-preview-container"
style=${styleMap({
display: this.state === 'finish' ? undefined : 'none',
})}
>
${this._finalPreview}
</div>
</div>
`;
}
}
export function effects() {
customElements.define('typst-preview', TypstPreview);
}
declare global {
interface HTMLElementTagNameMap {
'typst-preview': TypstPreview;
}
}

View File

@@ -0,0 +1,57 @@
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;

View File

@@ -29,3 +29,13 @@ declare module '*.inline.svg' {
const src: string;
export default src;
}
declare module '*.wasm' {
const url: string;
export default url;
}
declare module '*.wasm?url' {
const url: string;
export default url;
}

View File

@@ -43,6 +43,44 @@ test.describe('Code Block Preview', () => {
).toBeVisible();
});
test('enable mermaid preview', async ({ page }) => {
const code = page.locator('affine-code');
const mermaidSvg = page.locator('mermaid-preview .mermaid-preview-svg svg');
await openHomePage(page);
await createNewPage(page);
await waitForEditorLoad(page);
await gotoContentFromTitle(page);
await type(page, '```mermaid graph TD;A-->B');
await code.hover({
position: {
x: 155,
y: 65,
},
});
await page.getByText('Preview').click();
await expect(mermaidSvg).toBeVisible();
});
test('enable typst preview', async ({ page }) => {
const code = page.locator('affine-code');
const typstPreview = page.locator('typst-preview');
await openHomePage(page);
await createNewPage(page);
await waitForEditorLoad(page);
await gotoContentFromTitle(page);
await type(page, '```typst = Title');
await code.hover({
position: {
x: 155,
y: 65,
},
});
await page.getByText('Preview').click();
await expect(typstPreview).toBeVisible();
});
test('change lang without preview', async ({ page }) => {
const code = page.locator('affine-code');
const preview = page.locator('affine-code .affine-code-block-preview');

View File

@@ -38,7 +38,6 @@ declare interface BUILD_CONFIG_TYPE {
CAPTCHA_SITE_KEY: string;
SENTRY_DSN: string;
MIXPANEL_TOKEN: string;
DEBUG_JOTAI: string;
}
declare var BUILD_CONFIG: BUILD_CONFIG_TYPE;

View File

@@ -54,7 +54,6 @@ export function getBuildConfig(
CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '',
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
MIXPANEL_TOKEN: process.env.MIXPANEL_TOKEN ?? '',
DEBUG_JOTAI: process.env.DEBUG_JOTAI ?? '',
};
},
get beta() {

View File

@@ -416,6 +416,9 @@ __metadata:
"@juggle/resize-observer": "npm:^3.4.0"
"@lit/context": "npm:^1.1.4"
"@marsidev/react-turnstile": "npm:^1.1.0"
"@myriaddreamin/typst-ts-renderer": "npm:^0.7.0-rc2"
"@myriaddreamin/typst-ts-web-compiler": "npm:^0.7.0-rc2"
"@myriaddreamin/typst.ts": "npm:^0.7.0-rc2"
"@preact/signals-core": "npm:^1.8.0"
"@radix-ui/react-collapsible": "npm:^1.1.2"
"@radix-ui/react-context-menu": "npm:^2.1.15"
@@ -8869,6 +8872,37 @@ __metadata:
languageName: node
linkType: hard
"@myriaddreamin/typst-ts-renderer@npm:^0.7.0-rc2":
version: 0.7.0-rc2
resolution: "@myriaddreamin/typst-ts-renderer@npm:0.7.0-rc2"
checksum: 10/ee5e86ef78effd0ade507e9e8467d23a6e5026e1347e07d2277ecf4b664bfa1329f26006cb34f58e0a347fdf283c32d0548125bdbd675e13e3f26a1822efd977
languageName: node
linkType: hard
"@myriaddreamin/typst-ts-web-compiler@npm:^0.7.0-rc2":
version: 0.7.0-rc2
resolution: "@myriaddreamin/typst-ts-web-compiler@npm:0.7.0-rc2"
checksum: 10/a149d6d3644eafcc68ec7b9ac512981b9f530751c2d09f467acc11bd35525e0413daa423b1d3ae1d57da48bf31705e3122a0bee87d5b214693dae9c83a963de7
languageName: node
linkType: hard
"@myriaddreamin/typst.ts@npm:^0.7.0-rc2":
version: 0.7.0-rc2
resolution: "@myriaddreamin/typst.ts@npm:0.7.0-rc2"
dependencies:
idb: "npm:^7.1.1"
peerDependencies:
"@myriaddreamin/typst-ts-renderer": ^0.7.0-rc2
"@myriaddreamin/typst-ts-web-compiler": ^0.7.0-rc2
peerDependenciesMeta:
"@myriaddreamin/typst-ts-renderer":
optional: true
"@myriaddreamin/typst-ts-web-compiler":
optional: true
checksum: 10/b843c4f1f9e33d34d574f8f22108f4b67ac628d8ed5a71fb881f1a98e4f577090a537ce1862e610338de83e5b6a129dff00365a408ee522bb18df302802e5917
languageName: node
linkType: hard
"@napi-rs/cli@npm:3.0.0":
version: 3.0.0
resolution: "@napi-rs/cli@npm:3.0.0"