feat: capture images after they initialize (#9643)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Marcel Mraz 2025-06-15 23:43:14 +02:00 committed by GitHub
parent 3f194918e6
commit 058918f8e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 164 additions and 161 deletions

View File

@ -23,6 +23,8 @@ import {
syncInvalidIndicesImmutable, syncInvalidIndicesImmutable,
hashElementsVersion, hashElementsVersion,
hashString, hashString,
isInitializedImageElement,
isImageElement,
} from "./index"; } from "./index";
import type { import type {
@ -137,6 +139,8 @@ export class Store {
} else { } else {
// immediately create an immutable change of the scheduled updates, // immediately create an immutable change of the scheduled updates,
// compared to the current state, so that they won't mutate later on during batching // compared to the current state, so that they won't mutate later on during batching
// also, we have to compare against the current state,
// as comparing against the snapshot might include yet uncomitted changes (i.e. async freedraw / text / image, etc.)
const currentSnapshot = StoreSnapshot.create( const currentSnapshot = StoreSnapshot.create(
this.app.scene.getElementsMapIncludingDeleted(), this.app.scene.getElementsMapIncludingDeleted(),
this.app.state, this.app.state,
@ -869,7 +873,7 @@ export class StoreSnapshot {
} }
/** /**
* Detect if there any changed elements. * Detect if there are any changed elements.
*/ */
private detectChangedElements( private detectChangedElements(
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
@ -904,6 +908,14 @@ export class StoreSnapshot {
!prevElement || // element was added !prevElement || // element was added
prevElement.version < nextElement.version // element was updated prevElement.version < nextElement.version // element was updated
) { ) {
if (
isImageElement(nextElement) &&
!isInitializedImageElement(nextElement)
) {
// ignore any updates on uninitialized image elements
continue;
}
changedElements.set(nextElement.id, nextElement); changedElements.set(nextElement.id, nextElement);
} }
} }

View File

@ -3063,18 +3063,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const imageElement = this.createImageElement({ sceneX, sceneY }); this.createImageElement({ sceneX, sceneY, imageFile: file });
this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement);
this.store.scheduleCapture();
this.setState({
selectedElementIds: makeNextSelectedElementIds(
{
[imageElement.id]: true,
},
this.state,
),
});
return; return;
} }
@ -3380,15 +3369,12 @@ class App extends React.Component<AppProps, AppState> {
const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {}; const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {};
for (const response of responses) { for (const response of responses) {
if (response.file) { if (response.file) {
const imageElement = this.createImageElement({ const initializedImageElement = await this.createImageElement({
sceneX, sceneX,
sceneY: y, sceneY: y,
imageFile: response.file,
}); });
const initializedImageElement = await this.insertImageElement(
imageElement,
response.file,
);
if (initializedImageElement) { if (initializedImageElement) {
// vertically center first image in the batch // vertically center first image in the batch
if (!firstImageYOffsetDone) { if (!firstImageYOffsetDone) {
@ -3403,9 +3389,9 @@ class App extends React.Component<AppProps, AppState> {
{ informMutation: false, isDragging: false }, { informMutation: false, isDragging: false },
); );
y = imageElement.y + imageElement.height + 25; y = initializedImageElement.y + initializedImageElement.height + 25;
nextSelectedIds[imageElement.id] = true; nextSelectedIds[initializedImageElement.id] = true;
} }
} }
} }
@ -7628,14 +7614,16 @@ class App extends React.Component<AppProps, AppState> {
return element; return element;
}; };
private createImageElement = ({ private createImageElement = async ({
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,
@ -7652,10 +7640,10 @@ class App extends React.Component<AppProps, AppState> {
}) })
: null; : null;
const element = newImageElement({ const placeholderSize = 100 / this.state.zoom.value;
const placeholderImageElement = newImageElement({
type: "image", type: "image",
x: gridX,
y: gridY,
strokeColor: this.state.currentItemStrokeColor, strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor, backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle, fillStyle: this.state.currentItemFillStyle,
@ -7666,9 +7654,18 @@ class App extends React.Component<AppProps, AppState> {
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
x: gridX - placeholderSize / 2,
y: gridY - placeholderSize / 2,
width: placeholderSize,
height: placeholderSize,
}); });
return element; const initializedImageElement = await this.insertImageElement(
placeholderImageElement,
imageFile,
);
return initializedImageElement;
}; };
private handleLinearElementOnPointerDown = ( private handleLinearElementOnPointerDown = (
@ -9092,32 +9089,6 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (isImageElement(newElement)) {
const imageElement = newElement;
try {
this.initializeImageDimensions(imageElement);
this.setState(
{
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
},
() => {
this.actionManager.executeAction(actionFinalize);
},
);
} catch (error: any) {
console.error(error);
this.scene.replaceAllElements(
this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== imageElement.id),
);
this.actionManager.executeAction(actionFinalize);
}
return;
}
if (isLinearElement(newElement)) { if (isLinearElement(newElement)) {
if (newElement!.points.length > 1) { if (newElement!.points.length > 1) {
@ -9829,13 +9800,10 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private initializeImage = async ({ private initializeImage = async (
imageFile, placeholderImageElement: ExcalidrawImageElement,
imageElement: _imageElement, imageFile: File,
}: { ) => {
imageFile: File;
imageElement: ExcalidrawImageElement;
}) => {
// at this point this should be guaranteed image file, but we do this check // at this point this should be guaranteed image file, but we do this check
// to satisfy TS down the line // to satisfy TS down the line
if (!isSupportedImageFile(imageFile)) { if (!isSupportedImageFile(imageFile)) {
@ -9895,13 +9863,14 @@ 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));
let imageElement = newElementWith(_imageElement, {
fileId,
}) as NonDeleted<InitializedExcalidrawImageElement>;
return new Promise<NonDeleted<InitializedExcalidrawImageElement>>( return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
async (resolve, reject) => { async (resolve, reject) => {
try { try {
let initializedImageElement = this.getLatestInitializedImageElement(
placeholderImageElement,
fileId,
);
this.addMissingFiles([ this.addMissingFiles([
{ {
mimeType, mimeType,
@ -9912,34 +9881,39 @@ class App extends React.Component<AppProps, AppState> {
}, },
]); ]);
let cachedImageData = this.imageCache.get(fileId); if (!this.imageCache.get(fileId)) {
if (!cachedImageData) {
this.addNewImagesToImageCache(); this.addNewImagesToImageCache();
const { updatedFiles } = await this.updateImageCache([ const { erroredFiles } = await this.updateImageCache([
imageElement, initializedImageElement,
]); ]);
if (updatedFiles.size) { if (erroredFiles.size) {
ShapeCache.delete(_imageElement); throw new Error("Image cache update resulted with an error.");
} }
cachedImageData = this.imageCache.get(fileId);
} }
const imageHTML = await cachedImageData?.image; const imageHTML = await this.imageCache.get(fileId)?.image;
if (
imageHTML &&
this.state.newElement?.id !== initializedImageElement.id
) {
initializedImageElement = this.getLatestInitializedImageElement(
placeholderImageElement,
fileId,
);
if (imageHTML && this.state.newElement?.id !== imageElement.id) {
const naturalDimensions = this.getImageNaturalDimensions( const naturalDimensions = this.getImageNaturalDimensions(
imageElement, initializedImageElement,
imageHTML, imageHTML,
); );
imageElement = newElementWith(imageElement, naturalDimensions); // no need to create a new instance anymore, just assign the natural dimensions
Object.assign(initializedImageElement, naturalDimensions);
} }
resolve(imageElement); resolve(initializedImageElement);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
reject(new Error(t("errors.imageInsertError"))); reject(new Error(t("errors.imageInsertError")));
@ -9948,11 +9922,31 @@ class App extends React.Component<AppProps, AppState> {
); );
}; };
/**
* use during async image initialization,
* when the placeholder image could have been modified in the meantime,
* and when you don't want to loose those modifications
*/
private getLatestInitializedImageElement = (
imagePlaceholder: ExcalidrawImageElement,
fileId: FileId,
) => {
const latestImageElement =
this.scene.getElement(imagePlaceholder.id) ?? imagePlaceholder;
return newElementWith(
latestImageElement as InitializedExcalidrawImageElement,
{
fileId,
},
);
};
/** /**
* inserts image into elements array and rerenders * inserts image into elements array and rerenders
*/ */
insertImageElement = async ( private insertImageElement = async (
imageElement: ExcalidrawImageElement, placeholderImageElement: ExcalidrawImageElement,
imageFile: File, imageFile: File,
) => { ) => {
// we should be handling all cases upstream, but in case we forget to handle // we should be handling all cases upstream, but in case we forget to handle
@ -9962,34 +9956,39 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
this.scene.insertElement(imageElement); this.scene.insertElement(placeholderImageElement);
try { try {
const image = await this.initializeImage({ const initializedImageElement = await this.initializeImage(
placeholderImageElement,
imageFile, imageFile,
imageElement, );
});
const nextElements = this.scene const nextElements = this.scene
.getElementsIncludingDeleted() .getElementsIncludingDeleted()
.map((element) => { .map((element) => {
if (element.id === image.id) { if (element.id === initializedImageElement.id) {
return image; return initializedImageElement;
} }
return element; return element;
}); });
// schedules an immediate micro action, which will update snapshot,
// but won't be undoable, which is what we want!
this.updateScene({ this.updateScene({
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
elements: nextElements, elements: nextElements,
appState: {
selectedElementIds: makeNextSelectedElementIds(
{ [initializedImageElement.id]: true },
this.state,
),
},
}); });
return image; return initializedImageElement;
} catch (error: any) { } catch (error: any) {
this.scene.mutateElement(imageElement, { this.store.scheduleAction(CaptureUpdateAction.NEVER);
this.scene.mutateElement(placeholderImageElement, {
isDeleted: true, isDeleted: true,
}); });
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
@ -10017,26 +10016,17 @@ class App extends React.Component<AppProps, AppState> {
) as (keyof typeof IMAGE_MIME_TYPES)[], ) as (keyof typeof IMAGE_MIME_TYPES)[],
}); });
const imageElement = this.createImageElement({ await this.createImageElement({
sceneX: x, sceneX: x,
sceneY: y, sceneY: y,
addToFrameUnderCursor: false, addToFrameUnderCursor: false,
imageFile,
}); });
this.insertImageElement(imageElement, imageFile); // avoid being batched (just in case)
this.initializeImageDimensions(imageElement); this.setState({}, () => {
this.store.scheduleCapture(); this.actionManager.executeAction(actionFinalize);
this.setState( });
{
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
},
() => {
this.actionManager.executeAction(actionFinalize);
},
);
} catch (error: any) { } catch (error: any) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
console.error(error); console.error(error);
@ -10055,45 +10045,6 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
initializeImageDimensions = (imageElement: ExcalidrawImageElement) => {
const imageHTML =
isInitializedImageElement(imageElement) &&
this.imageCache.get(imageElement.fileId)?.image;
if (!imageHTML || imageHTML instanceof Promise) {
if (
imageElement.width < DRAGGING_THRESHOLD / this.state.zoom.value &&
imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value
) {
const placeholderSize = 100 / this.state.zoom.value;
this.scene.mutateElement(imageElement, {
x: imageElement.x - placeholderSize / 2,
y: imageElement.y - placeholderSize / 2,
width: placeholderSize,
height: placeholderSize,
});
}
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 (
imageElement.width < DRAGGING_THRESHOLD / this.state.zoom.value &&
imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value
) {
const naturalDimensions = this.getImageNaturalDimensions(
imageElement,
imageHTML,
);
this.scene.mutateElement(imageElement, naturalDimensions);
}
};
private getImageNaturalDimensions = ( private getImageNaturalDimensions = (
imageElement: ExcalidrawImageElement, imageElement: ExcalidrawImageElement,
imageHTML: HTMLImageElement, imageHTML: HTMLImageElement,
@ -10135,8 +10086,9 @@ class App extends React.Component<AppProps, AppState> {
}); });
if (erroredFiles.size) { if (erroredFiles.size) {
this.store.scheduleAction(CaptureUpdateAction.NEVER);
this.scene.replaceAllElements( this.scene.replaceAllElements(
this.scene.getElementsIncludingDeleted().map((element) => { elements.map((element) => {
if ( if (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
erroredFiles.has(element.fileId) erroredFiles.has(element.fileId)
@ -10357,17 +10309,7 @@ class App extends React.Component<AppProps, AppState> {
// if no scene is embedded or we fail for whatever reason, fall back // if no scene is embedded or we fail for whatever reason, fall back
// to importing as regular image // to importing as regular image
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
this.createImageElement({ sceneX, sceneY, imageFile: file });
const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement);
this.store.scheduleCapture();
this.setState({
selectedElementIds: makeNextSelectedElementIds(
{ [imageElement.id]: true },
this.state,
),
});
return; return;
} }

View File

@ -12514,7 +12514,7 @@ 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": 7, "version": 5,
"width": 318, "width": 318,
"x": -159, "x": -159,
"y": "-167.50000", "y": "-167.50000",
@ -12573,14 +12573,14 @@ 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": 7, "version": 5,
"width": 318, "width": 318,
"x": -159, "x": -159,
"y": "-167.50000", "y": "-167.50000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 6, "version": 4,
}, },
}, },
}, },
@ -12737,7 +12737,7 @@ 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": 7, "version": 5,
"width": 56, "width": 56,
"x": -28, "x": -28,
"y": "-38.50000", "y": "-38.50000",
@ -12796,14 +12796,14 @@ 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": 7, "version": 5,
"width": 56, "width": 56,
"x": -28, "x": -28,
"y": "-38.50000", "y": "-38.50000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 6, "version": 4,
}, },
}, },
}, },

View File

@ -38,6 +38,8 @@ import {
import { getTextEditor } from "./queries/dom"; import { getTextEditor } from "./queries/dom";
import { mockHTMLImageElement } from "./helpers/mocks";
import type { NormalizedZoomValue } from "../types"; import type { NormalizedZoomValue } from "../types";
const { h } = window; const { h } = window;
@ -742,6 +744,28 @@ 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(() => {
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
});
beforeAll(() => {
mockHTMLImageElement(
smileyImageDimensions.width,
smileyImageDimensions.height,
);
});
afterAll(() => {
vi.unstubAllGlobals();
h.state.height = 0;
});
const createImage = async () => { const createImage = async () => {
const sendPasteEvent = (file?: File) => { const sendPasteEvent = (file?: File) => {
const clipboardEvent = createPasteEvent({ files: file ? [file] : [] }); const clipboardEvent = createPasteEvent({ files: file ? [file] : [] });

View File

@ -642,6 +642,19 @@ describe("history", () => {
...deerImageDimensions, ...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(); Keyboard.undo();
@ -753,6 +766,18 @@ describe("history", () => {
...smileyImageDimensions, ...smileyImageDimensions,
}), }),
]); ]);
// 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();