mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-20 15:57:06 +08: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:
@@ -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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
27
blocksuite/affine/blocks/code/src/code-preview-extension.ts
Normal file
27
blocksuite/affine/blocks/code/src/code-preview-extension.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 { 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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user