mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 14:28:51 +08:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user