Files
AFFiNE-Mirror/blocksuite/affine/blocks/attachment/src/attachment-block.ts
T
fundon 362f89b669 feat(editor): adjust attachment block UI (#11763)
Closes: [BS-3143](https://linear.app/affine-design/issue/BS-3143/更新-attachment-错误样式)
Closes: [BS-3341](https://linear.app/affine-design/issue/BS-3341/attachment-select状态ui调整)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Improved attachment block with unified reactive state handling for loading, errors, and downloads.
  - Enhanced card layouts with modular rendering and dynamic buttons based on state.

- **Bug Fixes**
  - Fixed UI state for "Embed view" action, now correctly disabled when not embedded.
  - Resolved attachment card styling issues and text overflow with new utility classes.

- **Refactor**
  - Streamlined attachment block rendering and state management for better performance and maintainability.
  - Updated toolbar configuration for consistent parameter naming.
  - Simplified embedded and open methods for improved clarity.
  - Made internal functions private to reduce exported API surface.

- **Chores**
  - Removed unused icon export and legacy upload tracking functions.
  - Updated attachment utilities to use reactive state, added data refresh, and improved error handling.
  - Refined image dimension handling for consistent resizing behavior.
  - Improved test selectors to target focused attachment containers for better reliability.
  - Refactored CSS to separate container and card styling and adopt theme-based colors consistently.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-04-29 01:00:56 +00:00

359 lines
9.2 KiB
TypeScript

import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
import {
CaptionedBlockComponent,
SelectedStyle,
} from '@blocksuite/affine-components/caption';
import { getAttachmentFileIcon } from '@blocksuite/affine-components/icons';
import { Peekable } from '@blocksuite/affine-components/peek';
import { toast } from '@blocksuite/affine-components/toast';
import {
type AttachmentBlockModel,
AttachmentBlockStyles,
} from '@blocksuite/affine-model';
import {
FileSizeLimitService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import { AttachmentIcon, ResetIcon, WarningIcon } from '@blocksuite/icons/lit';
import { BlockSelection } from '@blocksuite/std';
import { Slice } from '@blocksuite/store';
import { type BlobState } from '@blocksuite/sync';
import { effect, signal } from '@preact/signals-core';
import { html, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import { AttachmentEmbedProvider } from './embed';
import { styles } from './styles';
import { downloadAttachmentBlob, refreshData } from './utils';
type State = 'loading' | 'uploading' | 'warning' | 'oversize' | 'none';
@Peekable({
enableOn: ({ model }: AttachmentBlockComponent) => {
return !model.doc.readonly && model.props.type.endsWith('pdf');
},
})
export class AttachmentBlockComponent extends CaptionedBlockComponent<AttachmentBlockModel> {
static override styles = styles;
blockDraggable = true;
blobState$ = signal<Partial<BlobState>>({});
protected containerStyleMap = styleMap({
position: 'relative',
width: '100%',
margin: '18px 0px',
});
private get _maxFileSize() {
return this.std.store.get(FileSizeLimitService).maxFileSize;
}
convertTo = () => {
return this.std
.get(AttachmentEmbedProvider)
.convertTo(this.model, this._maxFileSize);
};
copy = () => {
const slice = Slice.fromModels(this.doc, [this.model]);
this.std.clipboard.copySlice(slice).catch(console.error);
toast(this.host, 'Copied to clipboard');
};
download = () => {
downloadAttachmentBlob(this);
};
embedded = () => {
return (
Boolean(this.blobUrl) &&
this.std
.get(AttachmentEmbedProvider)
.embedded(this.model, this._maxFileSize)
);
};
open = () => {
const blobUrl = this.blobUrl;
if (!blobUrl) return;
window.open(blobUrl, '_blank');
};
refreshData = () => {
refreshData(this.std, this).catch(console.error);
};
updateBlobState(state: Partial<BlobState>) {
this.blobState$.value = { ...this.blobState$.value, ...state };
}
determineState = (
loading: boolean,
uploading: boolean,
overSize: boolean,
error: boolean
): State => {
if (overSize) return 'oversize';
if (error) return 'warning';
if (uploading) return 'uploading';
if (loading) return 'loading';
return 'none';
};
protected get embedView() {
return this.std
.get(AttachmentEmbedProvider)
.render(this.model, this.blobUrl ?? undefined, this._maxFileSize);
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
override connectedCallback() {
super.connectedCallback();
this.contentEditable = 'false';
this.refreshData();
this.disposables.add(
effect(() => {
const blobId = this.model.props.sourceId$.value;
if (!blobId) return;
const blobState$ = this.std.store.blobSync.blobState$(blobId);
if (!blobState$) return;
const subscription = blobState$.subscribe(state => {
if (state.overSize || state.errorMessage) {
state.uploading = false;
state.downloading = false;
}
this.updateBlobState(state);
});
return () => subscription.unsubscribe();
})
);
if (!this.model.props.style) {
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
style: AttachmentBlockStyles[1],
});
});
}
}
override disconnectedCallback() {
const blobUrl = this.blobUrl;
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
super.disconnectedCallback();
}
override firstUpdated() {
// lazy bindings
this.disposables.addFromEvent(this, 'click', this.onClick);
}
protected onClick(event: MouseEvent) {
// the peek view need handle shift + click
if (event.defaultPrevented) return;
event.stopPropagation();
if (!this.selected$.peek()) {
this._selectBlock();
}
}
protected renderUpgradeButton = () => {
return null;
};
protected renderReloadButton = () => {
return html`
<button
class="affine-attachment-content-button"
@click=${(event: MouseEvent) => {
event.stopPropagation();
this.refreshData();
}}
>
${ResetIcon()} Reload
</button>
`;
};
protected renderWithHorizontal(
classInfo: ClassInfo,
icon: TemplateResult,
title: string,
description: string,
kind: TemplateResult,
state: State
) {
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, [
['oversize', this.renderUpgradeButton],
['warning', this.renderReloadButton],
])}
</div>
</div>
<div class="affine-attachment-banner">${kind}</div>
</div>`;
}
protected renderWithVertical(
classInfo: ClassInfo,
icon: TemplateResult,
title: string,
description: string,
kind: TemplateResult,
state?: State
) {
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, [
['oversize', this.renderUpgradeButton],
['warning', this.renderReloadButton],
])}
</div>
</div>`;
}
protected renderCard = () => {
const { name, size, style } = this.model.props;
const cardStyle = style ?? AttachmentBlockStyles[1];
const theme = this.std.get(ThemeProvider).theme$.value;
const { LoadingIcon } = getEmbedCardIcons(theme);
const blobState = this.blobState$.value;
const {
uploading = false,
downloading = false,
overSize = false,
errorMessage,
} = blobState;
const warning = !overSize && Boolean(errorMessage);
const error = overSize || warning;
const loading = !error && downloading;
const state = this.determineState(loading, uploading, overSize, error);
const classInfo = {
'affine-attachment-card': true,
[cardStyle]: true,
error,
loading,
};
const icon = loading
? LoadingIcon
: error
? WarningIcon()
: AttachmentIcon();
const title = uploading ? 'Uploading...' : loading ? 'Loading...' : name;
const description = errorMessage || humanFileSize(size);
const kind = getAttachmentFileIcon(name.split('.').pop() ?? '');
return when(
cardStyle === 'cubeThick',
() =>
this.renderWithVertical(
classInfo,
icon,
title,
description,
kind,
state
),
() =>
this.renderWithHorizontal(
classInfo,
icon,
title,
description,
kind,
state
)
);
};
override renderBlock() {
return html`
<div
class=${classMap({
'affine-attachment-container': true,
focused: this.selected$.value,
})}
style=${this.containerStyleMap}
>
${when(
this.embedView,
() =>
html`<div class="affine-attachment-embed-container">
${this.embedView}
</div>`,
this.renderCard
)}
</div>
`;
}
@property({ attribute: false })
accessor blobUrl: string | null = null;
override accessor selectedStyle = SelectedStyle.Border;
override accessor useCaptionEditor = true;
}
declare global {
interface HTMLElementTagNameMap {
'affine-attachment': AttachmentBlockComponent;
}
}