feat(editor): improve status display in attachment embed view (#12180)

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)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fundon
2025-05-10 08:34:47 +00:00
parent 66500669c8
commit b3f0f38b41
11 changed files with 567 additions and 281 deletions

View File

@@ -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<Attachment
window.open(blobUrl, '_blank');
};
// Refreshes data.
refreshData = () => {
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<string | null>(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<Attachment
protected renderWithHorizontal(
classInfo: ClassInfo,
{ icon, title, description, state }: ResolvedStateInfo,
kind: TemplateResult
{ icon, title, description, kind, state }: AttachmentResolvedStateInfo
) {
return html`<div class=${classMap(classInfo)}>
<div class="affine-attachment-content">
<div class="affine-attachment-content-title">
<div class="affine-attachment-content-title-icon">${icon}</div>
<div class="affine-attachment-content-title-text truncate">
${title}
return html`
<div class=${classMap(classInfo)}>
<div class="affine-attachment-content">
<div class="affine-attachment-content-title">
<div class="affine-attachment-content-title-icon">${icon}</div>
<div class="affine-attachment-content-title-text truncate">
${title}
</div>
</div>
<div class="affine-attachment-content-description">
<div class="affine-attachment-content-info truncate">
${description}
</div>
${choose(state, [
['error', this.renderReloadButton],
['error:oversize', this.renderUpgradeButton],
])}
</div>
</div>
<div class="affine-attachment-content-description">
<div class="affine-attachment-banner">${kind}</div>
</div>
`;
}
protected renderWithVertical(
classInfo: ClassInfo,
{ icon, title, description, kind, state }: AttachmentResolvedStateInfo
) {
return html`
<div class=${classMap(classInfo)}>
<div class="affine-attachment-content">
<div class="affine-attachment-content-title">
<div class="affine-attachment-content-title-icon">${icon}</div>
<div class="affine-attachment-content-title-text truncate">
${title}
</div>
</div>
<div class="affine-attachment-content-info truncate">
${description}
</div>
</div>
<div class="affine-attachment-banner">
${kind}
${choose(state, [
['error', this.renderReloadButton],
['error:oversize', this.renderUpgradeButton],
])}
</div>
</div>
<div class="affine-attachment-banner">${kind}</div>
</div>`;
`;
}
protected renderWithVertical(
classInfo: ClassInfo,
{ icon, title, description, state }: ResolvedStateInfo,
kind: TemplateResult
) {
return html`<div class=${classMap(classInfo)}>
<div class="affine-attachment-content">
<div class="affine-attachment-content-title">
<div class="affine-attachment-content-title-icon">${icon}</div>
<div class="affine-attachment-content-title-text truncate">
${title}
</div>
</div>
<div class="affine-attachment-content-info truncate">
${description}
</div>
</div>
<div class="affine-attachment-banner">
${kind}
${choose(state, [
['error', this.renderReloadButton],
['error:oversize', this.renderUpgradeButton],
])}
</div>
</div>`;
}
protected renderCard = () => {
protected resolvedState$ = computed<AttachmentResolvedStateInfo>(() => {
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<Attachment
description: humanFileSize(size),
});
return { ...resolvedState, kind };
});
protected renderCardView = () => {
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<Attachment
return when(
cardStyle === 'cubeThick',
() => 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`
<div class="affine-attachment-embed-container">
${guard([this._refreshKey$.value], () => render(model, blobUrl))}
</div>
${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`
<affine-resource-status
class="affine-attachment-embed-status"
.message=${message}
.reload=${() => this.reload()}
></affine-resource-status>
`;
})}
`;
};
private readonly _renderCitation = () => {
const { name, footnoteIdentifier } = this.model.props;
const fileType = name.split('.').pop() ?? '';
@@ -305,15 +360,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
${when(
this.isCitation,
() => this._renderCitation(),
() =>
when(
this.embedView,
() =>
html`<div class="affine-attachment-embed-container">
${this.embedView}
</div>`,
this.renderCard
)
() => this.renderEmbedView() ?? this.renderCardView()
)}
</div>
`;

View File

@@ -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();
},
},
{

View File

@@ -39,9 +39,22 @@ export type AttachmentEmbedConfig = {
std: BlockStdScope
) => Promise<void> | 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`<video
style=${styleMap({
display: 'flex',
@@ -216,8 +243,12 @@ const embedConfig: AttachmentEmbedConfig[] = [
name: 'audio',
check: (model, maxFileSize) =>
model.props.type.startsWith('audio/') && model.props.size <= maxFileSize,
template: (_, blobUrl) =>
html`<audio controls src=${blobUrl} style="margin: 4px;"></audio>`,
render: (_, blobUrl) =>
html`<audio
style=${styleMap({ margin: '4px' })}
src=${blobUrl}
controls
></audio>`,
},
];

View File

@@ -143,6 +143,12 @@ export const styles = css`
height: 100%;
}
.affine-attachment-embed-status {
position: absolute;
left: 14px;
bottom: 64px;
}
.affine-attachment-embed-event-mask {
position: absolute;
inset: 0;