fix: utils leaking Scene state (#6461

* fix: utils leaking Scene state

* remove debug

* doc

* add tests for group duplicating

* tweaks
This commit is contained in:
David Luzar 2023-04-16 11:56:25 +02:00 committed by GitHub
parent b0b23353cf
commit 399c92d882
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 604 additions and 116 deletions

View File

@ -1,8 +1,9 @@
import { duplicateElement } from "./newElement"; import { duplicateElement, duplicateElements } from "./newElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { FONT_FAMILY, ROUNDNESS } from "../constants"; import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils"; import { isPrimitive } from "../utils";
import { ExcalidrawLinearElement } from "./types";
const assertCloneObjects = (source: any, clone: any) => { const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) { for (const key in clone) {
@ -15,79 +16,353 @@ const assertCloneObjects = (source: any, clone: any) => {
} }
}; };
it("clones arrow element", () => { describe("duplicating single elements", () => {
const element = API.createElement({ it("clones arrow element", () => {
type: "arrow", const element = API.createElement({
x: 0, type: "arrow",
y: 0, x: 0,
strokeColor: "#000000", y: 0,
backgroundColor: "transparent", strokeColor: "#000000",
fillStyle: "hachure", backgroundColor: "transparent",
strokeWidth: 1, fillStyle: "hachure",
strokeStyle: "solid", strokeWidth: 1,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, strokeStyle: "solid",
roughness: 1, roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
opacity: 100, roughness: 1,
opacity: 100,
});
// @ts-ignore
element.__proto__ = { hello: "world" };
mutateElement(element, {
points: [
[1, 2],
[3, 4],
],
});
const copy = duplicateElement(null, new Map(), element);
assertCloneObjects(element, copy);
// assert we clone the object's prototype
// @ts-ignore
expect(copy.__proto__).toEqual({ hello: "world" });
expect(copy.hasOwnProperty("hello")).toBe(false);
expect(copy.points).not.toBe(element.points);
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");
expect(copy.seed).not.toBe(element.seed);
expect(typeof copy.seed).toBe("number");
expect(copy).toEqual({
...element,
id: copy.id,
seed: copy.seed,
});
}); });
// @ts-ignore it("clones text element", () => {
element.__proto__ = { hello: "world" }; const element = API.createElement({
type: "text",
x: 0,
y: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: null,
roughness: 1,
opacity: 100,
text: "hello",
fontSize: 20,
fontFamily: FONT_FAMILY.Virgil,
textAlign: "left",
verticalAlign: "top",
});
mutateElement(element, { const copy = duplicateElement(null, new Map(), element);
points: [
[1, 2],
[3, 4],
],
});
const copy = duplicateElement(null, new Map(), element); assertCloneObjects(element, copy);
assertCloneObjects(element, copy); expect(copy).not.toHaveProperty("points");
expect(copy).not.toHaveProperty("shape");
// @ts-ignore expect(copy.id).not.toBe(element.id);
expect(copy.__proto__).toEqual({ hello: "world" }); expect(typeof copy.id).toBe("string");
expect(copy.hasOwnProperty("hello")).toBe(false); expect(typeof copy.seed).toBe("number");
expect(copy.points).not.toBe(element.points);
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");
expect(copy.seed).not.toBe(element.seed);
expect(typeof copy.seed).toBe("number");
expect(copy).toEqual({
...element,
id: copy.id,
seed: copy.seed,
}); });
}); });
it("clones text element", () => { describe("duplicating multiple elements", () => {
const element = API.createElement({ it("duplicateElements should clone bindings", () => {
type: "text", const rectangle1 = API.createElement({
x: 0, type: "rectangle",
y: 0, id: "rectangle1",
strokeColor: "#000000", boundElements: [
backgroundColor: "transparent", { id: "arrow1", type: "arrow" },
fillStyle: "hachure", { id: "arrow2", type: "arrow" },
strokeWidth: 1, { id: "text1", type: "text" },
strokeStyle: "solid", ],
roundness: null, });
roughness: 1,
opacity: 100, const text1 = API.createElement({
text: "hello", type: "text",
fontSize: 20, id: "text1",
fontFamily: FONT_FAMILY.Virgil, containerId: "rectangle1",
textAlign: "left", });
verticalAlign: "top",
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
boundElements: [{ id: "text2", type: "text" }],
});
const text2 = API.createElement({
type: "text",
id: "text2",
containerId: "arrow2",
});
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
const clonedElements = duplicateElements(origElements);
// generic id in-equality checks
// --------------------------------------------------------------------------
expect(origElements.map((e) => e.type)).toEqual(
clonedElements.map((e) => e.type),
);
origElements.forEach((origElement, idx) => {
const clonedElement = clonedElements[idx];
expect(origElement).toEqual(
expect.objectContaining({
id: expect.not.stringMatching(clonedElement.id),
type: clonedElement.type,
}),
);
if ("containerId" in origElement) {
expect(origElement.containerId).not.toBe(
(clonedElement as any).containerId,
);
}
if ("endBinding" in origElement) {
if (origElement.endBinding) {
expect(origElement.endBinding.elementId).not.toBe(
(clonedElement as any).endBinding?.elementId,
);
} else {
expect((clonedElement as any).endBinding).toBeNull();
}
}
if ("startBinding" in origElement) {
if (origElement.startBinding) {
expect(origElement.startBinding.elementId).not.toBe(
(clonedElement as any).startBinding?.elementId,
);
} else {
expect((clonedElement as any).startBinding).toBeNull();
}
}
});
// --------------------------------------------------------------------------
const clonedArrows = clonedElements.filter(
(e) => e.type === "arrow",
) as ExcalidrawLinearElement[];
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
clonedElements as any as typeof origElements;
expect(clonedText1.containerId).toBe(clonedRectangle.id);
expect(
clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
).toEqual(
expect.objectContaining({
id: clonedText1.id,
type: clonedText1.type,
}),
);
clonedArrows.forEach((arrow) => {
// console.log(arrow);
expect(
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
).toEqual(
expect.objectContaining({
id: arrow.id,
type: arrow.type,
}),
);
if (arrow.endBinding) {
expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
}
if (arrow.startBinding) {
expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
}
});
expect(clonedArrow2.boundElements).toEqual([
{ type: "text", id: clonedArrowLabel.id },
]);
expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
}); });
const copy = duplicateElement(null, new Map(), element); it("should remove id references of elements that aren't found", () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
boundElements: [
// should keep
{ id: "arrow1", type: "arrow" },
// should drop
{ id: "arrow-not-exists", type: "arrow" },
// should drop
{ id: "text-not-exists", type: "text" },
],
});
assertCloneObjects(element, copy); const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
expect(copy).not.toHaveProperty("points"); const text1 = API.createElement({
expect(copy).not.toHaveProperty("shape"); type: "text",
expect(copy.id).not.toBe(element.id); id: "text1",
expect(typeof copy.id).toBe("string"); containerId: "rectangle-not-exists",
expect(typeof copy.seed).toBe("number"); });
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
endBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
},
});
const arrow3 = API.createElement({
type: "arrow",
id: "arrow2",
startBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const clonedElements = duplicateElements(
origElements,
) as any as typeof origElements;
const [
clonedRectangle,
clonedText1,
clonedArrow1,
clonedArrow2,
clonedArrow3,
] = clonedElements;
expect(clonedRectangle.boundElements).toEqual([
{ id: clonedArrow1.id, type: "arrow" },
]);
expect(clonedText1.containerId).toBe(null);
expect(clonedArrow2.startBinding).toEqual({
...arrow2.startBinding,
elementId: clonedRectangle.id,
});
expect(clonedArrow2.endBinding).toBe(null);
expect(clonedArrow3.startBinding).toBe(null);
expect(clonedArrow3.endBinding).toEqual({
...arrow3.endBinding,
elementId: clonedRectangle.id,
});
});
describe("should duplicate all group ids", () => {
it("should regenerate all group ids and keep them consistent across elements", () => {
const rectangle1 = API.createElement({
type: "rectangle",
groupIds: ["g1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
groupIds: ["g2", "g1"],
});
const rectangle3 = API.createElement({
type: "rectangle",
groupIds: ["g2", "g1"],
});
const origElements = [rectangle1, rectangle2, rectangle3] as const;
const clonedElements = duplicateElements(
origElements,
) as any as typeof origElements;
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
clonedElements;
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
});
it("should keep and regenerate ids of groups even if invalid", () => {
// lone element shouldn't be able to be grouped with itself,
// but hard to check against in a performant way so we ignore it
const rectangle1 = API.createElement({
type: "rectangle",
groupIds: ["g1"],
});
const [clonedRectangle1] = duplicateElements([rectangle1]);
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
});
});
}); });

