mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
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:
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
200
blocksuite/affine/components/src/resource/resource.ts
Normal file
200
blocksuite/affine/components/src/resource/resource.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
141
blocksuite/affine/components/src/resource/status.ts
Normal file
141
blocksuite/affine/components/src/resource/status.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { effects as componentLinkPreviewEffects } from '@blocksuite/affine-compo
|
||||
import { effects as componentLinkedDocTitleEffects } from '@blocksuite/affine-components/linked-doc-title';
|
||||
import { effects as componentOpenDocDropdownMenuEffects } from '@blocksuite/affine-components/open-doc-dropdown-menu';
|
||||
import { effects as componentPortalEffects } from '@blocksuite/affine-components/portal';
|
||||
import { effects as componentResourceEffects } from '@blocksuite/affine-components/resource';
|
||||
import { effects as componentSizeDropdownMenuEffects } from '@blocksuite/affine-components/size-dropdown-menu';
|
||||
import { SmoothCorner } from '@blocksuite/affine-components/smooth-corner';
|
||||
import { effects as componentToggleButtonEffects } from '@blocksuite/affine-components/toggle-button';
|
||||
@@ -56,6 +57,7 @@ export function effects() {
|
||||
componentEdgelessLineStylesEffects();
|
||||
componentEdgelessShapeColorPickerEffects();
|
||||
componentOpenDocDropdownMenuEffects();
|
||||
componentResourceEffects();
|
||||
|
||||
customElements.define('icon-button', IconButton);
|
||||
customElements.define('smooth-corner', SmoothCorner);
|
||||
|
||||
Reference in New Issue
Block a user