feat(editor): support preview mode in code block (#11805)

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

- **New Features**
  - Introduced a preview mode for code blocks, allowing users to toggle between code and rendered previews (e.g., HTML output) directly within the editor.
  - Added a preview toggle button to the code block toolbar for supported languages.
  - Enabled dynamic rendering of code block previews using a shared WebContainer, allowing live HTML previews in an embedded iframe.
  - Added HTML preview support with loading and error states for enhanced user feedback.
  - Integrated the preview feature as a view extension provider for seamless framework support.

- **Bug Fixes**
  - Improved toolbar layout and button alignment for a cleaner user interface.

- **Tests**
  - Added end-to-end tests to verify the new code block preview functionality and language switching behavior.

- **Chores**
  - Updated development server configuration to include enhanced security headers.
  - Added a new runtime dependency for WebContainer support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Flrande
2025-05-06 09:14:12 +00:00
parent 1e89aa48cb
commit f79dfe837f
18 changed files with 520 additions and 7 deletions

View File

@@ -26,11 +26,13 @@ import { computed, effect, type Signal, signal } from '@preact/signals-core';
import { html, nothing, type TemplateResult } from 'lit'; import { html, nothing, type TemplateResult } from 'lit';
import { query } from 'lit/decorators.js'; import { query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { bundledLanguagesInfo, type ThemedToken } from 'shiki'; import { bundledLanguagesInfo, type ThemedToken } from 'shiki';
import { CodeBlockConfigExtension } from './code-block-config.js'; import { CodeBlockConfigExtension } from './code-block-config.js';
import { CodeBlockInlineManagerExtension } from './code-block-inline.js'; import { CodeBlockInlineManagerExtension } from './code-block-inline.js';
import { CodeBlockHighlighter } from './code-block-service.js'; import { CodeBlockHighlighter } from './code-block-service.js';
import { CodeBlockPreviewIdentifier } from './code-preview-extension.js';
import { codeBlockStyles } from './styles.js'; import { codeBlockStyles } from './styles.js';
export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel> { export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel> {
@@ -384,6 +386,12 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
this.std.getOptional(CodeBlockConfigExtension.identifier) this.std.getOptional(CodeBlockConfigExtension.identifier)
?.showLineNumbers ?? true; ?.showLineNumbers ?? true;
const preview = !!this.model.props.preview;
const previewContext = this.std.getOptional(
CodeBlockPreviewIdentifier(this.model.props.language ?? '')
);
const shouldRenderPreview = preview && previewContext;
return html` return html`
<div <div
class=${classMap({ class=${classMap({
@@ -393,6 +401,9 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
})} })}
> >
<rich-text <rich-text
style=${styleMap({
display: shouldRenderPreview ? 'none' : undefined,
})}
.yText=${this.model.props.text.yText} .yText=${this.model.props.text.yText}
.inlineEventSource=${this.topContenteditableElement ?? nothing} .inlineEventSource=${this.topContenteditableElement ?? nothing}
.undoManager=${this.doc.history} .undoManager=${this.doc.history}
@@ -416,7 +427,15 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
: undefined} : undefined}
> >
</rich-text> </rich-text>
<div
style=${styleMap({
display: shouldRenderPreview ? undefined : 'none',
})}
contenteditable="false"
class="affine-code-block-preview"
>
${previewContext?.renderer(this.model)}
</div>
${this.renderChildren(this.model)} ${Object.values(this.widgets)} ${this.renderChildren(this.model)} ${Object.values(this.widgets)}
</div> </div>
`; `;

View File

@@ -0,0 +1,27 @@
import type { CodeBlockModel } from '@blocksuite/affine-model';
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { HTMLTemplateResult } from 'lit';
export type CodeBlockPreviewRenderer = (
model: CodeBlockModel
) => HTMLTemplateResult | null;
export type CodeBlockPreviewContext = {
renderer: CodeBlockPreviewRenderer;
lang: string;
};
export const CodeBlockPreviewIdentifier =
createIdentifier<CodeBlockPreviewContext>('CodeBlockPreview');
export function CodeBlockPreviewExtension(
lang: string,
renderer: CodeBlockPreviewRenderer
): ExtensionType {
return {
setup: di => {
di.addImpl(CodeBlockPreviewIdentifier(lang), { renderer, lang });
},
};
}

View File

@@ -30,7 +30,6 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
padding: 4px; padding: 4px;
margin: 0; margin: 0;
display: flex; display: flex;
justify-content: flex-end;
} }
.code-toolbar-button { .code-toolbar-button {
@@ -39,6 +38,10 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
box-shadow: var(--affine-shadow-1); box-shadow: var(--affine-shadow-1);
border-radius: 4px; border-radius: 4px;
} }
.copy-code {
margin-left: auto;
}
`; `;
private _currentOpenMenu: AbortController | null = null; private _currentOpenMenu: AbortController | null = null;

