Compare commits

...

1 Commits

Author SHA1 Message Date
dwelle
d921887e2a feat: support inserting multiple images 2025-06-01 19:53:59 +02:00
3 changed files with 257 additions and 85 deletions

View File

@ -1094,7 +1094,9 @@ export interface BoundingBox {
}
export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
elements:
| readonly ExcalidrawElement[]
| readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return {

View File

@ -405,7 +405,7 @@ import {
generateIdFromFile,
getDataURL,
getDataURL_sync,
getFileFromEvent,
getFilesFromEvent,
ImageURLToFile,
isImageFileHandle,
isSupportedImageFile,
@ -3120,6 +3120,16 @@ class App extends React.Component<AppProps, AppState> {
this.state,
);
const filesData = await getFilesFromEvent(event);
const imageFiles = filesData
.map((data) => data.file)
.filter((file): file is File => isSupportedImageFile(file));
if (imageFiles.length > 0 && this.isToolSupported("image")) {
return this.insertMultipleImages(imageFiles, sceneX, sceneY);
}
// must be called in the same frame (thus before any awaits) as the paste
// event else some browsers (FF...) will clear the clipboardData
// (something something security)
@ -4833,7 +4843,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ suggestedBindings: [] });
}
if (nextActiveTool.type === "image") {
this.onImageAction({
this.onImageToolbarButtonClick({
insertOnCanvasDirectly:
(tool.type === "image" && tool.insertOnCanvasDirectly) ?? false,
});
@ -10114,7 +10124,7 @@ class App extends React.Component<AppProps, AppState> {
// a future case, let's throw here
if (!this.isToolSupported("image")) {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
return;
return imageElement;
}
this.scene.insertElement(imageElement);
@ -10133,7 +10143,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
errorMessage: error.message || t("errors.imageInsertError"),
});
return null;
return imageElement;
}
};
@ -10184,7 +10194,7 @@ class App extends React.Component<AppProps, AppState> {
}
};
private onImageAction = async ({
private onImageToolbarButtonClick = async ({
insertOnCanvasDirectly,
}: {
insertOnCanvasDirectly: boolean;
@ -10198,11 +10208,12 @@ class App extends React.Component<AppProps, AppState> {
this.state,
);
const imageFile = await fileOpen({
const imageFiles = await fileOpen({
description: "Image",
extensions: Object.keys(
IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[],
multiple: true,
});
const imageElement = this.createImageElement({
@ -10211,21 +10222,11 @@ class App extends React.Component<AppProps, AppState> {
addToFrameUnderCursor: false,
});
if (insertOnCanvasDirectly) {
this.insertImageElement(imageElement, imageFile);
this.initializeImageDimensions(imageElement);
this.setState(
{
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
},
() => {
this.actionManager.executeAction(actionFinalize);
},
);
if (insertOnCanvasDirectly || imageFiles.length > 1) {
this.insertMultipleImages(imageFiles, x, y);
} else {
const imageFile = imageFiles[0];
this.setState(
{
pendingImageElementId: imageElement.id,
@ -10503,69 +10504,205 @@ class App extends React.Component<AppProps, AppState> {
}
};
// TODO rewrite (vibe-coded)
private positionElementsOnGrid = (
elements: ExcalidrawElement[] | ExcalidrawElement[][],
centerX: number,
centerY: number,
padding = 50,
) => {
// Ensure there are elements to position
if (!elements || elements.length === 0) {
return;
}
// Normalize input to work with atomic units (groups of elements)
// If elements is a flat array, treat each element as its own atomic unit
const atomicUnits: ExcalidrawElement[][] = Array.isArray(elements[0])
? (elements as ExcalidrawElement[][])
: (elements as ExcalidrawElement[]).map((element) => [element]);
// Determine the number of columns for atomic units
// A common approach for a "grid-like" layout without specific column constraints
// is to aim for a roughly square arrangement.
const numUnits = atomicUnits.length;
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
// Group atomic units into rows based on the calculated number of columns
const rows: ExcalidrawElement[][][] = [];
for (let i = 0; i < numUnits; i += numColumns) {
rows.push(atomicUnits.slice(i, i + numColumns));
}
// Calculate properties for each row (total width, max height)
// and the total actual height of all row content.
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
const rowProperties = rows.map((rowUnits) => {
let rowWidth = 0;
let maxUnitHeightInRow = 0;
const unitBounds = rowUnits.map((unit) => {
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
return {
elements: unit,
bounds: [minX, minY, maxX, maxY] as const,
width: maxX - minX,
height: maxY - minY,
};
});
unitBounds.forEach((unitBound, index) => {
rowWidth += unitBound.width;
// Add padding between units in the same row, but not after the last one
if (index < unitBounds.length - 1) {
rowWidth += padding;
}
if (unitBound.height > maxUnitHeightInRow) {
maxUnitHeightInRow = unitBound.height;
}
});
totalGridActualHeight += maxUnitHeightInRow;
return {
unitBounds,
width: rowWidth,
maxHeight: maxUnitHeightInRow,
};
});
// Calculate the total height of the grid including padding between rows
const totalGridHeightWithPadding =
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
// Calculate the starting Y position to center the entire grid vertically around centerY
let currentY = centerY - totalGridHeightWithPadding / 2;
// Position atomic units row by row
rowProperties.forEach((rowProp) => {
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
// Calculate the starting X for the current row to center it horizontally around centerX
let currentX = centerX - rowWidth / 2;
unitBounds.forEach((unitBound) => {
// Calculate the offset needed to position this atomic unit
const [originalMinX, originalMinY] = unitBound.bounds;
const offsetX = currentX - originalMinX;
const offsetY = currentY - originalMinY;
// Apply the offset to all elements in this atomic unit
unitBound.elements.forEach((element) => {
this.scene.mutateElement(element, {
x: element.x + offsetX,
y: element.y + offsetY,
});
});
// Move X for the next unit in the row
currentX += unitBound.width + padding;
});
// Move Y to the starting position for the next row
// This accounts for the tallest unit in the current row and the inter-row padding
currentY += rowMaxHeight + padding;
});
};
private insertMultipleImages = async (
imageFiles: File[],
sceneX: number,
sceneY: number,
) => {
try {
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
const imageElements: Promise<NonDeleted<ExcalidrawImageElement>>[] = [];
for (let i = 0; i < imageFiles.length; i++) {
const file = imageFiles[i];
const imageElement = this.createImageElement({
sceneX,
sceneY,
});
imageElements.push(this.insertImageElement(imageElement, file));
this.initializeImageDimensions(imageElement);
selectedElementIds[imageElement.id] = true;
}
this.setState(
{
selectedElementIds: makeNextSelectedElementIds(
selectedElementIds,
this.state,
),
},
() => {
this.actionManager.executeAction(actionFinalize);
},
);
const initializedImageElements = await Promise.all(imageElements);
this.positionElementsOnGrid(initializedImageElements, sceneX, sceneY);
} catch (error: any) {
const errorMessage =
error instanceof Error ? error.message : String(error);
this.setState({
isLoading: false,
errorMessage,
});
}
};
private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => {
// must be retrieved first, in the same frame
const { file, fileHandle } = await getFileFromEvent(event);
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
try {
// if image tool not supported, don't show an error here and let it fall
// through so we still support importing scene data from images. If no
// scene data encoded, we'll show an error then
if (isSupportedImageFile(file) && this.isToolSupported("image")) {
// first attempt to decode scene from the image if it's embedded
// ---------------------------------------------------------------------
// must be retrieved first, in the same frame
const filesData = await getFilesFromEvent(event);
if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) {
try {
const scene = await loadFromBlob(
file,
this.state,
this.scene.getElementsIncludingDeleted(),
fileHandle,
);
this.syncActionResult({
...scene,
appState: {
...(scene.appState || this.state),
isLoading: false,
},
replaceFiles: true,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
return;
} catch (error: any) {
// Don't throw for image scene daa
if (error.name !== "EncodingError") {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
}
}
if (filesData.length === 1) {
const { file, fileHandle } = filesData[0];
// if no scene is embedded or we fail for whatever reason, fall back
// to importing as regular image
// ---------------------------------------------------------------------
const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement);
this.setState({
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
if (
file &&
(file.type === MIME_TYPES.png || file.type === MIME_TYPES.svg)
) {
try {
const scene = await loadFromBlob(
file,
this.state,
),
});
return;
this.scene.getElementsIncludingDeleted(),
fileHandle,
);
this.syncActionResult({
...scene,
appState: {
...(scene.appState || this.state),
isLoading: false,
},
replaceFiles: true,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
return;
} catch (error: any) {
if (error.name !== "EncodingError") {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
// if EncodingError, fall through to insert as regular image
}
}
} catch (error: any) {
return this.setState({
isLoading: false,
errorMessage: error.message,
});
}
const imageFiles = filesData
.map((data) => data.file)
.filter((file): file is File => isSupportedImageFile(file));
if (imageFiles.length > 0 && this.isToolSupported("image")) {
return this.insertMultipleImages(imageFiles, sceneX, sceneY);
}
const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
@ -10583,9 +10720,12 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (file) {
// Attempt to parse an excalidraw/excalidrawlib file
await this.loadFileToCanvas(file, fileHandle);
if (filesData.length > 1) {
const { file, fileHandle } = filesData[0];
if (file) {
// Attempt to parse an excalidraw/excalidrawlib file
await this.loadFileToCanvas(file, fileHandle);
}
}
if (event.dataTransfer?.types?.includes("text/plain")) {

View File

@ -385,23 +385,53 @@ export const ImageURLToFile = async (
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
};
export const getFileFromEvent = async (
event: React.DragEvent<HTMLDivElement>,
export const getFilesFromEvent = async (
event: React.DragEvent<HTMLDivElement> | ClipboardEvent,
) => {
const file = event.dataTransfer.files.item(0);
const fileHandle = await getFileHandle(event);
let fileList: FileList | undefined = undefined;
let items: DataTransferItemList | undefined = undefined;
return { file: file ? await normalizeFile(file) : null, fileHandle };
if (event instanceof ClipboardEvent) {
fileList = event.clipboardData?.files;
items = event.clipboardData?.items;
} else {
fileList = event.dataTransfer?.files;
items = event.dataTransfer?.items;
}
const files: (File | null)[] = Array.from(fileList || []);
return await Promise.all(
files.map(async (file, idx) => {
const dataTransferItem = items?.[idx];
const fileHandle = dataTransferItem
? getFileHandle(dataTransferItem)
: null;
return file
? {
file: await normalizeFile(file),
fileHandle: await fileHandle,
}
: {
file: null,
fileHandle: null,
};
}),
);
};
export const getFileHandle = async (
event: React.DragEvent<HTMLDivElement>,
event: DragEvent | React.DragEvent | DataTransferItem,
): Promise<FileSystemHandle | null> => {
if (nativeFileSystemSupported) {
try {
const item = event.dataTransfer.items[0];
const dataTransferItem =
event instanceof DataTransferItem
? event
: (event as DragEvent).dataTransfer?.items?.[0];
const handle: FileSystemHandle | null =
(await (item as any).getAsFileSystemHandle()) || null;
(await (dataTransferItem as any).getAsFileSystemHandle()) || null;
return handle;
} catch (error: any) {