From 8ed7dea8230237bfcee6a215ef0d9828b7a8f4bc Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Fri, 4 Jul 2025 07:39:51 +0800 Subject: [PATCH] feat(core): code artifact tool (#13015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image #### PR Dependency Tree * **PR #13015** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## 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. --- blocksuite/affine/blocks/code/src/index.ts | 2 + .../src/plugins/copilot/prompt/prompts.ts | 1 + .../src/plugins/copilot/providers/provider.ts | 5 + .../src/plugins/copilot/providers/types.ts | 1 + .../src/plugins/copilot/providers/utils.ts | 2 + .../plugins/copilot/tools/code-artifact.ts | 84 ++++ .../server/src/plugins/copilot/tools/index.ts | 1 + packages/frontend/core/package.json | 1 + .../ai-message-content/stream-objects.ts | 18 + ...rtifacts.ts => artifacts-preview-panel.ts} | 7 +- .../ai/components/ai-tools/code-artifact.ts | 466 ++++++++++++++++++ .../ai/components/ai-tools/doc-compose.ts | 4 +- .../core/src/blocksuite/ai/effects.ts | 8 +- .../code-block-preview/html-preview.ts | 43 +- .../code-block-preview/iframe-container.ts | 5 +- yarn.lock | 79 +-- 16 files changed, 671 insertions(+), 56 deletions(-) create mode 100644 packages/backend/server/src/plugins/copilot/tools/code-artifact.ts rename packages/frontend/core/src/blocksuite/ai/components/ai-tools/{artifacts.ts => artifacts-preview-panel.ts} (97%) create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-tools/code-artifact.ts diff --git a/blocksuite/affine/blocks/code/src/index.ts b/blocksuite/affine/blocks/code/src/index.ts index 51fe8ac804..430df892e2 100644 --- a/blocksuite/affine/blocks/code/src/index.ts +++ b/blocksuite/affine/blocks/code/src/index.ts @@ -2,7 +2,9 @@ export * from './adapters'; export * from './clipboard'; export * from './code-block'; export * from './code-block-config'; +export * from './code-block-service'; export * from './code-preview-extension'; export * from './code-toolbar'; +export * from './highlight/const'; export * from './turbo/code-layout-handler'; export * from './turbo/code-painter.worker'; diff --git a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts index 8989e53b4b..d7e7e15329 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts @@ -1753,6 +1753,7 @@ Below is the user's query. Please respond in the user's preferred language witho 'docSemanticSearch', 'webSearch', 'docCompose', + 'codeArtifact', ], }, }; diff --git a/packages/backend/server/src/plugins/copilot/providers/provider.ts b/packages/backend/server/src/plugins/copilot/providers/provider.ts index 1b58429208..889a06ab4d 100644 --- a/packages/backend/server/src/plugins/copilot/providers/provider.ts +++ b/packages/backend/server/src/plugins/copilot/providers/provider.ts @@ -19,6 +19,7 @@ import { buildDocContentGetter, buildDocKeywordSearchGetter, buildDocSearchGetter, + createCodeArtifactTool, createDocComposeTool, createDocEditTool, createDocKeywordSearchTool, @@ -203,6 +204,10 @@ export abstract class CopilotProvider { tools.doc_compose = createDocComposeTool(); break; } + case 'codeArtifact': { + tools.code_artifact = createCodeArtifactTool(); + break; + } } } return tools; diff --git a/packages/backend/server/src/plugins/copilot/providers/types.ts b/packages/backend/server/src/plugins/copilot/providers/types.ts index 673436909e..1a981fe6a9 100644 --- a/packages/backend/server/src/plugins/copilot/providers/types.ts +++ b/packages/backend/server/src/plugins/copilot/providers/types.ts @@ -71,6 +71,7 @@ export const PromptConfigStrictSchema = z.object({ 'webSearch', // artifact tools 'docCompose', + 'codeArtifact', ]) .array() .nullable() diff --git a/packages/backend/server/src/plugins/copilot/providers/utils.ts b/packages/backend/server/src/plugins/copilot/providers/utils.ts index e4f0835034..fee397bd32 100644 --- a/packages/backend/server/src/plugins/copilot/providers/utils.ts +++ b/packages/backend/server/src/plugins/copilot/providers/utils.ts @@ -11,6 +11,7 @@ import { import { ZodType } from 'zod'; import { + createCodeArtifactTool, createDocComposeTool, createDocEditTool, createDocKeywordSearchTool, @@ -392,6 +393,7 @@ export interface CustomAITools extends ToolSet { doc_compose: ReturnType; web_search_exa: ReturnType; web_crawl_exa: ReturnType; + code_artifact: ReturnType; } type ChunkType = TextStreamPart['type']; diff --git a/packages/backend/server/src/plugins/copilot/tools/code-artifact.ts b/packages/backend/server/src/plugins/copilot/tools/code-artifact.ts new file mode 100644 index 0000000000..8039a475ad --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/tools/code-artifact.ts @@ -0,0 +1,84 @@ +import { Logger } from '@nestjs/common'; +import { tool } from 'ai'; +import { z } from 'zod'; + +import { toolError } from './error'; + +const logger = new Logger('CodeArtifactTool'); + +/** + * A copilot tool that produces a completely self-contained HTML artifact. + * The returned HTML must include '); + } + parts.push(''); + parts.push(''); + parts.push(body); + if (js.trim().length) { + parts.push(''); + } + parts.push(''); + parts.push(''); + + const html = parts.join('\n'); + + return { + title, + html, + size: html.length, + }; + } catch (err: any) { + logger.error(`Failed to compose code artifact (${title})`, err); + return toolError('Code Artifact Failed', err.message ?? String(err)); + } + }, + }); +}; diff --git a/packages/backend/server/src/plugins/copilot/tools/index.ts b/packages/backend/server/src/plugins/copilot/tools/index.ts index 9e1472e937..c20ae0f670 100644 --- a/packages/backend/server/src/plugins/copilot/tools/index.ts +++ b/packages/backend/server/src/plugins/copilot/tools/index.ts @@ -1,3 +1,4 @@ +export * from './code-artifact'; export * from './doc-compose'; export * from './doc-edit'; export * from './doc-keyword-search'; diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 00e728236f..cca018c5a1 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -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", diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts index 3e0f6b18bb..706083b385 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts @@ -72,6 +72,15 @@ export class ChatContentStreamObjects extends WithDisposable( .imageProxyService=${imageProxyService} > `; + case 'code_artifact': + return html` + + `; default: { const name = streamObject.toolName + ' tool calling'; return html` @@ -112,6 +121,15 @@ export class ChatContentStreamObjects extends WithDisposable( .imageProxyService=${imageProxyService} > `; + case 'code_artifact': + return html` + + `; default: { const name = streamObject.toolName + ' tool result'; return html` diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifacts.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifacts-preview-panel.ts similarity index 97% rename from packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifacts.ts rename to packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifacts-preview-panel.ts index 5e469b2753..bed4fed782 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifacts.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifacts-preview-panel.ts @@ -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')}; } diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/code-artifact.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/code-artifact.ts new file mode 100644 index 0000000000..d8a18a0f17 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/code-artifact.ts @@ -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 = 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`
+ ${Array.from( + { length: lineCount }, + (_, i) => html`${i + 1}` + )} +
` + : nothing; + + const renderedCode = + tokens.length === 0 + ? this.code + : html`${tokens.map(lineTokens => { + const line = lineTokens.map(token => { + const style = this._tokenStyle(token); + return html`${token.content}`; + }); + return html`
${line}
`; + })}`; + + return html`
+
+        ${lineNumbersTemplate}
+        
${renderedCode}
+
+
`; + } +} + +/** + * 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 | 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``; + } + + 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` +
+
+ Code +
+
+ Preview +
+
+
+ + + ${CopyIcon({ width: '20', height: '20' })} + + `; + renderPreviewPanel( + this, + html`
+ ${this.mode === 'preview' + ? html`` + : html``} +
`, + controls + ); + }; + + renderPreview(); + }; + + return html` +
+
+
+
+ ${PageIcon({ width: '20', height: '20' })} +
+
+ ${title} +
+
+
+
+ `; + } + + return html``; + } + + protected override render() { + if (this.data.type === 'tool-call') { + return this.renderToolCall(); + } + if (this.data.type === 'tool-result') { + return this.renderToolResult(); + } + return nothing; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-compose.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-compose.ts index 52f9daed75..99067d75b4 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-compose.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-compose.ts @@ -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 { diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts index 84f42fdf62..466f0c502a 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects.ts @@ -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); diff --git a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/html-preview.ts b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/html-preview.ts index 316155f0d9..c679f4105e 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/html-preview.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/html-preview.ts @@ -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`` ); -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); diff --git a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/iframe-container.ts b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/iframe-container.ts index a7fc055e12..86124b976d 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/iframe-container.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/iframe-container.ts @@ -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'; diff --git a/yarn.lock b/yarn.lock index ed098ad68d..0e9807a0df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -471,6 +471,7 @@ __metadata: react-virtuoso: "npm:^4.12.3" rxjs: "npm:^7.8.1" ses: "npm:^1.10.0" + shiki: "npm:^3.7.0" socket.io-client: "npm:^4.8.1" swr: "npm:2.3.3" tinykeys: "patch:tinykeys@npm%3A2.1.0#~/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch" @@ -13003,64 +13004,64 @@ __metadata: languageName: node linkType: hard -"@shikijs/core@npm:3.4.2": - version: 3.4.2 - resolution: "@shikijs/core@npm:3.4.2" +"@shikijs/core@npm:3.7.0": + version: 3.7.0 + resolution: "@shikijs/core@npm:3.7.0" dependencies: - "@shikijs/types": "npm:3.4.2" + "@shikijs/types": "npm:3.7.0" "@shikijs/vscode-textmate": "npm:^10.0.2" "@types/hast": "npm:^3.0.4" hast-util-to-html: "npm:^9.0.5" - checksum: 10/82f044575fbc9cf89090f2f884f47cb7d12f6b1a6de1a52bbe3a30f739eb2fdf07e69fc45a14f8955b94d2ae927db063221379f40b311526ff2891e0700c4998 + checksum: 10/0832dd870a07528cc71765ae5451262966d93894fa3f0a2cb503306b9f743c422e1d9a18027e352218cf8caf5d36801dbfa1a8c44526ddf2e45044660d0a93ae languageName: node linkType: hard -"@shikijs/engine-javascript@npm:3.4.2": - version: 3.4.2 - resolution: "@shikijs/engine-javascript@npm:3.4.2" +"@shikijs/engine-javascript@npm:3.7.0": + version: 3.7.0 + resolution: "@shikijs/engine-javascript@npm:3.7.0" dependencies: - "@shikijs/types": "npm:3.4.2" + "@shikijs/types": "npm:3.7.0" "@shikijs/vscode-textmate": "npm:^10.0.2" oniguruma-to-es: "npm:^4.3.3" - checksum: 10/c522ebd464023145efaa30360fb44390da6a4b74e561cb84156f9635d0e4676eaa10c7820eceb6e0cfe657a602a33d93d5b4f54fcc3154029a5586cda830f53d + checksum: 10/e5d45a10ad5e9e71880a9a0b55d3c523aadfec8b04e3f080e3130195d0e17926c8f5ee05ce475788b377b909e6a5259bda61f58fea8a2cb33f078f1167f1856c languageName: node linkType: hard -"@shikijs/engine-oniguruma@npm:3.4.2, @shikijs/engine-oniguruma@npm:^3.4.0": - version: 3.4.2 - resolution: "@shikijs/engine-oniguruma@npm:3.4.2" +"@shikijs/engine-oniguruma@npm:3.7.0, @shikijs/engine-oniguruma@npm:^3.4.0": + version: 3.7.0 + resolution: "@shikijs/engine-oniguruma@npm:3.7.0" dependencies: - "@shikijs/types": "npm:3.4.2" + "@shikijs/types": "npm:3.7.0" "@shikijs/vscode-textmate": "npm:^10.0.2" - checksum: 10/3dc4f5cb63961b5b0b1f619d83edeb7780c5d4232aa68b056b9f4809660ac88ac038a56e0ca09d46c7008f46973e6d7df94fa7186ab410ddca2936eb254dd0b7 + checksum: 10/dee77bddb90efd2b164d46ed2b88793503d00cd2fb484b869cf7c78531b75fd024bbbd2d7b8be2eb3f063179d79aa441341478411403415156ad76672d751547 languageName: node linkType: hard -"@shikijs/langs@npm:3.4.2, @shikijs/langs@npm:^3.4.0": - version: 3.4.2 - resolution: "@shikijs/langs@npm:3.4.2" +"@shikijs/langs@npm:3.7.0, @shikijs/langs@npm:^3.4.0": + version: 3.7.0 + resolution: "@shikijs/langs@npm:3.7.0" dependencies: - "@shikijs/types": "npm:3.4.2" - checksum: 10/6d217d0ba6e550c86a5172533d3cd0cbe325b79fd2d018a8c85cc0c834bdc2cd29012bd8864cd701bfd819a32c7c9090685b3c2e2d49000d535b44826a260a44 + "@shikijs/types": "npm:3.7.0" + checksum: 10/11bc671751c6cd82ae83575bdf84ecf51f54b13b3c8e3b51ff299a8c1288b8eac0fa2c99c6905753a4790b7af9160c6ed5a15be4c54851be3d339540a8d292ee languageName: node linkType: hard -"@shikijs/themes@npm:3.4.2, @shikijs/themes@npm:^3.4.0": - version: 3.4.2 - resolution: "@shikijs/themes@npm:3.4.2" +"@shikijs/themes@npm:3.7.0, @shikijs/themes@npm:^3.4.0": + version: 3.7.0 + resolution: "@shikijs/themes@npm:3.7.0" dependencies: - "@shikijs/types": "npm:3.4.2" - checksum: 10/8e22fbd593a080fee63567f00c767b67d005613715932c0c72168b88febee86fb84036d8f2b6213129c9c91d363c5d988f773fab1a5bfbec3a1b0c758c9eaacf + "@shikijs/types": "npm:3.7.0" + checksum: 10/9fb5085bc9121124577823a28f9b2d1335875296232856e37c7cf9a9672958dff93b6d821896f6dbcbd44953043254acf28407c6e4cfba62d5fa8bf38142fd08 languageName: node linkType: hard -"@shikijs/types@npm:3.4.2, @shikijs/types@npm:^3.4.0": - version: 3.4.2 - resolution: "@shikijs/types@npm:3.4.2" +"@shikijs/types@npm:3.7.0, @shikijs/types@npm:^3.4.0": + version: 3.7.0 + resolution: "@shikijs/types@npm:3.7.0" dependencies: "@shikijs/vscode-textmate": "npm:^10.0.2" "@types/hast": "npm:^3.0.4" - checksum: 10/cf993b69399c4e0866637f92bba0bc42382d9380366fc355d1641a0925bc46812b218ae02a42bd23cf26e5355ca21a6afab9b5336b1f1d9fa54eb919ca91e5ab + checksum: 10/f6f4c6166968a24620b390bc4dc7d2238f8cbcb209d0b55583dafe0fea2de742479795b2bdede2b3f070159550b2e36519d3933ac9f23ca02e036c87fed74db4 languageName: node linkType: hard @@ -31234,19 +31235,19 @@ __metadata: languageName: node linkType: hard -"shiki@npm:^3.0.0": - version: 3.4.2 - resolution: "shiki@npm:3.4.2" +"shiki@npm:^3.0.0, shiki@npm:^3.7.0": + version: 3.7.0 + resolution: "shiki@npm:3.7.0" dependencies: - "@shikijs/core": "npm:3.4.2" - "@shikijs/engine-javascript": "npm:3.4.2" - "@shikijs/engine-oniguruma": "npm:3.4.2" - "@shikijs/langs": "npm:3.4.2" - "@shikijs/themes": "npm:3.4.2" - "@shikijs/types": "npm:3.4.2" + "@shikijs/core": "npm:3.7.0" + "@shikijs/engine-javascript": "npm:3.7.0" + "@shikijs/engine-oniguruma": "npm:3.7.0" + "@shikijs/langs": "npm:3.7.0" + "@shikijs/themes": "npm:3.7.0" + "@shikijs/types": "npm:3.7.0" "@shikijs/vscode-textmate": "npm:^10.0.2" "@types/hast": "npm:^3.0.4" - checksum: 10/e1737635772e355ef1035f23084b5520e9ce4ea051d821b1792c6ff8703379826ba5dbfd74e7a67dfe3996f29776f8040c1f742c3d7ff6f24ba83d313e455e36 + checksum: 10/bd9b2495e72fba00393ae99df7ebd7f29df89a194fe607f6a1c7e8980a3a95c1a2cf0699b72e970020a522bc03f446c254ece41755d3765d66dc35bdd1de19f3 languageName: node linkType: hard