View File

@@ -18,10 +18,6 @@ export class LanguageListButton extends WithDisposable(
SignalWatcher(LitElement) SignalWatcher(LitElement)
) { ) {
static override styles = css` static override styles = css`
:host {
margin-right: auto;
}
.lang-button { .lang-button {
background-color: var(--affine-background-primary-color); background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1); box-shadow: var(--affine-shadow-1);

View File

@@ -0,0 +1,97 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import type { CodeBlockComponent } from '../../code-block';
import { CodeBlockPreviewIdentifier } from '../../code-preview-extension';
export class PreviewButton extends WithDisposable(SignalWatcher(LitElement)) {
static override styles = css`
.preview-toggle-container {
display: flex;
padding: 2px;
align-items: flex-start;
gap: 4px;
border-radius: 4px;
background: ${unsafeCSSVarV2('segment/background')};
}
.toggle-button {
display: flex;
padding: 0px 4px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 4px;
color: ${unsafeCSSVarV2('text/primary')};
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px;
}
.toggle-button:hover {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.toggle-button.active {
background: ${unsafeCSSVarV2('segment/button')};
box-shadow:
var(--Shadow-buttonShadow-1-x, 0px) var(--Shadow-buttonShadow-1-y, 0px)
var(--Shadow-buttonShadow-1-blur, 1px) 0px
var(--Shadow-buttonShadow-1-color, rgba(0, 0, 0, 0.12)),
var(--Shadow-buttonShadow-2-x, 0px) var(--Shadow-buttonShadow-2-y, 1px)
var(--Shadow-buttonShadow-2-blur, 5px) 0px
var(--Shadow-buttonShadow-2-color, rgba(0, 0, 0, 0.12));
}
`;
private readonly _toggle = (value: boolean) => {
if (this.blockComponent.doc.readonly) return;
this.blockComponent.doc.updateBlock(this.blockComponent.model, {
preview: value,
});
};
get preview() {
return !!this.blockComponent.model.props.preview$.value;
}
override render() {
const lang = this.blockComponent.model.props.language$.value ?? '';
const previewContext = this.blockComponent.std.getOptional(
CodeBlockPreviewIdentifier(lang)
);
if (!previewContext) return nothing;
return html`
<div class="preview-toggle-container">
<div
class=${classMap({
'toggle-button': true,
active: !this.preview,
})}
@click=${() => this._toggle(false)}
>
Code
</div>
<div
class=${classMap({
'toggle-button': true,
active: this.preview,
})}
@click=${() => this._toggle(true)}
>
Preview
</div>
</div>
`;
}
@property({ attribute: false })
accessor blockComponent!: CodeBlockComponent;
}

View File

@@ -42,6 +42,18 @@ export const PRIMARY_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
}; };
}, },
}, },
{
type: 'preview',
generate: ({ blockComponent }) => {
return {
action: noop,
render: () => html`
<preview-button .blockComponent=${blockComponent}>
</preview-button>
`,
};
},
},
{ {
type: 'copy-code', type: 'copy-code',
label: 'Copy code', label: 'Copy code',

View File

@@ -5,6 +5,7 @@ import {
} from './code-toolbar'; } from './code-toolbar';
import { AffineCodeToolbar } from './code-toolbar/components/code-toolbar'; import { AffineCodeToolbar } from './code-toolbar/components/code-toolbar';
import { LanguageListButton } from './code-toolbar/components/lang-button'; import { LanguageListButton } from './code-toolbar/components/lang-button';
import { PreviewButton } from './code-toolbar/components/preview-button';
import { AffineCodeUnit } from './highlight/affine-code-unit'; import { AffineCodeUnit } from './highlight/affine-code-unit';
export function effects() { export function effects() {
@@ -13,12 +14,14 @@ export function effects() {
customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget); customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget);
customElements.define('affine-code-unit', AffineCodeUnit); customElements.define('affine-code-unit', AffineCodeUnit);
customElements.define('affine-code', CodeBlockComponent); customElements.define('affine-code', CodeBlockComponent);
customElements.define('preview-button', PreviewButton);
} }
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'language-list-button': LanguageListButton; 'language-list-button': LanguageListButton;
'affine-code-toolbar': AffineCodeToolbar; 'affine-code-toolbar': AffineCodeToolbar;
'preview-button': PreviewButton;
[AFFINE_CODE_TOOLBAR_WIDGET]: AffineCodeToolbarWidget; [AFFINE_CODE_TOOLBAR_WIDGET]: AffineCodeToolbarWidget;
} }
} }

