fix: add history capture for paste and drop of images and embeds (#9605)

This commit is contained in:
Marcel Mraz 2025-06-10 14:28:16 +02:00 committed by GitHub
parent 9e77373c81
commit 0d4abd1ddc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 2063 additions and 868 deletions

View File

@ -3006,6 +3006,7 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
// TODO: this is so spaghetti, we should refactor it and cover it with tests
public pasteFromClipboard = withBatchedUpdates( public pasteFromClipboard = withBatchedUpdates(
async (event: ClipboardEvent) => { async (event: ClipboardEvent) => {
const isPlainPaste = !!IS_PLAIN_PASTE; const isPlainPaste = !!IS_PLAIN_PASTE;
@ -3070,6 +3071,7 @@ class App extends React.Component<AppProps, AppState> {
const imageElement = this.createImageElement({ sceneX, sceneY }); const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file); this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement); this.initializeImageDimensions(imageElement);
this.store.scheduleCapture();
this.setState({ this.setState({
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
{ {
@ -3180,6 +3182,7 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
if (embeddables.length) { if (embeddables.length) {
this.store.scheduleCapture();
this.setState({ this.setState({
selectedElementIds: Object.fromEntries( selectedElementIds: Object.fromEntries(
embeddables.map((embeddable) => [embeddable.id, true]), embeddables.map((embeddable) => [embeddable.id, true]),
@ -3292,11 +3295,10 @@ class App extends React.Component<AppProps, AppState> {
this.addMissingFiles(opts.files); this.addMissingFiles(opts.files);
} }
this.store.scheduleCapture();
const nextElementsToSelect = const nextElementsToSelect =
excludeElementsInFramesFromSelection(duplicatedElements); excludeElementsInFramesFromSelection(duplicatedElements);
this.store.scheduleCapture();
this.setState( this.setState(
{ {
...this.state, ...this.state,
@ -3530,7 +3532,7 @@ class App extends React.Component<AppProps, AppState> {
} }
this.scene.insertElements(textElements); this.scene.insertElements(textElements);
this.store.scheduleCapture();
this.setState({ this.setState({
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
Object.fromEntries(textElements.map((el) => [el.id, true])), Object.fromEntries(textElements.map((el) => [el.id, true])),
@ -3552,8 +3554,6 @@ class App extends React.Component<AppProps, AppState> {
}); });
PLAIN_PASTE_TOAST_SHOWN = true; PLAIN_PASTE_TOAST_SHOWN = true;
} }
this.store.scheduleCapture();
} }
setAppState: React.Component<any, AppState>["setState"] = ( setAppState: React.Component<any, AppState>["setState"] = (
@ -8978,6 +8978,7 @@ class App extends React.Component<AppProps, AppState> {
); );
this.store.scheduleCapture(); this.store.scheduleCapture();
if (hitLockedElement?.locked) { if (hitLockedElement?.locked) {
this.setState({ this.setState({
activeLockedId: activeLockedId:
@ -9947,13 +9948,9 @@ class App extends React.Component<AppProps, AppState> {
const dataURL = const dataURL =
this.files[fileId]?.dataURL || (await getDataURL(imageFile)); this.files[fileId]?.dataURL || (await getDataURL(imageFile));
const imageElement = this.scene.mutateElement( let imageElement = newElementWith(_imageElement, {
_imageElement, fileId,
{ }) as NonDeleted<InitializedExcalidrawImageElement>;
fileId,
},
{ informMutation: false, isDragging: false },
) as NonDeleted<InitializedExcalidrawImageElement>;
return new Promise<NonDeleted<InitializedExcalidrawImageElement>>( return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
async (resolve, reject) => { async (resolve, reject) => {
@ -9967,20 +9964,38 @@ class App extends React.Component<AppProps, AppState> {
lastRetrieved: Date.now(), lastRetrieved: Date.now(),
}, },
]); ]);
const cachedImageData = this.imageCache.get(fileId);
let cachedImageData = this.imageCache.get(fileId);
if (!cachedImageData) { if (!cachedImageData) {
this.addNewImagesToImageCache(); this.addNewImagesToImageCache();
await this.updateImageCache([imageElement]);
} const { updatedFiles } = await this.updateImageCache([
if (cachedImageData?.image instanceof Promise) { imageElement,
await cachedImageData.image; ]);
if (updatedFiles.size) {
ShapeCache.delete(_imageElement);
}
cachedImageData = this.imageCache.get(fileId);
} }
const imageHTML = await cachedImageData?.image;
if ( if (
imageHTML &&
this.state.pendingImageElementId !== imageElement.id && this.state.pendingImageElementId !== imageElement.id &&
this.state.newElement?.id !== imageElement.id this.state.newElement?.id !== imageElement.id
) { ) {
this.initializeImageDimensions(imageElement, true); const naturalDimensions = this.getImageNaturalDimensions(
imageElement,
imageHTML,
);
imageElement = newElementWith(imageElement, naturalDimensions);
} }
resolve(imageElement); resolve(imageElement);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@ -10012,11 +10027,30 @@ class App extends React.Component<AppProps, AppState> {
this.scene.insertElement(imageElement); this.scene.insertElement(imageElement);
try { try {
return await this.initializeImage({ const image = await this.initializeImage({
imageFile, imageFile,
imageElement, imageElement,
showCursorImagePreview, showCursorImagePreview,
}); });
const nextElements = this.scene
.getElementsIncludingDeleted()
.map((element) => {
if (element.id === image.id) {
return image;
}
return element;
});
// schedules an immediate micro action, which will update snapshot,
// but won't be undoable, which is what we want!
this.updateScene({
captureUpdate: CaptureUpdateAction.NEVER,
elements: nextElements,
});
return image;
} catch (error: any) { } catch (error: any) {
this.scene.mutateElement(imageElement, { this.scene.mutateElement(imageElement, {
isDeleted: true, isDeleted: true,
@ -10106,6 +10140,7 @@ class App extends React.Component<AppProps, AppState> {
if (insertOnCanvasDirectly) { if (insertOnCanvasDirectly) {
this.insertImageElement(imageElement, imageFile); this.insertImageElement(imageElement, imageFile);
this.initializeImageDimensions(imageElement); this.initializeImageDimensions(imageElement);
this.store.scheduleCapture();
this.setState( this.setState(
{ {
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
@ -10150,20 +10185,18 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
initializeImageDimensions = ( initializeImageDimensions = (imageElement: ExcalidrawImageElement) => {
imageElement: ExcalidrawImageElement, const imageHTML =
forceNaturalSize = false,
) => {
const image =
isInitializedImageElement(imageElement) && isInitializedImageElement(imageElement) &&
this.imageCache.get(imageElement.fileId)?.image; this.imageCache.get(imageElement.fileId)?.image;
if (!image || image instanceof Promise) { if (!imageHTML || imageHTML instanceof Promise) {
if ( if (
imageElement.width < DRAGGING_THRESHOLD / this.state.zoom.value && imageElement.width < DRAGGING_THRESHOLD / this.state.zoom.value &&
imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value
) { ) {
const placeholderSize = 100 / this.state.zoom.value; const placeholderSize = 100 / this.state.zoom.value;
this.scene.mutateElement(imageElement, { this.scene.mutateElement(imageElement, {
x: imageElement.x - placeholderSize / 2, x: imageElement.x - placeholderSize / 2,
y: imageElement.y - placeholderSize / 2, y: imageElement.y - placeholderSize / 2,
@ -10175,39 +10208,50 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
// if user-created bounding box is below threshold, assume the
// intention was to click instead of drag, and use the image's
// intrinsic size
if ( if (
forceNaturalSize || imageElement.width < DRAGGING_THRESHOLD / this.state.zoom.value &&
// if user-created bounding box is below threshold, assume the imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value
// intention was to click instead of drag, and use the image's
// intrinsic size
(imageElement.width < DRAGGING_THRESHOLD / this.state.zoom.value &&
imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value)
) { ) {
const minHeight = Math.max(this.state.height - 120, 160); const naturalDimensions = this.getImageNaturalDimensions(
// max 65% of canvas height, clamped to <300px, vh - 120px> imageElement,
const maxHeight = Math.min( imageHTML,
minHeight,
Math.floor(this.state.height * 0.5) / this.state.zoom.value,
); );
const height = Math.min(image.naturalHeight, maxHeight); this.scene.mutateElement(imageElement, naturalDimensions);
const width = height * (image.naturalWidth / image.naturalHeight);
// add current imageElement width/height to account for previous centering
// of the placeholder image
const x = imageElement.x + imageElement.width / 2 - width / 2;
const y = imageElement.y + imageElement.height / 2 - height / 2;
this.scene.mutateElement(imageElement, {
x,
y,
width,
height,
crop: null,
});
} }
}; };
private getImageNaturalDimensions = (
imageElement: ExcalidrawImageElement,
imageHTML: HTMLImageElement,
) => {
const minHeight = Math.max(this.state.height - 120, 160);
// max 65% of canvas height, clamped to <300px, vh - 120px>
const maxHeight = Math.min(
minHeight,
Math.floor(this.state.height * 0.5) / this.state.zoom.value,
);
const height = Math.min(imageHTML.naturalHeight, maxHeight);
const width = height * (imageHTML.naturalWidth / imageHTML.naturalHeight);
// add current imageElement width/height to account for previous centering
// of the placeholder image
const x = imageElement.x + imageElement.width / 2 - width / 2;
const y = imageElement.y + imageElement.height / 2 - height / 2;
return {
x,
y,
width,
height,
crop: null,
};
};
/** updates image cache, refreshing updated elements and/or setting status /** updates image cache, refreshing updated elements and/or setting status
to error for images that fail during <img> element creation */ to error for images that fail during <img> element creation */
private updateImageCache = async ( private updateImageCache = async (
@ -10219,13 +10263,7 @@ class App extends React.Component<AppProps, AppState> {
fileIds: elements.map((element) => element.fileId), fileIds: elements.map((element) => element.fileId),
files, files,
}); });
if (updatedFiles.size || erroredFiles.size) {
for (const element of elements) {
if (updatedFiles.has(element.fileId)) {
ShapeCache.delete(element);
}
}
}
if (erroredFiles.size) { if (erroredFiles.size) {
this.scene.replaceAllElements( this.scene.replaceAllElements(
this.scene.getElementsIncludingDeleted().map((element) => { this.scene.getElementsIncludingDeleted().map((element) => {
@ -10261,6 +10299,15 @@ class App extends React.Component<AppProps, AppState> {
uncachedImageElements, uncachedImageElements,
files, files,
); );
if (updatedFiles.size) {
for (const element of uncachedImageElements) {
if (updatedFiles.has(element.fileId)) {
ShapeCache.delete(element);
}
}
}
if (updatedFiles.size) { if (updatedFiles.size) {
this.scene.triggerUpdate(); this.scene.triggerUpdate();
} }
@ -10444,6 +10491,7 @@ class App extends React.Component<AppProps, AppState> {
const imageElement = this.createImageElement({ sceneX, sceneY }); const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file); this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement); this.initializeImageDimensions(imageElement);
this.store.scheduleCapture();
this.setState({ this.setState({
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true }, { [imageElement.id]: true },
@ -10494,6 +10542,7 @@ class App extends React.Component<AppProps, AppState> {
link: normalizeLink(text), link: normalizeLink(text),
}); });
if (embeddable) { if (embeddable) {
this.store.scheduleCapture();
this.setState({ selectedElementIds: { [embeddable.id]: true } }); this.setState({ selectedElementIds: { [embeddable.id]: true } });
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -499,13 +499,21 @@ export class API {
value: { value: {
files, files,
getData: (type: string) => { getData: (type: string) => {
if (type === blob.type) { if (type === blob.type || type === "text") {
return text; return text;
} }
return ""; return "";
}, },
types: [blob.type],
}, },
}); });
Object.defineProperty(fileDropEvent, "clientX", {
value: 0,
});
Object.defineProperty(fileDropEvent, "clientY", {
value: 0,
});
await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent); await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
}; };

View File

@ -31,3 +31,30 @@ export const mockMermaidToExcalidraw = (opts: {
}); });
} }
}; };
// Mock for HTMLImageElement (use with `vi.unstubAllGlobals()`)
// as jsdom.resources: "usable" throws an error on image load
export const mockHTMLImageElement = (
naturalWidth: number,
naturalHeight: number,
) => {
vi.stubGlobal(
"Image",
class extends Image {
constructor() {
super();
Object.defineProperty(this, "naturalWidth", {
value: naturalWidth,
});
Object.defineProperty(this, "naturalHeight", {
value: naturalHeight,
});
queueMicrotask(() => {
this.onload?.({} as Event);
});
}
},
);
};

View File

@ -19,6 +19,7 @@ import {
COLOR_PALETTE, COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX, DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
reseed,
} from "@excalidraw/common"; } from "@excalidraw/common";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
@ -35,6 +36,7 @@ import type {
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FileId,
FixedPointBinding, FixedPointBinding,
FractionalIndex, FractionalIndex,
SceneElementsMap, SceneElementsMap,
@ -49,12 +51,16 @@ import {
} from "../actions"; } from "../actions";
import { createUndoAction, createRedoAction } from "../actions/actionHistory"; import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import * as StaticScene from "../renderer/staticScene";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import * as StaticScene from "../renderer/staticScene"; import { createPasteEvent } from "../clipboard";
import * as blobModule from "../data/blob";
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 { import {
GlobalTestState, GlobalTestState,
act, act,
@ -63,6 +69,7 @@ import {
togglePopover, togglePopover,
getCloneByOrigId, getCloneByOrigId,
checkpointHistory, checkpointHistory,
unmountComponent,
} from "./test-utils"; } from "./test-utils";
import type { AppState } from "../types"; import type { AppState } from "../types";
@ -106,7 +113,22 @@ const violet = COLOR_PALETTE.violet[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
describe("history", () => { describe("history", () => {
beforeEach(() => { beforeEach(() => {
unmountComponent();
renderStaticScene.mockClear(); renderStaticScene.mockClear();
vi.clearAllMocks();
vi.unstubAllGlobals();
reseed(7);
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId));
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
});
}); });
afterEach(() => { afterEach(() => {
@ -559,6 +581,227 @@ 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,
}),
]);
});
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 () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
await API.drop(
new Blob([link], {
type: MIME_TYPES.text,
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
it("should create new history entry on image paste", async () => {
await render(
<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")],
}),
);
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(),
...smileyImageDimensions,
}),
]);
});
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,
...smileyImageDimensions,
}),
]);
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,
...smileyImageDimensions,
}),
]);
});
it("should create new history entry on embeddable link paste", async () => {
await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
document.dispatchEvent(
createPasteEvent({
types: {
"text/plain": link,
},
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
it("should support appstate name or viewBackgroundColor change", async () => { it("should support appstate name or viewBackgroundColor change", async () => {
await render( await render(
<Excalidraw <Excalidraw