mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
feat(core): code artifact tool (#13015)
<img width="1272" alt="image" src="https://github.com/user-attachments/assets/429ec60a-48a9-490b-b45f-3ce7150ef32a" /> #### PR Dependency Tree * **PR #13015** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a new AI tool for generating self-contained HTML artifacts, including a dedicated interface for previewing, copying, and downloading generated HTML. * Added syntax highlighting and preview capabilities for HTML artifacts in chat and tool panels. * Integrated the new HTML artifact tool into the AI chat prompt and Copilot toolset. * **Enhancements** * Improved artifact preview panel layout and sizing for a better user experience. * Enhanced HTML preview components to support both model-based and raw HTML rendering. * **Dependency Updates** * Added the "shiki" library for advanced syntax highlighting. * **Bug Fixes** * None. * **Chores** * Updated internal imports and code structure to support new features and maintain consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -80,6 +80,7 @@
|
||||
"react-virtuoso": "^4.12.3",
|
||||
"rxjs": "^7.8.1",
|
||||
"ses": "^1.10.0",
|
||||
"shiki": "^3.7.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"swr": "2.3.3",
|
||||
"tinykeys": "patch:tinykeys@npm%3A2.1.0#~/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch",
|
||||
|
||||
@@ -72,6 +72,15 @@ export class ChatContentStreamObjects extends WithDisposable(
|
||||
.imageProxyService=${imageProxyService}
|
||||
></doc-compose-tool>
|
||||
`;
|
||||
case 'code_artifact':
|
||||
return html`
|
||||
<code-artifact-tool
|
||||
.std=${this.host?.std}
|
||||
.data=${streamObject}
|
||||
.width=${this.width}
|
||||
.imageProxyService=${imageProxyService}
|
||||
></code-artifact-tool>
|
||||
`;
|
||||
default: {
|
||||
const name = streamObject.toolName + ' tool calling';
|
||||
return html`
|
||||
@@ -112,6 +121,15 @@ export class ChatContentStreamObjects extends WithDisposable(
|
||||
.imageProxyService=${imageProxyService}
|
||||
></doc-compose-tool>
|
||||
`;
|
||||
case 'code_artifact':
|
||||
return html`
|
||||
<code-artifact-tool
|
||||
.std=${this.host?.std}
|
||||
.data=${streamObject}
|
||||
.width=${this.width}
|
||||
.imageProxyService=${imageProxyService}
|
||||
></code-artifact-tool>
|
||||
`;
|
||||
default: {
|
||||
const name = streamObject.toolName + ' tool result';
|
||||
return html`
|
||||
|
||||
@@ -61,7 +61,7 @@ export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) {
|
||||
border-radius: 16px;
|
||||
background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')};
|
||||
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.artifact-panel-close {
|
||||
@@ -102,6 +103,10 @@ export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) {
|
||||
color: ${unsafeCSSVarV2('icon/secondary')};
|
||||
}
|
||||
|
||||
.artifact-panel-content {
|
||||
height: calc(100% - 52px);
|
||||
}
|
||||
|
||||
.artifact-panel-close:hover {
|
||||
background-color: ${unsafeCSSVarV2('layer/background/tertiary')};
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { CodeBlockHighlighter } from '@blocksuite/affine/blocks/code';
|
||||
import { toast } from '@blocksuite/affine/components/toast';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { ImageProxyService } from '@blocksuite/affine/shared/adapters';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { CopyIcon, PageIcon, ToolIcon } from '@blocksuite/icons/lit';
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { bundledLanguagesInfo, type ThemedToken } from 'shiki';
|
||||
|
||||
import {
|
||||
closePreviewPanel,
|
||||
isPreviewPanelOpen,
|
||||
renderPreviewPanel,
|
||||
} from './artifacts-preview-panel';
|
||||
import type { ToolError } from './type';
|
||||
|
||||
interface CodeArtifactToolCall {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
toolName: string; // 'code_artifact'
|
||||
args: { title: string };
|
||||
}
|
||||
|
||||
interface CodeArtifactToolResult {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
toolName: string; // 'code_artifact'
|
||||
args: { title: string };
|
||||
result:
|
||||
| {
|
||||
title: string;
|
||||
html: string;
|
||||
size: number;
|
||||
}
|
||||
| ToolError
|
||||
| null;
|
||||
}
|
||||
|
||||
export class CodeHighlighter extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
static override styles = css`
|
||||
.code-highlighter {
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.code-highlighter pre {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
font-family: ${unsafeCSSVar('fontMonoFamily')};
|
||||
}
|
||||
|
||||
/* Line numbers */
|
||||
.code-highlighter .line-numbers {
|
||||
user-select: none;
|
||||
text-align: right;
|
||||
line-height: 20px;
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
white-space: nowrap;
|
||||
min-width: 3rem;
|
||||
padding: 0 0 12px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-highlighter .line-number {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Code area */
|
||||
.code-highlighter .code-container {
|
||||
flex: 1;
|
||||
white-space: pre;
|
||||
line-height: 20px;
|
||||
font-size: 12px;
|
||||
padding: 0 12px 12px 12px;
|
||||
}
|
||||
|
||||
.code-highlighter .code-line {
|
||||
display: flex;
|
||||
min-height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor code: string = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor language: string = 'html';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showLineNumbers: boolean = false;
|
||||
|
||||
// signal holding tokens generated by shiki
|
||||
highlightTokens: Signal<ThemedToken[][]> = signal([]);
|
||||
|
||||
get highlighter() {
|
||||
return this.std.get(CodeBlockHighlighter);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// recompute highlight when code / language changes
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
return this._updateHighlightTokens();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _updateHighlightTokens() {
|
||||
let cancelled = false;
|
||||
const language = this.language;
|
||||
const highlighter = this.highlighter.highlighter$.value;
|
||||
if (!highlighter) return;
|
||||
|
||||
const updateTokens = () => {
|
||||
if (cancelled) return;
|
||||
this.highlightTokens.value = highlighter.codeToTokensBase(this.code, {
|
||||
lang: language,
|
||||
theme: this.highlighter.themeKey,
|
||||
});
|
||||
};
|
||||
|
||||
const loadedLanguages = highlighter.getLoadedLanguages();
|
||||
if (!loadedLanguages.includes(language)) {
|
||||
const matchedInfo = bundledLanguagesInfo.find(
|
||||
info =>
|
||||
info.id === language ||
|
||||
info.name === language ||
|
||||
info.aliases?.includes(language)
|
||||
);
|
||||
|
||||
if (matchedInfo) {
|
||||
highlighter
|
||||
.loadLanguage(matchedInfo.import)
|
||||
.then(updateTokens)
|
||||
.catch(console.error);
|
||||
} else {
|
||||
console.warn(`Language not supported: ${language}`);
|
||||
}
|
||||
} else {
|
||||
updateTokens();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
private _tokenStyle(token: ThemedToken): string {
|
||||
let result = '';
|
||||
if (token.color) {
|
||||
result += `color: ${token.color};`;
|
||||
}
|
||||
if (token.fontStyle) {
|
||||
result += `font-style: ${token.fontStyle};`;
|
||||
}
|
||||
if (token.bgColor) {
|
||||
result += `background-color: ${token.bgColor};`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const tokens = this.highlightTokens.value;
|
||||
const lineCount =
|
||||
tokens.length > 0 ? tokens.length : this.code.split('\n').length;
|
||||
|
||||
const lineNumbersTemplate = this.showLineNumbers
|
||||
? html`<div class="line-numbers">
|
||||
${Array.from(
|
||||
{ length: lineCount },
|
||||
(_, i) => html`<span class="line-number">${i + 1}</span>`
|
||||
)}
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const renderedCode =
|
||||
tokens.length === 0
|
||||
? this.code
|
||||
: html`${tokens.map(lineTokens => {
|
||||
const line = lineTokens.map(token => {
|
||||
const style = this._tokenStyle(token);
|
||||
return html`<span style="${style}">${token.content}</span>`;
|
||||
});
|
||||
return html`<div class="code-line">${line}</div>`;
|
||||
})}`;
|
||||
|
||||
return html`<div class="code-highlighter">
|
||||
<pre>
|
||||
${lineNumbersTemplate}
|
||||
<div class="code-container">${renderedCode}</div>
|
||||
</pre>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to render code artifact tool call/result inside chat.
|
||||
*/
|
||||
export class CodeArtifactTool extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.code-artifact-result {
|
||||
cursor: pointer;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.code-artifact-result:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.code-artifact-preview {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code-artifact-preview > html-preview {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.code-artifact-preview :is(.html-preview-iframe, .html-preview-container) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.code-artifact-control-btn {
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${unsafeCSSVarV2('button/innerBlackBorder')};
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-artifact-control-btn:hover {
|
||||
background: ${unsafeCSSVarV2('switch/buttonBackground/hover')};
|
||||
}
|
||||
|
||||
/* Toggle styles (migrated from PreviewButton) */
|
||||
.code-artifact-toggle-container {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
background: ${unsafeCSSVarV2('segment/background')};
|
||||
}
|
||||
|
||||
.code-artifact-toggle-container .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;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.code-artifact-toggle-container .toggle-button:hover {
|
||||
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
|
||||
.code-artifact-toggle-container .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));
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor data!: CodeArtifactToolCall | CodeArtifactToolResult;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor width: Signal<number | undefined> | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor imageProxyService: ImageProxyService | null | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std: BlockStdScope | undefined;
|
||||
|
||||
@state()
|
||||
private accessor mode: 'preview' | 'code' = 'code';
|
||||
|
||||
private renderToolCall() {
|
||||
const { args } = this.data as CodeArtifactToolCall;
|
||||
const name = `Generating HTML artifact "${args.title}"`;
|
||||
return html`<tool-call-card
|
||||
.name=${name}
|
||||
.icon=${ToolIcon()}
|
||||
></tool-call-card>`;
|
||||
}
|
||||
|
||||
private renderToolResult() {
|
||||
if (!this.std) return nothing;
|
||||
if (this.data.type !== 'tool-result') return nothing;
|
||||
const resultData = this.data as CodeArtifactToolResult;
|
||||
const result = resultData.result;
|
||||
|
||||
if (result && typeof result === 'object' && 'title' in result) {
|
||||
const { title, html: htmlContent } = result as {
|
||||
title: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
const onClick = () => {
|
||||
if (isPreviewPanelOpen(this)) {
|
||||
closePreviewPanel(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const copyHTML = async () => {
|
||||
if (this.std) {
|
||||
await navigator.clipboard
|
||||
.writeText(htmlContent)
|
||||
.catch(console.error);
|
||||
toast(this.std.host, 'Copied HTML to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const downloadHTML = () => {
|
||||
try {
|
||||
const blob = new Blob([htmlContent], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'artifact'}.html`;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const setCodeMode = () => {
|
||||
if (this.mode !== 'code') {
|
||||
this.mode = 'code';
|
||||
renderPreview();
|
||||
}
|
||||
};
|
||||
|
||||
const setPreviewMode = () => {
|
||||
if (this.mode !== 'preview') {
|
||||
this.mode = 'preview';
|
||||
renderPreview();
|
||||
}
|
||||
};
|
||||
|
||||
const renderPreview = () => {
|
||||
const controls = html`
|
||||
<div class="code-artifact-toggle-container">
|
||||
<div
|
||||
class=${classMap({
|
||||
'toggle-button': true,
|
||||
active: this.mode === 'code',
|
||||
})}
|
||||
@click=${setCodeMode}
|
||||
>
|
||||
Code
|
||||
</div>
|
||||
<div
|
||||
class=${classMap({
|
||||
'toggle-button': true,
|
||||
active: this.mode === 'preview',
|
||||
})}
|
||||
@click=${setPreviewMode}
|
||||
>
|
||||
Preview
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1"></div>
|
||||
<button class="code-artifact-control-btn" @click=${downloadHTML}>
|
||||
${PageIcon({
|
||||
width: '20',
|
||||
height: '20',
|
||||
style: `color: ${unsafeCSSVarV2('icon/primary')}`,
|
||||
})}
|
||||
Download
|
||||
</button>
|
||||
<icon-button @click=${copyHTML} title="Copy HTML">
|
||||
${CopyIcon({ width: '20', height: '20' })}
|
||||
</icon-button>
|
||||
`;
|
||||
renderPreviewPanel(
|
||||
this,
|
||||
html`<div class="code-artifact-preview">
|
||||
${this.mode === 'preview'
|
||||
? html`<html-preview .html=${htmlContent}></html-preview>`
|
||||
: html`<code-highlighter
|
||||
.std=${this.std}
|
||||
.code=${htmlContent}
|
||||
.language=${'html'}
|
||||
.showLineNumbers=${true}
|
||||
></code-highlighter>`}
|
||||
</div>`,
|
||||
controls
|
||||
);
|
||||
};
|
||||
|
||||
renderPreview();
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="affine-embed-linked-doc-block code-artifact-result horizontal"
|
||||
@click=${onClick}
|
||||
>
|
||||
<div class="affine-embed-linked-doc-content">
|
||||
<div class="affine-embed-linked-doc-content-title">
|
||||
<div class="affine-embed-linked-doc-content-title-icon">
|
||||
${PageIcon({ width: '20', height: '20' })}
|
||||
</div>
|
||||
<div class="affine-embed-linked-doc-content-title-text">
|
||||
${title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<tool-call-failed
|
||||
.name=${'Code artifact failed'}
|
||||
.icon=${ToolIcon()}
|
||||
></tool-call-failed>`;
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
if (this.data.type === 'tool-call') {
|
||||
return this.renderToolCall();
|
||||
}
|
||||
if (this.data.type === 'tool-result') {
|
||||
return this.renderToolResult();
|
||||
}
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
|
||||
import { CopyIcon, PageIcon, ToolIcon } from '@blocksuite/icons/lit';
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
import { type Signal } from '@preact/signals-core';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
closePreviewPanel,
|
||||
isPreviewPanelOpen,
|
||||
renderPreviewPanel,
|
||||
} from './artifacts';
|
||||
} from './artifacts-preview-panel';
|
||||
import type { ToolError } from './type';
|
||||
|
||||
interface DocComposeToolCall {
|
||||
|
||||
@@ -47,7 +47,11 @@ import { ChatContentPureText } from './components/ai-message-content/pure-text';
|
||||
import { ChatContentRichText } from './components/ai-message-content/rich-text';
|
||||
import { ChatContentStreamObjects } from './components/ai-message-content/stream-objects';
|
||||
import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer';
|
||||
import { ArtifactPreviewPanel } from './components/ai-tools/artifacts';
|
||||
import { ArtifactPreviewPanel } from './components/ai-tools/artifacts-preview-panel';
|
||||
import {
|
||||
CodeArtifactTool,
|
||||
CodeHighlighter,
|
||||
} from './components/ai-tools/code-artifact';
|
||||
import { DocComposeTool } from './components/ai-tools/doc-compose';
|
||||
import { ToolCallCard } from './components/ai-tools/tool-call-card';
|
||||
import { ToolFailedCard } from './components/ai-tools/tool-failed-card';
|
||||
@@ -179,6 +183,8 @@ export function registerAIEffects() {
|
||||
customElements.define('web-crawl-tool', WebCrawlTool);
|
||||
customElements.define('web-search-tool', WebSearchTool);
|
||||
customElements.define('doc-compose-tool', DocComposeTool);
|
||||
customElements.define('code-artifact-tool', CodeArtifactTool);
|
||||
customElements.define('code-highlighter', CodeHighlighter);
|
||||
customElements.define('artifact-preview-panel', ArtifactPreviewPanel);
|
||||
|
||||
customElements.define(AFFINE_AI_PANEL_WIDGET, AffineAIPanelWidget);
|
||||
|
||||
@@ -2,7 +2,8 @@ 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 { ShadowlessElement } from '@blocksuite/std';
|
||||
import { css, html, 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';
|
||||
@@ -14,7 +15,9 @@ export const CodeBlockHtmlPreview = CodeBlockPreviewExtension(
|
||||
model => html`<html-preview .model=${model}></html-preview>`
|
||||
);
|
||||
|
||||
export class HTMLPreview extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
export class HTMLPreview extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
.html-preview-loading {
|
||||
color: ${unsafeCSSVarV2('text/placeholder')};
|
||||
@@ -53,7 +56,10 @@ export class HTMLPreview extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor model!: CodeBlockModel;
|
||||
accessor model: CodeBlockModel | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor html: string | null = null;
|
||||
|
||||
@state()
|
||||
accessor state: 'loading' | 'error' | 'finish' | 'fallback' = 'loading';
|
||||
@@ -66,20 +72,39 @@ export class HTMLPreview extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
|
||||
this._link();
|
||||
|
||||
this.disposables.add(
|
||||
this.model.props.text$.subscribe(() => {
|
||||
this._link();
|
||||
})
|
||||
);
|
||||
if (this.model) {
|
||||
this.disposables.add(
|
||||
this.model.props.text$.subscribe(() => {
|
||||
this._link();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
override updated(changedProperties: PropertyValues): void {
|
||||
const result = super.updated(changedProperties);
|
||||
if (changedProperties.has('html')) {
|
||||
this._link();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
get normalizedHtml() {
|
||||
return this.model?.props.text.toString() ?? this.html;
|
||||
}
|
||||
|
||||
private _link() {
|
||||
this.state = 'loading';
|
||||
|
||||
if (!this.normalizedHtml) {
|
||||
this.state = 'fallback';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
linkIframe(this.iframe, this.model);
|
||||
linkIframe(this.iframe, this.normalizedHtml);
|
||||
this.state = 'finish';
|
||||
} catch (error) {
|
||||
console.error('HTML preview iframe failed:', error);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type { CodeBlockModel } from '@blocksuite/affine/model';
|
||||
|
||||
export function linkIframe(iframe: HTMLIFrameElement, model: CodeBlockModel) {
|
||||
const html = model.props.text.toString();
|
||||
export function linkIframe(iframe: HTMLIFrameElement, html: string) {
|
||||
// force reload iframe
|
||||
iframe.src = '';
|
||||
iframe.src = 'https://affine.run/static/container.html';
|
||||
|
||||
Reference in New Issue
Block a user