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
@@ -1,193 +1,8 @@
import type { Disposable } from '@blocksuite/global/disposable';
import type { BlobEngine, BlobState } from '@blocksuite/sync';
import {
computed,
effect,
type ReadonlySignal,
signal,
} from '@preact/signals-core';
import type { TemplateResult } from 'lit-html';
import { ResourceStatus } from './status';
export type ResourceKind = 'Blob' | 'File' | 'Image';
export * from './resource';
export * from './status';
export type StateKind =
| 'loading'
| 'uploading'
| 'error'
| 'error:oversize'
| 'none';
export type StateInfo = {
icon: TemplateResult;
title?: string;
description?: string;
};
export type ResolvedStateInfoPart = {
loading: boolean;
error: boolean;
state: StateKind;
};
export type ResolvedStateInfo = StateInfo & ResolvedStateInfoPart;
export class ResourceController implements Disposable {
readonly blobUrl$ = signal<string | null>(null);
readonly state$ = signal<Partial<BlobState>>({});
readonly resolvedState$ = computed<ResolvedStateInfoPart>(() => {
const {
uploading = false,
downloading = false,
overSize = false,
errorMessage,
} = this.state$.value;
const hasExceeded = overSize;
const hasError = hasExceeded || Boolean(errorMessage);
const state = this.determineState(
hasExceeded,
hasError,
uploading,
downloading
);
const loading = state === 'uploading' || state === 'loading';
return { error: hasError, loading, state };
});
private engine?: BlobEngine;
constructor(
readonly blobId$: ReadonlySignal<string | undefined>,
readonly kind: ResourceKind = 'File'
) {}
// This is a tradeoff, initializing `Blob Sync Engine`.
setEngine(engine: BlobEngine) {
this.engine = engine;
return this;
}
determineState(
hasExceeded: boolean,
hasError: boolean,
uploading: boolean,
downloading: boolean
): StateKind {
if (hasExceeded) return 'error:oversize';
if (hasError) return 'error';
if (uploading) return 'uploading';
if (downloading) return 'loading';
return 'none';
}
resolveStateWith(
info: {
loadingIcon: TemplateResult;
errorIcon?: TemplateResult;
} & StateInfo
): ResolvedStateInfo {
const { error, loading, state } = this.resolvedState$.value;
const { icon, title, description, loadingIcon, errorIcon } = info;
const result = {
error,
loading,
state,
icon,
title,
description,
};
if (loading) {
result.icon = loadingIcon ?? icon;
result.title = state === 'uploading' ? 'Uploading...' : 'Loading...';
} else if (error) {
result.icon = errorIcon ?? icon;
result.description = this.state$.value.errorMessage ?? description;
}
return result;
}
updateState(state: Partial<BlobState>) {
this.state$.value = { ...this.state$.value, ...state };
}
subscribe() {
return effect(() => {
const blobId = this.blobId$.value;
if (!blobId) return;
const blobState$ = this.engine?.blobState$(blobId);
if (!blobState$) return;
const subscription = blobState$.subscribe(state => {
let { uploading, downloading } = state;
if (state.overSize || state.errorMessage) {
uploading = false;
downloading = false;
}
this.updateState({ ...state, uploading, downloading });
});
return () => subscription.unsubscribe();
});
}
async blob() {
const blobId = this.blobId$.peek();
if (!blobId) return null;
let blob: Blob | null = null;
let errorMessage: string | null = null;
try {
blob = (await this.engine?.get(blobId)) ?? null;
if (!blob) errorMessage = `${this.kind} not found`;
} catch (err) {
console.error(err);
errorMessage = `Failed to retrieve ${this.kind}`;
}
if (errorMessage) this.updateState({ errorMessage });
return blob;
}
async createUrlWith(type?: string) {
let blob = await this.blob();
if (!blob) return null;
if (type) blob = new Blob([blob], { type });
return URL.createObjectURL(blob);
}
async refreshUrlWith(type?: string) {
const url = await this.createUrlWith(type);
if (!url) return;
const prevUrl = this.blobUrl$.peek();
this.blobUrl$.value = url;
if (!prevUrl) return;
// Releases the previous url.
URL.revokeObjectURL(prevUrl);
}
dispose() {
const url = this.blobUrl$.peek();
if (!url) return;
// Releases the current url.
URL.revokeObjectURL(url);
}
export function effects() {
customElements.define('affine-resource-status', ResourceStatus);
}
@@ -0,0 +1,200 @@
import type { Disposable } from '@blocksuite/global/disposable';
import type { BlobEngine, BlobState } from '@blocksuite/sync';
import {
computed,
effect,
type ReadonlySignal,
signal,
} from '@preact/signals-core';
import type { TemplateResult } from 'lit-html';
export type ResourceKind = 'Blob' | 'File' | 'Image';
export type StateKind =
| 'loading'
| 'uploading'
| 'error'
| 'error:oversize'
| 'none';
export type StateInfo = {
icon: TemplateResult;
title?: string;
description?: string;
};
export type ResolvedStateInfoPart = {
loading: boolean;
error: boolean;
state: StateKind;
url: string | null;
};
export type ResolvedStateInfo = StateInfo & ResolvedStateInfoPart;
export class ResourceController implements Disposable {
readonly blobUrl$ = signal<string | null>(null);
readonly state$ = signal<Partial<BlobState>>({});
readonly resolvedState$ = computed<ResolvedStateInfoPart>(() => {
const url = this.blobUrl$.value;
const {
uploading = false,
downloading = false,
overSize = false,
errorMessage,
} = this.state$.value;
const hasExceeded = overSize;
const hasError = hasExceeded || Boolean(errorMessage);
const state = this.determineState(
hasExceeded,
hasError,
uploading,
downloading
);
const loading = state === 'uploading' || state === 'loading';
return { error: hasError, loading, state, url };
});
private engine?: BlobEngine;
constructor(
readonly blobId$: ReadonlySignal<string | undefined>,
readonly kind: ResourceKind = 'File'
) {}
// This is a tradeoff, initializing `Blob Sync Engine`.
setEngine(engine: BlobEngine) {
this.engine = engine;
return this;
}
determineState(
hasExceeded: boolean,
hasError: boolean,
uploading: boolean,
downloading: boolean
): StateKind {
if (hasExceeded) return 'error:oversize';
if (hasError) return 'error';
if (uploading) return 'uploading';
if (downloading) return 'loading';
return 'none';
}
resolveStateWith(
info: {
loadingIcon: TemplateResult;
errorIcon?: TemplateResult;
} & StateInfo
): ResolvedStateInfo {
const { error, loading, state, url } = this.resolvedState$.value;
const { icon, title, description, loadingIcon, errorIcon } = info;
const result = {
error,
loading,
state,
icon,
title,
description,
url,
};
if (loading) {
result.icon = loadingIcon ?? icon;
result.title = state === 'uploading' ? 'Uploading...' : 'Loading...';
} else if (error) {
result.icon = errorIcon ?? icon;
result.description = this.state$.value.errorMessage ?? description;
}
return result;
}
updateState(state: Partial<BlobState>) {
this.state$.value = { ...this.state$.value, ...state };
}
subscribe() {
return effect(() => {
const blobId = this.blobId$.value;
if (!blobId) return;
const blobState$ = this.engine?.blobState$(blobId);
if (!blobState$) return;
const subscription = blobState$.subscribe(state => {
let { uploading, downloading } = state;
if (state.overSize || state.errorMessage) {
uploading = false;
downloading = false;
}
this.updateState({ ...state, uploading, downloading });
});
return () => subscription.unsubscribe();
});
}
async blob() {
const blobId = this.blobId$.peek();
if (!blobId) return null;
let blob: Blob | null = null;
let errorMessage: string | null = null;
try {
if (!this.engine) {
throw new Error('Blob engine is not initialized');
}
blob = (await this.engine.get(blobId)) ?? null;
if (!blob) errorMessage = `${this.kind} not found`;
} catch (err) {
console.error(err);
errorMessage = `Failed to retrieve ${this.kind}`;
}
if (errorMessage) this.updateState({ errorMessage });
return blob;
}
async createUrlWith(type?: string) {
let blob = await this.blob();
if (!blob) return null;
if (type) blob = new Blob([blob], { type });
return URL.createObjectURL(blob);
}
async refreshUrlWith(type?: string) {
const url = await this.createUrlWith(type);
if (!url) return;
const prevUrl = this.blobUrl$.peek();
this.blobUrl$.value = url;
if (!prevUrl) return;
// Releases the previous url.
URL.revokeObjectURL(prevUrl);
}
dispose() {
const url = this.blobUrl$.peek();
if (!url) return;
// Releases the current url.
URL.revokeObjectURL(url);
}
}
@@ -0,0 +1,141 @@
import {
fontBaseStyle,
panelBaseColorsStyle,
} from '@blocksuite/affine-shared/styles';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
createButtonPopper,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { InformationIcon } from '@blocksuite/icons/lit';
import { PropTypes, requiredProperties } from '@blocksuite/std';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
@requiredProperties({
message: PropTypes.string,
reload: PropTypes.instanceOf(Function),
})
export class ResourceStatus extends WithDisposable(LitElement) {
static override styles = css`
button.status {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 18px;
outline: none;
border: none;
cursor: pointer;
color: ${unsafeCSSVarV2('button/pureWhiteText')};
background: ${unsafeCSSVarV2('status/error')};
box-shadow: var(--affine-overlay-shadow);
}
${panelBaseColorsStyle('.popper')}
${fontBaseStyle('.popper')}
.popper {
display: none;
outline: none;
padding: 8px;
border-radius: 8px;
width: 260px;
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px;
&[data-show] {
display: flex;
flex-direction: column;
gap: 8px;
}
}
.content {
color: ${unsafeCSSVarV2('text/primary')};
}
.footer {
display: flex;
justify-content: flex-end;
}
button.reload {
display: flex;
align-items: center;
padding: 2px 12px;
border-radius: 8px;
border: none;
background: none;
cursor: pointer;
outline: none;
color: ${unsafeCSSVarV2('button/primary')};
}
`;
private _popper: ReturnType<typeof createButtonPopper> | null = null;
private _updatePopper() {
this._popper?.dispose();
this._popper = createButtonPopper({
reference: this._trigger,
popperElement: this._content,
mainAxis: 8,
allowedPlacements: ['top-start', 'bottom-start'],
});
}
override firstUpdated() {
this._updatePopper();
this.disposables.addFromEvent(this, 'click', stopPropagation);
this.disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Escape') {
this._popper?.hide();
}
});
this.disposables.addFromEvent(this._trigger, 'click', (_: MouseEvent) => {
this._popper?.toggle();
});
this.disposables.addFromEvent(
this._reloadButton,
'click',
(_: MouseEvent) => {
this._popper?.hide();
this.reload();
}
);
this.disposables.add(() => this._popper?.dispose());
}
override render() {
return html`
<button class="status">${InformationIcon()}</button>
<div class="popper">
<div class="content">${this.message}</div>
<div class="footer">
<button class="reload">Reload</button>
</div>
</div>
`;
}
@query('div.popper')
private accessor _content!: HTMLDivElement;
@query('button.status')
private accessor _trigger!: HTMLButtonElement;
@query('button.reload')
private accessor _reloadButton!: HTMLButtonElement;
@property({ attribute: false })
accessor message!: string;
@property({ attribute: false })
accessor reload!: () => void;
}