mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-13 12:29:58 +02:00
feat: [cont.] support inserting multiple images (#9875)
* feat: support inserting multiple images * Initial * handleAppOnDrop, onImageToolbarButtonClick, pasteFromClipboard * Initial get history working * insertMultipleImages -> insertImages * Bug fixes, improvements * Remove redundant branch * Refactor addElementsFromMixedContentPaste * History, drag & drop bug fixes * Update snapshots * Remove redundant try-catch * Refactor pasteFromClipboard * Plain paste check in mermaid paste * Move comment * processClipboardData -> insertClipboardContent * Redundant variable * Redundant variable * Refactor insertImages * createImagePlaceholder -> newImagePlaceholder * Get rid of unneeded NEVER schedule, filter out failed images * Trigger CI * Position placeholders before initializing * Don't mutate scene with positionElementsOnGrid, captureUpdate: CaptureUpdateAction.IMMEDIATELY * Comment * Move positionOnGrid out of file * Rename file * Get rid of generic * Initial tests * More asserts, test paste * Test image tool * De-duplicate * Stricter assert, move rest of logic outside of waitFor * Modify history tests * De-duplicate update snapshots * Trigger CI * Fix package build * Make setupImageTest more explicit * Re-introduce generic to use latest placeholder versions * newElementWith instead of mutateElement to delete failed placeholder * Insert failed images separately with CaptureUpdateAction.NEVER * Refactor * Don't re-order elements * WIP * Get rid of 'never' for failed * refactor type check * align max file size constant * make grid padding scale to zoom --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
|||||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||||
|
|
||||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
// should be aligned with MAX_ALLOWED_FILE_BYTES
|
||||||
|
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
||||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||||
|
|
||||||
|
@@ -1126,7 +1126,9 @@ export interface BoundingBox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getCommonBoundingBox = (
|
export const getCommonBoundingBox = (
|
||||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
elements:
|
||||||
|
| readonly ExcalidrawElement[]
|
||||||
|
| readonly NonDeleted<ExcalidrawElement>[],
|
||||||
): BoundingBox => {
|
): BoundingBox => {
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
return {
|
return {
|
||||||
|
@@ -97,6 +97,7 @@ export * from "./image";
|
|||||||
export * from "./linearElementEditor";
|
export * from "./linearElementEditor";
|
||||||
export * from "./mutateElement";
|
export * from "./mutateElement";
|
||||||
export * from "./newElement";
|
export * from "./newElement";
|
||||||
|
export * from "./positionElementsOnGrid";
|
||||||
export * from "./renderElement";
|
export * from "./renderElement";
|
||||||
export * from "./resizeElements";
|
export * from "./resizeElements";
|
||||||
export * from "./resizeTest";
|
export * from "./resizeTest";
|
||||||
|
112
packages/element/src/positionElementsOnGrid.ts
Normal file
112
packages/element/src/positionElementsOnGrid.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { getCommonBounds } from "./bounds";
|
||||||
|
import { type ElementUpdate, newElementWith } from "./mutateElement";
|
||||||
|
|
||||||
|
import type { ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
|
// TODO rewrite (mostly vibe-coded)
|
||||||
|
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
|
||||||
|
elements: TElement[] | TElement[][],
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
padding = 50,
|
||||||
|
): TElement[] => {
|
||||||
|
// Ensure there are elements to position
|
||||||
|
if (!elements || elements.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const res: TElement[] = [];
|
||||||
|
// 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: TElement[][] = Array.isArray(elements[0])
|
||||||
|
? (elements as TElement[][])
|
||||||
|
: (elements as TElement[]).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: TElement[][][] = [];
|
||||||
|
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) => {
|
||||||
|
res.push(
|
||||||
|
newElementWith(element, {
|
||||||
|
x: element.x + offsetX,
|
||||||
|
y: element.y + offsetY,
|
||||||
|
} as ElementUpdate<TElement>),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
};
|
@@ -5,6 +5,7 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
isMemberOf,
|
isMemberOf,
|
||||||
isPromiseLike,
|
isPromiseLike,
|
||||||
|
EVENT,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "@excalidraw/element";
|
import { mutateElement } from "@excalidraw/element";
|
||||||
@@ -92,7 +93,7 @@ export const createPasteEvent = ({
|
|||||||
console.warn("createPasteEvent: no types or files provided");
|
console.warn("createPasteEvent: no types or files provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new ClipboardEvent("paste", {
|
const event = new ClipboardEvent(EVENT.PASTE, {
|
||||||
clipboardData: new DataTransfer(),
|
clipboardData: new DataTransfer(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -519,3 +520,14 @@ const copyTextViaExecCommand = (text: string | null) => {
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isClipboardEvent = (
|
||||||
|
event: React.SyntheticEvent | Event,
|
||||||
|
): event is ClipboardEvent => {
|
||||||
|
/** not using instanceof ClipboardEvent due to tests (jsdom) */
|
||||||
|
return (
|
||||||
|
event.type === EVENT.PASTE ||
|
||||||
|
event.type === EVENT.COPY ||
|
||||||
|
event.type === EVENT.CUT
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@@ -237,6 +237,7 @@ import {
|
|||||||
isSimpleArrow,
|
isSimpleArrow,
|
||||||
StoreDelta,
|
StoreDelta,
|
||||||
type ApplyToOptions,
|
type ApplyToOptions,
|
||||||
|
positionElementsOnGrid,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
@@ -345,7 +346,7 @@ import {
|
|||||||
generateIdFromFile,
|
generateIdFromFile,
|
||||||
getDataURL,
|
getDataURL,
|
||||||
getDataURL_sync,
|
getDataURL_sync,
|
||||||
getFileFromEvent,
|
getFilesFromEvent,
|
||||||
ImageURLToFile,
|
ImageURLToFile,
|
||||||
isImageFileHandle,
|
isImageFileHandle,
|
||||||
isSupportedImageFile,
|
isSupportedImageFile,
|
||||||
@@ -432,7 +433,7 @@ import type {
|
|||||||
ScrollBars,
|
ScrollBars,
|
||||||
} from "../scene/types";
|
} from "../scene/types";
|
||||||
|
|
||||||
import type { PastedMixedContent } from "../clipboard";
|
import type { ClipboardData, PastedMixedContent } from "../clipboard";
|
||||||
import type { ExportedElements } from "../data";
|
import type { ExportedElements } from "../data";
|
||||||
import type { ContextMenuItems } from "./ContextMenu";
|
import type { ContextMenuItems } from "./ContextMenu";
|
||||||
import type { FileSystemHandle } from "../data/filesystem";
|
import type { FileSystemHandle } from "../data/filesystem";
|
||||||
@@ -3066,31 +3067,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: this is so spaghetti, we should refactor it and cover it with tests
|
// TODO: Cover with tests
|
||||||
public pasteFromClipboard = withBatchedUpdates(
|
private async insertClipboardContent(
|
||||||
async (event: ClipboardEvent) => {
|
data: ClipboardData,
|
||||||
const isPlainPaste = !!IS_PLAIN_PASTE;
|
filesData: Awaited<ReturnType<typeof getFilesFromEvent>>,
|
||||||
|
isPlainPaste: boolean,
|
||||||
// #686
|
|
||||||
const target = document.activeElement;
|
|
||||||
const isExcalidrawActive =
|
|
||||||
this.excalidrawContainerRef.current?.contains(target);
|
|
||||||
if (event && !isExcalidrawActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elementUnderCursor = document.elementFromPoint(
|
|
||||||
this.lastViewportPosition.x,
|
|
||||||
this.lastViewportPosition.y,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
event &&
|
|
||||||
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
|
|
||||||
isWritableElement(target))
|
|
||||||
) {
|
) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||||
{
|
{
|
||||||
clientX: this.lastViewportPosition.x,
|
clientX: this.lastViewportPosition.x,
|
||||||
@@ -3099,60 +3081,58 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// must be called in the same frame (thus before any awaits) as the paste
|
// ------------------- Error -------------------
|
||||||
// event else some browsers (FF...) will clear the clipboardData
|
if (data.errorMessage) {
|
||||||
// (something something security)
|
this.setState({ errorMessage: data.errorMessage });
|
||||||
let file = event?.clipboardData?.files[0];
|
return;
|
||||||
const data = await parseClipboard(event, isPlainPaste);
|
}
|
||||||
if (!file && !isPlainPaste) {
|
|
||||||
if (data.mixedContent) {
|
// ------------------- Mixed content with no files -------------------
|
||||||
return this.addElementsFromMixedContentPaste(data.mixedContent, {
|
if (filesData.length === 0 && !isPlainPaste && data.mixedContent) {
|
||||||
|
await this.addElementsFromMixedContentPaste(data.mixedContent, {
|
||||||
isPlainPaste,
|
isPlainPaste,
|
||||||
sceneX,
|
sceneX,
|
||||||
sceneY,
|
sceneY,
|
||||||
});
|
});
|
||||||
} else if (data.text) {
|
|
||||||
const string = data.text.trim();
|
|
||||||
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
|
||||||
// ignore SVG validation/normalization which will be done during image
|
|
||||||
// initialization
|
|
||||||
file = SVGStringToFile(string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// prefer spreadsheet data over image file (MS Office/Libre Office)
|
|
||||||
if (isSupportedImageFile(file) && !data.spreadsheet) {
|
|
||||||
if (!this.isToolSupported("image")) {
|
|
||||||
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.createImageElement({ sceneX, sceneY, imageFile: file });
|
// ------------------- Spreadsheet -------------------
|
||||||
|
if (data.spreadsheet && !isPlainPaste) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.onPaste) {
|
|
||||||
try {
|
|
||||||
if ((await this.props.onPaste(data, event)) === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.errorMessage) {
|
|
||||||
this.setState({ errorMessage: data.errorMessage });
|
|
||||||
} else if (data.spreadsheet && !isPlainPaste) {
|
|
||||||
this.setState({
|
this.setState({
|
||||||
pasteDialog: {
|
pasteDialog: {
|
||||||
data: data.spreadsheet,
|
data: data.spreadsheet,
|
||||||
shown: true,
|
shown: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (data.elements) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- Images or SVG code -------------------
|
||||||
|
const imageFiles = filesData
|
||||||
|
.map((data) => data.file)
|
||||||
|
.filter((file): file is File => isSupportedImageFile(file));
|
||||||
|
|
||||||
|
if (imageFiles.length === 0 && data.text && !isPlainPaste) {
|
||||||
|
const trimmedText = data.text.trim();
|
||||||
|
if (trimmedText.startsWith("<svg") && trimmedText.endsWith("</svg>")) {
|
||||||
|
// ignore SVG validation/normalization which will be done during image
|
||||||
|
// initialization
|
||||||
|
imageFiles.push(SVGStringToFile(trimmedText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
if (this.isToolSupported("image")) {
|
||||||
|
await this.insertImages(imageFiles, sceneX, sceneY);
|
||||||
|
} else {
|
||||||
|
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- Elements -------------------
|
||||||
|
if (data.elements) {
|
||||||
const elements = (
|
const elements = (
|
||||||
data.programmaticAPI
|
data.programmaticAPI
|
||||||
? convertToExcalidrawElements(
|
? convertToExcalidrawElements(
|
||||||
@@ -3160,17 +3140,24 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
)
|
)
|
||||||
: data.elements
|
: data.elements
|
||||||
) as readonly ExcalidrawElement[];
|
) as readonly ExcalidrawElement[];
|
||||||
// TODO remove formatting from elements if isPlainPaste
|
// TODO: remove formatting from elements if isPlainPaste
|
||||||
this.addElementsFromPasteOrLibrary({
|
this.addElementsFromPasteOrLibrary({
|
||||||
elements,
|
elements,
|
||||||
files: data.files || null,
|
files: data.files || null,
|
||||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
position: this.isMobileOrTablet() ? "center" : "cursor",
|
||||||
retainSeed: isPlainPaste,
|
retainSeed: isPlainPaste,
|
||||||
});
|
});
|
||||||
} else if (data.text) {
|
return;
|
||||||
if (data.text && isMaybeMermaidDefinition(data.text)) {
|
}
|
||||||
const api = await import("@excalidraw/mermaid-to-excalidraw");
|
|
||||||
|
|
||||||
|
// ------------------- Only textual stuff remaining -------------------
|
||||||
|
if (!data.text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- Successful Mermaid -------------------
|
||||||
|
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
|
||||||
|
const api = await import("@excalidraw/mermaid-to-excalidraw");
|
||||||
try {
|
try {
|
||||||
const { elements: skeletonElements, files } =
|
const { elements: skeletonElements, files } =
|
||||||
await api.parseMermaidToExcalidraw(data.text);
|
await api.parseMermaidToExcalidraw(data.text);
|
||||||
@@ -3193,26 +3180,23 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------- Pure embeddable URLs -------------------
|
||||||
const nonEmptyLines = normalizeEOL(data.text)
|
const nonEmptyLines = normalizeEOL(data.text)
|
||||||
.split(/\n+/)
|
.split(/\n+/)
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const embbeddableUrls = nonEmptyLines
|
const embbeddableUrls = nonEmptyLines
|
||||||
.map((str) => maybeParseEmbedSrc(str))
|
.map((str) => maybeParseEmbedSrc(str))
|
||||||
.filter((string) => {
|
.filter(
|
||||||
return (
|
(string) =>
|
||||||
embeddableURLValidator(string, this.props.validateEmbeddable) &&
|
embeddableURLValidator(string, this.props.validateEmbeddable) &&
|
||||||
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
|
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
|
||||||
getEmbedLink(string)?.type === "video")
|
getEmbedLink(string)?.type === "video"),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!IS_PLAIN_PASTE &&
|
!isPlainPaste &&
|
||||||
embbeddableUrls.length > 0 &&
|
embbeddableUrls.length > 0 &&
|
||||||
// if there were non-embeddable text (lines) mixed in with embeddable
|
|
||||||
// urls, ignore and paste as text
|
|
||||||
embbeddableUrls.length === nonEmptyLines.length
|
embbeddableUrls.length === nonEmptyLines.length
|
||||||
) {
|
) {
|
||||||
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
|
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
|
||||||
@@ -3240,8 +3224,52 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------- Text -------------------
|
||||||
this.addTextFromPaste(data.text, isPlainPaste);
|
this.addTextFromPaste(data.text, isPlainPaste);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public pasteFromClipboard = withBatchedUpdates(
|
||||||
|
async (event: ClipboardEvent) => {
|
||||||
|
const isPlainPaste = !!IS_PLAIN_PASTE;
|
||||||
|
|
||||||
|
// #686
|
||||||
|
const target = document.activeElement;
|
||||||
|
const isExcalidrawActive =
|
||||||
|
this.excalidrawContainerRef.current?.contains(target);
|
||||||
|
if (event && !isExcalidrawActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementUnderCursor = document.elementFromPoint(
|
||||||
|
this.lastViewportPosition.x,
|
||||||
|
this.lastViewportPosition.y,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
event &&
|
||||||
|
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
|
||||||
|
isWritableElement(target))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
const filesData = await getFilesFromEvent(event);
|
||||||
|
const data = await parseClipboard(event, isPlainPaste);
|
||||||
|
|
||||||
|
if (this.props.onPaste) {
|
||||||
|
try {
|
||||||
|
if ((await this.props.onPaste(data, event)) === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.insertClipboardContent(data, filesData, isPlainPaste);
|
||||||
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
},
|
},
|
||||||
@@ -3431,45 +3459,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
let y = sceneY;
|
|
||||||
let firstImageYOffsetDone = false;
|
|
||||||
const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {};
|
|
||||||
for (const response of responses) {
|
|
||||||
if (response.file) {
|
|
||||||
const initializedImageElement = await this.createImageElement({
|
|
||||||
sceneX,
|
|
||||||
sceneY: y,
|
|
||||||
imageFile: response.file,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (initializedImageElement) {
|
|
||||||
// vertically center first image in the batch
|
|
||||||
if (!firstImageYOffsetDone) {
|
|
||||||
firstImageYOffsetDone = true;
|
|
||||||
y -= initializedImageElement.height / 2;
|
|
||||||
}
|
|
||||||
// hack to reset the `y` coord because we vertically center during
|
|
||||||
// insertImageElement
|
|
||||||
this.scene.mutateElement(
|
|
||||||
initializedImageElement,
|
|
||||||
{ y },
|
|
||||||
{ informMutation: false, isDragging: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
y = initializedImageElement.y + initializedImageElement.height + 25;
|
|
||||||
|
|
||||||
nextSelectedIds[initializedImageElement.id] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
selectedElementIds: makeNextSelectedElementIds(
|
|
||||||
nextSelectedIds,
|
|
||||||
this.state,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const imageFiles = responses
|
||||||
|
.filter((response): response is { file: File } => !!response.file)
|
||||||
|
.map((response) => response.file);
|
||||||
|
await this.insertImages(imageFiles, sceneX, sceneY);
|
||||||
const error = responses.find((response) => !!response.errorMessage);
|
const error = responses.find((response) => !!response.errorMessage);
|
||||||
if (error && error.errorMessage) {
|
if (error && error.errorMessage) {
|
||||||
this.setState({ errorMessage: error.errorMessage });
|
this.setState({ errorMessage: error.errorMessage });
|
||||||
@@ -4806,7 +4800,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({ suggestedBindings: [] });
|
this.setState({ suggestedBindings: [] });
|
||||||
}
|
}
|
||||||
if (nextActiveTool.type === "image") {
|
if (nextActiveTool.type === "image") {
|
||||||
this.onImageAction();
|
this.onImageToolbarButtonClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState((prevState) => {
|
this.setState((prevState) => {
|
||||||
@@ -7842,16 +7836,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return element;
|
return element;
|
||||||
};
|
};
|
||||||
|
|
||||||
private createImageElement = async ({
|
private newImagePlaceholder = ({
|
||||||
sceneX,
|
sceneX,
|
||||||
sceneY,
|
sceneY,
|
||||||
addToFrameUnderCursor = true,
|
addToFrameUnderCursor = true,
|
||||||
imageFile,
|
|
||||||
}: {
|
}: {
|
||||||
sceneX: number;
|
sceneX: number;
|
||||||
sceneY: number;
|
sceneY: number;
|
||||||
addToFrameUnderCursor?: boolean;
|
addToFrameUnderCursor?: boolean;
|
||||||
imageFile: File;
|
|
||||||
}) => {
|
}) => {
|
||||||
const [gridX, gridY] = getGridPoint(
|
const [gridX, gridY] = getGridPoint(
|
||||||
sceneX,
|
sceneX,
|
||||||
@@ -7870,7 +7862,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
const placeholderSize = 100 / this.state.zoom.value;
|
const placeholderSize = 100 / this.state.zoom.value;
|
||||||
|
|
||||||
const placeholderImageElement = newImageElement({
|
return newImageElement({
|
||||||
type: "image",
|
type: "image",
|
||||||
strokeColor: this.state.currentItemStrokeColor,
|
strokeColor: this.state.currentItemStrokeColor,
|
||||||
backgroundColor: this.state.currentItemBackgroundColor,
|
backgroundColor: this.state.currentItemBackgroundColor,
|
||||||
@@ -7887,13 +7879,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
width: placeholderSize,
|
width: placeholderSize,
|
||||||
height: placeholderSize,
|
height: placeholderSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
const initializedImageElement = await this.insertImageElement(
|
|
||||||
placeholderImageElement,
|
|
||||||
imageFile,
|
|
||||||
);
|
|
||||||
|
|
||||||
return initializedImageElement;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleLinearElementOnPointerDown = (
|
private handleLinearElementOnPointerDown = (
|
||||||
@@ -10215,64 +10200,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
private onImageToolbarButtonClick = async () => {
|
||||||
* inserts image into elements array and rerenders
|
|
||||||
*/
|
|
||||||
private insertImageElement = async (
|
|
||||||
placeholderImageElement: ExcalidrawImageElement,
|
|
||||||
imageFile: File,
|
|
||||||
) => {
|
|
||||||
// we should be handling all cases upstream, but in case we forget to handle
|
|
||||||
// a future case, let's throw here
|
|
||||||
if (!this.isToolSupported("image")) {
|
|
||||||
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scene.insertElement(placeholderImageElement);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const initializedImageElement = await this.initializeImage(
|
|
||||||
placeholderImageElement,
|
|
||||||
imageFile,
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextElements = this.scene
|
|
||||||
.getElementsIncludingDeleted()
|
|
||||||
.map((element) => {
|
|
||||||
if (element.id === initializedImageElement.id) {
|
|
||||||
return initializedImageElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateScene({
|
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
|
||||||
elements: nextElements,
|
|
||||||
appState: {
|
|
||||||
selectedElementIds: makeNextSelectedElementIds(
|
|
||||||
{ [initializedImageElement.id]: true },
|
|
||||||
this.state,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return initializedImageElement;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.store.scheduleAction(CaptureUpdateAction.NEVER);
|
|
||||||
this.scene.mutateElement(placeholderImageElement, {
|
|
||||||
isDeleted: true,
|
|
||||||
});
|
|
||||||
this.actionManager.executeAction(actionFinalize);
|
|
||||||
this.setState({
|
|
||||||
errorMessage: error.message || t("errors.imageInsertError"),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onImageAction = async () => {
|
|
||||||
try {
|
try {
|
||||||
const clientX = this.state.width / 2 + this.state.offsetLeft;
|
const clientX = this.state.width / 2 + this.state.offsetLeft;
|
||||||
const clientY = this.state.height / 2 + this.state.offsetTop;
|
const clientY = this.state.height / 2 + this.state.offsetTop;
|
||||||
@@ -10282,24 +10210,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
const imageFile = await fileOpen({
|
const imageFiles = await fileOpen({
|
||||||
description: "Image",
|
description: "Image",
|
||||||
extensions: Object.keys(
|
extensions: Object.keys(
|
||||||
IMAGE_MIME_TYPES,
|
IMAGE_MIME_TYPES,
|
||||||
) as (keyof typeof IMAGE_MIME_TYPES)[],
|
) as (keyof typeof IMAGE_MIME_TYPES)[],
|
||||||
|
multiple: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.createImageElement({
|
this.insertImages(imageFiles, x, y);
|
||||||
sceneX: x,
|
|
||||||
sceneY: y,
|
|
||||||
addToFrameUnderCursor: false,
|
|
||||||
imageFile,
|
|
||||||
});
|
|
||||||
|
|
||||||
// avoid being batched (just in case)
|
|
||||||
this.setState({}, () => {
|
|
||||||
this.actionManager.executeAction(actionFinalize);
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name !== "AbortError") {
|
if (error.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -10496,23 +10415,81 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private insertImages = async (
|
||||||
|
imageFiles: File[],
|
||||||
|
sceneX: number,
|
||||||
|
sceneY: number,
|
||||||
|
) => {
|
||||||
|
const gridPadding = 50 / this.state.zoom.value;
|
||||||
|
// Create, position, and insert placeholders
|
||||||
|
const placeholders = positionElementsOnGrid(
|
||||||
|
imageFiles.map(() => this.newImagePlaceholder({ sceneX, sceneY })),
|
||||||
|
sceneX,
|
||||||
|
sceneY,
|
||||||
|
gridPadding,
|
||||||
|
);
|
||||||
|
placeholders.forEach((el) => this.scene.insertElement(el));
|
||||||
|
|
||||||
|
// Create, position, insert and select initialized (replacing placeholders)
|
||||||
|
const initialized = await Promise.all(
|
||||||
|
placeholders.map(async (placeholder, i) => {
|
||||||
|
try {
|
||||||
|
return await this.initializeImage(placeholder, imageFiles[i]);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.setState({
|
||||||
|
errorMessage: error.message || t("errors.imageInsertError"),
|
||||||
|
});
|
||||||
|
return newElementWith(placeholder, { isDeleted: true });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const initializedMap = arrayToMap(initialized);
|
||||||
|
|
||||||
|
const positioned = positionElementsOnGrid(
|
||||||
|
initialized.filter((el) => !el.isDeleted),
|
||||||
|
sceneX,
|
||||||
|
sceneY,
|
||||||
|
gridPadding,
|
||||||
|
);
|
||||||
|
const positionedMap = arrayToMap(positioned);
|
||||||
|
|
||||||
|
const nextElements = this.scene
|
||||||
|
.getElementsIncludingDeleted()
|
||||||
|
.map((el) => positionedMap.get(el.id) ?? initializedMap.get(el.id) ?? el);
|
||||||
|
|
||||||
|
this.updateScene({
|
||||||
|
appState: {
|
||||||
|
selectedElementIds: makeNextSelectedElementIds(
|
||||||
|
Object.fromEntries(positioned.map((el) => [el.id, true])),
|
||||||
|
this.state,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
elements: nextElements,
|
||||||
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState({}, () => {
|
||||||
|
// actionFinalize after all state values have been updated
|
||||||
|
this.actionManager.executeAction(actionFinalize);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => {
|
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(
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||||
event,
|
event,
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
// must be retrieved first, in the same frame
|
||||||
// if image tool not supported, don't show an error here and let it fall
|
const filesData = await getFilesFromEvent(event);
|
||||||
// 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
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) {
|
if (filesData.length === 1) {
|
||||||
|
const { file, fileHandle } = filesData[0];
|
||||||
|
|
||||||
|
if (
|
||||||
|
file &&
|
||||||
|
(file.type === MIME_TYPES.png || file.type === MIME_TYPES.svg)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const scene = await loadFromBlob(
|
const scene = await loadFromBlob(
|
||||||
file,
|
file,
|
||||||
@@ -10531,25 +10508,20 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Don't throw for image scene daa
|
|
||||||
if (error.name !== "EncodingError") {
|
if (error.name !== "EncodingError") {
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||||
}
|
}
|
||||||
|
// if EncodingError, fall through to insert as regular image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no scene is embedded or we fail for whatever reason, fall back
|
const imageFiles = filesData
|
||||||
// to importing as regular image
|
.map((data) => data.file)
|
||||||
// ---------------------------------------------------------------------
|
.filter((file): file is File => isSupportedImageFile(file));
|
||||||
this.createImageElement({ sceneX, sceneY, imageFile: file });
|
|
||||||
|
|
||||||
return;
|
if (imageFiles.length > 0 && this.isToolSupported("image")) {
|
||||||
}
|
return this.insertImages(imageFiles, sceneX, sceneY);
|
||||||
} catch (error: any) {
|
|
||||||
return this.setState({
|
|
||||||
isLoading: false,
|
|
||||||
errorMessage: error.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
|
const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
|
||||||
@@ -10567,10 +10539,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filesData.length > 0) {
|
||||||
|
const { file, fileHandle } = filesData[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
// Attempt to parse an excalidraw/excalidrawlib file
|
// Attempt to parse an excalidraw/excalidrawlib file
|
||||||
await this.loadFileToCanvas(file, fileHandle);
|
await this.loadFileToCanvas(file, fileHandle);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (event.dataTransfer?.types?.includes("text/plain")) {
|
if (event.dataTransfer?.types?.includes("text/plain")) {
|
||||||
const text = event.dataTransfer?.getData("text");
|
const text = event.dataTransfer?.getData("text");
|
||||||
|
@@ -18,6 +18,8 @@ import { CanvasError, ImageSceneDataError } from "../errors";
|
|||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { decodeSvgBase64Payload } from "../scene/export";
|
import { decodeSvgBase64Payload } from "../scene/export";
|
||||||
|
|
||||||
|
import { isClipboardEvent } from "../clipboard";
|
||||||
|
|
||||||
import { base64ToString, stringToBase64, toByteString } from "./encode";
|
import { base64ToString, stringToBase64, toByteString } from "./encode";
|
||||||
import { nativeFileSystemSupported } from "./filesystem";
|
import { nativeFileSystemSupported } from "./filesystem";
|
||||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||||
@@ -389,23 +391,54 @@ export const ImageURLToFile = async (
|
|||||||
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
|
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFileFromEvent = async (
|
export const getFilesFromEvent = async (
|
||||||
event: React.DragEvent<HTMLDivElement>,
|
event: React.DragEvent<HTMLDivElement> | ClipboardEvent,
|
||||||
) => {
|
) => {
|
||||||
const file = event.dataTransfer.files.item(0);
|
let fileList: FileList | undefined = undefined;
|
||||||
const fileHandle = await getFileHandle(event);
|
let items: DataTransferItemList | undefined = undefined;
|
||||||
|
|
||||||
return { file: file ? await normalizeFile(file) : null, fileHandle };
|
if (isClipboardEvent(event)) {
|
||||||
|
fileList = event.clipboardData?.files;
|
||||||
|
items = event.clipboardData?.items;
|
||||||
|
} else {
|
||||||
|
const dragEvent = event as React.DragEvent<HTMLDivElement>;
|
||||||
|
fileList = dragEvent.dataTransfer?.files;
|
||||||
|
items = dragEvent.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 (
|
export const getFileHandle = async (
|
||||||
event: React.DragEvent<HTMLDivElement>,
|
event: DragEvent | React.DragEvent | DataTransferItem,
|
||||||
): Promise<FileSystemHandle | null> => {
|
): Promise<FileSystemHandle | null> => {
|
||||||
if (nativeFileSystemSupported) {
|
if (nativeFileSystemSupported) {
|
||||||
try {
|
try {
|
||||||
const item = event.dataTransfer.items[0];
|
const dataTransferItem =
|
||||||
|
event instanceof DataTransferItem
|
||||||
|
? event
|
||||||
|
: (event as DragEvent).dataTransfer?.items?.[0];
|
||||||
|
|
||||||
const handle: FileSystemHandle | null =
|
const handle: FileSystemHandle | null =
|
||||||
(await (item as any).getAsFileSystemHandle()) || null;
|
(await (dataTransferItem as any).getAsFileSystemHandle()) || null;
|
||||||
|
|
||||||
return handle;
|
return handle;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
@@ -12534,10 +12534,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
|||||||
"openMenu": null,
|
"openMenu": null,
|
||||||
"openPopup": null,
|
"openPopup": null,
|
||||||
"openSidebar": null,
|
"openSidebar": null,
|
||||||
"originSnapOffset": {
|
"originSnapOffset": null,
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
|
||||||
},
|
|
||||||
"pasteDialog": {
|
"pasteDialog": {
|
||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
@@ -12759,6 +12756,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
|
"id1": true,
|
||||||
},
|
},
|
||||||
"selectedElementsAreBeingDragged": false,
|
"selectedElementsAreBeingDragged": false,
|
||||||
"selectedGroupIds": {},
|
"selectedGroupIds": {},
|
||||||
@@ -12793,7 +12791,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"crop": null,
|
"crop": null,
|
||||||
"customData": undefined,
|
"customData": undefined,
|
||||||
"fileId": "fileId",
|
"fileId": "id2",
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -12816,16 +12814,53 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 5,
|
"version": 7,
|
||||||
"width": 318,
|
"width": 318,
|
||||||
"x": -159,
|
"x": -212,
|
||||||
"y": "-167.50000",
|
"y": "-167.50000",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of elements 1`] = `1`;
|
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] element 1 1`] = `
|
||||||
|
{
|
||||||
|
"angle": 0,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"boundElements": null,
|
||||||
|
"crop": null,
|
||||||
|
"customData": undefined,
|
||||||
|
"fileId": "id3",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"frameId": null,
|
||||||
|
"groupIds": [],
|
||||||
|
"height": 77,
|
||||||
|
"id": "id1",
|
||||||
|
"index": "a1",
|
||||||
|
"isDeleted": false,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"opacity": 100,
|
||||||
|
"roughness": 1,
|
||||||
|
"roundness": null,
|
||||||
|
"scale": [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
"status": "pending",
|
||||||
|
"strokeColor": "transparent",
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"type": "image",
|
||||||
|
"updated": 1,
|
||||||
|
"version": 7,
|
||||||
|
"width": 56,
|
||||||
|
"x": 156,
|
||||||
|
"y": "-167.50000",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of renders 1`] = `7`;
|
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of elements 1`] = `2`;
|
||||||
|
|
||||||
|
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] number of renders 1`] = `8`;
|
||||||
|
|
||||||
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] redo stack 1`] = `[]`;
|
exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] redo stack 1`] = `[]`;
|
||||||
|
|
||||||
@@ -12837,6 +12872,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"deleted": {
|
"deleted": {
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
|
"id1": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -12854,7 +12890,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"crop": null,
|
"crop": null,
|
||||||
"customData": undefined,
|
"customData": undefined,
|
||||||
"fileId": "fileId",
|
"fileId": "id2",
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -12875,20 +12911,58 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"version": 5,
|
"version": 7,
|
||||||
"width": 318,
|
"width": 318,
|
||||||
"x": -159,
|
"x": -212,
|
||||||
"y": "-167.50000",
|
"y": "-167.50000",
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"isDeleted": true,
|
"isDeleted": true,
|
||||||
"version": 4,
|
"version": 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"id1": {
|
||||||
|
"deleted": {
|
||||||
|
"angle": 0,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"boundElements": null,
|
||||||
|
"crop": null,
|
||||||
|
"customData": undefined,
|
||||||
|
"fileId": "id3",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"frameId": null,
|
||||||
|
"groupIds": [],
|
||||||
|
"height": 77,
|
||||||
|
"index": "a1",
|
||||||
|
"isDeleted": false,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"opacity": 100,
|
||||||
|
"roughness": 1,
|
||||||
|
"roundness": null,
|
||||||
|
"scale": [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
"status": "pending",
|
||||||
|
"strokeColor": "transparent",
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"type": "image",
|
||||||
|
"version": 7,
|
||||||
|
"width": 56,
|
||||||
|
"x": 156,
|
||||||
|
"y": "-167.50000",
|
||||||
|
},
|
||||||
|
"inserted": {
|
||||||
|
"isDeleted": true,
|
||||||
|
"version": 6,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"updated": {},
|
"updated": {},
|
||||||
},
|
},
|
||||||
"id": "id4",
|
"id": "id7",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
@@ -12964,10 +13038,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"openMenu": null,
|
"openMenu": null,
|
||||||
"openPopup": null,
|
"openPopup": null,
|
||||||
"openSidebar": null,
|
"openSidebar": null,
|
||||||
"originSnapOffset": {
|
"originSnapOffset": null,
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
|
||||||
},
|
|
||||||
"pasteDialog": {
|
"pasteDialog": {
|
||||||
"data": null,
|
"data": null,
|
||||||
"shown": false,
|
"shown": false,
|
||||||
@@ -12981,6 +13052,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
|
"id1": true,
|
||||||
},
|
},
|
||||||
"selectedElementsAreBeingDragged": false,
|
"selectedElementsAreBeingDragged": false,
|
||||||
"selectedGroupIds": {},
|
"selectedGroupIds": {},
|
||||||
@@ -13015,11 +13087,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"crop": null,
|
"crop": null,
|
||||||
"customData": undefined,
|
"customData": undefined,
|
||||||
"fileId": "fileId",
|
"fileId": "id2",
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 77,
|
"height": 335,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
@@ -13038,16 +13110,53 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 5,
|
"version": 7,
|
||||||
"width": 56,
|
"width": 318,
|
||||||
"x": -28,
|
"x": -212,
|
||||||
"y": "-38.50000",
|
"y": "-167.50000",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of elements 1`] = `1`;
|
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] element 1 1`] = `
|
||||||
|
{
|
||||||
|
"angle": 0,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"boundElements": null,
|
||||||
|
"crop": null,
|
||||||
|
"customData": undefined,
|
||||||
|
"fileId": "id3",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"frameId": null,
|
||||||
|
"groupIds": [],
|
||||||
|
"height": 77,
|
||||||
|
"id": "id1",
|
||||||
|
"index": "a1",
|
||||||
|
"isDeleted": false,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"opacity": 100,
|
||||||
|
"roughness": 1,
|
||||||
|
"roundness": null,
|
||||||
|
"scale": [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
"status": "pending",
|
||||||
|
"strokeColor": "transparent",
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"type": "image",
|
||||||
|
"updated": 1,
|
||||||
|
"version": 7,
|
||||||
|
"width": 56,
|
||||||
|
"x": 156,
|
||||||
|
"y": "-167.50000",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of renders 1`] = `7`;
|
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of elements 1`] = `2`;
|
||||||
|
|
||||||
|
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] number of renders 1`] = `8`;
|
||||||
|
|
||||||
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] redo stack 1`] = `[]`;
|
exports[`history > singleplayer undo/redo > should create new history entry on image paste > [end of test] redo stack 1`] = `[]`;
|
||||||
|
|
||||||
@@ -13059,6 +13168,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"deleted": {
|
"deleted": {
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
|
"id1": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -13076,11 +13186,11 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"crop": null,
|
"crop": null,
|
||||||
"customData": undefined,
|
"customData": undefined,
|
||||||
"fileId": "fileId",
|
"fileId": "id2",
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 77,
|
"height": 335,
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
@@ -13097,20 +13207,58 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"version": 5,
|
"version": 7,
|
||||||
"width": 56,
|
"width": 318,
|
||||||
"x": -28,
|
"x": -212,
|
||||||
"y": "-38.50000",
|
"y": "-167.50000",
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"isDeleted": true,
|
"isDeleted": true,
|
||||||
"version": 4,
|
"version": 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"id1": {
|
||||||
|
"deleted": {
|
||||||
|
"angle": 0,
|
||||||
|
"backgroundColor": "transparent",
|
||||||
|
"boundElements": null,
|
||||||
|
"crop": null,
|
||||||
|
"customData": undefined,
|
||||||
|
"fileId": "id3",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"frameId": null,
|
||||||
|
"groupIds": [],
|
||||||
|
"height": 77,
|
||||||
|
"index": "a1",
|
||||||
|
"isDeleted": false,
|
||||||
|
"link": null,
|
||||||
|
"locked": false,
|
||||||
|
"opacity": 100,
|
||||||
|
"roughness": 1,
|
||||||
|
"roundness": null,
|
||||||
|
"scale": [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
"status": "pending",
|
||||||
|
"strokeColor": "transparent",
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"type": "image",
|
||||||
|
"version": 7,
|
||||||
|
"width": 56,
|
||||||
|
"x": 156,
|
||||||
|
"y": "-167.50000",
|
||||||
|
},
|
||||||
|
"inserted": {
|
||||||
|
"isDeleted": true,
|
||||||
|
"version": 6,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"updated": {},
|
"updated": {},
|
||||||
},
|
},
|
||||||
"id": "id4",
|
"id": "id7",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
9
packages/excalidraw/tests/fixtures/constants.ts
vendored
Normal file
9
packages/excalidraw/tests/fixtures/constants.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const DEER_IMAGE_DIMENSIONS = {
|
||||||
|
width: 318,
|
||||||
|
height: 335,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SMILEY_IMAGE_DIMENSIONS = {
|
||||||
|
width: 56,
|
||||||
|
height: 77,
|
||||||
|
};
|
@@ -25,6 +25,7 @@ import { Excalidraw } from "../index";
|
|||||||
// Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason)
|
// Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason)
|
||||||
import * as blobModule from "../data/blob";
|
import * as blobModule from "../data/blob";
|
||||||
|
|
||||||
|
import { SMILEY_IMAGE_DIMENSIONS } from "./fixtures/constants";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||||
import {
|
import {
|
||||||
@@ -744,11 +745,6 @@ describe("freedraw", () => {
|
|||||||
//image
|
//image
|
||||||
//TODO: currently there is no test for pixel colors at flipped positions.
|
//TODO: currently there is no test for pixel colors at flipped positions.
|
||||||
describe("image", () => {
|
describe("image", () => {
|
||||||
const smileyImageDimensions = {
|
|
||||||
width: 56,
|
|
||||||
height: 77,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
||||||
h.state.height = 1000;
|
h.state.height = 1000;
|
||||||
@@ -756,8 +752,8 @@ describe("image", () => {
|
|||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
mockHTMLImageElement(
|
mockHTMLImageElement(
|
||||||
smileyImageDimensions.width,
|
SMILEY_IMAGE_DIMENSIONS.width,
|
||||||
smileyImageDimensions.height,
|
SMILEY_IMAGE_DIMENSIONS.height,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -478,9 +478,13 @@ export class API {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
static drop = async (blob: Blob) => {
|
static drop = async (_blobs: Blob[] | Blob) => {
|
||||||
|
const blobs = Array.isArray(_blobs) ? _blobs : [_blobs];
|
||||||
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
|
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
|
||||||
const text = await new Promise<string>((resolve, reject) => {
|
const texts = await Promise.all(
|
||||||
|
blobs.map(
|
||||||
|
(blob) =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
@@ -490,21 +494,27 @@ export class API {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const files = [blob] as File[] & { item: (index: number) => File };
|
const files = blobs as File[] & { item: (index: number) => File };
|
||||||
files.item = (index: number) => files[index];
|
files.item = (index: number) => files[index];
|
||||||
|
|
||||||
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
||||||
value: {
|
value: {
|
||||||
files,
|
files,
|
||||||
getData: (type: string) => {
|
getData: (type: string) => {
|
||||||
if (type === blob.type || type === "text") {
|
const idx = blobs.findIndex((b) => b.type === type);
|
||||||
return text;
|
if (idx >= 0) {
|
||||||
|
return texts[idx];
|
||||||
|
}
|
||||||
|
if (type === "text") {
|
||||||
|
return texts.join("\n");
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
types: [blob.type],
|
types: Array.from(new Set(blobs.map((b) => b.type))),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Object.defineProperty(fileDropEvent, "clientX", {
|
Object.defineProperty(fileDropEvent, "clientX", {
|
||||||
|
6
packages/excalidraw/tests/helpers/constants.ts
Normal file
6
packages/excalidraw/tests/helpers/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const INITIALIZED_IMAGE_PROPS = {
|
||||||
|
type: "image",
|
||||||
|
fileId: expect.any(String),
|
||||||
|
x: expect.toBeNonNaNNumber(),
|
||||||
|
y: expect.toBeNonNaNNumber(),
|
||||||
|
};
|
@@ -58,3 +58,35 @@ export const mockHTMLImageElement = (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mocks for multiple HTMLImageElements (dimensions are assigned in the order of image initialization)
|
||||||
|
export const mockMultipleHTMLImageElements = (
|
||||||
|
sizes: (readonly [number, number])[],
|
||||||
|
) => {
|
||||||
|
const _sizes = [...sizes];
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"Image",
|
||||||
|
class extends Image {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
const size = _sizes.shift();
|
||||||
|
if (!size) {
|
||||||
|
throw new Error("Insufficient sizes");
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(this, "naturalWidth", {
|
||||||
|
value: size[0],
|
||||||
|
});
|
||||||
|
Object.defineProperty(this, "naturalHeight", {
|
||||||
|
value: size[1],
|
||||||
|
});
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.onload?.({} as Event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@@ -20,6 +20,7 @@ import {
|
|||||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||||
reseed,
|
reseed,
|
||||||
|
randomId,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import "@excalidraw/utils/test-utils";
|
import "@excalidraw/utils/test-utils";
|
||||||
@@ -58,9 +59,13 @@ import { createPasteEvent } from "../clipboard";
|
|||||||
|
|
||||||
import * as blobModule from "../data/blob";
|
import * as blobModule from "../data/blob";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEER_IMAGE_DIMENSIONS,
|
||||||
|
SMILEY_IMAGE_DIMENSIONS,
|
||||||
|
} from "./fixtures/constants";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||||
import { mockHTMLImageElement } from "./helpers/mocks";
|
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
|
||||||
import {
|
import {
|
||||||
GlobalTestState,
|
GlobalTestState,
|
||||||
act,
|
act,
|
||||||
@@ -71,6 +76,7 @@ import {
|
|||||||
checkpointHistory,
|
checkpointHistory,
|
||||||
unmountComponent,
|
unmountComponent,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
|
import { setupImageTest as _setupImageTest } from "./image.test";
|
||||||
|
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
|
|
||||||
@@ -123,7 +129,9 @@ describe("history", () => {
|
|||||||
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
|
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
|
||||||
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
|
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
|
||||||
|
|
||||||
generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId));
|
generateIdSpy.mockImplementation(() =>
|
||||||
|
Promise.resolve(randomId() as FileId),
|
||||||
|
);
|
||||||
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
|
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
|
||||||
|
|
||||||
Object.assign(document, {
|
Object.assign(document, {
|
||||||
@@ -612,80 +620,6 @@ describe("history", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create new history entry on image drag&drop", async () => {
|
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
|
||||||
|
|
||||||
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
|
||||||
h.state.height = 1000;
|
|
||||||
|
|
||||||
const deerImageDimensions = {
|
|
||||||
width: 318,
|
|
||||||
height: 335,
|
|
||||||
};
|
|
||||||
|
|
||||||
mockHTMLImageElement(
|
|
||||||
deerImageDimensions.width,
|
|
||||||
deerImageDimensions.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
await API.drop(await API.loadFile("./fixtures/deer.png"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
|
||||||
expect(h.elements).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "image",
|
|
||||||
fileId: expect.any(String),
|
|
||||||
x: expect.toBeNonNaNNumber(),
|
|
||||||
y: expect.toBeNonNaNNumber(),
|
|
||||||
...deerImageDimensions,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
|
|
||||||
expect(
|
|
||||||
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
|
|
||||||
).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "image",
|
|
||||||
fileId: expect.any(String),
|
|
||||||
x: expect.toBeNonNaNNumber(),
|
|
||||||
y: expect.toBeNonNaNNumber(),
|
|
||||||
...deerImageDimensions,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Keyboard.undo();
|
|
||||||
expect(API.getUndoStack().length).toBe(0);
|
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
|
||||||
expect(h.elements).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "image",
|
|
||||||
fileId: expect.any(String),
|
|
||||||
x: expect.toBeNonNaNNumber(),
|
|
||||||
y: expect.toBeNonNaNNumber(),
|
|
||||||
isDeleted: true,
|
|
||||||
...deerImageDimensions,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Keyboard.redo();
|
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
|
||||||
expect(h.elements).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "image",
|
|
||||||
fileId: expect.any(String),
|
|
||||||
x: expect.toBeNonNaNNumber(),
|
|
||||||
y: expect.toBeNonNaNNumber(),
|
|
||||||
isDeleted: false,
|
|
||||||
...deerImageDimensions,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create new history entry on embeddable link drag&drop", async () => {
|
it("should create new history entry on embeddable link drag&drop", async () => {
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
@@ -730,54 +664,29 @@ describe("history", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create new history entry on image paste", async () => {
|
const setupImageTest = () =>
|
||||||
await render(
|
_setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
|
||||||
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
|
||||||
h.state.height = 1000;
|
|
||||||
|
|
||||||
const smileyImageDimensions = {
|
|
||||||
width: 56,
|
|
||||||
height: 77,
|
|
||||||
};
|
|
||||||
|
|
||||||
mockHTMLImageElement(
|
|
||||||
smileyImageDimensions.width,
|
|
||||||
smileyImageDimensions.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
document.dispatchEvent(
|
|
||||||
createPasteEvent({
|
|
||||||
files: [await API.loadFile("./fixtures/smiley_embedded_v2.png")],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const assertImageTest = async () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(h.elements).toEqual([
|
|
||||||
|
// need to check that delta actually contains initialized image elements (with fileId & natural dimensions)
|
||||||
|
expect(
|
||||||
|
Object.values(h.history.undoStack[0].elements.removed).map(
|
||||||
|
(val) => val.deleted,
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "image",
|
...INITIALIZED_IMAGE_PROPS,
|
||||||
fileId: expect.any(String),
|
...DEER_IMAGE_DIMENSIONS,
|
||||||
x: expect.toBeNonNaNNumber(),
|
}),
|
||||||
y: expect.toBeNonNaNNumber(),
|
expect.objectContaining({
|
||||||
...smileyImageDimensions,
|
...INITIALIZED_IMAGE_PROPS,
|
||||||
|
...SMILEY_IMAGE_DIMENSIONS,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
|
|
||||||
expect(
|
|
||||||
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
|
|
||||||
).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "image",
|
|
||||||
fileId: expect.any(String),
|
|
||||||
x: expect.toBeNonNaNNumber(),
|
|
||||||
y: expect.toBeNonNaNNumber(),
|
|
||||||
...smileyImageDimensions,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -785,12 +694,14 @@ describe("history", () => {
|
|||||||
expect(API.getRedoStack().length).toBe(1);
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "image",
|
...INITIALIZED_IMAGE_PROPS,
|
||||||
fileId: expect.any(String),
|
|
||||||
x: expect.toBeNonNaNNumber(),
|
|
||||||
y: expect.toBeNonNaNNumber(),
|
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
...smileyImageDimensions,
|
...DEER_IMAGE_DIMENSIONS,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
...INITIALIZED_IMAGE_PROPS,
|
||||||
|
isDeleted: true,
|
||||||
|
...SMILEY_IMAGE_DIMENSIONS,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -799,14 +710,44 @@ describe("history", () => {
|
|||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "image",
|
...INITIALIZED_IMAGE_PROPS,
|
||||||
fileId: expect.any(String),
|
|
||||||
x: expect.toBeNonNaNNumber(),
|
|
||||||
y: expect.toBeNonNaNNumber(),
|
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
...smileyImageDimensions,
|
...DEER_IMAGE_DIMENSIONS,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
...INITIALIZED_IMAGE_PROPS,
|
||||||
|
isDeleted: false,
|
||||||
|
...SMILEY_IMAGE_DIMENSIONS,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should create new history entry on image drag&drop", async () => {
|
||||||
|
await setupImageTest();
|
||||||
|
|
||||||
|
await API.drop(
|
||||||
|
await Promise.all([
|
||||||
|
API.loadFile("./fixtures/deer.png"),
|
||||||
|
API.loadFile("./fixtures/smiley.png"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assertImageTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create new history entry on image paste", async () => {
|
||||||
|
await setupImageTest();
|
||||||
|
|
||||||
|
document.dispatchEvent(
|
||||||
|
createPasteEvent({
|
||||||
|
files: await Promise.all([
|
||||||
|
API.loadFile("./fixtures/deer.png"),
|
||||||
|
API.loadFile("./fixtures/smiley.png"),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assertImageTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create new history entry on embeddable link paste", async () => {
|
it("should create new history entry on embeddable link paste", async () => {
|
||||||
|
115
packages/excalidraw/tests/image.test.tsx
Normal file
115
packages/excalidraw/tests/image.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { randomId, reseed } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import type { FileId } from "@excalidraw/element/types";
|
||||||
|
|
||||||
|
import * as blobModule from "../data/blob";
|
||||||
|
import * as filesystemModule from "../data/filesystem";
|
||||||
|
import { Excalidraw } from "../index";
|
||||||
|
import { createPasteEvent } from "../clipboard";
|
||||||
|
|
||||||
|
import { API } from "./helpers/api";
|
||||||
|
import { mockMultipleHTMLImageElements } from "./helpers/mocks";
|
||||||
|
import { UI } from "./helpers/ui";
|
||||||
|
import { GlobalTestState, render, waitFor } from "./test-utils";
|
||||||
|
import {
|
||||||
|
DEER_IMAGE_DIMENSIONS,
|
||||||
|
SMILEY_IMAGE_DIMENSIONS,
|
||||||
|
} from "./fixtures/constants";
|
||||||
|
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
export const setupImageTest = async (
|
||||||
|
sizes: { width: number; height: number }[],
|
||||||
|
) => {
|
||||||
|
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
|
h.state.height = 1000;
|
||||||
|
|
||||||
|
mockMultipleHTMLImageElements(sizes.map((size) => [size.width, size.height]));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("image insertion", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
|
||||||
|
reseed(7);
|
||||||
|
|
||||||
|
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
|
||||||
|
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
|
||||||
|
|
||||||
|
generateIdSpy.mockImplementation(() =>
|
||||||
|
Promise.resolve(randomId() as FileId),
|
||||||
|
);
|
||||||
|
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
|
||||||
|
|
||||||
|
Object.assign(document, {
|
||||||
|
elementFromPoint: () => GlobalTestState.canvas,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const setup = () =>
|
||||||
|
setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
|
||||||
|
|
||||||
|
const assert = async () => {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
...INITIALIZED_IMAGE_PROPS,
|
||||||
|
...DEER_IMAGE_DIMENSIONS,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
...INITIALIZED_IMAGE_PROPS,
|
||||||
|
...SMILEY_IMAGE_DIMENSIONS,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
// Not placed on top of each other
|
||||||
|
const dimensionsSet = new Set(h.elements.map((el) => `${el.x}-${el.y}`));
|
||||||
|
expect(dimensionsSet.size).toEqual(h.elements.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should eventually initialize all dropped images", async () => {
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
const files = await Promise.all([
|
||||||
|
API.loadFile("./fixtures/deer.png"),
|
||||||
|
API.loadFile("./fixtures/smiley.png"),
|
||||||
|
]);
|
||||||
|
await API.drop(files);
|
||||||
|
|
||||||
|
await assert();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should eventually initialize all pasted images", async () => {
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
document.dispatchEvent(
|
||||||
|
createPasteEvent({
|
||||||
|
files: await Promise.all([
|
||||||
|
API.loadFile("./fixtures/deer.png"),
|
||||||
|
API.loadFile("./fixtures/smiley.png"),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should eventually initialize all images added through image tool", async () => {
|
||||||
|
await setup();
|
||||||
|
|
||||||
|
const fileOpenSpy = vi.spyOn(filesystemModule, "fileOpen");
|
||||||
|
fileOpenSpy.mockImplementation(
|
||||||
|
async () =>
|
||||||
|
await Promise.all([
|
||||||
|
API.loadFile("./fixtures/deer.png"),
|
||||||
|
API.loadFile("./fixtures/smiley.png"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
UI.clickTool("image");
|
||||||
|
|
||||||
|
await assert();
|
||||||
|
});
|
||||||
|
});
|
Reference in New Issue
Block a user