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(); 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 { 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 = { 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 = { 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 { 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 & { 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 = { 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 { 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); }); }); 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); }