Files
AFFiNE-Mirror/blocksuite/affine/blocks/attachment/src/utils.ts
fundon 93b1d6c729 fix(editor): improve image block upload and download states (#12017)
Related to: [BS-3143](https://linear.app/affine-design/issue/BS-3143/更新-loading-和错误样式)

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

- **New Features**
  - Introduced a unified resource controller for managing image and attachment resources, providing improved loading, error, and state handling.
  - Added a visual loading indicator overlay to image blocks for better feedback during image loading.

- **Improvements**
  - Simplified and centralized image and attachment state management, reducing redundant properties and manual state tracking.
  - Updated fallback UI for image blocks with clearer titles, descriptions, and improved layout.
  - Enhanced batch image block creation and download handling for improved efficiency.
  - Refined image block accessibility with improved alt text and streamlined rendering logic.
  - Centralized target model selection for image insertion in AI actions.
  - Reordered CSS declarations without affecting styling.
  - Improved reactive state tracking for blob upload/download operations in mock server.

- **Bug Fixes**
  - Improved cleanup of object URLs to prevent resource leaks.
  - Adjusted toolbar logic to more accurately reflect available actions based on image state.

- **Tests**
  - Updated end-to-end tests to match new UI text and behaviors for image loading and error states.

- **Chores**
  - Refactored internal logic and updated comments for clarity and maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-07 05:15:57 +00:00

217 lines
5.7 KiB
TypeScript

import { toast } from '@blocksuite/affine-components/toast';
import {
type AttachmentBlockModel,
type AttachmentBlockProps,
AttachmentBlockSchema,
} from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
type AttachmentUploadedEvent,
FileSizeLimitProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import { Bound, type IVec, Vec } from '@blocksuite/global/gfx';
import type { BlockStdScope } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import type { AttachmentBlockComponent } from './attachment-block';
export async function getAttachmentBlob(model: AttachmentBlockModel) {
const { sourceId$, type$ } = model.props;
const sourceId = sourceId$.peek();
const type = type$.peek();
if (!sourceId) return null;
const doc = model.doc;
const blob = await doc.blobSync.get(sourceId);
if (!blob) return null;
return new Blob([blob], { type });
}
/**
* Since the size of the attachment may be very large,
* the download process may take a long time!
*/
export function downloadAttachmentBlob(block: AttachmentBlockComponent) {
const { host, model, blobUrl, resourceController } = block;
if (resourceController.state$.peek().downloading) {
toast(host, 'Download in progress...');
return;
}
const name = model.props.name;
const shortName = name.length < 20 ? name : name.slice(0, 20) + '...';
if (!blobUrl) {
toast(host, `Failed to download ${shortName}!`);
return;
}
resourceController.updateState({ downloading: true });
toast(host, `Downloading ${shortName}`);
const tmpLink = document.createElement('a');
const event = new MouseEvent('click');
tmpLink.download = name;
tmpLink.href = blobUrl;
tmpLink.dispatchEvent(event);
tmpLink.remove();
resourceController.updateState({ downloading: false });
}
export async function refreshData(block: AttachmentBlockComponent) {
const model = block.model;
const type = model.props.type$.peek();
await block.resourceController.refreshUrlWith(type);
}
export async function getFileType(file: File) {
if (file.type) return file.type;
// If the file type is not available, try to get it from the buffer.
const buffer = await file.arrayBuffer();
const FileType = await import('file-type');
const fileType = await FileType.fileTypeFromBuffer(buffer);
return fileType?.mime ?? '';
}
function hasExceeded(
std: BlockStdScope,
files: File[],
maxFileSize = std.get(FileSizeLimitProvider).maxFileSize
) {
const exceeded = files.some(file => file.size > maxFileSize);
if (exceeded) {
const size = humanFileSize(maxFileSize, true, 0);
toast(std.host, `You can only upload files less than ${size}`);
}
return exceeded;
}
async function buildPropsWith(
std: BlockStdScope,
file: File,
embed?: boolean,
mode: 'doc' | 'whiteboard' = 'doc'
) {
let type = file.type;
let category: AttachmentUploadedEvent['category'] = 'success';
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);
return {
name,
size,
type,
sourceId,
embed,
} satisfies Partial<AttachmentBlockProps>;
} catch (err) {
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',
segment: 'attachment',
control: 'uploader',
type,
category,
});
}
}
/**
* Add a new attachment block before / after the specified block.
*/
export async function addSiblingAttachmentBlocks(
std: BlockStdScope,
files: File[],
targetModel: BlockModel,
placement: 'before' | 'after' = 'after',
embed?: boolean
) {
if (!files.length) return [];
if (hasExceeded(std, files)) return [];
const flavour = AttachmentBlockSchema.model.flavour;
const propsArray = await Promise.all(
files.map(file => buildPropsWith(std, file, embed))
);
const blockIds = std.store.addSiblingBlocks(
targetModel,
propsArray.map(props => ({ ...props, flavour })),
placement
);
return blockIds;
}
export async function addAttachments(
std: BlockStdScope,
files: File[],
point?: IVec,
shouldTransformPoint?: boolean // determines whether we should use `toModelCoord` to convert the point
): Promise<string[]> {
if (!files.length) return [];
if (hasExceeded(std, files)) return [];
const propsArray = await Promise.all(
files.map(file => buildPropsWith(std, file, undefined, 'whiteboard'))
);
const gfx = std.get(GfxControllerIdentifier);
let { x, y } = gfx.viewport.center;
if (point) {
shouldTransformPoint = shouldTransformPoint ?? true;
if (shouldTransformPoint) {
[x, y] = gfx.viewport.toModelCoord(...point);
} else {
[x, y] = point;
}
}
const xy = [x, y];
const style = 'cubeThick';
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) => {
const center = Vec.addScalar(xy, index * gap);
const xywh = Bound.fromCenter(center, width, height).serialize();
return { flavour, blockProps: { ...props, style, xywh } };
});
const blockIds = std.store.addBlocks(blocks, gfx.surface);
gfx.selection.set({
elements: blockIds,
editing: false,
});
return blockIds;
}