mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +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:
@@ -24,7 +24,7 @@ export const attachmentSlashMenuConfig: SlashMenuConfig = {
|
|||||||
const file = await openFileOrFiles();
|
const file = await openFileOrFiles();
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
await addSiblingAttachmentBlocks(std, [file], model, 'after');
|
await addSiblingAttachmentBlocks(std, [file], model);
|
||||||
if (model.text?.length === 0) {
|
if (model.text?.length === 0) {
|
||||||
std.store.deleteBlock(model);
|
std.store.deleteBlock(model);
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ export const attachmentSlashMenuConfig: SlashMenuConfig = {
|
|||||||
const file = await openFileOrFiles();
|
const file = await openFileOrFiles();
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
await addSiblingAttachmentBlocks(std, [file], model, 'after');
|
await addSiblingAttachmentBlocks(std, [file], model);
|
||||||
if (model.text?.length === 0) {
|
if (model.text?.length === 0) {
|
||||||
std.store.deleteBlock(model);
|
std.store.deleteBlock(model);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
EMBED_CARD_WIDTH,
|
EMBED_CARD_WIDTH,
|
||||||
} from '@blocksuite/affine-shared/consts';
|
} from '@blocksuite/affine-shared/consts';
|
||||||
import {
|
import {
|
||||||
|
type AttachmentUploadedEvent,
|
||||||
FileSizeLimitService,
|
FileSizeLimitService,
|
||||||
TelemetryProvider,
|
TelemetryProvider,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
@@ -18,7 +19,7 @@ import type { BlockStdScope } from '@blocksuite/std';
|
|||||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||||
import type { BlockModel } from '@blocksuite/store';
|
import type { BlockModel } from '@blocksuite/store';
|
||||||
|
|
||||||
import type { AttachmentBlockComponent } from './attachment-block.js';
|
import type { AttachmentBlockComponent } from './attachment-block';
|
||||||
|
|
||||||
const attachmentUploads = new Set<string>();
|
const attachmentUploads = new Set<string>();
|
||||||
export function setAttachmentUploading(blockId: string) {
|
export function setAttachmentUploading(blockId: string) {
|
||||||
@@ -34,6 +35,7 @@ function isAttachmentUploading(blockId: string) {
|
|||||||
/**
|
/**
|
||||||
* This function will not verify the size of the file.
|
* This function will not verify the size of the file.
|
||||||
*/
|
*/
|
||||||
|
// TODO(@fundon): should remove
|
||||||
export async function uploadAttachmentBlob(
|
export async function uploadAttachmentBlob(
|
||||||
std: BlockStdScope,
|
std: BlockStdScope,
|
||||||
blockId: string,
|
blockId: string,
|
||||||
@@ -41,9 +43,7 @@ export async function uploadAttachmentBlob(
|
|||||||
filetype: string,
|
filetype: string,
|
||||||
isEdgeless?: boolean
|
isEdgeless?: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (isAttachmentUploading(blockId)) {
|
if (isAttachmentUploading(blockId)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sourceId: string | undefined;
|
let sourceId: string | undefined;
|
||||||
|
|
||||||
@@ -98,6 +98,7 @@ export async function getAttachmentBlob(model: AttachmentBlockModel) {
|
|||||||
return blob;
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(@fundon): should remove
|
||||||
export async function checkAttachmentBlob(block: AttachmentBlockComponent) {
|
export async function checkAttachmentBlob(block: AttachmentBlockComponent) {
|
||||||
const model = block.model;
|
const model = block.model;
|
||||||
const { id } = model;
|
const { id } = model;
|
||||||
@@ -192,6 +193,57 @@ export async function getFileType(file: File) {
|
|||||||
return fileType ? fileType.mime : '';
|
return fileType ? fileType.mime : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
embed?: boolean,
|
||||||
|
mode: 'doc' | 'whiteboard' = 'doc'
|
||||||
|
) {
|
||||||
|
let type = file.type;
|
||||||
|
let category: AttachmentUploadedEvent['category'] = 'success';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { name, size } = file;
|
||||||
|
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 {
|
||||||
|
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.
|
* Add a new attachment block before / after the specified block.
|
||||||
*/
|
*/
|
||||||
@@ -199,59 +251,25 @@ export async function addSiblingAttachmentBlocks(
|
|||||||
std: BlockStdScope,
|
std: BlockStdScope,
|
||||||
files: File[],
|
files: File[],
|
||||||
targetModel: BlockModel,
|
targetModel: BlockModel,
|
||||||
place: 'before' | 'after' = 'after',
|
placement: 'before' | 'after' = 'after',
|
||||||
isEmbed?: boolean
|
embed?: boolean
|
||||||
) {
|
) {
|
||||||
if (!files.length) {
|
if (!files.length) return [];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
|
if (hasExceeded(std, files)) return [];
|
||||||
const isSizeExceeded = files.some(file => file.size > maxFileSize);
|
|
||||||
if (isSizeExceeded) {
|
|
||||||
toast(
|
|
||||||
std.host,
|
|
||||||
`You can only upload files less than ${humanFileSize(
|
|
||||||
maxFileSize,
|
|
||||||
true,
|
|
||||||
0
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = targetModel.doc;
|
|
||||||
const flavour = AttachmentBlockSchema.model.flavour;
|
const flavour = AttachmentBlockSchema.model.flavour;
|
||||||
|
|
||||||
const droppedInfos = await Promise.all(
|
const propsArray = await Promise.all(
|
||||||
files.map(async file => {
|
files.map(file => buildPropsWith(std, file, embed))
|
||||||
const { name, size } = file;
|
|
||||||
const type = await getFileType(file);
|
|
||||||
const props = {
|
|
||||||
flavour,
|
|
||||||
name,
|
|
||||||
size,
|
|
||||||
type,
|
|
||||||
embed: isEmbed,
|
|
||||||
} satisfies Partial<AttachmentBlockProps> & {
|
|
||||||
flavour: typeof flavour;
|
|
||||||
};
|
|
||||||
return { props, file };
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const blockIds = doc.addSiblingBlocks(
|
const blockIds = std.store.addSiblingBlocks(
|
||||||
targetModel,
|
targetModel,
|
||||||
droppedInfos.map(info => info.props),
|
propsArray.map(props => ({ ...props, flavour })),
|
||||||
place
|
placement
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadPromises = blockIds.map(async (blockId, index) => {
|
|
||||||
const { props, file } = droppedInfos[index];
|
|
||||||
await uploadAttachmentBlob(std, blockId, file, props.type);
|
|
||||||
});
|
|
||||||
await Promise.all(uploadPromises);
|
|
||||||
|
|
||||||
return blockIds;
|
return blockIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,29 +277,21 @@ export async function addAttachments(
|
|||||||
std: BlockStdScope,
|
std: BlockStdScope,
|
||||||
files: File[],
|
files: File[],
|
||||||
point?: IVec,
|
point?: IVec,
|
||||||
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[]> {
|
): Promise<string[]> {
|
||||||
if (!files.length) return [];
|
if (!files.length) return [];
|
||||||
|
|
||||||
const gfx = std.get(GfxControllerIdentifier);
|
if (hasExceeded(std, files)) return [];
|
||||||
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
|
|
||||||
const isSizeExceeded = files.some(file => file.size > maxFileSize);
|
|
||||||
if (isSizeExceeded) {
|
|
||||||
toast(
|
|
||||||
std.host,
|
|
||||||
`You can only upload files less than ${humanFileSize(
|
|
||||||
maxFileSize,
|
|
||||||
true,
|
|
||||||
0
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
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;
|
let { x, y } = gfx.viewport.center;
|
||||||
if (point) {
|
if (point) {
|
||||||
let transform = transformPoint ?? true;
|
shouldTransformPoint = shouldTransformPoint ?? true;
|
||||||
if (transform) {
|
if (shouldTransformPoint) {
|
||||||
[x, y] = gfx.viewport.toModelCoord(...point);
|
[x, y] = gfx.viewport.toModelCoord(...point);
|
||||||
} else {
|
} else {
|
||||||
[x, y] = point;
|
[x, y] = point;
|
||||||
@@ -294,35 +304,15 @@ export async function addAttachments(
|
|||||||
const width = EMBED_CARD_WIDTH.cubeThick;
|
const width = EMBED_CARD_WIDTH.cubeThick;
|
||||||
const height = EMBED_CARD_HEIGHT.cubeThick;
|
const height = EMBED_CARD_HEIGHT.cubeThick;
|
||||||
|
|
||||||
const droppedInfos = files.map((file, index) => {
|
const flavour = AttachmentBlockSchema.model.flavour;
|
||||||
const { name, size } = file;
|
|
||||||
|
const blocks = propsArray.map((props, index) => {
|
||||||
const center = Vec.addScalar(xy, index * gap);
|
const center = Vec.addScalar(xy, index * gap);
|
||||||
const xywh = Bound.fromCenter(center, width, height).serialize();
|
const xywh = Bound.fromCenter(center, width, height).serialize();
|
||||||
const props = {
|
return { flavour, blockProps: { ...props, style, xywh } };
|
||||||
style,
|
|
||||||
name,
|
|
||||||
size,
|
|
||||||
xywh,
|
|
||||||
} satisfies Partial<AttachmentBlockProps>;
|
|
||||||
|
|
||||||
return { file, props };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// upload file and update the attachment model
|
const blockIds = std.store.addBlocks(blocks);
|
||||||
const uploadPromises = droppedInfos.map(async ({ props, file }) => {
|
|
||||||
const type = await getFileType(file);
|
|
||||||
|
|
||||||
const blockId = std.store.addBlock(
|
|
||||||
AttachmentBlockSchema.model.flavour,
|
|
||||||
{ ...props, type },
|
|
||||||
gfx.surface
|
|
||||||
);
|
|
||||||
|
|
||||||
await uploadAttachmentBlob(std, blockId, file, type, true);
|
|
||||||
|
|
||||||
return blockId;
|
|
||||||
});
|
|
||||||
const blockIds = await Promise.all(uploadPromises);
|
|
||||||
|
|
||||||
gfx.selection.set({
|
gfx.selection.set({
|
||||||
elements: blockIds,
|
elements: blockIds,
|
||||||
|
|||||||
@@ -1,48 +1,40 @@
|
|||||||
import { FileSizeLimitService } from '@blocksuite/affine-shared/services';
|
|
||||||
import { getImageFilesFromLocal } from '@blocksuite/affine-shared/utils';
|
import { getImageFilesFromLocal } from '@blocksuite/affine-shared/utils';
|
||||||
import type { Command } from '@blocksuite/std';
|
import type { Command } from '@blocksuite/std';
|
||||||
import type { BlockModel } from '@blocksuite/store';
|
import type { BlockModel } from '@blocksuite/store';
|
||||||
|
|
||||||
import { addSiblingImageBlock } from '../utils.js';
|
import { addSiblingImageBlocks } from '../utils';
|
||||||
|
|
||||||
export const insertImagesCommand: Command<
|
export const insertImagesCommand: Command<
|
||||||
{
|
{
|
||||||
selectedModels?: BlockModel[];
|
selectedModels?: BlockModel[];
|
||||||
removeEmptyLine?: boolean;
|
removeEmptyLine?: boolean;
|
||||||
place?: 'after' | 'before';
|
placement?: 'after' | 'before';
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
insertedImageIds: Promise<string[]>;
|
insertedImageIds: Promise<string[]>;
|
||||||
}
|
}
|
||||||
> = (ctx, next) => {
|
> = (ctx, next) => {
|
||||||
const { selectedModels, place, removeEmptyLine, std } = ctx;
|
const { selectedModels, placement, removeEmptyLine, std } = ctx;
|
||||||
if (!selectedModels) return;
|
if (!selectedModels?.length) return;
|
||||||
|
|
||||||
|
const targetModel =
|
||||||
|
placement === 'before'
|
||||||
|
? selectedModels[0]
|
||||||
|
: selectedModels[selectedModels.length - 1];
|
||||||
|
|
||||||
return next({
|
return next({
|
||||||
insertedImageIds: getImageFilesFromLocal().then(imageFiles => {
|
insertedImageIds: getImageFilesFromLocal()
|
||||||
if (imageFiles.length === 0) return [];
|
.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 [];
|
return result;
|
||||||
|
}),
|
||||||
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 ?? [];
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
||||||
import { FileDropConfigExtension } from '@blocksuite/affine-components/drop-indicator';
|
import { FileDropConfigExtension } from '@blocksuite/affine-components/drop-indicator';
|
||||||
import { ImageBlockSchema, MAX_IMAGE_WIDTH } from '@blocksuite/affine-model';
|
import { ImageBlockSchema, MAX_IMAGE_WIDTH } from '@blocksuite/affine-model';
|
||||||
import {
|
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
|
||||||
FileSizeLimitService,
|
|
||||||
TelemetryProvider,
|
|
||||||
} from '@blocksuite/affine-shared/services';
|
|
||||||
import {
|
import {
|
||||||
isInsideEdgelessEditor,
|
isInsideEdgelessEditor,
|
||||||
matchModels,
|
matchModels,
|
||||||
} from '@blocksuite/affine-shared/utils';
|
} from '@blocksuite/affine-shared/utils';
|
||||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||||
|
|
||||||
import { addImages, addSiblingImageBlock } from './utils.js';
|
import { addImages, addSiblingImageBlocks } from './utils.js';
|
||||||
|
|
||||||
export const ImageDropOption = FileDropConfigExtension({
|
export const ImageDropOption = FileDropConfigExtension({
|
||||||
flavour: ImageBlockSchema.model.flavour,
|
flavour: ImageBlockSchema.model.flavour,
|
||||||
@@ -19,15 +16,9 @@ export const ImageDropOption = FileDropConfigExtension({
|
|||||||
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
||||||
if (!imageFiles.length) return false;
|
if (!imageFiles.length) return false;
|
||||||
|
|
||||||
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
|
|
||||||
|
|
||||||
if (targetModel && !matchModels(targetModel, [SurfaceBlockModel])) {
|
if (targetModel && !matchModels(targetModel, [SurfaceBlockModel])) {
|
||||||
addSiblingImageBlock(
|
addSiblingImageBlocks(std, imageFiles, targetModel, placement).catch(
|
||||||
std.host,
|
console.error
|
||||||
imageFiles,
|
|
||||||
maxFileSize,
|
|
||||||
targetModel,
|
|
||||||
placement
|
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { autoResizeElementsCommand } from '@blocksuite/affine-block-surface';
|
import { autoResizeElementsCommand } from '@blocksuite/affine-block-surface';
|
||||||
import { toast } from '@blocksuite/affine-components/toast';
|
import { toast } from '@blocksuite/affine-components/toast';
|
||||||
import type {
|
import {
|
||||||
AttachmentBlockProps,
|
type AttachmentBlockProps,
|
||||||
ImageBlockModel,
|
type ImageBlockModel,
|
||||||
ImageBlockProps,
|
type ImageBlockProps,
|
||||||
|
ImageBlockSchema,
|
||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
import {
|
import {
|
||||||
FileSizeLimitService,
|
FileSizeLimitService,
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
transformModel,
|
transformModel,
|
||||||
withTempBlobData,
|
withTempBlobData,
|
||||||
} from '@blocksuite/affine-shared/utils';
|
} 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 {
|
import {
|
||||||
BlockSelection,
|
BlockSelection,
|
||||||
type BlockStdScope,
|
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.
|
* Turn the image block into a attachment block.
|
||||||
*/
|
*/
|
||||||
@@ -436,115 +350,171 @@ export async function turnImageIntoCardView(
|
|||||||
transformModel(model, 'affine:attachment', attachmentProp);
|
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(
|
export async function addImages(
|
||||||
std: BlockStdScope,
|
std: BlockStdScope,
|
||||||
files: File[],
|
files: File[],
|
||||||
options: {
|
options: {
|
||||||
point?: IVec;
|
point?: IVec;
|
||||||
maxWidth?: number;
|
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[]> {
|
): Promise<string[]> {
|
||||||
const imageFiles = [...files].filter(file => file.type.startsWith('image/'));
|
files = files.filter(file => file.type.startsWith('image/'));
|
||||||
if (!imageFiles.length) return [];
|
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 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;
|
let { x, y } = gfx.viewport.center;
|
||||||
if (point) {
|
if (point) {
|
||||||
if (transformPoint) {
|
if (shouldTransformPoint) {
|
||||||
[x, y] = gfx.viewport.toModelCoord(...point);
|
[x, y] = gfx.viewport.toModelCoord(...point);
|
||||||
} else {
|
} else {
|
||||||
[x, y] = point;
|
[x, y] = point;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropInfos: { point: Point; blockId: string }[] = [];
|
const xy = [x, y];
|
||||||
const IMAGE_STACK_GAP = 32;
|
|
||||||
const isMultipleFiles = imageFiles.length > 1;
|
|
||||||
const inTopLeft = isMultipleFiles ? true : false;
|
|
||||||
|
|
||||||
// create image cards without image data
|
const blockIds = propsArray.map((props, index) => {
|
||||||
imageFiles.forEach((file, index) => {
|
const center = Vec.addScalar(xy, index * gap);
|
||||||
const point = new Point(
|
|
||||||
x + index * IMAGE_STACK_GAP,
|
// If maxWidth is provided, limit the width of the image to maxWidth
|
||||||
y + index * IMAGE_STACK_GAP
|
// Otherwise, use the original width
|
||||||
);
|
const width = maxWidth ? Math.min(props.width, maxWidth) : props.width;
|
||||||
const center = Vec.toVec(point);
|
const height = maxWidth
|
||||||
const bound = calcBoundByOrigin(center, inTopLeft);
|
? (props.height / props.width) * width
|
||||||
const blockId = std.store.addBlock(
|
: props.height;
|
||||||
'affine:image',
|
|
||||||
|
const xywh = calcBoundByOrigin(
|
||||||
|
center,
|
||||||
|
inTopLeft,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
).serialize();
|
||||||
|
|
||||||
|
return std.store.addBlock(
|
||||||
|
flavour,
|
||||||
{
|
{
|
||||||
size: file.size,
|
...props,
|
||||||
xywh: bound.serialize(),
|
width,
|
||||||
|
height,
|
||||||
|
xywh,
|
||||||
index: gfx.layer.generateIndex(),
|
index: gfx.layer.generateIndex(),
|
||||||
},
|
},
|
||||||
gfx.surface
|
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({
|
gfx.selection.set({
|
||||||
elements: blockIds,
|
elements: blockIds,
|
||||||
editing: false,
|
editing: false,
|
||||||
});
|
});
|
||||||
if (isMultipleFiles) {
|
|
||||||
|
if (isMultiple) {
|
||||||
std.command.exec(autoResizeElementsCommand);
|
std.command.exec(autoResizeElementsCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
return blockIds;
|
return blockIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export const mediaRender: DraggableTool['render'] = async (bound, edgeless) => {
|
|||||||
const [id] = await addImages(edgeless.std, [file], {
|
const [id] = await addImages(edgeless.std, [file], {
|
||||||
point: [bound.x, bound.y],
|
point: [bound.x, bound.y],
|
||||||
maxWidth: MAX_IMAGE_WIDTH,
|
maxWidth: MAX_IMAGE_WIDTH,
|
||||||
transformPoint: false,
|
shouldTransformPoint: false,
|
||||||
});
|
});
|
||||||
if (id) return id;
|
if (id) return id;
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ Array of IDs of the newly created blocks
|
|||||||
|
|
||||||
### addSiblingBlocks()
|
### addSiblingBlocks()
|
||||||
|
|
||||||
> **addSiblingBlocks**(`targetModel`, `props`, `place`): `string`[]
|
> **addSiblingBlocks**(`targetModel`, `props`, `placement`): `string`[]
|
||||||
|
|
||||||
Add sibling blocks to the store
|
Add sibling blocks to the store
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ The target block model
|
|||||||
|
|
||||||
Array of block properties
|
Array of block properties
|
||||||
|
|
||||||
##### place
|
##### placement
|
||||||
|
|
||||||
Optional position to place the new blocks ('after' or 'before')
|
Optional position to place the new blocks ('after' or 'before')
|
||||||
|
|
||||||
|
|||||||
@@ -764,7 +764,7 @@ export class Store {
|
|||||||
* Add sibling blocks to the store
|
* Add sibling blocks to the store
|
||||||
* @param targetModel - The target block model
|
* @param targetModel - The target block model
|
||||||
* @param props - Array of block properties
|
* @param props - Array of block properties
|
||||||
* @param place - Optional position to place the new blocks ('after' or 'before')
|
* @param placement - Optional position to place the new blocks ('after' or 'before')
|
||||||
* @returns Array of IDs of the newly created blocks
|
* @returns Array of IDs of the newly created blocks
|
||||||
*
|
*
|
||||||
* @category Block CRUD
|
* @category Block CRUD
|
||||||
@@ -772,7 +772,7 @@ export class Store {
|
|||||||
addSiblingBlocks(
|
addSiblingBlocks(
|
||||||
targetModel: BlockModel,
|
targetModel: BlockModel,
|
||||||
props: Array<Partial<BlockProps>>,
|
props: Array<Partial<BlockProps>>,
|
||||||
place: 'after' | 'before' = 'after'
|
placement: 'after' | 'before' = 'after'
|
||||||
): string[] {
|
): string[] {
|
||||||
if (!props.length) return [];
|
if (!props.length) return [];
|
||||||
const parent = this.getParent(targetModel);
|
const parent = this.getParent(targetModel);
|
||||||
@@ -780,7 +780,7 @@ export class Store {
|
|||||||
|
|
||||||
const targetIndex =
|
const targetIndex =
|
||||||
parent.children.findIndex(({ id }) => id === targetModel.id) ?? 0;
|
parent.children.findIndex(({ id }) => id === targetModel.id) ?? 0;
|
||||||
const insertIndex = place === 'before' ? targetIndex : targetIndex + 1;
|
const insertIndex = placement === 'before' ? targetIndex : targetIndex + 1;
|
||||||
|
|
||||||
if (props.length <= 1) {
|
if (props.length <= 1) {
|
||||||
if (!props[0]?.flavour) return [];
|
if (!props[0]?.flavour) return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user