fix: text restore & deletion issues (#9853)

This commit is contained in:
Marcel Mraz 2025-08-12 09:27:04 +02:00 committed by GitHub
parent cc8e490c75
commit 54c148f390
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 229 additions and 154 deletions

View File

@ -259,7 +259,9 @@ export const loadFromFirebase = async (
} }
const storedScene = docSnap.data() as FirebaseStoredScene; const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements( const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null), restoreElements(await decryptElements(storedScene, roomKey), null, {
deleteEmptyTextElements: true,
}),
); );
if (socket) { if (socket) {

View File

@ -258,11 +258,16 @@ export const loadScene = async (
await importFromBackend(id, privateKey), await importFromBackend(id, privateKey),
localDataState?.appState, localDataState?.appState,
localDataState?.elements, localDataState?.elements,
{ repairBindings: true, refreshDimensions: false }, {
repairBindings: true,
refreshDimensions: false,
deleteEmptyTextElements: true,
},
); );
} else { } else {
data = restore(localDataState || null, null, null, { data = restore(localDataState || null, null, null, {
repairBindings: true, repairBindings: true,
deleteEmptyTextElements: true,
}); });
} }

View File

@ -1088,7 +1088,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const nextElement = nextElements.get(prevElement.id); const nextElement = nextElements.get(prevElement.id);
if (!nextElement) { if (!nextElement) {
const deleted = { ...prevElement, isDeleted: false } as ElementPartial; const deleted = { ...prevElement } as ElementPartial;
const inserted = { const inserted = {
isDeleted: true, isDeleted: true,
@ -1102,9 +1102,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
// ignore updates which would "delete" already deleted element
if (!prevElement.isDeleted) {
removed[prevElement.id] = delta; removed[prevElement.id] = delta;
} }
} }
}
for (const nextElement of nextElements.values()) { for (const nextElement of nextElements.values()) {
const prevElement = prevElements.get(nextElement.id); const prevElement = prevElements.get(nextElement.id);
@ -1118,7 +1121,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inserted = { const inserted = {
...nextElement, ...nextElement,
isDeleted: false,
} as ElementPartial; } as ElementPartial;
const delta = Delta.create( const delta = Delta.create(
@ -1127,7 +1129,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
// ignore updates which would "delete" already deleted element
if (!nextElement.isDeleted) {
added[nextElement.id] = delta; added[nextElement.id] = delta;
}
continue; continue;
} }
@ -1156,8 +1161,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
// making sure there are at least some changes const strippedDeleted = ElementsDelta.stripVersionProps(delta.deleted);
if (!Delta.isEmpty(delta)) { const strippedInserted = ElementsDelta.stripVersionProps(
delta.inserted,
);
// making sure there are at least some changes and only changed version & versionNonce does not count!
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted, true)) {
updated[nextElement.id] = delta; updated[nextElement.id] = delta;
} }
} }
@ -1273,8 +1283,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
latestDelta = delta; latestDelta = delta;
} }
const strippedDeleted = ElementsDelta.stripVersionProps(
latestDelta.deleted,
);
const strippedInserted = ElementsDelta.stripVersionProps(
latestDelta.inserted,
);
// it might happen that after applying latest changes the delta itself does not contain any changes // it might happen that after applying latest changes the delta itself does not contain any changes
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) { if (Delta.isInnerDifferent(strippedDeleted, strippedInserted)) {
modifiedDeltas[id] = latestDelta; modifiedDeltas[id] = latestDelta;
} }
} }
@ -1854,4 +1871,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return strippedPartial; return strippedPartial;
} }
private static stripVersionProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}
} }

View File

