From b3f0f38b41fec3143185e0139c36af08f889cf6f Mon Sep 17 00:00:00 2001 From: fundon Date: Sat, 10 May 2025 08:34:47 +0000 Subject: [PATCH] feat(editor): improve status display in attachment embed view (#12180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: [BS-3438](https://linear.app/affine-design/issue/BS-3438/attachment-embed-view-中的-status-组件) Closes: [BS-3447](https://linear.app/affine-design/issue/BS-3447/触发-litportal-re-render) ## Summary by CodeRabbit - **New Features** - Introduced a visual status indicator for embedded attachments with reload capability. - Added a new resource status component to display error messages and reload actions. - **Improvements** - Enhanced attachment rendering flow with reactive state and unified embed handling. - Simplified resource state and blob URL lifecycle management. - Added status visibility flags for PDF and video embeds. - **Bug Fixes** - Improved error handling and refresh support for embedded content including PDFs, videos, and audio. - **Style** - Added styles for the attachment embed status indicator positioning. - **Refactor** - Streamlined attachment and resource controller implementations for better maintainability. - **Tests** - Added end-to-end test verifying PDF viewer reload and re-rendering in embed mode. --- .../blocks/attachment/src/attachment-block.ts | 171 +++++++++------ .../blocks/attachment/src/configs/toolbar.ts | 12 +- .../affine/blocks/attachment/src/embed.ts | 63 ++++-- .../affine/blocks/attachment/src/styles.ts | 6 + .../affine/components/src/resource/index.ts | 195 +---------------- .../components/src/resource/resource.ts | 200 ++++++++++++++++++ .../affine/components/src/resource/status.ts | 141 ++++++++++++ blocksuite/affine/foundation/src/effects.ts | 2 + .../src/lit-react/lit-portal/lit-portal.tsx | 12 +- .../extensions/attachment-embed-view.tsx | 7 +- .../e2e/attachment-preview.spec.ts | 39 ++++ 11 files changed, 567 insertions(+), 281 deletions(-) create mode 100644 blocksuite/affine/components/src/resource/resource.ts create mode 100644 blocksuite/affine/components/src/resource/status.ts diff --git a/blocksuite/affine/blocks/attachment/src/attachment-block.ts b/blocksuite/affine/blocks/attachment/src/attachment-block.ts index 8b2bde40ee..382a87daa8 100644 --- a/blocksuite/affine/blocks/attachment/src/attachment-block.ts +++ b/blocksuite/affine/blocks/attachment/src/attachment-block.ts @@ -28,11 +28,12 @@ import { WarningIcon, } from '@blocksuite/icons/lit'; import { BlockSelection } from '@blocksuite/std'; -import { Slice } from '@blocksuite/store'; -import { computed } from '@preact/signals-core'; +import { nanoid, Slice } from '@blocksuite/store'; +import { computed, signal } from '@preact/signals-core'; import { html, type TemplateResult } from 'lit'; import { choose } from 'lit/directives/choose.js'; import { type ClassInfo, classMap } from 'lit/directives/class-map.js'; +import { guard } from 'lit/directives/guard.js'; import { styleMap } from 'lit/directives/style-map.js'; import { when } from 'lit/directives/when.js'; @@ -40,6 +41,10 @@ import { AttachmentEmbedProvider } from './embed'; import { styles } from './styles'; import { downloadAttachmentBlob, refreshData } from './utils'; +type AttachmentResolvedStateInfo = ResolvedStateInfo & { + kind?: TemplateResult; +}; + @Peekable({ enableOn: ({ model }: AttachmentBlockComponent) => { return !model.store.readonly && model.props.type.endsWith('pdf'); @@ -103,15 +108,22 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { refreshData(this).catch(console.error); }; - protected get embedView() { - return this.std - .get(AttachmentEmbedProvider) - .render(this.model, this.blobUrl ?? undefined, this._maxFileSize); - } + private readonly _refreshKey$ = signal(null); + + // Refreshes the embed component. + reload = () => { + if (this.model.props.embed) { + this._refreshKey$.value = nanoid(); + return; + } + + this.refreshData(); + }; private _selectBlock() { const selectionManager = this.host.selection; @@ -195,68 +207,70 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent -
-
-
${icon}
-
- ${title} + return html` +
+
+
+
${icon}
+
+ ${title} +
+
+ +
+ + ${choose(state, [ + ['error', this.renderReloadButton], + ['error:oversize', this.renderUpgradeButton], + ])}
-
+
${kind}
+
+ `; + } + + protected renderWithVertical( + classInfo: ClassInfo, + { icon, title, description, kind, state }: AttachmentResolvedStateInfo + ) { + return html` +
+
+
+
${icon}
+
+ ${title} +
+
+ +
+ +
+ ${kind} ${choose(state, [ ['error', this.renderReloadButton], ['error:oversize', this.renderUpgradeButton], ])}
- -
${kind}
-
`; + `; } - protected renderWithVertical( - classInfo: ClassInfo, - { icon, title, description, state }: ResolvedStateInfo, - kind: TemplateResult - ) { - return html`
-
-
-
${icon}
-
- ${title} -
-
- - -
- -
- ${kind} - ${choose(state, [ - ['error', this.renderReloadButton], - ['error:oversize', this.renderUpgradeButton], - ])} -
-
`; - } - - protected renderCard = () => { + protected resolvedState$ = computed(() => { const theme = this.std.get(ThemeProvider).theme$.value; const loadingIcon = getLoadingIconWith(theme); - const { name, size, style } = this.model.props; - const cardStyle = style ?? AttachmentBlockStyles[1]; + const size = this.model.props.size; + const name = this.model.props.name$.value; const kind = getAttachmentFileIcon(name.split('.').pop() ?? ''); const resolvedState = this.resourceController.resolveStateWith({ @@ -267,6 +281,13 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { + const resolvedState = this.resolvedState$.value; + const cardStyle = this.model.props.style$.value ?? AttachmentBlockStyles[1]; + const classInfo = { 'affine-attachment-card': true, [cardStyle]: true, @@ -276,11 +297,45 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent this.renderWithVertical(classInfo, resolvedState, kind), - () => this.renderWithHorizontal(classInfo, resolvedState, kind) + () => this.renderWithVertical(classInfo, resolvedState), + () => this.renderWithHorizontal(classInfo, resolvedState) ); }; + protected renderEmbedView = () => { + const { model, blobUrl } = this; + if (!model.props.embed || !blobUrl) return null; + + const { std, _maxFileSize } = this; + const provider = std.get(AttachmentEmbedProvider); + + const render = provider.getRender(model, _maxFileSize); + if (!render) return null; + + const enabled = provider.shouldShowStatus(model); + + return html` +
+ ${guard([this._refreshKey$.value], () => render(model, blobUrl))} +
+ ${when(enabled, () => { + const resolvedState = this.resolvedState$.value; + if (resolvedState.state !== 'error') return null; + // It should be an error messge. + const message = resolvedState.description; + if (!message) return null; + + return html` + this.reload()} + > + `; + })} + `; + }; + private readonly _renderCitation = () => { const { name, footnoteIdentifier } = this.model.props; const fileType = name.split('.').pop() ?? ''; @@ -305,15 +360,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent this._renderCitation(), - () => - when( - this.embedView, - () => - html`
- ${this.embedView} -
`, - this.renderCard - ) + () => this.renderEmbedView() ?? this.renderCardView() )}
`; diff --git a/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts b/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts index 254d880cd5..58f5d48fd4 100644 --- a/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts @@ -77,13 +77,19 @@ export const attachmentViewDropdownMenu = { const model = ctx.getCurrentModelByType(AttachmentBlockModel); if (!model) return; - if (!ctx.hasSelectedSurfaceModels) { + const provider = ctx.std.get(AttachmentEmbedProvider); + + // TODO(@fundon): should auto focus image block. + if ( + provider.shouldBeConverted(model) && + !ctx.hasSelectedSurfaceModels + ) { // Clears ctx.reset(); ctx.select('note'); } - ctx.std.get(AttachmentEmbedProvider).convertTo(model); + provider.convertTo(model); ctx.track('SelectedView', { ...trackBaseProps, @@ -257,7 +263,7 @@ const builtinToolbarConfig = { icon: ResetIcon(), run(ctx) { const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); - block?.refreshData(); + block?.reload(); }, }, { diff --git a/blocksuite/affine/blocks/attachment/src/embed.ts b/blocksuite/affine/blocks/attachment/src/embed.ts index f2f91fcecb..fbe44c1c1f 100644 --- a/blocksuite/affine/blocks/attachment/src/embed.ts +++ b/blocksuite/affine/blocks/attachment/src/embed.ts @@ -39,9 +39,22 @@ export type AttachmentEmbedConfig = { std: BlockStdScope ) => Promise | void; /** - * The template will be used to render the embed view. + * Renders the embed view. */ - template?: (model: AttachmentBlockModel, blobUrl: string) => TemplateResult; + render?: ( + model: AttachmentBlockModel, + blobUrl: string + ) => TemplateResult | null; + + /** + * Should show status when turned on. + */ + shouldShowStatus?: boolean; + + /** + * Should block type conversion be required. + */ + shouldBeConverted?: boolean; }; // Single embed config. @@ -115,26 +128,38 @@ export class AttachmentEmbedService extends Extension { return this.values.some(config => config.check(model, maxFileSize)); } - render( + getRender(model: AttachmentBlockModel, maxFileSize = this._maxFileSize) { + return ( + this.values.find(config => config.check(model, maxFileSize))?.render ?? + null + ); + } + + shouldShowStatus( model: AttachmentBlockModel, - blobUrl?: string, maxFileSize = this._maxFileSize ) { - if (!model.props.embed || !blobUrl) return; + return ( + this.values.find(config => config.check(model, maxFileSize)) + ?.shouldShowStatus ?? false + ); + } - const config = this.values.find(config => config.check(model, maxFileSize)); - if (!config || !config.template) { - console.error('No embed view template found!', model, model.props.type); - return; - } - - return config.template(model, blobUrl); + shouldBeConverted( + model: AttachmentBlockModel, + maxFileSize = this._maxFileSize + ) { + return ( + this.values.find(config => config.check(model, maxFileSize)) + ?.shouldBeConverted ?? false + ); } } const embedConfig: AttachmentEmbedConfig[] = [ { name: 'image', + shouldBeConverted: true, check: model => model.store.schema.flavourSchemaMap.has('affine:image') && model.props.type.startsWith('image/'), @@ -147,6 +172,7 @@ const embedConfig: AttachmentEmbedConfig[] = [ }, { name: 'pdf', + shouldShowStatus: true, check: (model, maxFileSize) => model.props.type === 'application/pdf' && model.props.size <= maxFileSize, action: model => { @@ -159,7 +185,7 @@ const embedConfig: AttachmentEmbedConfig[] = [ xywh: bound.serialize(), }); }, - template: (_, blobUrl) => { + render: (_, blobUrl) => { // More options: https://tinytip.co/tips/html-pdf-params/ // https://chromium.googlesource.com/chromium/src/+/refs/tags/121.0.6153.1/chrome/browser/resources/pdf/open_pdf_params_parser.ts const parameters = '#toolbar=0'; @@ -185,6 +211,7 @@ const embedConfig: AttachmentEmbedConfig[] = [ }, { name: 'video', + shouldShowStatus: true, check: (model, maxFileSize) => model.props.type.startsWith('video/') && model.props.size <= maxFileSize, action: model => { @@ -197,7 +224,7 @@ const embedConfig: AttachmentEmbedConfig[] = [ xywh: bound.serialize(), }); }, - template: (_, blobUrl) => + render: (_, blobUrl) => html`