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 -->
This commit is contained in:
fundon
2025-04-29 01:00:56 +00:00
parent df565f2fbf
commit 362f89b669
9 changed files with 336 additions and 272 deletions

View File

@@ -1,9 +1,9 @@
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import {
AttachmentIcon16,
getAttachmentFileIcon,
} from '@blocksuite/affine-components/icons';
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 {
@@ -15,17 +15,22 @@ import {
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 { html } from 'lit';
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 { classMap } from 'lit/directives/class-map.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 { checkAttachmentBlob, downloadAttachmentBlob } from './utils';
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');
@@ -36,6 +41,8 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
blockDraggable = true;
blobState$ = signal<Partial<BlobState>>({});
protected containerStyleMap = styleMap({
position: 'relative',
width: '100%',
@@ -63,26 +70,45 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
};
embedded = () => {
return this.std
.get(AttachmentEmbedProvider)
.embedded(this.model, this._maxFileSize);
return (
Boolean(this.blobUrl) &&
this.std
.get(AttachmentEmbedProvider)
.embedded(this.model, this._maxFileSize)
);
};
open = () => {
if (!this.blobUrl) {
return;
}
window.open(this.blobUrl, '_blank');
const blobUrl = this.blobUrl;
if (!blobUrl) return;
window.open(blobUrl, '_blank');
};
refreshData = () => {
checkAttachmentBlob(this).catch(console.error);
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, this._maxFileSize);
.render(this.model, this.blobUrl ?? undefined, this._maxFileSize);
}
private _selectBlock() {
@@ -96,9 +122,30 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
override connectedCallback() {
super.connectedCallback();
this.contentEditable = 'false';
this.refreshData();
this.contentEditable = 'false';
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(() => {
@@ -107,27 +154,12 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
});
});
}
this.model.propsUpdated.subscribe(({ key }) => {
if (key === 'sourceId') {
// Reset the blob url when the sourceId is changed
if (this.blobUrl) {
URL.revokeObjectURL(this.blobUrl);
this.blobUrl = undefined;
}
this.refreshData();
}
});
// Workaround for https://github.com/toeverything/blocksuite/issues/4724
this.disposables.add(
this.std.get(ThemeProvider).theme$.subscribe(() => this.requestUpdate())
);
}
override disconnectedCallback() {
if (this.blobUrl) {
URL.revokeObjectURL(this.blobUrl);
const blobUrl = this.blobUrl;
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
super.disconnectedCallback();
}
@@ -148,71 +180,173 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
}
}
override renderBlock() {
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;
const theme = this.std.get(ThemeProvider).theme$.value;
const { LoadingIcon } = getEmbedCardIcons(theme);
const titleIcon = this.loading ? LoadingIcon : AttachmentIcon16;
const titleText = this.loading ? 'Loading...' : name;
const infoText = this.error ? 'File loading failed.' : humanFileSize(size);
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 fileType = name.split('.').pop() ?? '';
const FileTypeIcon = getAttachmentFileIcon(fileType);
const classInfo = {
'affine-attachment-card': true,
[cardStyle]: true,
error,
loading,
};
const embedView = this.embedView;
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="affine-attachment-container" style=${this.containerStyleMap}>
${embedView
? html`<div class="affine-attachment-embed-container">
${embedView}
</div>`
: html`<div
class=${classMap({
'affine-attachment-card': true,
[cardStyle]: true,
loading: this.loading,
error: this.error,
unsynced: false,
})}
>
<div class="affine-attachment-content">
<div class="affine-attachment-content-title">
<div class="affine-attachment-content-title-icon">
${titleIcon}
</div>
<div class="affine-attachment-content-title-text">
${titleText}
</div>
</div>
<div class="affine-attachment-content-info">${infoText}</div>
</div>
<div class="affine-attachment-banner">${FileTypeIcon}</div>
</div>`}
<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 allowEmbed = false;
accessor blobUrl: string | null = null;
@property({ attribute: false })
accessor blobUrl: string | undefined = undefined;
@property({ attribute: false })
accessor downloading = false;
@property({ attribute: false })
accessor error = false;
@property({ attribute: false })
accessor loading = false;
override accessor selectedStyle = SelectedStyle.Border;
override accessor useCaptionEditor = true;
}

