mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 20:46:38 +08:00
388641bc89
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Refactor** - Unified internal data access by replacing all references from `doc` to `store` across all components, blocks, widgets, and utilities. This affects how readonly state, block operations, and service retrieval are handled throughout the application. - **Tests** - Updated all test utilities and test cases to use `store` instead of `doc` for document-related operations. - **Chores** - Updated context providers and property names to reflect the change from `doc` to `store` for improved consistency and maintainability. No user-facing features or behaviors have changed. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
455 lines
12 KiB
TypeScript
455 lines
12 KiB
TypeScript
import { autoResizeElementsCommand } from '@blocksuite/affine-block-surface';
|
|
import { toast } from '@blocksuite/affine-components/toast';
|
|
import {
|
|
type AttachmentBlockProps,
|
|
type ImageBlockModel,
|
|
type ImageBlockProps,
|
|
ImageBlockSchema,
|
|
} from '@blocksuite/affine-model';
|
|
import {
|
|
FileSizeLimitProvider,
|
|
NativeClipboardProvider,
|
|
} from '@blocksuite/affine-shared/services';
|
|
import {
|
|
getBlockProps,
|
|
humanFileSize,
|
|
isInsidePageEditor,
|
|
readImageSize,
|
|
transformModel,
|
|
withTempBlobData,
|
|
} from '@blocksuite/affine-shared/utils';
|
|
import { Bound, type IVec, Vec } from '@blocksuite/global/gfx';
|
|
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';
|
|
import type { ImageBlockComponent } from './image-block';
|
|
import type { ImageEdgelessBlockComponent } from './image-edgeless-block';
|
|
|
|
const DEFAULT_ATTACHMENT_NAME = 'affine-attachment';
|
|
|
|
async function getImageBlob(model: ImageBlockModel) {
|
|
const sourceId = model.props.sourceId$.peek();
|
|
if (!sourceId) return null;
|
|
|
|
const doc = model.store;
|
|
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);
|
|
|
|
blob = new Blob([buffer], { type: fileType?.mime });
|
|
}
|
|
|
|
if (!blob.type.startsWith('image/')) return null;
|
|
|
|
return blob;
|
|
}
|
|
|
|
export async function refreshData(
|
|
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
|
) {
|
|
await block.resourceController.refreshUrlWith();
|
|
}
|
|
|
|
export async function downloadImageBlob(
|
|
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
|
) {
|
|
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;
|
|
}
|
|
|
|
resourceController.updateState({ downloading: true });
|
|
|
|
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();
|
|
|
|
resourceController.updateState({ downloading: false });
|
|
}
|
|
|
|
export async function resetImageSize(
|
|
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
|
) {
|
|
const { model } = block;
|
|
|
|
const blob = await getImageBlob(model);
|
|
if (!blob) {
|
|
console.error('Failed to get image blob');
|
|
return;
|
|
}
|
|
|
|
const imageSize = await readImageSize(blob);
|
|
|
|
const bound = model.elementBound;
|
|
bound.w = imageSize.width;
|
|
bound.h = imageSize.height;
|
|
|
|
const xywh = bound.serialize();
|
|
const props: Partial<ImageBlockProps> = { ...imageSize, xywh };
|
|
|
|
block.store.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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Turn the image block into a attachment block.
|
|
*/
|
|
export async function turnImageIntoCardView(
|
|
block: ImageBlockComponent | ImageEdgelessBlockComponent
|
|
) {
|
|
const doc = block.store;
|
|
if (!doc.schema.flavourSchemaMap.has('affine:attachment')) {
|
|
console.error('The attachment flavour is not supported!');
|
|
return;
|
|
}
|
|
|
|
const model = block.model;
|
|
const sourceId = model.props.sourceId$.peek();
|
|
const blob = await getImageBlob(model);
|
|
if (!sourceId || !blob) {
|
|
console.error('Image data not available');
|
|
return;
|
|
}
|
|
|
|
const { saveImageData, getAttachmentData } = withTempBlobData();
|
|
saveImageData(sourceId, {
|
|
width: model.props.width,
|
|
height: model.props.height,
|
|
});
|
|
const attachmentConvertData = getAttachmentData(sourceId);
|
|
const attachmentProp: Partial<AttachmentBlockProps> = {
|
|
sourceId,
|
|
name: DEFAULT_ATTACHMENT_NAME,
|
|
size: blob.size,
|
|
type: blob.type,
|
|
caption: model.props.caption,
|
|
...attachmentConvertData,
|
|
};
|
|
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.get(FileSizeLimitProvider).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 blocks = propsArray.map(blockProps => ({ flavour, blockProps }));
|
|
|
|
const blockIds = std.store.addBlocks(blocks, parent, parentIndex);
|
|
|
|
return blockIds;
|
|
}
|
|
|
|
export async function addImages(
|
|
std: BlockStdScope,
|
|
files: File[],
|
|
options: {
|
|
point?: IVec;
|
|
maxWidth?: number;
|
|
shouldTransformPoint?: boolean; // determines whether we should use `toModelCoord` to convert the point
|
|
}
|
|
): Promise<string[]> {
|
|
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;
|
|
|
|
let { x, y } = gfx.viewport.center;
|
|
if (point) {
|
|
if (shouldTransformPoint) {
|
|
[x, y] = gfx.viewport.toModelCoord(...point);
|
|
} else {
|
|
[x, y] = point;
|
|
}
|
|
}
|
|
|
|
const xy = [x, y];
|
|
|
|
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) {
|
|
const p = props.height / props.width;
|
|
props.width = Math.min(props.width, maxWidth);
|
|
props.height = props.width * p;
|
|
}
|
|
|
|
const center = Vec.addScalar(xy, i * gap);
|
|
const index = gfx.layer.generateIndex();
|
|
|
|
const { width, height } = props;
|
|
const xywh = calcBoundByOrigin(
|
|
center,
|
|
inTopLeft,
|
|
width,
|
|
height
|
|
).serialize();
|
|
|
|
return {
|
|
flavour,
|
|
blockProps: {
|
|
...props,
|
|
width,
|
|
height,
|
|
xywh,
|
|
index,
|
|
},
|
|
};
|
|
});
|
|
|
|
const blockIds = std.store.addBlocks(blocks, gfx.surface);
|
|
|
|
gfx.selection.set({
|
|
elements: blockIds,
|
|
editing: false,
|
|
});
|
|
|
|
if (isMultiple) {
|
|
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);
|
|
}
|
|
|
|
export function duplicate(block: ImageBlockComponent) {
|
|
const model = block.model;
|
|
const blockProps = getBlockProps(model);
|
|
const {
|
|
width: _width,
|
|
height: _height,
|
|
xywh: _xywh,
|
|
rotate: _rotate,
|
|
zIndex: _zIndex,
|
|
...duplicateProps
|
|
} = blockProps;
|
|
|
|
const { store } = model;
|
|
const parent = store.getParent(model);
|
|
if (!parent) {
|
|
console.error(`Parent not found for block(${model.flavour}) ${model.id}`);
|
|
return;
|
|
}
|
|
|
|
const index = parent?.children.indexOf(model);
|
|
const duplicateId = store.addBlock(
|
|
model.flavour,
|
|
duplicateProps,
|
|
parent,
|
|
index + 1
|
|
);
|
|
|
|
const editorHost = block.host;
|
|
editorHost.updateComplete
|
|
.then(() => {
|
|
const { selection } = editorHost;
|
|
selection.setGroup('note', [
|
|
selection.create(BlockSelection, {
|
|
blockId: duplicateId,
|
|
}),
|
|
]);
|
|
if (isInsidePageEditor(editorHost)) {
|
|
const duplicateElement = editorHost.view.getBlock(duplicateId);
|
|
if (duplicateElement) {
|
|
duplicateElement.scrollIntoView(true);
|
|
}
|
|
}
|
|
})
|
|
.catch(console.error);
|
|
}
|