mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
551 lines
14 KiB
TypeScript
551 lines
14 KiB
TypeScript
import { autoResizeElementsCommand } from '@blocksuite/affine-block-surface';
|
|
import { toast } from '@blocksuite/affine-components/toast';
|
|
import type {
|
|
AttachmentBlockProps,
|
|
ImageBlockModel,
|
|
ImageBlockProps,
|
|
} from '@blocksuite/affine-model';
|
|
import {
|
|
FileSizeLimitService,
|
|
NativeClipboardProvider,
|
|
} from '@blocksuite/affine-shared/services';
|
|
import {
|
|
downloadBlob,
|
|
humanFileSize,
|
|
readImageSize,
|
|
transformModel,
|
|
withTempBlobData,
|
|
} from '@blocksuite/affine-shared/utils';
|
|
import type { BlockStdScope, EditorHost } from '@blocksuite/block-std';
|
|
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
|
import { Bound, type IVec, Point, Vec } from '@blocksuite/global/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';
|
|
|
|
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.getBlockById(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.sourceId;
|
|
if (!sourceId) {
|
|
return null;
|
|
}
|
|
|
|
const doc = model.doc;
|
|
const 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 });
|
|
}
|
|
|
|
if (!blob.type.startsWith('image/')) {
|
|
return null;
|
|
}
|
|
|
|
return blob;
|
|
}
|
|
|
|
export async function fetchImageBlob(
|
|
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
|
) {
|
|
try {
|
|
if (block.model.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 { id, sourceId, 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function downloadImageBlob(
|
|
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
|
) {
|
|
const { host, downloading } = block;
|
|
if (downloading) {
|
|
toast(host, 'Download in progress...');
|
|
return;
|
|
}
|
|
|
|
block.downloading = true;
|
|
|
|
const blob = await getImageBlob(block.model);
|
|
if (!blob) {
|
|
toast(host, `Unable to download image!`);
|
|
return;
|
|
}
|
|
|
|
toast(host, `Downloading image...`);
|
|
|
|
downloadBlob(blob, 'image');
|
|
|
|
block.downloading = false;
|
|
}
|
|
|
|
export async function resetImageSize(
|
|
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
|
) {
|
|
const { blob, model } = block;
|
|
if (!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,
|
|
};
|
|
|
|
if (!bound.w || !bound.h) {
|
|
bound.w = size.width;
|
|
bound.h = size.height;
|
|
props.xywh = bound.serialize();
|
|
}
|
|
|
|
block.doc.updateBlock(model, props);
|
|
}
|
|
|
|
function convertToPng(blob: Blob): Promise<Blob | null> {
|
|
return new Promise(resolve => {
|
|
const reader = new FileReader();
|
|
reader.addEventListener('load', _ => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const c = document.createElement('canvas');
|
|
c.width = img.width;
|
|
c.height = img.height;
|
|
const ctx = c.getContext('2d');
|
|
if (!ctx) return;
|
|
ctx.drawImage(img, 0, 0);
|
|
c.toBlob(resolve, 'image/png');
|
|
};
|
|
img.onerror = () => resolve(null);
|
|
img.src = reader.result as string;
|
|
});
|
|
reader.addEventListener('error', () => resolve(null));
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
export async function copyImageBlob(
|
|
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
|
) {
|
|
const { host, model, std } = block;
|
|
let blob = await getImageBlob(model);
|
|
if (!blob) {
|
|
console.error('Failed to get image blob');
|
|
return;
|
|
}
|
|
|
|
let copied = false;
|
|
|
|
try {
|
|
// Copies the image as PNG in Electron.
|
|
const copyAsPNG = std.getOptional(NativeClipboardProvider)?.copyAsPNG;
|
|
if (copyAsPNG) {
|
|
copied = await copyAsPNG(await blob.arrayBuffer());
|
|
}
|
|
|
|
// The current clipboard only supports the `image/png` image format.
|
|
// The `ClipboardItem.supports('image/svg+xml')` is not currently used,
|
|
// because when pasting, the content is not read correctly.
|
|
//
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem
|
|
// https://alexharri.com/blog/clipboard
|
|
if (!copied) {
|
|
if (blob.type !== 'image/png') {
|
|
blob = await convertToPng(blob);
|
|
if (!blob) {
|
|
console.error('Failed to convert blob to PNG');
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!globalThis.isSecureContext) {
|
|
console.error(
|
|
'Clipboard API is not available in insecure context',
|
|
blob.type,
|
|
blob
|
|
);
|
|
return;
|
|
}
|
|
|
|
await navigator.clipboard.write([
|
|
new ClipboardItem({ [blob.type]: blob }),
|
|
]);
|
|
}
|
|
|
|
toast(host, 'Copied image to clipboard');
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
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.
|
|
*/
|
|
export async function turnImageIntoCardView(
|
|
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
|
) {
|
|
const doc = block.doc;
|
|
if (!doc.schema.flavourSchemaMap.has('affine:attachment')) {
|
|
console.error('The attachment flavour is not supported!');
|
|
return;
|
|
}
|
|
|
|
const model = block.model;
|
|
const sourceId = model.sourceId;
|
|
const blob = await getImageBlob(model);
|
|
if (!sourceId || !blob) {
|
|
console.error('Image data not available');
|
|
return;
|
|
}
|
|
|
|
const { saveImageData, getAttachmentData } = withTempBlobData();
|
|
saveImageData(sourceId, { width: model.width, height: model.height });
|
|
const attachmentConvertData = getAttachmentData(sourceId);
|
|
const attachmentProp: Partial<AttachmentBlockProps> = {
|
|
sourceId,
|
|
name: DEFAULT_ATTACHMENT_NAME,
|
|
size: blob.size,
|
|
type: blob.type,
|
|
caption: model.caption,
|
|
...attachmentConvertData,
|
|
};
|
|
transformModel(model, 'affine:attachment', attachmentProp);
|
|
}
|
|
|
|
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
|
|
}
|
|
): Promise<string[]> {
|
|
const imageFiles = [...files].filter(file => file.type.startsWith('image/'));
|
|
if (!imageFiles.length) return [];
|
|
|
|
const gfx = std.get(GfxControllerIdentifier);
|
|
|
|
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) {
|
|
[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;
|
|
|
|
// 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',
|
|
{
|
|
size: file.size,
|
|
xywh: bound.serialize(),
|
|
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) {
|
|
std.command.exec(autoResizeElementsCommand);
|
|
}
|
|
return blockIds;
|
|
}
|
|
|
|
export function calcBoundByOrigin(
|
|
point: IVec,
|
|
inTopLeft = false,
|
|
width = SURFACE_IMAGE_CARD_WIDTH,
|
|
height = SURFACE_IMAGE_CARD_HEIGHT
|
|
) {
|
|
return inTopLeft
|
|
? new Bound(point[0], point[1], width, height)
|
|
: Bound.fromCenter(point, width, height);
|
|
}
|