View File

@@ -69,6 +69,10 @@ export const attachmentViewDropdownMenu = {
{
id: 'embed',
label: 'Embed view',
disabled: ctx => {
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
return block ? !block.embedded() : true;
},
run(ctx) {
const model = ctx.getCurrentModelByType(AttachmentBlockModel);
if (!model) return;
@@ -156,24 +160,24 @@ const builtinToolbarConfig = {
actions: [
{
id: 'a.rename',
content(cx) {
const block = cx.getCurrentBlockByType(AttachmentBlockComponent);
content(ctx) {
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
if (!block) return null;
const abortController = new AbortController();
abortController.signal.onabort = () => cx.show();
abortController.signal.onabort = () => ctx.show();
return html`
<editor-icon-button
aria-label="Rename"
.tooltip="${'Rename'}"
@click=${() => {
cx.hide();
ctx.hide();
createLitPortal({
template: RenameModal({
model: block.model,
editorHost: cx.host,
editorHost: ctx.host,
abortController,
}),
computePosition: {

View File

@@ -187,7 +187,7 @@ const embedConfig: AttachmentEmbedConfig[] = [
/**
* Turn the attachment block into an image block.
*/
export async function turnIntoImageBlock(model: AttachmentBlockModel) {
async function turnIntoImageBlock(model: AttachmentBlockModel) {
if (!model.doc.schema.flavourSchemaMap.has('affine:image')) {
console.error('The image flavour is not supported!');
return;

View File

@@ -1,31 +1,33 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css } from 'lit';
export const styles = css`
.affine-attachment-card {
margin: 0 auto;
.affine-attachment-container {
border-radius: 8px;
box-sizing: border-box;
user-select: none;
border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')};
background: ${unsafeCSSVarV2('layer/background/primary')};
overflow: hidden;
&.focused {
border-color: ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')};
}
}
.affine-attachment-card {
display: flex;
gap: 12px;
width: 100%;
height: 100%;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--affine-background-tertiary-color);
opacity: var(--add, 1);
background: var(--affine-background-primary-color);
user-select: none;
}
.affine-attachment-content {
height: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
flex: 1 0 0;
min-width: 0;
}
.affine-attachment-content-title {
@@ -33,7 +35,6 @@ export const styles = css`
flex-direction: row;
gap: 8px;
align-items: center;
align-self: stretch;
}
@@ -43,24 +44,18 @@ export const styles = css`
height: 16px;
align-items: center;
justify-content: center;
color: var(--affine-text-primary-color);
}
.affine-attachment-content-title-icon svg {
width: 16px;
height: 16px;
fill: var(--affine-background-primary-color);
.truncate {
align-self: stretch;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.affine-attachment-content-title-text {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
@@ -68,17 +63,15 @@ export const styles = css`
line-height: 22px;
}
.affine-attachment-content-description {
display: flex;
align-items: center;
align-self: stretch;
gap: 8px;
}
.affine-attachment-content-info {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
flex: 1 0 0;
word-break: break-all;
overflow: hidden;
color: var(--affine-text-secondary-color);
text-overflow: ellipsis;
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
@@ -86,6 +79,26 @@ export const styles = css`
line-height: 20px;
}
.affine-attachment-content-button {
display: flex;
height: 20px;
align-items: center;
align-self: stretch;
gap: 4px;
white-space: nowrap;
padding: 0 4px;
color: ${unsafeCSSVarV2('button/primary')};
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px;
svg {
font-size: 16px;
}
}
.affine-attachment-banner {
display: flex;
align-items: center;
@@ -93,16 +106,15 @@ export const styles = css`
}
.affine-attachment-card.loading {
background: var(--affine-background-secondary-color);
.affine-attachment-content-title-text {
color: var(--affine-placeholder-color);
}
}
.affine-attachment-card.error,
.affine-attachment-card.unsynced {
background: var(--affine-background-secondary-color);
.affine-attachment-card.error {
.affine-attachment-content-title-icon {
color: ${unsafeCSSVarV2('status/error')};
}
}
.affine-attachment-card.cubeThick {
@@ -116,7 +128,7 @@ export const styles = css`
}
.affine-attachment-banner {
justify-content: flex-start;
justify-content: space-between;
}
}

View File

@@ -21,128 +21,19 @@ import type { BlockModel } from '@blocksuite/store';
import type { AttachmentBlockComponent } from './attachment-block';
const attachmentUploads = new Set<string>();
export function setAttachmentUploading(blockId: string) {
attachmentUploads.add(blockId);
}
export function setAttachmentUploaded(blockId: string) {
attachmentUploads.delete(blockId);
}
function isAttachmentUploading(blockId: string) {
return attachmentUploads.has(blockId);
}
/**
* This function will not verify the size of the file.
*/
// TODO(@fundon): should remove
export async function uploadAttachmentBlob(
std: BlockStdScope,
blockId: string,
blob: Blob,
filetype: string,
isEdgeless?: boolean
): Promise<void> {
if (isAttachmentUploading(blockId)) return;
let sourceId: string | undefined;
try {
setAttachmentUploading(blockId);
sourceId = await std.store.blobSync.set(blob);
} catch (error) {
console.error(error);
if (error instanceof Error) {
toast(
std.host,
`Failed to upload attachment! ${error.message || error.toString()}`
);
}
} finally {
setAttachmentUploaded(blockId);
const block = std.store.getBlock(blockId);
std.store.withoutTransact(() => {
if (!block) return;
std.store.updateBlock(block.model, {
sourceId,
} satisfies Partial<AttachmentBlockProps>);
});
std.getOptional(TelemetryProvider)?.track('AttachmentUploadedEvent', {
page: `${isEdgeless ? 'whiteboard' : 'doc'} editor`,
module: 'attachment',
segment: 'attachment',
control: 'uploader',
type: filetype,
category: block && sourceId ? 'success' : 'failure',
});
}
}
export async function getAttachmentBlob(model: AttachmentBlockModel) {
const sourceId = model.props.sourceId;
if (!sourceId) {
return null;
}
const {
sourceId$: { value: sourceId },
type$: { value: type },
} = model.props;
if (!sourceId) return null;
const doc = model.doc;
let blob = await doc.blobSync.get(sourceId);
if (blob) {
blob = new Blob([blob], { type: model.props.type });
}
if (!blob) return null;
return blob;
}
// TODO(@fundon): should remove
export async function checkAttachmentBlob(block: AttachmentBlockComponent) {
const model = block.model;
const { id } = model;
const { sourceId } = model.props;
if (isAttachmentUploading(id)) {
block.loading = true;
block.error = false;
block.allowEmbed = false;
if (block.blobUrl) {
URL.revokeObjectURL(block.blobUrl);
block.blobUrl = undefined;
}
return;
}
try {
if (!sourceId) {
return;
}
const blob = await getAttachmentBlob(model);
if (!blob) {
return;
}
block.loading = false;
block.error = false;
block.allowEmbed = block.embedded();
if (block.blobUrl) {
URL.revokeObjectURL(block.blobUrl);
}
block.blobUrl = URL.createObjectURL(blob);
} catch (error) {
console.warn(error, model, sourceId);
block.loading = false;
block.error = true;
block.allowEmbed = false;
if (block.blobUrl) {
URL.revokeObjectURL(block.blobUrl);
block.blobUrl = undefined;
}
}
return new Blob([blob], { type });
}
/**
@@ -150,26 +41,22 @@ export async function checkAttachmentBlob(block: AttachmentBlockComponent) {
* the download process may take a long time!
*/
export function downloadAttachmentBlob(block: AttachmentBlockComponent) {
const { host, model, loading, error, downloading, blobUrl } = block;
if (downloading) {
toast(host, 'Download in progress...');
return;
}
const { host, model, blobUrl, blobState$ } = block;
if (loading) {
toast(host, 'Please wait, file is loading...');
if (blobState$.peek().downloading) {
toast(host, 'Download in progress...');
return;
}
const name = model.props.name;
const shortName = name.length < 20 ? name : name.slice(0, 20) + '...';
if (error || !blobUrl) {
if (!blobUrl) {
toast(host, `Failed to download ${shortName}!`);
return;
}
block.downloading = true;
block.updateBlobState({ downloading: true });
toast(host, `Downloading ${shortName}`);
@@ -180,7 +67,34 @@ export function downloadAttachmentBlob(block: AttachmentBlockComponent) {
tmpLink.dispatchEvent(event);
tmpLink.remove();
block.downloading = false;
block.updateBlobState({ downloading: false });
}
export async function refreshData(
std: BlockStdScope,
block: AttachmentBlockComponent
) {
const model = block.model;
const sourceId = model.props.sourceId$.peek();
if (!sourceId) return;
const blobUrl = block.blobUrl;
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
block.blobUrl = null;
}
let blob = await std.store.blobSync.get(sourceId);
if (!blob) {
block.updateBlobState({ errorMessage: 'File not found' });
return;
}
const type = model.props.type$.peek();
blob = new Blob([blob], { type });
block.blobUrl = URL.createObjectURL(blob);
}
export async function getFileType(file: File) {
@@ -219,6 +133,7 @@ async function buildPropsWith(
try {
const { name, size } = file;
// TODO(@fundon): should re-upload when upload timeout
const sourceId = await std.store.blobSync.set(file);
type = await getFileType(file);
@@ -233,6 +148,7 @@ async function buildPropsWith(
category = 'failure';
throw err;
} finally {
// TODO(@fundon): should change event name because this is just a local operation.
std.getOptional(TelemetryProvider)?.track('AttachmentUploadedEvent', {
page: `${mode} editor`,
module: 'attachment',
@@ -303,7 +219,6 @@ export async function addAttachments(
const gap = 32;
const width = EMBED_CARD_WIDTH.cubeThick;
const height = EMBED_CARD_HEIGHT.cubeThick;
const flavour = AttachmentBlockSchema.model.flavour;
const blocks = propsArray.map((props, index) => {
@@ -312,7 +227,7 @@ export async function addAttachments(
return { flavour, blockProps: { ...props, style, xywh } };
});
const blockIds = std.store.addBlocks(blocks);
const blockIds = std.store.addBlocks(blocks, gfx.surface);
gfx.selection.set({
elements: blockIds,

View File

@@ -481,10 +481,12 @@ export async function addImages(
// If maxWidth is provided, limit the width of the image to maxWidth
// Otherwise, use the original width
const width = maxWidth ? Math.min(props.width, maxWidth) : props.width;
const height = maxWidth
? (props.height / props.width) * width
: props.height;
if (maxWidth) {
const p = props.height / props.width;
props.width = Math.min(props.width, maxWidth);
props.height = props.width * p;
}
const { width, height } = props;
const xywh = calcBoundByOrigin(
center,

View File

@@ -340,11 +340,6 @@ export const FontFamilyIcon = icons.FontIcon({
height: '20',
});
export const AttachmentIcon16 = icons.AttachmentIcon({
width: '16',
height: '16',
});
export const TextBackgroundDuotoneIcon = html` <svg
xmlns="http://www.w3.org/2000/svg"
width="20"