@ -1,7 +1,74 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { ObservedAppState } from "@excalidraw/excalidraw/types"; import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element"; import type { LinearElementEditor } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { AppStateDelta } from "../src/delta"; import { AppStateDelta, ElementsDelta } from "../src/delta";
describe("ElementsDelta", () => {
describe("elements delta calculation", () => {
it("should not create removed delta when element gets removed but was already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map([[element.id, element]]);
const nextElements = new Map();
const delta = ElementsDelta.calculate(prevElements, nextElements);
expect(delta.isEmpty()).toBeTruthy();
});
it("should not create added delta when adding element as already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map();
const nextElements = new Map([[element.id, element]]);
const delta = ElementsDelta.calculate(prevElements, nextElements);
expect(delta.isEmpty()).toBeTruthy();
});
it("should not create updated delta when there is only version and versionNonce change", () => {
const baseElement = API.createElement({
type: "rectangle",
x: 100,
y: 100,
strokeColor: "#000000",
backgroundColor: "#ffffff",
});
const modifiedElement = {
...baseElement,
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
};
// Create maps for the delta calculation
const prevElements = new Map([[baseElement.id, baseElement]]);
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
// Calculate the delta
const delta = ElementsDelta.calculate(
prevElements as SceneElementsMap,
nextElements as SceneElementsMap,
);
expect(delta.isEmpty()).toBeTruthy();
});
});
});
describe("AppStateDelta", () => { describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => { describe("ensure stable delta properties order", () => {

View File

@ -2342,7 +2342,10 @@ class App extends React.Component<AppProps, AppState> {
}, },
}; };
} }
const scene = restore(initialData, null, null, { repairBindings: true }); const scene = restore(initialData, null, null, {
repairBindings: true,
deleteEmptyTextElements: true,
});
scene.appState = { scene.appState = {
...scene.appState, ...scene.appState,
theme: this.props.theme || scene.appState.theme, theme: this.props.theme || scene.appState.theme,
@ -3200,7 +3203,9 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean; fitToContent?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null, undefined); const elements = restoreElements(opts.elements, null, {
deleteEmptyTextElements: true,
});
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
@ -4927,17 +4932,8 @@ class App extends React.Component<AppProps, AppState> {
}), }),
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
const isDeleted = !nextOriginalText.trim(); const isDeleted = !nextOriginalText.trim();
if (isDeleted && !isExistingElement) {
// let's just remove the element from the scene, as it's an empty just created text element
this.scene.replaceAllElements(
this.scene
.getElementsIncludingDeleted()
.filter((x) => x.id !== element.id),
);
} else {
updateElement(nextOriginalText, isDeleted); updateElement(nextOriginalText, isDeleted);
}
// select the created text element only if submitting via keyboard // select the created text element only if submitting via keyboard
// (when submitting via click it should act as signal to deselect) // (when submitting via click it should act as signal to deselect)
if (!isDeleted && viaKeyboard) { if (!isDeleted && viaKeyboard) {
@ -4961,15 +4957,16 @@ class App extends React.Component<AppProps, AppState> {
})); }));
}); });
} }
if (isDeleted) { if (isDeleted) {
fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [ fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [
element, element,
]); ]);
} }
// we need to record either way, whether the text element was added or removed if (!isDeleted || isExistingElement) {
// since we need to sync this delta to other clients, otherwise it would end up with inconsistencies
this.store.scheduleCapture(); this.store.scheduleCapture();
}
flushSync(() => { flushSync(() => {
this.setState({ this.setState({

View File

@ -170,7 +170,11 @@ export const loadSceneOrLibraryFromBlob = async (
}, },
localAppState, localAppState,
localElements, localElements,
{ repairBindings: true, refreshDimensions: false }, {
repairBindings: true,
refreshDimensions: false,
deleteEmptyTextElements: true,
},
), ),
}; };
} else if (isValidLibrary(data)) { } else if (isValidLibrary(data)) {

View File

@ -241,8 +241,9 @@ const restoreElementWithProperties = <
return ret; return ret;
}; };
const restoreElement = ( export const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
opts?: { deleteEmptyTextElements?: boolean },
): typeof element | null => { ): typeof element | null => {
element = { ...element }; element = { ...element };
@ -290,7 +291,7 @@ const restoreElement = (
// if empty text, mark as deleted. We keep in array // if empty text, mark as deleted. We keep in array
// for data integrity purposes (collab etc.) // for data integrity purposes (collab etc.)
if (!text && !element.isDeleted) { if (opts?.deleteEmptyTextElements && !text && !element.isDeleted) {
// TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded) // TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded)
element = { ...element, originalText: text, isDeleted: true }; element = { ...element, originalText: text, isDeleted: true };
element = bumpVersion(element); element = bumpVersion(element);
@ -524,7 +525,13 @@ export const restoreElements = (
elements: ImportedDataState["elements"], elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */ /** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, opts?:
| {
refreshDimensions?: boolean;
repairBindings?: boolean;
deleteEmptyTextElements?: boolean;
}
| undefined,
): OrderedExcalidrawElement[] => { ): OrderedExcalidrawElement[] => {
// used to detect duplicate top-level element ids // used to detect duplicate top-level element ids
const existingIds = new Set<string>(); const existingIds = new Set<string>();
@ -534,7 +541,12 @@ export const restoreElements = (
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained // and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) { if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
let migratedElement: ExcalidrawElement | null = restoreElement(element); let migratedElement: ExcalidrawElement | null = restoreElement(
element,
{
deleteEmptyTextElements: opts?.deleteEmptyTextElements,
},
);
if (migratedElement) { if (migratedElement) {
const localElement = localElementsMap?.get(element.id); const localElement = localElementsMap?.get(element.id);
if (localElement && localElement.version > migratedElement.version) { if (localElement && localElement.version > migratedElement.version) {
@ -791,7 +803,11 @@ export const restore = (
*/ */
localAppState: Partial<AppState> | null | undefined, localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean }, elementsConfig?: {
refreshDimensions?: boolean;
repairBindings?: boolean;
deleteEmptyTextElements?: boolean;
},
): RestoredDataState => { ): RestoredDataState => {
return { return {
elements: restoreElements(data?.elements, localElements, elementsConfig), elements: restoreElements(data?.elements, localElements, elementsConfig),

View File

@ -229,6 +229,7 @@ export { defaultLang, useI18n, languages } from "./i18n";
export { export {
restore, restore,
restoreAppState, restoreAppState,
restoreElement,
restoreElements, restoreElements,
restoreLibraryItems, restoreLibraryItems,
} from "./data/restore"; } from "./data/restore";

View File

@ -282,14 +282,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {
"id0": {
"deleted": {
"version": 17,
},
"inserted": {
"version": 15,
},
},
"id1": { "id1": {
"deleted": { "deleted": {
"boundElements": [], "boundElements": [],
@ -404,14 +396,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"version": 17, "version": 17,
}, },
}, },
"id15": {
"deleted": {
"version": 14,
},
"inserted": {
"version": 12,
},
},
"id4": { "id4": {
"deleted": { "deleted": {
"height": "99.19972", "height": "99.19972",
@ -853,14 +837,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"added": {}, "added": {},
"removed": {}, "removed": {},
"updated": { "updated": {
"id0": {
"deleted": {
"version": 18,
},
"inserted": {
"version": 16,
},
},
"id1": { "id1": {
"deleted": { "deleted": {
"boundElements": [], "boundElements": [],
@ -2656,7 +2632,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"height": 100, "height": 100,
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": true,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -2667,7 +2643,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 6, "version": 9,
"width": 100, "width": 100,
"x": 10, "x": 10,
"y": 10, "y": 10,
@ -2719,7 +2695,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id0", "containerId": null,
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@ -2744,7 +2720,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"textAlign": "left", "textAlign": "left",
"type": "text", "type": "text",
"updated": 1, "updated": 1,
"version": 7, "version": 10,
"verticalAlign": "top", "verticalAlign": "top",
"width": 30, "width": 30,
"x": 15, "x": 15,
@ -2766,15 +2742,49 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
}, },
}, },
"elements": { "elements": {
"added": {}, "added": {
"id0": {
"deleted": {
"isDeleted": true,
"version": 9,
},
"inserted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"version": 8,
"width": 100,
"x": 10,
"y": 10,
},
},
},
"removed": {}, "removed": {},
"updated": { "updated": {
"id5": { "id5": {
"deleted": { "deleted": {
"version": 7, "containerId": null,
"version": 10,
}, },
"inserted": { "inserted": {
"version": 5, "containerId": "id0",
"version": 9,
}, },
}, },
}, },
@ -3086,14 +3096,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"version": 10, "version": 10,
}, },
}, },
"id5": {
"deleted": {
"version": 11,
},
"inserted": {
"version": 9,
},
},
}, },
}, },
"id": "id9", "id": "id9",
@ -4643,15 +4645,15 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"id1": { "id1": {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"version": 5, "version": 4,
"x": 15, "x": 15,
"y": 15, "y": 15,
}, },
"inserted": { "inserted": {
"angle": 0, "angle": 90,
"version": 7, "version": 3,
"x": 15, "x": 205,
"y": 15, "y": 205,
}, },
}, },
}, },
@ -5630,12 +5632,12 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"updated": { "updated": {
"id1": { "id1": {
"deleted": { "deleted": {
"frameId": null, "frameId": "id0",
"version": 10, "version": 5,
}, },
"inserted": { "inserted": {
"frameId": null, "frameId": null,
"version": 8, "version": 6,
}, },
}, },
}, },
@ -15773,14 +15775,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 5, "version": 5,
}, },
}, },
"id1": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id2": { "id2": {
"deleted": { "deleted": {
"boundElements": [ "boundElements": [
@ -16742,14 +16736,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 5, "version": 5,
}, },
}, },
"id1": {
"deleted": {
"version": 8,
},
"inserted": {
"version": 6,
},
},
"id2": { "id2": {
"deleted": { "deleted": {
"boundElements": [ "boundElements": [
@ -17375,14 +17361,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 9, "version": 9,
}, },
}, },
"id1": {
"deleted": {
"version": 12,
},
"inserted": {
"version": 10,
},
},
"id2": { "id2": {
"deleted": { "deleted": {
"boundElements": [ "boundElements": [
@ -17744,14 +17722,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"version": 7, "version": 7,
}, },
}, },
"id2": {
"deleted": {
"version": 5,
},
"inserted": {
"version": 3,
},
},
}, },
}, },
"id": "id21", "id": "id21",