View File

@@ -3,6 +3,7 @@ export * from './clipboard';
export * from './code-block'; export * from './code-block';
export * from './code-block-config'; export * from './code-block-config';
export * from './code-block-spec'; export * from './code-block-spec';
export * from './code-preview-extension';
export * from './code-toolbar'; export * from './code-toolbar';
export * from './turbo/code-layout-handler'; export * from './turbo/code-layout-handler';
export * from './turbo/code-painter.worker'; export * from './turbo/code-painter.worker';

View File

@@ -49,4 +49,8 @@ export const codeBlockStyles = css`
box-sizing: border-box; box-sizing: border-box;
user-select: none; user-select: none;
} }
affine-code .affine-code-block-preview {
padding: 12px;
}
`; `;

View File

@@ -12,6 +12,7 @@ type CodeBlockProps = {
language: string | null; language: string | null;
wrap: boolean; wrap: boolean;
caption: string; caption: string;
preview?: boolean;
} & BlockMeta; } & BlockMeta;
export const CodeBlockSchema = defineBlockSchema({ export const CodeBlockSchema = defineBlockSchema({
@@ -22,6 +23,7 @@ export const CodeBlockSchema = defineBlockSchema({
language: null, language: null,
wrap: false, wrap: false,
caption: '', caption: '',
preview: undefined,
'meta:createdAt': undefined, 'meta:createdAt': undefined,
'meta:createdBy': undefined, 'meta:createdBy': undefined,
'meta:updatedAt': undefined, 'meta:updatedAt': undefined,

View File

@@ -39,6 +39,7 @@
"@toeverything/pdf-viewer": "^0.1.1", "@toeverything/pdf-viewer": "^0.1.1",
"@toeverything/theme": "^1.1.14", "@toeverything/theme": "^1.1.14",
"@vanilla-extract/dynamic": "^2.1.2", "@vanilla-extract/dynamic": "^2.1.2",
"@webcontainer/api": "^1.6.1",
"animejs": "^4.0.0", "animejs": "^4.0.0",
"bytes": "^3.1.2", "bytes": "^3.1.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -0,0 +1,123 @@
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 { css, html, LitElement, 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 { linkWebContainer } from './web-container';
export const CodeBlockHtmlPreview = CodeBlockPreviewExtension(
'html',
model => html`<html-preview .model=${model}></html-preview>`
);
export class HTMLPreview extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css`
.html-preview-loading {
color: ${unsafeCSSVarV2('text/placeholder')};
font-feature-settings:
'liga' off,
'clig' off;
/* light/code/base */
font-family: 'IBM Plex Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
.html-preview-error {
color: ${unsafeCSSVarV2('button/error')};
font-feature-settings:
'liga' off,
'clig' off;
/* light/code/base */
font-family: 'IBM Plex Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
`;
@property({ attribute: false })
accessor model!: CodeBlockModel;
@state()
accessor state: 'loading' | 'error' | 'finish' = 'loading';
@query('iframe')
accessor iframe!: HTMLIFrameElement;
override firstUpdated(_changedProperties: PropertyValues): void {
const result = super.firstUpdated(_changedProperties);
this._link();
this.disposables.add(
this.model.props.text$.subscribe(() => {
this._link();
})
);
return result;
}
private _link() {
this.state = 'loading';
linkWebContainer(this.iframe, this.model)
.then(() => {
this.state = 'finish';
})
.catch(error => {
console.error('Failed to link WebContainer:', error);
this.state = 'error';
});
}
override render() {
return html`
<div class="html-preview-container">
${choose(this.state, [
[
'loading',
() =>
html`<div class="html-preview-loading">
Rendering the code...
</div>`,
],
[
'error',
() =>
html`<div class="html-preview-error">
Failed to render the preview. Please check your HTML code for
errors.
</div>`,
],
])}
<iframe
class="html-preview-iframe"
title="HTML Preview"
style=${styleMap({
display: this.state === 'finish' ? undefined : 'none',
})}
></iframe>
</div>
`;
}
}
export function effects() {
customElements.define('html-preview', HTMLPreview);
}
declare global {
interface HTMLElementTagNameMap {
'html-preview': HTMLPreview;
}
}

View File

@@ -0,0 +1,110 @@
import type { CodeBlockModel } from '@blocksuite/affine-model';
import { WebContainer } from '@webcontainer/api';
// cross-browser replacement for `Promise.withResolvers`
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
}
const createDeferred = <T>(): Deferred<T> => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
let sharedWebContainer: WebContainer | null = null;
let bootPromise: Promise<WebContainer> | null = null;
const getSharedWebContainer = async (): Promise<WebContainer> => {
if (sharedWebContainer) {
return sharedWebContainer;
}
if (bootPromise) {
return bootPromise;
}
bootPromise = WebContainer.boot();
try {
sharedWebContainer = await bootPromise;
return sharedWebContainer;
} catch (e) {
throw new Error('Failed to boot WebContainer: ' + e);
}
};
let serveUrl: string | null = null;
let settingServerUrlPromise: Promise<string> | null = null;
const resetServerUrl = () => {
serveUrl = null;
settingServerUrlPromise = null;
};
const getServeUrl = async (): Promise<string> => {
if (serveUrl) {
return serveUrl;
}
if (settingServerUrlPromise) {
return settingServerUrlPromise;
}
const { promise, resolve, reject } = createDeferred<string>();
settingServerUrlPromise = promise;
try {
const webContainer = await getSharedWebContainer();
await webContainer.fs.writeFile(
'package.json',
`{
"name":"preview",
"devDependencies":{"serve":"^14.0.0"}
}`
);
const dispose = webContainer.on('server-ready', (_, url) => {
dispose();
serveUrl = url;
resolve(url);
});
const installProcess = await webContainer.spawn('npm', ['install']);
await installProcess.exit;
const serverProcess = await webContainer.spawn('npx', ['serve']);
serverProcess.exit
.then(() => {
resetServerUrl();
})
.catch(e => {
resetServerUrl();
reject(e);
});
} catch (e) {
resetServerUrl();
reject(e);
}
return promise;
};
export async function linkWebContainer(
iframe: HTMLIFrameElement,
model: CodeBlockModel
) {
const html = model.props.text.toString();
const id = model.id;
const webContainer = await getSharedWebContainer();
const serveUrl = await getServeUrl();
await webContainer.fs.writeFile(`${id}.html`, html);
iframe.src = `${serveUrl}/${id}.html`;
}

View File

@@ -0,0 +1,24 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import {
CodeBlockHtmlPreview,
effects as htmlPreviewEffects,
} from '../extensions/code-block-preview/html-preview';
export class CodeBlockPreviewExtensionProvider extends ViewExtensionProvider {
override name = 'code-block-preview';
override effect() {
super.effect();
htmlPreviewEffects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(CodeBlockHtmlPreview);
}
}

View File

@@ -12,11 +12,14 @@ import { getInternalViewExtensions } from '@blocksuite/affine/extensions/view';
import { LinkedDocViewExtension } from '@blocksuite/affine/widgets/linked-doc/view'; import { LinkedDocViewExtension } from '@blocksuite/affine/widgets/linked-doc/view';
import type { FrameworkProvider } from '@toeverything/infra'; import type { FrameworkProvider } from '@toeverything/infra';
import { CodeBlockPreviewExtensionProvider } from './code-block-preview';
const manager = new ViewExtensionManager([ const manager = new ViewExtensionManager([
...getInternalViewExtensions(), ...getInternalViewExtensions(),
AffineCommonViewExtension, AffineCommonViewExtension,
AffineEditorViewExtension, AffineEditorViewExtension,
CodeBlockPreviewExtensionProvider,
]); ]);
export function getViewManager( export function getViewManager(

View File

@@ -1,7 +1,13 @@
import { test } from '@affine-test/kit/playwright'; import { test } from '@affine-test/kit/playwright';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import { type, waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import { initCodeBlockByOneStep } from './utils'; import {
createNewPage,
gotoContentFromTitle,
initCodeBlockByOneStep,
} from './utils';
test.describe('Code Block Autocomplete Operations', () => { test.describe('Code Block Autocomplete Operations', () => {
test('angle brackets are not supported', async ({ page }) => { test('angle brackets are not supported', async ({ page }) => {
@@ -12,3 +18,73 @@ test.describe('Code Block Autocomplete Operations', () => {
await expect(codeUnit).toHaveText('<'); await expect(codeUnit).toHaveText('<');
}); });
}); });
test.describe('Code Block Preview', () => {
test('enable html preview', async ({ page }) => {
const code = page.locator('affine-code');
const htmlPreview = page.locator('html-preview');
await openHomePage(page);
await createNewPage(page);
await waitForEditorLoad(page);
await gotoContentFromTitle(page);
await type(page, '```html aaa');
await page.waitForTimeout(3000);
// web container can not load as expected at the first time in playwright, not sure why
await page.reload();
await code.hover({
position: {
x: 155,
y: 65,
},
});
await page.getByText('Preview').click();
await expect(
page
.locator('iframe[title="HTML Preview"]')
.contentFrame()
.getByText('aaa')
).toBeHidden();
await expect(htmlPreview).toHaveText('Rendering the code...');
await page.waitForTimeout(20000);
await expect(
page
.locator('iframe[title="HTML Preview"]')
.contentFrame()
.getByText('aaa')
).toBeVisible();
});
test('change lang without preview', async ({ page }) => {
const code = page.locator('affine-code');
const preview = page.locator('affine-code .affine-code-block-preview');
await openHomePage(page);
await createNewPage(page);
await waitForEditorLoad(page);
await gotoContentFromTitle(page);
await type(page, '```html aaa');
await code.hover({
position: {
x: 155,
y: 65,
},
});
await page.getByText('Preview').click();
await expect(preview).toBeVisible();
// change to lang without preview support
await page.getByTestId('lang-button').click();
await page.getByRole('button', { name: 'ABAP' }).click();
await expect(preview).toBeHidden();
// change back to html
await page.getByTestId('lang-button').click();
await page.getByRole('button', { name: 'HTML', exact: true }).click();
await expect(preview).toBeVisible();
});
});

