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

@@ -39,6 +39,7 @@
"@toeverything/pdf-viewer": "^0.1.1",
"@toeverything/theme": "^1.1.14",
"@vanilla-extract/dynamic": "^2.1.2",
"@webcontainer/api": "^1.6.1",
"animejs": "^4.0.0",
"bytes": "^3.1.2",
"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 type { FrameworkProvider } from '@toeverything/infra';
import { CodeBlockPreviewExtensionProvider } from './code-block-preview';
const manager = new ViewExtensionManager([
...getInternalViewExtensions(),
AffineCommonViewExtension,
AffineEditorViewExtension,
CodeBlockPreviewExtensionProvider,
]);
export function getViewManager(