mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user