View File

@ -2216,16 +2216,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] undo
}, },
}, },
}, },
"updated": { "updated": {},
"id0": {
"deleted": {
"version": 5,
},
"inserted": {
"version": 3,
},
},
},
}, },
"id": "id6", "id": "id6",
}, },
@ -10901,32 +10892,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] undo s
}, },
}, },
}, },
"updated": { "updated": {},
"id0": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id3": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id6": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
},
}, },
"id": "id21", "id": "id21",
}, },

View File

@ -85,6 +85,23 @@ describe("restoreElements", () => {
}); });
}); });
it("should not delete empty text element when deleteEmptyTextElements is not defined", () => {
const textElement = API.createElement({
type: "text",
text: "",
isDeleted: false,
});
const restoredElements = restore.restoreElements([textElement], null);
expect(restoredElements).toEqual([
expect.objectContaining({
id: textElement.id,
isDeleted: false,
}),
]);
});
it("should restore text element correctly with unknown font family, null text and undefined alignment", () => { it("should restore text element correctly with unknown font family, null text and undefined alignment", () => {
const textElement: any = API.createElement({ const textElement: any = API.createElement({
type: "text", type: "text",
@ -97,10 +114,9 @@ describe("restoreElements", () => {
textElement.font = "10 unknown"; textElement.font = "10 unknown";
expect(textElement.isDeleted).toBe(false); expect(textElement.isDeleted).toBe(false);
const restoredText = restore.restoreElements( const restoredText = restore.restoreElements([textElement], null, {
[textElement], deleteEmptyTextElements: true,
null, })[0] as ExcalidrawTextElement;
)[0] as ExcalidrawTextElement;
expect(restoredText.isDeleted).toBe(true); expect(restoredText.isDeleted).toBe(true);
expect(restoredText).toMatchSnapshot({ expect(restoredText).toMatchSnapshot({
seed: expect.any(Number), seed: expect.any(Number),

View File

@ -4055,7 +4055,7 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
id: container.id, id: container.id,
boundElements: [{ id: remoteText.id, type: "text" }], boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false, // isDeleted got remotely updated to false isDeleted: true,
}), }),
expect.objectContaining({ expect.objectContaining({
id: text.id, id: text.id,
@ -4065,7 +4065,7 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
id: remoteText.id, id: remoteText.id,
// unbound // unbound
containerId: container.id, containerId: null,
isDeleted: false, isDeleted: false,
}), }),
]); ]);

View File

@ -704,7 +704,7 @@ describe("textWysiwyg", () => {
rectangle.x + rectangle.width / 2, rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
); );
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(3);
text = h.elements[1] as ExcalidrawTextElementWithContainer; text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text"); expect(text.type).toBe("text");
@ -1198,7 +1198,11 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, " "); updateTextEditor(editor, " ");
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
expect(rectangle.boundElements).toStrictEqual([]); expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1]).toBeUndefined(); expect(h.elements[1]).toEqual(
expect.objectContaining({
isDeleted: true,
}),
);
}); });
it("should restore original container height and clear cache once text is unbind", async () => { it("should restore original container height and clear cache once text is unbind", async () => {

View File

@ -49,6 +49,7 @@ export const exportToCanvas = ({
{ elements, appState }, { elements, appState },
null, null,
null, null,
{ deleteEmptyTextElements: true },
); );
const { exportBackground, viewBackgroundColor } = restoredAppState; const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas( return _exportToCanvas(
@ -179,6 +180,7 @@ export const exportToSvg = async ({
{ elements, appState }, { elements, appState },
null, null,
null, null,
{ deleteEmptyTextElements: true },
); );
const exportAppState = { const exportAppState = {