mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 19:02:23 +08:00
feat(core): optimize artifact preview loading (#13224)
fix AI-369 #### PR Dependency Tree * **PR #13224** 👈 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 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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`<div class="icon">${this.icon}</div>` : nothing}
|
||||
<div class="line line1"></div>
|
||||
<div class="line line2"></div>
|
||||
<div class="line line3"></div>
|
||||
<div class="line line4"></div>
|
||||
<div class="line line5"></div>
|
||||
<div class="line line6"></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'artifact-skeleton': ArtifactSkeleton;
|
||||
}
|
||||
}
|
||||
@@ -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<number | undefined> | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor notificationService!: NotificationService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor theme!: Signal<ColorScheme>;
|
||||
|
||||
@@ -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`<div class="artifact-skeleton-container">
|
||||
<artifact-skeleton .icon=${icon}></artifact-skeleton>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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`<div class="code-artifact-preview">
|
||||
<div
|
||||
style="display:flex;justify-content:center;align-items:center;height:100%"
|
||||
>
|
||||
${CodeBlockIcon({ width: '24', height: '24' })}
|
||||
</div>
|
||||
</div>`;
|
||||
return html``;
|
||||
}
|
||||
|
||||
const result = this.data.result;
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
></text-renderer>`
|
||||
: html`<div class="doc-compose-result-preview-loading">
|
||||
${LoadingIcon({
|
||||
size: '32px',
|
||||
})}
|
||||
</div>`}
|
||||
: html``}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user