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 -->
This commit is contained in:
fundon
2025-05-07 05:15:57 +00:00
parent 8f6e604774
commit 93b1d6c729
17 changed files with 484 additions and 532 deletions
+55 -179
View File
@@ -11,7 +11,6 @@ import {
NativeClipboardProvider,
} from '@blocksuite/affine-shared/services';
import {
downloadBlob,
getBlockProps,
humanFileSize,
isInsidePageEditor,
@@ -20,219 +19,94 @@ import {
withTempBlobData,
} from '@blocksuite/affine-shared/utils';
import { Bound, type IVec, Vec } from '@blocksuite/global/gfx';
import {
BlockSelection,
type BlockStdScope,
type EditorHost,
} from '@blocksuite/std';
import { BlockSelection, type BlockStdScope } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import {
SURFACE_IMAGE_CARD_HEIGHT,
SURFACE_IMAGE_CARD_WIDTH,
} from './components/image-block-fallback.js';
import type { ImageBlockComponent } from './image-block.js';
import type { ImageEdgelessBlockComponent } from './image-edgeless-block.js';
} from './components/image-block-fallback';
import type { ImageBlockComponent } from './image-block';
import type { ImageEdgelessBlockComponent } from './image-edgeless-block';
const MAX_RETRY_COUNT = 3;
const DEFAULT_ATTACHMENT_NAME = 'affine-attachment';
const imageUploads = new Set<string>();
export function setImageUploading(blockId: string) {
imageUploads.add(blockId);
}
export function setImageUploaded(blockId: string) {
imageUploads.delete(blockId);
}
export function isImageUploading(blockId: string) {
return imageUploads.has(blockId);
}
export async function uploadBlobForImage(
editorHost: EditorHost,
blockId: string,
blob: Blob
): Promise<void> {
if (isImageUploading(blockId)) {
console.error('The image is already uploading!');
return;
}
setImageUploading(blockId);
const doc = editorHost.doc;
let sourceId: string | undefined;
try {
sourceId = await doc.blobSync.set(blob);
} catch (error) {
console.error(error);
if (error instanceof Error) {
toast(
editorHost,
`Failed to upload image! ${error.message || error.toString()}`
);
}
} finally {
setImageUploaded(blockId);
const imageModel = doc.getModelById(blockId) as ImageBlockModel | null;
if (sourceId && imageModel) {
const props: Partial<ImageBlockProps> = {
sourceId,
// Assign a default size to make sure the image can be displayed correctly.
width: 100,
height: 100,
};
const blob = await doc.blobSync.get(sourceId);
if (blob) {
try {
const size = await readImageSize(blob);
props.width = size.width;
props.height = size.height;
} catch {
// Ignore the error
console.warn('Failed to read image size');
}
}
doc.withoutTransact(() => {
doc.updateBlock(imageModel, props);
});
}
}
}
async function getImageBlob(model: ImageBlockModel) {
const sourceId = model.props.sourceId;
if (!sourceId) {
return null;
}
const sourceId = model.props.sourceId$.peek();
if (!sourceId) return null;
const doc = model.doc;
const blob = await doc.blobSync.get(sourceId);
if (!blob) {
return null;
}
let blob = await doc.blobSync.get(sourceId);
if (!blob) return null;
if (!blob.type) {
const buffer = await blob.arrayBuffer();
const FileType = await import('file-type');
const fileType = await FileType.fileTypeFromBuffer(buffer);
if (!fileType?.mime.startsWith('image/')) {
return null;
}
return new Blob([buffer], { type: fileType.mime });
blob = new Blob([buffer], { type: fileType?.mime });
}
if (!blob.type.startsWith('image/')) {
return null;
}
if (!blob.type.startsWith('image/')) return null;
return blob;
}
export async function fetchImageBlob(
export async function refreshData(
block: ImageBlockComponent | ImageEdgelessBlockComponent
) {
try {
if (block.model.props.sourceId !== block.lastSourceId || !block.blobUrl) {
block.loading = true;
block.error = false;
block.blob = undefined;
if (block.blobUrl) {
URL.revokeObjectURL(block.blobUrl);
block.blobUrl = undefined;
}
} else if (block.blobUrl) {
return;
}
const { model } = block;
const { sourceId } = model.props;
const { id, doc } = model;
if (isImageUploading(id)) {
return;
}
if (!sourceId) {
return;
}
const blob = await doc.blobSync.get(sourceId);
if (!blob) {
return;
}
block.loading = false;
block.blob = blob;
block.blobUrl = URL.createObjectURL(blob);
block.lastSourceId = sourceId;
} catch (error) {
block.retryCount++;
console.warn(`${error}, retrying`, block.retryCount);
if (block.retryCount < MAX_RETRY_COUNT) {
setTimeout(() => {
fetchImageBlob(block).catch(console.error);
// 1s, 2s, 3s
}, 1000 * block.retryCount);
} else {
block.loading = false;
block.error = true;
}
}
await block.resourceController.refreshUrlWith();
}
export async function downloadImageBlob(
block: ImageBlockComponent | ImageEdgelessBlockComponent
) {
const { host, downloading } = block;
if (downloading) {
const { host, blobUrl, resourceController } = block;
if (!blobUrl) {
toast(host, 'Failed to download image!');
return;
}
if (resourceController.state$.peek().downloading) {
toast(host, 'Download in progress...');
return;
}
block.downloading = true;
resourceController.updateState({ downloading: true });
const blob = await getImageBlob(block.model);
if (!blob) {
toast(host, `Unable to download image!`);
return;
}
toast(host, 'Downloading image...');
toast(host, `Downloading image...`);
const tmpLink = document.createElement('a');
const event = new MouseEvent('click');
tmpLink.download = 'image';
tmpLink.href = blobUrl;
tmpLink.dispatchEvent(event);
tmpLink.remove();
downloadBlob(blob, 'image');
block.downloading = false;
resourceController.updateState({ downloading: false });
}
export async function resetImageSize(
block: ImageBlockComponent | ImageEdgelessBlockComponent
) {
const { blob, model } = block;
const { model } = block;
const blob = await getImageBlob(model);
if (!blob) {
console.error('Failed to get image blob');
return;
}
const file = new File([blob], 'image.png', { type: blob.type });
const size = await readImageSize(file);
const bound = model.elementBound;
const props: Partial<ImageBlockProps> = {
width: size.width,
height: size.height,
};
const imageSize = await readImageSize(blob);
if (!bound.w || !bound.h) {
bound.w = size.width;
bound.h = size.height;
props.xywh = bound.serialize();
}
const bound = model.elementBound;
bound.w = imageSize.width;
bound.h = imageSize.height;
const xywh = bound.serialize();
const props: Partial<ImageBlockProps> = { ...imageSize, xywh };
block.doc.updateBlock(model, props);
}
@@ -326,7 +200,7 @@ export async function turnImageIntoCardView(
}
const model = block.model;
const sourceId = model.props.sourceId;
const sourceId = model.props.sourceId$.peek();
const blob = await getImageBlob(model);
if (!sourceId || !blob) {
console.error('Image data not available');
@@ -432,9 +306,9 @@ export async function addImageBlocks(
files.map(file => buildPropsWith(std, file))
);
const blockIds = propsArray.map(props =>
std.store.addBlock(flavour, props, parent, parentIndex)
);
const blocks = propsArray.map(blockProps => ({ flavour, blockProps }));
const blockIds = std.store.addBlocks(blocks, parent, parentIndex);
return blockIds;
}
@@ -476,9 +350,7 @@ export async function addImages(
const xy = [x, y];
const blockIds = propsArray.map((props, index) => {
const center = Vec.addScalar(xy, index * gap);
const blocks = propsArray.map((props, i) => {
// If maxWidth is provided, limit the width of the image to maxWidth
// Otherwise, use the original width
if (maxWidth) {
@@ -486,8 +358,11 @@ export async function addImages(
props.width = Math.min(props.width, maxWidth);
props.height = props.width * p;
}
const { width, height } = props;
const center = Vec.addScalar(xy, i * gap);
const index = gfx.layer.generateIndex();
const { width, height } = props;
const xywh = calcBoundByOrigin(
center,
inTopLeft,
@@ -495,19 +370,20 @@ export async function addImages(
height
).serialize();
return std.store.addBlock(
return {
flavour,
{
blockProps: {
...props,
width,
height,
xywh,
index: gfx.layer.generateIndex(),
index,
},
gfx.surface
);
};
});
const blockIds = std.store.addBlocks(blocks, gfx.surface);
gfx.selection.set({
elements: blockIds,
editing: false,