From 04e002eb7707257df6f7f5758492a4972c8de804 Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Wed, 16 Jul 2025 10:08:32 +0800 Subject: [PATCH] feat(core): optimize artifact preview loading (#13224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix AI-369 #### PR Dependency Tree * **PR #13224** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Introduced a loading skeleton component for artifact previews, providing a smoother visual experience during loading states. * Artifact loading skeleton is now globally available as a custom element. * **Refactor** * Streamlined icon and loading state handling in AI tools, centralizing logic and removing redundant loading indicators. * Simplified card metadata by removing loading and icon properties from card meta methods. * **Chores** * Improved resource management for code block highlighting, ensuring efficient disposal and avoiding unnecessary operations. --- .../blocks/code/src/code-block-service.ts | 32 ++- .../components/ai-artifact-skeleton/index.ts | 196 ++++++++++++++++++ .../ai/components/ai-tools/artifact-tool.ts | 57 +++-- .../ai/components/ai-tools/code-artifact.ts | 20 +- .../ai/components/ai-tools/doc-compose.ts | 18 +- .../core/src/blocksuite/ai/effects.ts | 2 + 6 files changed, 277 insertions(+), 48 deletions(-) create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-artifact-skeleton/index.ts diff --git a/blocksuite/affine/blocks/code/src/code-block-service.ts b/blocksuite/affine/blocks/code/src/code-block-service.ts index 5a309cd93..43cca7a33 100644 --- a/blocksuite/affine/blocks/code/src/code-block-service.ts +++ b/blocksuite/affine/blocks/code/src/code-block-service.ts @@ -39,6 +39,13 @@ export class CodeBlockHighlighter extends LifeCycleWatcher { private readonly _loadTheme = async ( highlighter: HighlighterCore ): Promise => { + // It is possible that by the time the highlighter is ready all instances + // have already been unmounted. In that case there is no need to load + // themes or update state. + if (CodeBlockHighlighter._refCount === 0) { + return; + } + const config = this.std.getOptional(CodeBlockConfigExtension.identifier); const darkTheme = config?.theme?.dark ?? CODE_BLOCK_DEFAULT_DARK_THEME; const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME; @@ -78,14 +85,27 @@ export class CodeBlockHighlighter extends LifeCycleWatcher { override unmounted(): void { CodeBlockHighlighter._refCount--; - // Only dispose the shared highlighter when no instances are using it - if ( - CodeBlockHighlighter._refCount === 0 && - CodeBlockHighlighter._sharedHighlighter - ) { - CodeBlockHighlighter._sharedHighlighter.dispose(); + // Dispose the shared highlighter **after** any in-flight creation finishes. + if (CodeBlockHighlighter._refCount !== 0) { + return; + } + + const doDispose = (highlighter: HighlighterCore | null) => { + if (highlighter) { + highlighter.dispose(); + } CodeBlockHighlighter._sharedHighlighter = null; CodeBlockHighlighter._highlighterPromise = null; + }; + + if (CodeBlockHighlighter._sharedHighlighter) { + // Highlighter already created – dispose immediately. + doDispose(CodeBlockHighlighter._sharedHighlighter); + } else if (CodeBlockHighlighter._highlighterPromise) { + // Highlighter still being created – wait for it, then dispose. + CodeBlockHighlighter._highlighterPromise + .then(doDispose) + .catch(console.error); } } } diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-artifact-skeleton/index.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-artifact-skeleton/index.ts new file mode 100644 index 000000000..b82e87465 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-artifact-skeleton/index.ts @@ -0,0 +1,196 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +/** + * ArtifactSkeleton + * + * A lightweight loading skeleton used while an artifact preview is fetching / processing. + * It mimics the layout of a document – an optional icon followed by several animated grey lines. + * + * Animation is implemented with pure CSS keyframes (no framer-motion dependency). + * Only a single prop is supported for now: + * - `icon` – TemplateResult that will be rendered at the top-left position. + */ +export class ArtifactSkeleton extends LitElement { + /* ----- Styling --------------------------------------------------------------------------- */ + static override styles = css` + :host { + /* The host is an inline-block so it can size to its contents. */ + display: inline-block; + position: relative; + /* The size roughly follows the design used in the legacy React implementation. */ + width: 250px; + height: 200px; + box-sizing: border-box; + } + + /* Optional icon wrapper */ + .icon { + position: absolute; + top: 10px; + left: 11px; + width: 32px; + height: 32px; + + svg { + color: ${unsafeCSSVarV2('icon/activated')}; + width: 100%; + height: 100%; + } + } + + /* Base line style */ + .line { + position: absolute; + left: 11px; + height: 10px; + border-radius: 6px; + background-color: ${unsafeCSSVarV2('layer/background/tertiary')}; + } + + /* Keyframes for each line – width cycles through a handful of values to create movement */ + @keyframes line1Anim { + 0%, + 100% { + width: 98px; + } + 25% { + width: 120px; + } + 50% { + width: 85px; + } + 75% { + width: 110px; + } + } + @keyframes line2Anim { + 0%, + 100% { + width: 195px; + } + 30% { + width: 180px; + } + 60% { + width: 210px; + } + 80% { + width: 165px; + } + } + @keyframes line3Anim { + 0%, + 100% { + width: 163px; + } + 40% { + width: 140px; + } + 70% { + width: 180px; + } + 90% { + width: 155px; + } + } + @keyframes line4Anim { + 0%, + 100% { + width: 107px; + } + 20% { + width: 130px; + } + 60% { + width: 90px; + } + 85% { + width: 115px; + } + } + @keyframes line5Anim { + 0%, + 100% { + width: 134px; + } + 35% { + width: 160px; + } + 65% { + width: 120px; + } + 80% { + width: 145px; + } + } + @keyframes line6Anim { + 0%, + 100% { + width: 154px; + } + 30% { + width: 135px; + } + 55% { + width: 175px; + } + 75% { + width: 160px; + } + } + + .line1 { + top: 48.5px; + animation: line1Anim 3.2s ease-in-out infinite; + } + .line2 { + top: 73.5px; + animation: line2Anim 4.1s ease-in-out infinite; + } + .line3 { + top: 98.5px; + animation: line3Anim 2.8s ease-in-out infinite; + } + .line4 { + top: 123.5px; + animation: line4Anim 3.7s ease-in-out infinite; + } + .line5 { + top: 148.5px; + animation: line5Anim 3.5s ease-in-out infinite; + } + .line6 { + top: 170.5px; + animation: line6Anim 4.3s ease-in-out infinite; + } + `; + + /* ----- Public API ------------------------------------------------------------------------ */ + /** + * Optional icon rendered at the top-left corner. + * It should be a lit `TemplateResult`, typically an inline SVG. + */ + @property({ attribute: false }) + accessor icon: TemplateResult | null = null; + + /* ----- Render --------------------------------------------------------------------------- */ + override render() { + return html` + ${this.icon ? html`
${this.icon}
` : nothing} +
+
+
+
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'artifact-skeleton': ArtifactSkeleton; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifact-tool.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifact-tool.ts index 684783e49..d9f13afed 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifact-tool.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifact-tool.ts @@ -2,7 +2,6 @@ import { LoadingIcon } from '@blocksuite/affine/components/icons'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import type { ColorScheme } from '@blocksuite/affine/model'; import { ShadowlessElement } from '@blocksuite/affine/std'; -import { type NotificationService } from '@blocksuite/affine-shared/services'; import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import type { Signal } from '@preact/signals-core'; import { @@ -42,18 +41,23 @@ export abstract class ArtifactTool< background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; } } + + .artifact-skeleton-container { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + + artifact-skeleton { + margin-top: -24px; + } + } `; /** Tool data coming from ChatGPT (tool-call / tool-result). */ @property({ attribute: false }) accessor data!: TData; - @property({ attribute: false }) - accessor width: Signal | undefined; - - @property({ attribute: false }) - accessor notificationService!: NotificationService; - @property({ attribute: false }) accessor theme!: Signal; @@ -64,14 +68,15 @@ export abstract class ArtifactTool< */ protected abstract getCardMeta(): { title: string; - /** Page / file icon shown when not loading */ - icon: TemplateResult | HTMLElement | string | null; - /** Whether the spinner should be displayed */ - loading: boolean; /** Extra css class appended to card root */ className?: string; }; + /** + * Icon shown in the card (when not loading) and in the loading skeleton. + */ + protected abstract getIcon(): TemplateResult | HTMLElement | string | null; + /** Banner shown on the right side of the card (can be undefined). */ protected abstract getBanner( theme: ColorScheme @@ -90,11 +95,14 @@ export abstract class ArtifactTool< /** Open or refresh the preview panel. */ private openOrUpdatePreviewPanel() { - renderPreviewPanel( - this, - this.getPreviewContent(), - this.getPreviewControls() - ); + const content = this.isLoading() + ? this.renderLoadingSkeleton() + : this.getPreviewContent(); + renderPreviewPanel(this, content, this.getPreviewControls()); + } + + protected isLoading(): boolean { + return this.data.type !== 'tool-result'; } protected refreshPreviewPanel() { @@ -108,18 +116,23 @@ export abstract class ArtifactTool< return null; } + protected renderLoadingSkeleton() { + const icon = this.getIcon(); + return html`
+ +
`; + } + private readonly onCardClick = (_e: Event) => { this.openOrUpdatePreviewPanel(); }; protected renderCard() { - const { title, icon, loading, className } = this.getCardMeta(); + const { title, className } = this.getCardMeta(); - const resolvedIcon = loading - ? LoadingIcon({ - size: '20px', - }) - : icon; + const resolvedIcon = this.isLoading() + ? LoadingIcon({ size: '20px' }) + : this.getIcon(); const banner = this.getBanner(this.theme.value); 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 index 2dac661fe..a58f1a198 100644 --- 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 @@ -3,6 +3,7 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { ColorScheme } from '@blocksuite/affine/model'; import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { type BlockStdScope } from '@blocksuite/affine/std'; +import type { NotificationService } from '@blocksuite/affine-shared/services'; import { CodeBlockIcon, CopyIcon, @@ -437,6 +438,9 @@ export class CodeArtifactTool extends ArtifactTool< @property({ attribute: false }) accessor std: BlockStdScope | undefined; + @property({ attribute: false }) + accessor notificationService!: NotificationService; + @state() private accessor mode: 'preview' | 'code' = 'code'; @@ -447,25 +451,19 @@ export class CodeArtifactTool extends ArtifactTool< } protected getCardMeta() { - const loading = this.data.type === 'tool-call'; return { title: this.data.args.title, - icon: CodeBlockIcon({ width: '20', height: '20' }), - loading, className: 'code-artifact-result', }; } + protected override getIcon() { + return CodeBlockIcon(); + } + protected override getPreviewContent() { if (this.data.type !== 'tool-result' || !this.data.result) { - // loading state - return html`
-
- ${CodeBlockIcon({ width: '24', height: '24' })} -
-
`; + return html``; } const result = this.data.result; 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 fbdc28b15..ad0de2171 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 @@ -1,11 +1,11 @@ import { getStoreManager } from '@affine/core/blocksuite/manager/store'; import { getAFFiNEWorkspaceSchema } from '@affine/core/modules/workspace'; import { getEmbedLinkedDocIcons } from '@blocksuite/affine/blocks/embed-doc'; -import { LoadingIcon } from '@blocksuite/affine/components/icons'; import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference'; import type { ColorScheme } from '@blocksuite/affine/model'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc'; +import type { NotificationService } from '@blocksuite/affine-shared/services'; import { CopyIcon, PageIcon, ToolIcon } from '@blocksuite/icons/lit'; import type { BlockStdScope } from '@blocksuite/std'; import { css, html } from 'lit'; @@ -88,6 +88,9 @@ export class DocComposeTool extends ArtifactTool< @property({ attribute: false }) accessor std: BlockStdScope | undefined; + @property({ attribute: false }) + accessor notificationService!: NotificationService; + protected getBanner(theme: ColorScheme) { const { LinkedDocEmptyBanner } = getEmbedLinkedDocIcons( theme, @@ -98,15 +101,16 @@ export class DocComposeTool extends ArtifactTool< } protected getCardMeta() { - const composing = this.data.type === 'tool-call'; return { title: this.data.args.title, - icon: PageIcon(), - loading: composing, className: 'doc-compose-result', }; } + protected override getIcon() { + return PageIcon(); + } + protected override getPreviewContent() { if (!this.std) return html``; const resultData = this.data; @@ -126,11 +130,7 @@ export class DocComposeTool extends ArtifactTool< theme: this.theme, }} >` - : html`
- ${LoadingIcon({ - size: '32px', - })} -
`} + : html``} `; } diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts index 01c6bee10..39255d49f 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects.ts @@ -27,6 +27,7 @@ import { ChatMessageAction } from './chat-panel/message/action'; import { ChatMessageAssistant } from './chat-panel/message/assistant'; import { ChatMessageUser } from './chat-panel/message/user'; import { ChatPanelSplitView } from './chat-panel/split-view'; +import { ArtifactSkeleton } from './components/ai-artifact-skeleton'; import { AIChatAddContext } from './components/ai-chat-add-context'; import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover'; import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover'; @@ -243,4 +244,5 @@ export function registerAIEffects() { customElements.define('transcription-block', LitTranscriptionBlock); customElements.define('chat-panel-split-view', ChatPanelSplitView); + customElements.define('artifact-skeleton', ArtifactSkeleton); }