View File

@ -13,7 +13,12 @@ import {
FontFamilyValues, FontFamilyValues,
ExcalidrawTextContainer, ExcalidrawTextContainer,
} from "../element/types"; } from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils"; import {
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { mutateElement, newElementWith } from "./mutateElement"; import { mutateElement, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
@ -357,16 +362,24 @@ export const newImageElement = (
}; };
}; };
// Simplified deep clone for the purpose of cloning ExcalidrawElement only // Simplified deep clone for the purpose of cloning ExcalidrawElement.
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.) //
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
// Typed arrays and other non-null objects.
// //
// Adapted from https://github.com/lukeed/klona // Adapted from https://github.com/lukeed/klona
export const deepCopyElement = (val: any, depth: number = 0) => { //
// The reason for `deepCopyElement()` wrapper is type safety (only allow
// passing ExcalidrawElement as the top-level argument).
const _deepCopyElement = (val: any, depth: number = 0) => {
// only clone non-primitives
if (val == null || typeof val !== "object") { if (val == null || typeof val !== "object") {
return val; return val;
} }
if (Object.prototype.toString.call(val) === "[object Object]") { const objectType = Object.prototype.toString.call(val);
if (objectType === "[object Object]") {
const tmp = const tmp =
typeof val.constructor === "function" typeof val.constructor === "function"
? Object.create(Object.getPrototypeOf(val)) ? Object.create(Object.getPrototypeOf(val))
@ -378,7 +391,7 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
if (depth === 0 && (key === "shape" || key === "canvas")) { if (depth === 0 && (key === "shape" || key === "canvas")) {
continue; continue;
} }
tmp[key] = deepCopyElement(val[key], depth + 1); tmp[key] = _deepCopyElement(val[key], depth + 1);
} }
} }
return tmp; return tmp;
@ -388,14 +401,44 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
let k = val.length; let k = val.length;
const arr = new Array(k); const arr = new Array(k);
while (k--) { while (k--) {
arr[k] = deepCopyElement(val[k], depth + 1); arr[k] = _deepCopyElement(val[k], depth + 1);
} }
return arr; return arr;
} }
// we're not cloning non-array & non-plain-object objects because we
// don't support them on excalidraw elements yet. If we do, we need to make
// sure we start cloning them, so let's warn about it.
if (process.env.NODE_ENV === "development") {
if (
objectType !== "[object Object]" &&
objectType !== "[object Array]" &&
objectType.startsWith("[object ")
) {
console.warn(
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
);
}
}
return val; return val;
}; };
/**
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
* any value. The purpose is to to break object references for immutability
* reasons, whenever we want to keep the original element, but ensure it's not
* mutated.
*
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
* Typed arrays and other non-null objects.
*/
export const deepCopyElement = <T extends ExcalidrawElement>(
val: T,
): Mutable<T> => {
return _deepCopyElement(val);
};
/** /**
* Duplicate an element, often used in the alt-drag operation. * Duplicate an element, often used in the alt-drag operation.
* Note that this method has gotten a bit complicated since the * Note that this method has gotten a bit complicated since the
@ -410,13 +453,13 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
* @param element Element to duplicate * @param element Element to duplicate
* @param overrides Any element properties to override * @param overrides Any element properties to override
*/ */
export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>( export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId: AppState["editingGroupId"], editingGroupId: AppState["editingGroupId"],
groupIdMapForOperation: Map<GroupId, GroupId>, groupIdMapForOperation: Map<GroupId, GroupId>,
element: TElement, element: TElement,
overrides?: Partial<TElement>, overrides?: Partial<TElement>,
): TElement => { ): Readonly<TElement> => {
let copy: TElement = deepCopyElement(element); let copy = deepCopyElement(element);
if (isTestEnv()) { if (isTestEnv()) {
copy.id = `${copy.id}_copy`; copy.id = `${copy.id}_copy`;
@ -449,3 +492,102 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
} }
return copy; return copy;
}; };
/**
* Clones elements, regenerating their ids (including bindings) and group ids.
*
* If bindings don't exist in the elements array, they are removed. Therefore,
* it's advised to supply the whole elements array, or sets of elements that
* are encapsulated (such as library items), if the purpose is to retain
* bindings to the cloned elements intact.
*/
export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
const clonedElements: ExcalidrawElement[] = [];
const origElementsMap = arrayToMap(elements);
// used for for migrating old ids to new ids
const elementNewIdsMap = new Map<
/* orig */ ExcalidrawElement["id"],
/* new */ ExcalidrawElement["id"]
>();
const maybeGetNewId = (id: ExcalidrawElement["id"]) => {
// if we've already migrated the element id, return the new one directly
if (elementNewIdsMap.has(id)) {
return elementNewIdsMap.get(id)!;
}
// if we haven't migrated the element id, but an old element with the same
// id exists, generate a new id for it and return it
if (origElementsMap.has(id)) {
const newId = randomId();
elementNewIdsMap.set(id, newId);
return newId;
}
// if old element doesn't exist, return null to mark it for removal
return null;
};
const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>();
for (const element of elements) {
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
clonedElement.id = maybeGetNewId(element.id)!;
if (clonedElement.groupIds) {
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
if (!groupNewIdsMap.has(groupId)) {
groupNewIdsMap.set(groupId, randomId());
}
return groupNewIdsMap.get(groupId)!;
});
}
if ("containerId" in clonedElement && clonedElement.containerId) {
const newContainerId = maybeGetNewId(clonedElement.containerId);
clonedElement.containerId = newContainerId;
}
if ("boundElements" in clonedElement && clonedElement.boundElements) {
clonedElement.boundElements = clonedElement.boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const newBindingId = maybeGetNewId(binding.id);
if (newBindingId) {
acc.push({ ...binding, id: newBindingId });
}
return acc;
},
[],
);
}
if ("endBinding" in clonedElement && clonedElement.endBinding) {
const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId);
clonedElement.endBinding = newEndBindingId
? {
...clonedElement.endBinding,
elementId: newEndBindingId,
}
: null;
}
if ("startBinding" in clonedElement && clonedElement.startBinding) {
const newEndBindingId = maybeGetNewId(
clonedElement.startBinding.elementId,
);
clonedElement.startBinding = newEndBindingId
? {
...clonedElement.startBinding,
elementId: newEndBindingId,
}
: null;
}
clonedElements.push(clonedElement);
}
return clonedElements;
};

