From 4f1d57ade5e4fd708471f92736a22fea346d0c6b Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sun, 28 Dec 2025 04:55:22 +0800 Subject: [PATCH] feat: integrate typst preview & fix mermaid style (#14168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ✏️ Tip: You can customize this high-level summary in your review settings. --- packages/frontend/core/package.json | 3 + .../code-block-preview/index.ts | 6 + .../code-block-preview/mermaid-preview.ts | 3 + .../code-block-preview/typst-preview.ts | 457 ++++++++++++++++++ .../code-block-preview/typst.ts | 57 +++ packages/frontend/core/src/types/types.d.ts | 10 + .../e2e/blocksuite/code/crud.spec.ts | 38 ++ tools/@types/build-config/__all.d.ts | 1 - tools/utils/src/build-config.ts | 1 - yarn.lock | 34 ++ 10 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/typst-preview.ts create mode 100644 packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/typst.ts diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index da30d6363..91ef6116b 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -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", diff --git a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/index.ts b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/index.ts index 5d2e10574..899ee05e6 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/index.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/index.ts @@ -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); } } 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 5ca2f0210..2c4edf7ae 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 @@ -77,6 +77,9 @@ export class MermaidPreview extends SignalWatcher( } .mermaid-preview-svg > div { + display: flex; + justify-content: center; + width: 100%; transform-origin: center; } diff --git a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/typst-preview.ts b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/typst-preview.ts new file mode 100644 index 000000000..7da66135c --- /dev/null +++ b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/typst-preview.ts @@ -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`` +); + +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 | 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`
+ Error details +
+${this.errorMessage}
+
+ +
+
` + : 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` +
+ + + +
+
+ ${this.svgContent + ? html`
` + : nothing} +
+ ` + : 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` +
+ ${choose(this.state, [ + [ + 'loading', + () => + html`
+ Rendering Typst code... +
`, + ], + [ + 'error', + () => + html`
+ ${this._errorMessageComponent} +
`, + ], + [ + 'syntax-error', + () => + html`
+ Typst code has errors: ${this.errorMessage ?? 'Unknown error.'} + ${this._errorMessageDetail} +
`, + ], + [ + 'fallback', + () => + html`
+ Enter Typst code to preview. +
`, + ], + ])} +
+ ${this._finalPreview} +
+
+ `; + } +} + +export function effects() { + customElements.define('typst-preview', TypstPreview); +} + +declare global { + interface HTMLElementTagNameMap { + 'typst-preview': TypstPreview; + } +} diff --git a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/typst.ts b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/typst.ts new file mode 100644 index 000000000..d48594d84 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/typst.ts @@ -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 | 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; diff --git a/packages/frontend/core/src/types/types.d.ts b/packages/frontend/core/src/types/types.d.ts index cfc08c0ac..19dd57cf2 100644 --- a/packages/frontend/core/src/types/types.d.ts +++ b/packages/frontend/core/src/types/types.d.ts @@ -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; +} diff --git a/tests/affine-local/e2e/blocksuite/code/crud.spec.ts b/tests/affine-local/e2e/blocksuite/code/crud.spec.ts index c85210c09..a1481ef90 100644 --- a/tests/affine-local/e2e/blocksuite/code/crud.spec.ts +++ b/tests/affine-local/e2e/blocksuite/code/crud.spec.ts @@ -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'); diff --git a/tools/@types/build-config/__all.d.ts b/tools/@types/build-config/__all.d.ts index 745893bbd..58da669ad 100644 --- a/tools/@types/build-config/__all.d.ts +++ b/tools/@types/build-config/__all.d.ts @@ -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; diff --git a/tools/utils/src/build-config.ts b/tools/utils/src/build-config.ts index 9f6c26071..1ee80698e 100644 --- a/tools/utils/src/build-config.ts +++ b/tools/utils/src/build-config.ts @@ -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() { diff --git a/yarn.lock b/yarn.lock index dd2b9644c..cc143d5c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"