View File

@@ -120,6 +120,10 @@ const defaultDevServerConfig: DevServerConfiguration = {
}, },
], ],
}, },
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
proxy: [ proxy: [
{ {
context: '/api', context: '/api',

View File

@@ -431,6 +431,7 @@ __metadata:
"@types/lodash-es": "npm:^4.17.12" "@types/lodash-es": "npm:^4.17.12"
"@vanilla-extract/css": "npm:^1.17.0" "@vanilla-extract/css": "npm:^1.17.0"
"@vanilla-extract/dynamic": "npm:^2.1.2" "@vanilla-extract/dynamic": "npm:^2.1.2"
"@webcontainer/api": "npm:^1.6.1"
animejs: "npm:^4.0.0" animejs: "npm:^4.0.0"
bytes: "npm:^3.1.2" bytes: "npm:^3.1.2"
clsx: "npm:^2.1.1" clsx: "npm:^2.1.1"
@@ -16444,6 +16445,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@webcontainer/api@npm:^1.6.1":
version: 1.6.1
resolution: "@webcontainer/api@npm:1.6.1"
checksum: 10/c5502da3a86425199f1171665f4c32bdb73cc5b8291abdee3a934023c2f276bb35382e0a36b067f9bd41d96a722419048ee89847852dcc3f60cba70bccf51ca6
languageName: node
linkType: hard
"@whatwg-node/disposablestack@npm:^0.0.6": "@whatwg-node/disposablestack@npm:^0.0.6":
version: 0.0.6 version: 0.0.6
resolution: "@whatwg-node/disposablestack@npm:0.0.6" resolution: "@whatwg-node/disposablestack@npm:0.0.6"