mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 18:02:47 +08:00
refactor(editor): simplify attachment and image upload handling (#11987)
Closes: [BS-3303](https://linear.app/affine-design/issue/BS-3303/改進-pack-attachment-props-流程) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Enhanced attachment and image uploads with improved file size validation and clearer notifications. - Upload telemetry tracking added for attachments to monitor upload success or failure. - **Refactor** - Streamlined and unified the process of adding attachments and images, making uploads more reliable and efficient. - Parameter names updated for clarity across attachment and image insertion features. - **Documentation** - Updated API documentation to reflect parameter name changes for consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,48 +1,40 @@
|
||||
import { FileSizeLimitService } from '@blocksuite/affine-shared/services';
|
||||
import { getImageFilesFromLocal } from '@blocksuite/affine-shared/utils';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { addSiblingImageBlock } from '../utils.js';
|
||||
import { addSiblingImageBlocks } from '../utils';
|
||||
|
||||
export const insertImagesCommand: Command<
|
||||
{
|
||||
selectedModels?: BlockModel[];
|
||||
removeEmptyLine?: boolean;
|
||||
place?: 'after' | 'before';
|
||||
placement?: 'after' | 'before';
|
||||
},
|
||||
{
|
||||
insertedImageIds: Promise<string[]>;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { selectedModels, place, removeEmptyLine, std } = ctx;
|
||||
if (!selectedModels) return;
|
||||
const { selectedModels, placement, removeEmptyLine, std } = ctx;
|
||||
if (!selectedModels?.length) return;
|
||||
|
||||
const targetModel =
|
||||
placement === 'before'
|
||||
? selectedModels[0]
|
||||
: selectedModels[selectedModels.length - 1];
|
||||
|
||||
return next({
|
||||
insertedImageIds: getImageFilesFromLocal().then(imageFiles => {
|
||||
if (imageFiles.length === 0) return [];
|
||||
insertedImageIds: getImageFilesFromLocal()
|
||||
.then(files => addSiblingImageBlocks(std, files, targetModel, placement))
|
||||
.then(result => {
|
||||
if (
|
||||
result.length &&
|
||||
removeEmptyLine &&
|
||||
targetModel.text?.length === 0
|
||||
) {
|
||||
std.store.deleteBlock(targetModel);
|
||||
}
|
||||
|
||||
if (selectedModels.length === 0) return [];
|
||||
|
||||
const targetModel =
|
||||
place === 'before'
|
||||
? selectedModels[0]
|
||||
: selectedModels[selectedModels.length - 1];
|
||||
|
||||
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
|
||||
|
||||
const result = addSiblingImageBlock(
|
||||
std.host,
|
||||
imageFiles,
|
||||
maxFileSize,
|
||||
targetModel,
|
||||
place
|
||||
);
|
||||
if (removeEmptyLine && targetModel.text?.length === 0) {
|
||||
std.store.deleteBlock(targetModel);
|
||||
}
|
||||
|
||||
return result ?? [];
|
||||
}),
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
||||
import { FileDropConfigExtension } from '@blocksuite/affine-components/drop-indicator';
|
||||
import { ImageBlockSchema, MAX_IMAGE_WIDTH } from '@blocksuite/affine-model';
|
||||
import {
|
||||
FileSizeLimitService,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
isInsideEdgelessEditor,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
import { addImages, addSiblingImageBlock } from './utils.js';
|
||||
import { addImages, addSiblingImageBlocks } from './utils.js';
|
||||
|
||||
export const ImageDropOption = FileDropConfigExtension({
|
||||
flavour: ImageBlockSchema.model.flavour,
|
||||
@@ -19,15 +16,9 @@ export const ImageDropOption = FileDropConfigExtension({
|
||||
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
||||
if (!imageFiles.length) return false;
|
||||
|
||||
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
|
||||
|
||||
if (targetModel && !matchModels(targetModel, [SurfaceBlockModel])) {
|
||||
addSiblingImageBlock(
|
||||
std.host,
|
||||
imageFiles,
|
||||
maxFileSize,
|
||||
targetModel,
|
||||
placement
|
||||
addSiblingImageBlocks(std, imageFiles, targetModel, placement).catch(
|
||||
console.error
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { autoResizeElementsCommand } from '@blocksuite/affine-block-surface';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import type {
|
||||
AttachmentBlockProps,
|
||||
ImageBlockModel,
|
||||
ImageBlockProps,
|
||||
import {
|
||||
type AttachmentBlockProps,
|
||||
type ImageBlockModel,
|
||||
type ImageBlockProps,
|
||||
ImageBlockSchema,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
FileSizeLimitService,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
transformModel,
|
||||
withTempBlobData,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { Bound, type IVec, Point, Vec } from '@blocksuite/global/gfx';
|
||||
import { Bound, type IVec, Vec } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
BlockSelection,
|
||||
type BlockStdScope,
|
||||
@@ -312,93 +313,6 @@ export async function copyImageBlob(
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldResizeImage(node: Node, target: EventTarget | null) {
|
||||
return !!(
|
||||
target &&
|
||||
target instanceof HTMLElement &&
|
||||
node.contains(target) &&
|
||||
target.classList.contains('resize')
|
||||
);
|
||||
}
|
||||
|
||||
export function addSiblingImageBlock(
|
||||
editorHost: EditorHost,
|
||||
files: File[],
|
||||
maxFileSize: number,
|
||||
targetModel: BlockModel,
|
||||
place: 'after' | 'before' = 'after'
|
||||
) {
|
||||
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
||||
if (!imageFiles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSizeExceeded = imageFiles.some(file => file.size > maxFileSize);
|
||||
if (isSizeExceeded) {
|
||||
toast(
|
||||
editorHost,
|
||||
`You can only upload files less than ${humanFileSize(
|
||||
maxFileSize,
|
||||
true,
|
||||
0
|
||||
)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const imageBlockProps: Partial<ImageBlockProps> &
|
||||
{
|
||||
flavour: 'affine:image';
|
||||
}[] = imageFiles.map(file => ({
|
||||
flavour: 'affine:image',
|
||||
size: file.size,
|
||||
}));
|
||||
|
||||
const doc = editorHost.doc;
|
||||
const blockIds = doc.addSiblingBlocks(targetModel, imageBlockProps, place);
|
||||
blockIds.forEach(
|
||||
(blockId, index) =>
|
||||
void uploadBlobForImage(editorHost, blockId, imageFiles[index])
|
||||
);
|
||||
return blockIds;
|
||||
}
|
||||
|
||||
export function addImageBlocks(
|
||||
editorHost: EditorHost,
|
||||
files: File[],
|
||||
maxFileSize: number,
|
||||
parent?: BlockModel | string | null,
|
||||
parentIndex?: number
|
||||
) {
|
||||
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
||||
if (!imageFiles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSizeExceeded = imageFiles.some(file => file.size > maxFileSize);
|
||||
if (isSizeExceeded) {
|
||||
toast(
|
||||
editorHost,
|
||||
`You can only upload files less than ${humanFileSize(
|
||||
maxFileSize,
|
||||
true,
|
||||
0
|
||||
)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = editorHost.doc;
|
||||
const blockIds = imageFiles.map(file =>
|
||||
doc.addBlock('affine:image', { size: file.size }, parent, parentIndex)
|
||||
);
|
||||
blockIds.forEach(
|
||||
(blockId, index) =>
|
||||
void uploadBlobForImage(editorHost, blockId, imageFiles[index])
|
||||
);
|
||||
return blockIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the image block into a attachment block.
|
||||
*/
|
||||
@@ -436,115 +350,171 @@ export async function turnImageIntoCardView(
|
||||
transformModel(model, 'affine:attachment', attachmentProp);
|
||||
}
|
||||
|
||||
export function shouldResizeImage(node: Node, target: EventTarget | null) {
|
||||
return !!(
|
||||
target &&
|
||||
target instanceof HTMLElement &&
|
||||
node.contains(target) &&
|
||||
target.classList.contains('resize')
|
||||
);
|
||||
}
|
||||
|
||||
function hasExceeded(
|
||||
std: BlockStdScope,
|
||||
files: File[],
|
||||
maxFileSize = std.store.get(FileSizeLimitService).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) {
|
||||
const { size } = file;
|
||||
const [imageSize, sourceId] = await Promise.all([
|
||||
readImageSize(file),
|
||||
std.store.blobSync.set(file),
|
||||
]);
|
||||
|
||||
if (!(imageSize.width * imageSize.height)) {
|
||||
toast(std.host, 'Failed to read image size, please try another image');
|
||||
throw new Error('Failed to read image size');
|
||||
}
|
||||
|
||||
return { size, sourceId, ...imageSize } satisfies Partial<ImageBlockProps>;
|
||||
}
|
||||
|
||||
export async function addSiblingImageBlocks(
|
||||
std: BlockStdScope,
|
||||
files: File[],
|
||||
targetModel: BlockModel,
|
||||
placement: 'after' | 'before' = 'after'
|
||||
) {
|
||||
files = files.filter(file => file.type.startsWith('image/'));
|
||||
if (!files.length) return [];
|
||||
|
||||
if (hasExceeded(std, files)) return [];
|
||||
|
||||
const flavour = ImageBlockSchema.model.flavour;
|
||||
|
||||
const propsArray = await Promise.all(
|
||||
files.map(file => buildPropsWith(std, file))
|
||||
);
|
||||
|
||||
const blockIds = std.store.addSiblingBlocks(
|
||||
targetModel,
|
||||
propsArray.map(props => ({ ...props, flavour })),
|
||||
placement
|
||||
);
|
||||
|
||||
return blockIds;
|
||||
}
|
||||
|
||||
export async function addImageBlocks(
|
||||
std: BlockStdScope,
|
||||
files: File[],
|
||||
parent?: BlockModel | string | null,
|
||||
parentIndex?: number
|
||||
) {
|
||||
files = files.filter(file => file.type.startsWith('image/'));
|
||||
if (!files.length) return [];
|
||||
|
||||
if (hasExceeded(std, files)) return [];
|
||||
|
||||
const flavour = ImageBlockSchema.model.flavour;
|
||||
|
||||
const propsArray = await Promise.all(
|
||||
files.map(file => buildPropsWith(std, file))
|
||||
);
|
||||
|
||||
const blockIds = propsArray.map(props =>
|
||||
std.store.addBlock(flavour, props, parent, parentIndex)
|
||||
);
|
||||
|
||||
return blockIds;
|
||||
}
|
||||
|
||||
export async function addImages(
|
||||
std: BlockStdScope,
|
||||
files: File[],
|
||||
options: {
|
||||
point?: IVec;
|
||||
maxWidth?: number;
|
||||
transformPoint?: boolean; // determines whether we should use `toModelCoord` to convert the point
|
||||
shouldTransformPoint?: boolean; // determines whether we should use `toModelCoord` to convert the point
|
||||
}
|
||||
): Promise<string[]> {
|
||||
const imageFiles = [...files].filter(file => file.type.startsWith('image/'));
|
||||
if (!imageFiles.length) return [];
|
||||
files = files.filter(file => file.type.startsWith('image/'));
|
||||
if (!files.length) return [];
|
||||
|
||||
if (hasExceeded(std, files)) return [];
|
||||
|
||||
const flavour = ImageBlockSchema.model.flavour;
|
||||
|
||||
const propsArray = await Promise.all(
|
||||
files.map(file => buildPropsWith(std, file))
|
||||
);
|
||||
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const isMultiple = propsArray.length > 1;
|
||||
const inTopLeft = isMultiple;
|
||||
const gap = 32;
|
||||
const { point, maxWidth, shouldTransformPoint = true } = options;
|
||||
|
||||
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
|
||||
const isSizeExceeded = imageFiles.some(file => file.size > maxFileSize);
|
||||
if (isSizeExceeded) {
|
||||
toast(
|
||||
std.host,
|
||||
`You can only upload files less than ${humanFileSize(
|
||||
maxFileSize,
|
||||
true,
|
||||
0
|
||||
)}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const { point, maxWidth, transformPoint = true } = options;
|
||||
let { x, y } = gfx.viewport.center;
|
||||
if (point) {
|
||||
if (transformPoint) {
|
||||
if (shouldTransformPoint) {
|
||||
[x, y] = gfx.viewport.toModelCoord(...point);
|
||||
} else {
|
||||
[x, y] = point;
|
||||
}
|
||||
}
|
||||
|
||||
const dropInfos: { point: Point; blockId: string }[] = [];
|
||||
const IMAGE_STACK_GAP = 32;
|
||||
const isMultipleFiles = imageFiles.length > 1;
|
||||
const inTopLeft = isMultipleFiles ? true : false;
|
||||
const xy = [x, y];
|
||||
|
||||
// create image cards without image data
|
||||
imageFiles.forEach((file, index) => {
|
||||
const point = new Point(
|
||||
x + index * IMAGE_STACK_GAP,
|
||||
y + index * IMAGE_STACK_GAP
|
||||
);
|
||||
const center = Vec.toVec(point);
|
||||
const bound = calcBoundByOrigin(center, inTopLeft);
|
||||
const blockId = std.store.addBlock(
|
||||
'affine:image',
|
||||
const blockIds = propsArray.map((props, index) => {
|
||||
const center = Vec.addScalar(xy, index * gap);
|
||||
|
||||
// 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;
|
||||
|
||||
const xywh = calcBoundByOrigin(
|
||||
center,
|
||||
inTopLeft,
|
||||
width,
|
||||
height
|
||||
).serialize();
|
||||
|
||||
return std.store.addBlock(
|
||||
flavour,
|
||||
{
|
||||
size: file.size,
|
||||
xywh: bound.serialize(),
|
||||
...props,
|
||||
width,
|
||||
height,
|
||||
xywh,
|
||||
index: gfx.layer.generateIndex(),
|
||||
},
|
||||
gfx.surface
|
||||
);
|
||||
dropInfos.push({ point, blockId });
|
||||
});
|
||||
|
||||
// upload image data and update the image model
|
||||
const uploadPromises = imageFiles.map(async (file, index) => {
|
||||
const { point, blockId } = dropInfos[index];
|
||||
const block = std.store.getBlock(blockId);
|
||||
const imageSize = await readImageSize(file);
|
||||
|
||||
if (!imageSize.width || !imageSize.height) {
|
||||
std.store.deleteBlock(block!.model);
|
||||
|
||||
toast(std.host, 'Failed to read image size, please try another image');
|
||||
throw new Error('Failed to read image size');
|
||||
}
|
||||
|
||||
const sourceId = await std.store.blobSync.set(file);
|
||||
|
||||
const center = Vec.toVec(point);
|
||||
// If maxWidth is provided, limit the width of the image to maxWidth
|
||||
// Otherwise, use the original width
|
||||
const width = maxWidth
|
||||
? Math.min(imageSize.width, maxWidth)
|
||||
: imageSize.width;
|
||||
const height = maxWidth
|
||||
? (imageSize.height / imageSize.width) * width
|
||||
: imageSize.height;
|
||||
const bound = calcBoundByOrigin(center, inTopLeft, width, height);
|
||||
|
||||
std.store.withoutTransact(() => {
|
||||
gfx.updateElement(blockId, {
|
||||
sourceId,
|
||||
...imageSize,
|
||||
width,
|
||||
height,
|
||||
xywh: bound.serialize(),
|
||||
} satisfies Partial<ImageBlockProps>);
|
||||
});
|
||||
});
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
const blockIds = dropInfos.map(info => info.blockId);
|
||||
gfx.selection.set({
|
||||
elements: blockIds,
|
||||
editing: false,
|
||||
});
|
||||
if (isMultipleFiles) {
|
||||
|
||||
if (isMultiple) {
|
||||
std.command.exec(autoResizeElementsCommand);
|
||||
}
|
||||
|
||||
return blockIds;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user