View File

@ -15,6 +15,23 @@ import {
copyToClipboard, copyToClipboard,
} from "../clipboard"; } from "../clipboard";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { duplicateElements } from "../element/newElement";
// getContainerElement and getBoundTextElement and potentially other helpers
// depend on `Scene` which will not be available when these pure utils are
// called outside initialized Excalidraw editor instance or even if called
// from inside Excalidraw if the elements were never cached by Scene (e.g.
// for library elements).
//
// As such, before passing the elements down, we need to initialize a custom
// Scene instance and assign them to it.
//
// FIXME This is a super hacky workaround and we'll need to rewrite this soon.
const passElementsSafely = (elements: readonly ExcalidrawElement[]) => {
const scene = new Scene();
scene.replaceAllElements(duplicateElements(elements));
return scene.getNonDeletedElements();
};
export { MIME_TYPES }; export { MIME_TYPES };
@ -44,17 +61,9 @@ export const exportToCanvas = ({
null, null,
null, null,
); );
// The helper methods getContainerElement and getBoundTextElement are
// dependent on Scene which will not be available
// when these pure utils are called outside Excalidraw or even if called
// from inside Excalidraw when Scene isn't available eg when using library items from store, as a result the element cannot be extracted
// hence initailizing a new scene with the elements
// so its always available to helper methods
const scene = new Scene();
scene.replaceAllElements(restoredElements);
const { exportBackground, viewBackgroundColor } = restoredAppState; const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas( return _exportToCanvas(
scene.getNonDeletedElements(), passElementsSafely(restoredElements),
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 }, { ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
files || {}, files || {},
{ exportBackground, exportPadding, viewBackgroundColor }, { exportBackground, exportPadding, viewBackgroundColor },
@ -122,17 +131,9 @@ export const exportToBlob = async (
}; };
} }
// The helper methods getContainerElement and getBoundTextElement are
// dependent on Scene which will not be available
// when these pure utils are called outside Excalidraw or even if called
// from inside Excalidraw when Scene isn't available eg when using library items from store, as a result the element cannot be extracted
// hence initailizing a new scene with the elements
// so its always available to helper methods
const scene = new Scene();
scene.replaceAllElements(opts.elements);
const canvas = await exportToCanvas({ const canvas = await exportToCanvas({
...opts, ...opts,
elements: scene.getNonDeletedElements(), elements: passElementsSafely(opts.elements),
}); });
quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8; quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
@ -150,7 +151,10 @@ export const exportToBlob = async (
blob = await encodePngMetadata({ blob = await encodePngMetadata({
blob, blob,
metadata: serializeAsJSON( metadata: serializeAsJSON(
scene.getNonDeletedElements(), // NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable
opts.elements,
opts.appState, opts.appState,
opts.files || {}, opts.files || {},
"local", "local",
@ -178,21 +182,24 @@ export const exportToSvg = async ({
null, null,
null, null,
); );
// The helper methods getContainerElement and getBoundTextElement are
// dependent on Scene which will not be available const exportAppState = {
// when these pure utils are called outside Excalidraw or even if called ...restoredAppState,
// from inside Excalidraw when Scene isn't available eg when using library items from store, as a result the element cannot be extracted exportPadding,
// hence initailizing a new scene with the elements };
// so its always available to helper methods
const scene = new Scene();
scene.replaceAllElements(restoredElements);
return _exportToSvg( return _exportToSvg(
scene.getNonDeletedElements(), passElementsSafely(restoredElements),
{ exportAppState,
...restoredAppState,
exportPadding,
},
files, files,
{
// NOTE as long as we're using the Scene hack, we need to ensure
// we pass the original, uncloned elements when serializing
// so that we keep ids stable. Hence adding the serializeAsJSON helper
// support into the downstream exportToSvg function.
serializeAsJSON: () =>
serializeAsJSON(restoredElements, exportAppState, files || {}, "local"),
},
); );
}; };
@ -203,14 +210,6 @@ export const exportToClipboard = async (
type: "png" | "svg" | "json"; type: "png" | "svg" | "json";
}, },
) => { ) => {
// The helper methods getContainerElement and getBoundTextElement are
// dependent on Scene which will not be available
// when these pure utils are called outside Excalidraw or even if called
// from inside Excalidraw when Scene isn't available eg when using library items from store, as a result the element cannot be extracted
// hence initailizing a new scene with the elements
// so its always available to helper methods
const scene = new Scene();
scene.replaceAllElements(opts.elements);
if (opts.type === "svg") { if (opts.type === "svg") {
const svg = await exportToSvg(opts); const svg = await exportToSvg(opts);
await copyTextToSystemClipboard(svg.outerHTML); await copyTextToSystemClipboard(svg.outerHTML);
@ -225,7 +224,7 @@ export const exportToClipboard = async (
...getDefaultAppState(), ...getDefaultAppState(),
...opts.appState, ...opts.appState,
}; };
await copyToClipboard(scene.getNonDeletedElements(), appState, opts.files); await copyToClipboard(opts.elements, appState, opts.files);
} else { } else {
throw new Error("Invalid export type"); throw new Error("Invalid export type");
} }

View File

@ -90,6 +90,9 @@ export const exportToSvg = async (
exportEmbedScene?: boolean; exportEmbedScene?: boolean;
}, },
files: BinaryFiles | null, files: BinaryFiles | null,
opts?: {
serializeAsJSON?: () => string;
},
): Promise<SVGSVGElement> => { ): Promise<SVGSVGElement> => {
const { const {
exportPadding = DEFAULT_EXPORT_PADDING, exportPadding = DEFAULT_EXPORT_PADDING,
@ -103,7 +106,9 @@ export const exportToSvg = async (
metadata = await ( metadata = await (
await import(/* webpackChunkName: "image" */ "../../src/data/image") await import(/* webpackChunkName: "image" */ "../../src/data/image")
).encodeSvgMetadata({ ).encodeSvgMetadata({
text: serializeAsJSON(elements, appState, files || {}, "local"), text: opts?.serializeAsJSON
? opts?.serializeAsJSON?.()
: serializeAsJSON(elements, appState, files || {}, "local"),
}); });
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);

View File

@ -0,0 +1,67 @@
import { decodePngMetadata, decodeSvgMetadata } from "../../data/image";
import { ImportedDataState } from "../../data/types";
import * as utils from "../../packages/utils";
import { API } from "../helpers/api";
// NOTE this test file is using the actual API, unmocked. Hence splitting it
// from the other test file, because I couldn't figure out how to test
// mocked and unmocked API in the same file.
describe("embedding scene data", () => {
describe("exportToSvg", () => {
it("embedding scene data shouldn't modify them", async () => {
const rectangle = API.createElement({ type: "rectangle" });
const ellipse = API.createElement({ type: "ellipse" });
const sourceElements = [rectangle, ellipse];
const svgNode = await utils.exportToSvg({
elements: sourceElements,
appState: {
viewBackgroundColor: "#ffffff",
gridSize: null,
exportEmbedScene: true,
},
files: null,
});
const svg = svgNode.outerHTML;
const parsedString = await decodeSvgMetadata({ svg });
const importedData: ImportedDataState = JSON.parse(parsedString);
expect(sourceElements.map((x) => x.id)).toEqual(
importedData.elements?.map((el) => el.id),
);
});
});
// skipped because we can't test png encoding right now
// (canvas.toBlob not supported in jsdom)
describe.skip("exportToBlob", () => {
it("embedding scene data shouldn't modify them", async () => {
const rectangle = API.createElement({ type: "rectangle" });
const ellipse = API.createElement({ type: "ellipse" });
const sourceElements = [rectangle, ellipse];
const blob = await utils.exportToBlob({
mimeType: "image/png",
elements: sourceElements,
appState: {
viewBackgroundColor: "#ffffff",
gridSize: null,
exportEmbedScene: true,
},
files: null,
});
const parsedString = await decodePngMetadata(blob);
const importedData: ImportedDataState = JSON.parse(parsedString);
expect(sourceElements.map((x) => x.id)).toEqual(
importedData.elements?.map((el) => el.id),
);
});
});
});