aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/data
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/data')
-rw-r--r--packages/excalidraw/data/EditorLocalStorage.ts51
-rw-r--r--packages/excalidraw/data/__snapshots__/transform.test.ts.snap2691
-rw-r--r--packages/excalidraw/data/ai/types.ts300
-rw-r--r--packages/excalidraw/data/blob.ts496
-rw-r--r--packages/excalidraw/data/encode.ts413
-rw-r--r--packages/excalidraw/data/encryption.ts93
-rw-r--r--packages/excalidraw/data/filesystem.ts104
-rw-r--r--packages/excalidraw/data/image.ts69
-rw-r--r--packages/excalidraw/data/index.ts202
-rw-r--r--packages/excalidraw/data/json.ts156
-rw-r--r--packages/excalidraw/data/library.test.ts105
-rw-r--r--packages/excalidraw/data/library.ts978
-rw-r--r--packages/excalidraw/data/reconcile.ts118
-rw-r--r--packages/excalidraw/data/resave.ts41
-rw-r--r--packages/excalidraw/data/restore.ts813
-rw-r--r--packages/excalidraw/data/transform.test.ts970
-rw-r--r--packages/excalidraw/data/transform.ts791
-rw-r--r--packages/excalidraw/data/types.ts59
-rw-r--r--packages/excalidraw/data/url.test.tsx31
-rw-r--r--packages/excalidraw/data/url.ts36
20 files changed, 8517 insertions, 0 deletions
diff --git a/packages/excalidraw/data/EditorLocalStorage.ts b/packages/excalidraw/data/EditorLocalStorage.ts
new file mode 100644
index 0000000..bb6eeb4
--- /dev/null
+++ b/packages/excalidraw/data/EditorLocalStorage.ts
@@ -0,0 +1,51 @@
+import type { EDITOR_LS_KEYS } from "../constants";
+import type { JSONValue } from "../types";
+
+export class EditorLocalStorage {
+ static has(key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS]) {
+ try {
+ return !!window.localStorage.getItem(key);
+ } catch (error: any) {
+ console.warn(`localStorage.getItem error: ${error.message}`);
+ return false;
+ }
+ }
+
+ static get<T extends JSONValue>(
+ key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
+ ) {
+ try {
+ const value = window.localStorage.getItem(key);
+ if (value) {
+ return JSON.parse(value) as T;
+ }
+ return null;
+ } catch (error: any) {
+ console.warn(`localStorage.getItem error: ${error.message}`);
+ return null;
+ }
+ }
+
+ static set = (
+ key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
+ value: JSONValue,
+ ) => {
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ return true;
+ } catch (error: any) {
+ console.warn(`localStorage.setItem error: ${error.message}`);
+ return false;
+ }
+ };
+
+ static delete = (
+ name: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
+ ) => {
+ try {
+ window.localStorage.removeItem(name);
+ } catch (error: any) {
+ console.warn(`localStorage.removeItem error: ${error.message}`);
+ }
+ };
+}
diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap
new file mode 100644
index 0000000..917f3d9
--- /dev/null
+++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap
@@ -0,0 +1,2691 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 1`] = `
+{
+ "angle": 0,
+ "backgroundColor": "#d8f5a2",
+ "boundElements": [
+ {
+ "id": "id47",
+ "type": "arrow",
+ },
+ {
+ "id": "id48",
+ "type": "arrow",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 300,
+ "id": Any<String>,
+ "index": "a0",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#66a80f",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "ellipse",
+ "updated": 1,
+ "version": 4,
+ "versionNonce": Any<Number>,
+ "width": 300,
+ "x": 630,
+ "y": 316,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 2`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id48",
+ "type": "arrow",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 100,
+ "id": Any<String>,
+ "index": "a1",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#9c36b5",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "diamond",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 140,
+ "x": 96,
+ "y": 400,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 3`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": {
+ "elementId": "ellipse-1",
+ "focus": -0.007519379844961235,
+ "gap": 11.562288374879595,
+ },
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 35,
+ "id": Any<String>,
+ "index": "a2",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0.5,
+ ],
+ [
+ 394.5,
+ 34.5,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": {
+ "elementId": "id49",
+ "focus": -0.0813953488372095,
+ "gap": 1,
+ },
+ "strokeColor": "#1864ab",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 4,
+ "versionNonce": Any<Number>,
+ "width": 395,
+ "x": 247,
+ "y": 420,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 4`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": {
+ "elementId": "ellipse-1",
+ "focus": 0.10666666666666667,
+ "gap": 3.8343264684446097,
+ },
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a3",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 399.5,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": {
+ "elementId": "diamond-1",
+ "focus": 0,
+ "gap": 4.545343408287929,
+ },
+ "strokeColor": "#e67700",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 4,
+ "versionNonce": Any<Number>,
+ "width": 400,
+ "x": 227,
+ "y": 450,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 5`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id47",
+ "type": "arrow",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 300,
+ "id": Any<String>,
+ "index": "a4",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 300,
+ "x": -53,
+ "y": 270,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id50",
+ "type": "arrow",
+ },
+ ],
+ "containerId": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a0",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "HEYYYYY",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#c2255c",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "HEYYYYY",
+ "textAlign": "left",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "top",
+ "width": 70,
+ "x": 185,
+ "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id50",
+ "type": "arrow",
+ },
+ ],
+ "containerId": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a1",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "Whats up ?",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "Whats up ?",
+ "textAlign": "left",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "top",
+ "width": 100,
+ "x": 560,
+ "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 3`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id51",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": {
+ "elementId": "text-2",
+ "focus": 0,
+ "gap": 14,
+ },
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a2",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 99.5,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": {
+ "elementId": "text-1",
+ "focus": 0,
+ "gap": 1,
+ },
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 4,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 255,
+ "y": 239,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id50",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a3",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "HELLO WORLD!!",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "HELLO WORLD!!",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 130,
+ "x": 240,
+ "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 1`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id40",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": {
+ "elementId": "id42",
+ "focus": -0,
+ "gap": 1,
+ },
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a0",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 99.5,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": {
+ "elementId": "id41",
+ "focus": 0,
+ "gap": 1,
+ },
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 4,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 255,
+ "y": 239,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id39",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a1",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "HELLO WORLD!!",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "HELLO WORLD!!",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 130,
+ "x": 240,
+ "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 3`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id39",
+ "type": "arrow",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 100,
+ "id": Any<String>,
+ "index": "a2",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 155,
+ "y": 189,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 4`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id39",
+ "type": "arrow",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 100,
+ "id": Any<String>,
+ "index": "a3",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "ellipse",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 355,
+ "y": 189,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 1`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id44",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": {
+ "elementId": "id46",
+ "focus": -0,
+ "gap": 1,
+ },
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a0",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 99.5,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": {
+ "elementId": "id45",
+ "focus": 0,
+ "gap": 1,
+ },
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 4,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 255,
+ "y": 239,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id43",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a1",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "HELLO WORLD!!",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "HELLO WORLD!!",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 130,
+ "x": 240,
+ "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id43",
+ "type": "arrow",
+ },
+ ],
+ "containerId": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a2",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "HEYYYYY",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "HEYYYYY",
+ "textAlign": "left",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "top",
+ "width": 70,
+ "x": 185,
+ "y": 226.5,
+}
+`;
+
+exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id43",
+ "type": "arrow",
+ },
+ ],
+ "containerId": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a3",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "WHATS UP ?",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "WHATS UP ?",
+ "textAlign": "left",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "top",
+ "width": 100,
+ "x": 355,
+ "y": 226.5,
+}
+`;
+
+exports[`Test Transform > should not allow duplicate ids 1`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 200,
+ "id": "rect-1",
+ "index": "a0",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 300,
+ "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform linear elements 1`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": null,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a0",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 99.5,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": null,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 100,
+ "y": 20,
+}
+`;
+
+exports[`Test Transform > should transform linear elements 2`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "triangle",
+ "endBinding": null,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a1",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 99.5,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": "dot",
+ "startBinding": null,
+ "strokeColor": "#1971c2",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 450,
+ "y": 20,
+}
+`;
+
+exports[`Test Transform > should transform linear elements 3`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "customData": undefined,
+ "endArrowhead": null,
+ "endBinding": null,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a2",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0,
+ 0,
+ ],
+ [
+ 100,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": null,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "line",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 100,
+ "y": 60,
+}
+`;
+
+exports[`Test Transform > should transform linear elements 4`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "customData": undefined,
+ "endArrowhead": null,
+ "endBinding": null,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a3",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0,
+ 0,
+ ],
+ [
+ 100,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": null,
+ "strokeColor": "#2f9e44",
+ "strokeStyle": "dotted",
+ "strokeWidth": 2,
+ "type": "line",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 450,
+ "y": 60,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 1`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 100,
+ "id": Any<String>,
+ "index": "a0",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 100,
+ "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 2`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 100,
+ "id": Any<String>,
+ "index": "a1",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "ellipse",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 100,
+ "y": 250,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 3`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 100,
+ "id": Any<String>,
+ "index": "a2",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "diamond",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 100,
+ "y": 400,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 4`] = `
+{
+ "angle": 0,
+ "backgroundColor": "#c0eb75",
+ "boundElements": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 100,
+ "id": Any<String>,
+ "index": "a3",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 200,
+ "x": 300,
+ "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 5`] = `
+{
+ "angle": 0,
+ "backgroundColor": "#ffc9c9",
+ "boundElements": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 100,
+ "id": Any<String>,
+ "index": "a4",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "dotted",
+ "strokeWidth": 2,
+ "type": "ellipse",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 200,
+ "x": 300,
+ "y": 250,
+}
+`;
+
+exports[`Test Transform > should transform regular shapes 6`] = `
+{
+ "angle": 0,
+ "backgroundColor": "#a5d8ff",
+ "boundElements": null,
+ "customData": undefined,
+ "fillStyle": "cross-hatch",
+ "frameId": null,
+ "groupIds": [],
+ "height": 100,
+ "id": Any<String>,
+ "index": "a5",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1971c2",
+ "strokeStyle": "dashed",
+ "strokeWidth": 2,
+ "type": "diamond",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 200,
+ "x": 300,
+ "y": 400,
+}
+`;
+
+exports[`Test Transform > should transform text element 1`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a0",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "HELLO WORLD!",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "HELLO WORLD!",
+ "textAlign": "left",
+ "type": "text",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "top",
+ "width": 120,
+ "x": 100,
+ "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform text element 2`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": null,
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a1",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "STYLED HELLO WORLD!",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#5f3dc4",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "STYLED HELLO WORLD!",
+ "textAlign": "left",
+ "type": "text",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "top",
+ "width": 190,
+ "x": 100,
+ "y": 150,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 1`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id56",
+ "type": "text",
+ },
+ {
+ "id": "Bob_B",
+ "type": "arrow",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [
+ "subgraph_group_B",
+ ],
+ "height": 163,
+ "id": Any<String>,
+ "index": "a0",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 166.03125,
+ "x": 0,
+ "y": 0,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 2`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id57",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [
+ "subgraph_group_A",
+ ],
+ "height": 114,
+ "id": Any<String>,
+ "index": "a1",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 120.265625,
+ "x": 364.546875,
+ "y": 0,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 3`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id58",
+ "type": "text",
+ },
+ {
+ "id": "Bob_Alice",
+ "type": "arrow",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [
+ "subgraph_group_A",
+ ],
+ "height": 44,
+ "id": Any<String>,
+ "index": "a2",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 70.265625,
+ "x": 389.546875,
+ "y": 35,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 4`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id59",
+ "type": "text",
+ },
+ {
+ "id": "Bob_Alice",
+ "type": "arrow",
+ },
+ {
+ "id": "Bob_B",
+ "type": "arrow",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [
+ "subgraph_group_B",
+ ],
+ "height": 44,
+ "id": Any<String>,
+ "index": "a3",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 4,
+ "versionNonce": Any<Number>,
+ "width": 56.4921875,
+ "x": 54.76953125,
+ "y": 35,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 5`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id60",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": {
+ "elementId": "Alice",
+ "focus": -0,
+ "gap": 5.299874999999986,
+ },
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a4",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 272.485,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": {
+ "type": 2,
+ },
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": {
+ "elementId": "Bob",
+ "focus": 0,
+ "gap": 1,
+ },
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 4,
+ "versionNonce": Any<Number>,
+ "width": 272.985,
+ "x": 111.262,
+ "y": 57,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 6`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id61",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": {
+ "elementId": "B",
+ "focus": 0,
+ "gap": 14,
+ },
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a5",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": {
+ "type": 2,
+ },
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": {
+ "elementId": "Bob",
+ "focus": 0,
+ "gap": 1,
+ },
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 4,
+ "versionNonce": Any<Number>,
+ "width": 0,
+ "x": 77.017,
+ "y": 79,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "B",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [
+ "subgraph_group_B",
+ ],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a6",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "B",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "B",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "top",
+ "width": 10,
+ "x": 78.015625,
+ "y": 5,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "A",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [
+ "subgraph_group_A",
+ ],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a7",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "A",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "A",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "top",
+ "width": 10,
+ "x": 419.6796875,
+ "y": 5,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "Alice",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [
+ "subgraph_group_A",
+ ],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a8",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "Alice",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "Alice",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 50,
+ "x": 399.6796875,
+ "y": 44.5,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "Bob",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [
+ "subgraph_group_B",
+ ],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a9",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "Bob",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "Bob",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 30,
+ "x": 68.015625,
+ "y": 44.5,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "Bob_Alice",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "aA",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "How are you?",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "How are you?",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 120,
+ "x": 187.7545,
+ "y": 44.5,
+}
+`;
+
+exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "Bob_B",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "aB",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "Friendship",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "Friendship",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 100,
+ "x": 27.016999999999996,
+ "y": 66.5,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 1`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id29",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": null,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a0",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 99.5,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": null,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 100,
+ "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 2`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id30",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": null,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a1",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 99.5,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": null,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 100,
+ "y": 200,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 3`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id31",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": null,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a2",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 99.5,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": null,
+ "strokeColor": "#1098ad",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 100,
+ "y": 300,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 4`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id32",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "elbowed": false,
+ "endArrowhead": "arrow",
+ "endBinding": null,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 0,
+ "id": Any<String>,
+ "index": "a3",
+ "isDeleted": false,
+ "lastCommittedPoint": null,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "points": [
+ [
+ 0.5,
+ 0,
+ ],
+ [
+ 99.5,
+ 0,
+ ],
+ ],
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "startArrowhead": null,
+ "startBinding": null,
+ "strokeColor": "#1098ad",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "arrow",
+ "updated": 1,
+ "version": 2,
+ "versionNonce": Any<Number>,
+ "width": 100,
+ "x": 100,
+ "y": 400,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id25",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a4",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "LABELED ARROW",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "LABELED ARROW",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 130,
+ "x": 85,
+ "y": 87.5,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id26",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a5",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "STYLED LABELED ARROW",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#099268",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "STYLED LABELED ARROW",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 200,
+ "x": 50,
+ "y": 187.5,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id27",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 50,
+ "id": Any<String>,
+ "index": "a6",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "ANOTHER STYLED LABELLED ARROW",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1098ad",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "ANOTHER STYLED
+LABELLED ARROW",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 140,
+ "x": 80,
+ "y": 275,
+}
+`;
+
+exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id28",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 50,
+ "id": Any<String>,
+ "index": "a7",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "ANOTHER STYLED LABELLED ARROW",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#099268",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "ANOTHER STYLED
+LABELLED ARROW",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 140,
+ "x": 80,
+ "y": 375,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 1`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id19",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 35,
+ "id": Any<String>,
+ "index": "a0",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 4,
+ "versionNonce": Any<Number>,
+ "width": 250,
+ "x": 100,
+ "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 2`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id20",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 85,
+ "id": Any<String>,
+ "index": "a1",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "ellipse",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 200,
+ "x": 500,
+ "y": 100,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 3`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id21",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 170,
+ "id": Any<String>,
+ "index": "a2",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "diamond",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 280,
+ "x": 100,
+ "y": 150,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 4`] = `
+{
+ "angle": 0,
+ "backgroundColor": "#fff3bf",
+ "boundElements": [
+ {
+ "id": "id22",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 120,
+ "id": Any<String>,
+ "index": "a3",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "diamond",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 300,
+ "x": 100,
+ "y": 400,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 5`] = `
+{
+ "angle": 0,
+ "backgroundColor": "transparent",
+ "boundElements": [
+ {
+ "id": "id23",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 85,
+ "id": Any<String>,
+ "index": "a4",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#c2255c",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "rectangle",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 200,
+ "x": 500,
+ "y": 300,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 6`] = `
+{
+ "angle": 0,
+ "backgroundColor": "#ffec99",
+ "boundElements": [
+ {
+ "id": "id24",
+ "type": "text",
+ },
+ ],
+ "customData": undefined,
+ "fillStyle": "solid",
+ "frameId": null,
+ "groupIds": [],
+ "height": 120,
+ "id": Any<String>,
+ "index": "a5",
+ "isDeleted": false,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#f08c00",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "type": "ellipse",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "width": 200,
+ "x": 500,
+ "y": 500,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 7`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id13",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 25,
+ "id": Any<String>,
+ "index": "a6",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "RECTANGLE TEXT CONTAINER",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "RECTANGLE TEXT CONTAINER",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 240,
+ "x": 105,
+ "y": 105,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 8`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id14",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 50,
+ "id": Any<String>,
+ "index": "a7",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "ELLIPSE TEXT CONTAINER",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "ELLIPSE TEXT
+CONTAINER",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 120,
+ "x": 539.7893218813452,
+ "y": 117.44796179957173,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 9`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id15",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 75,
+ "id": Any<String>,
+ "index": "a8",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "DIAMOND
+TEXT CONTAINER",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#1e1e1e",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "DIAMOND
+TEXT
+CONTAINER",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 90,
+ "x": 195,
+ "y": 197.5,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 10`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id16",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 50,
+ "id": Any<String>,
+ "index": "a9",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "STYLED DIAMOND TEXT CONTAINER",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#099268",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "STYLED DIAMOND
+TEXT CONTAINER",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 140,
+ "x": 180,
+ "y": 435,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 11`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id17",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 75,
+ "id": Any<String>,
+ "index": "aA",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#c2255c",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "TOP LEFT ALIGNED
+RECTANGLE TEXT
+CONTAINER",
+ "textAlign": "left",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "top",
+ "width": 160,
+ "x": 505,
+ "y": 305,
+}
+`;
+
+exports[`Test Transform > should transform to text containers when label provided 12`] = `
+{
+ "angle": 0,
+ "autoResize": true,
+ "backgroundColor": "transparent",
+ "boundElements": null,
+ "containerId": "id18",
+ "customData": undefined,
+ "fillStyle": "solid",
+ "fontFamily": 5,
+ "fontSize": 20,
+ "frameId": null,
+ "groupIds": [],
+ "height": 75,
+ "id": Any<String>,
+ "index": "aB",
+ "isDeleted": false,
+ "lineHeight": 1.25,
+ "link": null,
+ "locked": false,
+ "opacity": 100,
+ "originalText": "STYLED ELLIPSE TEXT CONTAINER",
+ "roughness": 1,
+ "roundness": null,
+ "seed": Any<Number>,
+ "strokeColor": "#c2255c",
+ "strokeStyle": "solid",
+ "strokeWidth": 2,
+ "text": "STYLED
+ELLIPSE TEXT
+CONTAINER",
+ "textAlign": "center",
+ "type": "text",
+ "updated": 1,
+ "version": 3,
+ "versionNonce": Any<Number>,
+ "verticalAlign": "middle",
+ "width": 120,
+ "x": 539.7893218813452,
+ "y": 522.5735931288071,
+}
+`;
diff --git a/packages/excalidraw/data/ai/types.ts b/packages/excalidraw/data/ai/types.ts
new file mode 100644
index 0000000..4ea0310
--- /dev/null
+++ b/packages/excalidraw/data/ai/types.ts
@@ -0,0 +1,300 @@
+export namespace OpenAIInput {
+ type ChatCompletionContentPart =
+ | ChatCompletionContentPartText
+ | ChatCompletionContentPartImage;
+
+ interface ChatCompletionContentPartImage {
+ image_url: ChatCompletionContentPartImage.ImageURL;
+
+ /**
+ * The type of the content part.
+ */
+ type: "image_url";
+ }
+
+ namespace ChatCompletionContentPartImage {
+ export interface ImageURL {
+ /**
+ * Either a URL of the image or the base64 encoded image data.
+ */
+ url: string;
+
+ /**
+ * Specifies the detail level of the image.
+ */
+ detail?: "auto" | "low" | "high";
+ }
+ }
+
+ interface ChatCompletionContentPartText {
+ /**
+ * The text content.
+ */
+ text: string;
+
+ /**
+ * The type of the content part.
+ */
+ type: "text";
+ }
+
+ interface ChatCompletionUserMessageParam {
+ /**
+ * The contents of the user message.
+ */
+ content: string | Array<ChatCompletionContentPart> | null;
+
+ /**
+ * The role of the messages author, in this case `user`.
+ */
+ role: "user";
+ }
+
+ interface ChatCompletionSystemMessageParam {
+ /**
+ * The contents of the system message.
+ */
+ content: string | null;
+
+ /**
+ * The role of the messages author, in this case `system`.
+ */
+ role: "system";
+ }
+
+ export interface ChatCompletionCreateParamsBase {
+ /**
+ * A list of messages comprising the conversation so far.
+ * [Example Python code](https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models).
+ */
+ messages: Array<
+ ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam
+ >;
+
+ /**
+ * ID of the model to use. See the
+ * [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility)
+ * table for details on which models work with the Chat API.
+ */
+ model:
+ | (string & {})
+ | "gpt-4-1106-preview"
+ | "gpt-4-vision-preview"
+ | "gpt-4"
+ | "gpt-4-0314"
+ | "gpt-4-0613"
+ | "gpt-4-32k"
+ | "gpt-4-32k-0314"
+ | "gpt-4-32k-0613"
+ | "gpt-3.5-turbo"
+ | "gpt-3.5-turbo-16k"
+ | "gpt-3.5-turbo-0301"
+ | "gpt-3.5-turbo-0613"
+ | "gpt-3.5-turbo-16k-0613";
+
+ /**
+ * Number between -2.0 and 2.0. Positive values penalize new tokens based on their
+ * existing frequency in the text so far, decreasing the model's likelihood to
+ * repeat the same line verbatim.
+ *
+ * [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
+ */
+ frequency_penalty?: number | null;
+
+ /**
+ * Modify the likelihood of specified tokens appearing in the completion.
+ *
+ * Accepts a JSON object that maps tokens (specified by their token ID in the
+ * tokenizer) to an associated bias value from -100 to 100. Mathematically, the
+ * bias is added to the logits generated by the model prior to sampling. The exact
+ * effect will vary per model, but values between -1 and 1 should decrease or
+ * increase likelihood of selection; values like -100 or 100 should result in a ban
+ * or exclusive selection of the relevant token.
+ */
+ logit_bias?: Record<string, number> | null;
+
+ /**
+ * The maximum number of [tokens](/tokenizer) to generate in the chat completion.
+ *
+ * The total length of input tokens and generated tokens is limited by the model's
+ * context length.
+ * [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken)
+ * for counting tokens.
+ */
+ max_tokens?: number | null;
+
+ /**
+ * How many chat completion choices to generate for each input message.
+ */
+ n?: number | null;
+
+ /**
+ * Number between -2.0 and 2.0. Positive values penalize new tokens based on
+ * whether they appear in the text so far, increasing the model's likelihood to
+ * talk about new topics.
+ *
+ * [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
+ */
+ presence_penalty?: number | null;
+
+ /**
+ * This feature is in Beta. If specified, our system will make a best effort to
+ * sample deterministically, such that repeated requests with the same `seed` and
+ * parameters should return the same result. Determinism is not guaranteed, and you
+ * should refer to the `system_fingerprint` response parameter to monitor changes
+ * in the backend.
+ */
+ seed?: number | null;
+
+ /**
+ * Up to 4 sequences where the API will stop generating further tokens.
+ */
+ stop?: string | null | Array<string>;
+
+ /**
+ * If set, partial message deltas will be sent, like in ChatGPT. Tokens will be
+ * sent as data-only
+ * [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)
+ * as they become available, with the stream terminated by a `data: [DONE]`
+ * message.
+ * [Example Python code](https://cookbook.openai.com/examples/how_to_stream_completions).
+ */
+ stream?: boolean | null;
+
+ /**
+ * What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
+ * make the output more random, while lower values like 0.2 will make it more
+ * focused and deterministic.
+ *
+ * We generally recommend altering this or `top_p` but not both.
+ */
+ temperature?: number | null;
+
+ /**
+ * An alternative to sampling with temperature, called nucleus sampling, where the
+ * model considers the results of the tokens with top_p probability mass. So 0.1
+ * means only the tokens comprising the top 10% probability mass are considered.
+ *
+ * We generally recommend altering this or `temperature` but not both.
+ */
+ top_p?: number | null;
+
+ /**
+ * A unique identifier representing your end-user, which can help OpenAI to monitor
+ * and detect abuse.
+ * [Learn more](https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids).
+ */
+ user?: string;
+ }
+}
+
+export namespace OpenAIOutput {
+ export interface ChatCompletion {
+ /**
+ * A unique identifier for the chat completion.
+ */
+ id: string;
+
+ /**
+ * A list of chat completion choices. Can be more than one if `n` is greater
+ * than 1.
+ */
+ choices: Array<Choice>;
+
+ /**
+ * The Unix timestamp (in seconds) of when the chat completion was created.
+ */
+ created: number;
+
+ /**
+ * The model used for the chat completion.
+ */
+ model: string;
+
+ /**
+ * The object type, which is always `chat.completion`.
+ */
+ object: "chat.completion";
+
+ /**
+ * This fingerprint represents the backend configuration that the model runs with.
+ *
+ * Can be used in conjunction with the `seed` request parameter to understand when
+ * backend changes have been made that might impact determinism.
+ */
+ system_fingerprint?: string;
+
+ /**
+ * Usage statistics for the completion request.
+ */
+ usage?: CompletionUsage;
+ }
+ export interface Choice {
+ /**
+ * The reason the model stopped generating tokens. This will be `stop` if the model
+ * hit a natural stop point or a provided stop sequence, `length` if the maximum
+ * number of tokens specified in the request was reached, `content_filter` if
+ * content was omitted due to a flag from our content filters, `tool_calls` if the
+ * model called a tool, or `function_call` (deprecated) if the model called a
+ * function.
+ */
+ finish_reason:
+ | "stop"
+ | "length"
+ | "tool_calls"
+ | "content_filter"
+ | "function_call";
+
+ /**
+ * The index of the choice in the list of choices.
+ */
+ index: number;
+
+ /**
+ * A chat completion message generated by the model.
+ */
+ message: ChatCompletionMessage;
+ }
+
+ interface ChatCompletionMessage {
+ /**
+ * The contents of the message.
+ */
+ content: string | null;
+
+ /**
+ * The role of the author of this message.
+ */
+ role: "assistant";
+ }
+
+ /**
+ * Usage statistics for the completion request.
+ */
+ interface CompletionUsage {
+ /**
+ * Number of tokens in the generated completion.
+ */
+ completion_tokens: number;
+
+ /**
+ * Number of tokens in the prompt.
+ */
+ prompt_tokens: number;
+
+ /**
+ * Total number of tokens used in the request (prompt + completion).
+ */
+ total_tokens: number;
+ }
+
+ export interface APIError {
+ readonly status: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | undefined;
+ readonly headers: Headers | undefined;
+ readonly error: { message: string } | undefined;
+
+ readonly code: string | null | undefined;
+ readonly param: string | null | undefined;
+ readonly type: string | undefined;
+ }
+}
diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts
new file mode 100644
index 0000000..2293f86
--- /dev/null
+++ b/packages/excalidraw/data/blob.ts
@@ -0,0 +1,496 @@
+import { nanoid } from "nanoid";
+import { cleanAppStateForExport } from "../appState";
+import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
+import { clearElementsForExport } from "../element";
+import type { ExcalidrawElement, FileId } from "../element/types";
+import { CanvasError, ImageSceneDataError } from "../errors";
+import { calculateScrollCenter } from "../scene";
+import { decodeSvgBase64Payload } from "../scene/export";
+import type { AppState, DataURL, LibraryItem } from "../types";
+import type { ValueOf } from "../utility-types";
+import { bytesToHexString, isPromiseLike } from "../utils";
+import { base64ToString, stringToBase64, toByteString } from "./encode";
+import type { FileSystemHandle } from "./filesystem";
+import { nativeFileSystemSupported } from "./filesystem";
+import { isValidExcalidrawData, isValidLibrary } from "./json";
+import { restore, restoreLibraryItems } from "./restore";
+import type { ImportedLibraryData } from "./types";
+
+const parseFileContents = async (blob: Blob | File): Promise<string> => {
+ let contents: string;
+
+ if (blob.type === MIME_TYPES.png) {
+ try {
+ return await (await import("./image")).decodePngMetadata(blob);
+ } catch (error: any) {
+ if (error.message === "INVALID") {
+ throw new ImageSceneDataError(
+ "Image doesn't contain scene",
+ "IMAGE_NOT_CONTAINS_SCENE_DATA",
+ );
+ } else {
+ throw new ImageSceneDataError("Error: cannot restore image");
+ }
+ }
+ } else {
+ if ("text" in Blob) {
+ contents = await blob.text();
+ } else {
+ contents = await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.readAsText(blob, "utf8");
+ reader.onloadend = () => {
+ if (reader.readyState === FileReader.DONE) {
+ resolve(reader.result as string);
+ }
+ };
+ });
+ }
+ if (blob.type === MIME_TYPES.svg) {
+ try {
+ return decodeSvgBase64Payload({
+ svg: contents,
+ });
+ } catch (error: any) {
+ if (error.message === "INVALID") {
+ throw new ImageSceneDataError(
+ "Image doesn't contain scene",
+ "IMAGE_NOT_CONTAINS_SCENE_DATA",
+ );
+ } else {
+ throw new ImageSceneDataError("Error: cannot restore image");
+ }
+ }
+ }
+ }
+ return contents;
+};
+
+export const getMimeType = (blob: Blob | string): string => {
+ let name: string;
+ if (typeof blob === "string") {
+ name = blob;
+ } else {
+ if (blob.type) {
+ return blob.type;
+ }
+ name = blob.name || "";
+ }
+ if (/\.(excalidraw|json)$/.test(name)) {
+ return MIME_TYPES.json;
+ } else if (/\.png$/.test(name)) {
+ return MIME_TYPES.png;
+ } else if (/\.jpe?g$/.test(name)) {
+ return MIME_TYPES.jpg;
+ } else if (/\.svg$/.test(name)) {
+ return MIME_TYPES.svg;
+ }
+ return "";
+};
+
+export const getFileHandleType = (handle: FileSystemHandle | null) => {
+ if (!handle) {
+ return null;
+ }
+
+ return handle.name.match(/\.(json|excalidraw|png|svg)$/)?.[1] || null;
+};
+
+export const isImageFileHandleType = (
+ type: string | null,
+): type is "png" | "svg" => {
+ return type === "png" || type === "svg";
+};
+
+export const isImageFileHandle = (handle: FileSystemHandle | null) => {
+ const type = getFileHandleType(handle);
+ return type === "png" || type === "svg";
+};
+
+export const isSupportedImageFileType = (type: string | null | undefined) => {
+ return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
+};
+
+export const isSupportedImageFile = (
+ blob: Blob | null | undefined,
+): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
+ const { type } = blob || {};
+ return isSupportedImageFileType(type);
+};
+
+export const loadSceneOrLibraryFromBlob = async (
+ blob: Blob | File,
+ /** @see restore.localAppState */
+ localAppState: AppState | null,
+ localElements: readonly ExcalidrawElement[] | null,
+ /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
+ fileHandle?: FileSystemHandle | null,
+) => {
+ const contents = await parseFileContents(blob);
+ let data;
+ try {
+ try {
+ data = JSON.parse(contents);
+ } catch (error: any) {
+ if (isSupportedImageFile(blob)) {
+ throw new ImageSceneDataError(
+ "Image doesn't contain scene",
+ "IMAGE_NOT_CONTAINS_SCENE_DATA",
+ );
+ }
+ throw error;
+ }
+ if (isValidExcalidrawData(data)) {
+ return {
+ type: MIME_TYPES.excalidraw,
+ data: restore(
+ {
+ elements: clearElementsForExport(data.elements || []),
+ appState: {
+ theme: localAppState?.theme,
+ fileHandle: fileHandle || blob.handle || null,
+ ...cleanAppStateForExport(data.appState || {}),
+ ...(localAppState
+ ? calculateScrollCenter(data.elements || [], localAppState)
+ : {}),
+ },
+ files: data.files,
+ },
+ localAppState,
+ localElements,
+ { repairBindings: true, refreshDimensions: false },
+ ),
+ };
+ } else if (isValidLibrary(data)) {
+ return {
+ type: MIME_TYPES.excalidrawlib,
+ data,
+ };
+ }
+ throw new Error("Error: invalid file");
+ } catch (error: any) {
+ if (error instanceof ImageSceneDataError) {
+ throw error;
+ }
+ throw new Error("Error: invalid file");
+ }
+};
+
+export const loadFromBlob = async (
+ blob: Blob,
+ /** @see restore.localAppState */
+ localAppState: AppState | null,
+ localElements: readonly ExcalidrawElement[] | null,
+ /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
+ fileHandle?: FileSystemHandle | null,
+) => {
+ const ret = await loadSceneOrLibraryFromBlob(
+ blob,
+ localAppState,
+ localElements,
+ fileHandle,
+ );
+ if (ret.type !== MIME_TYPES.excalidraw) {
+ throw new Error("Error: invalid file");
+ }
+ return ret.data;
+};
+
+export const parseLibraryJSON = (
+ json: string,
+ defaultStatus: LibraryItem["status"] = "unpublished",
+) => {
+ const data: ImportedLibraryData | undefined = JSON.parse(json);
+ if (!isValidLibrary(data)) {
+ throw new Error("Invalid library");
+ }
+ const libraryItems = data.libraryItems || data.library;
+ return restoreLibraryItems(libraryItems, defaultStatus);
+};
+
+export const loadLibraryFromBlob = async (
+ blob: Blob,
+ defaultStatus: LibraryItem["status"] = "unpublished",
+) => {
+ return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
+};
+
+export const canvasToBlob = async (
+ canvas: HTMLCanvasElement | Promise<HTMLCanvasElement>,
+): Promise<Blob> => {
+ return new Promise(async (resolve, reject) => {
+ try {
+ if (isPromiseLike(canvas)) {
+ canvas = await canvas;
+ }
+ canvas.toBlob((blob) => {
+ if (!blob) {
+ return reject(
+ new CanvasError("Error: Canvas too big", "CANVAS_POSSIBLY_TOO_BIG"),
+ );
+ }
+ resolve(blob);
+ });
+ } catch (error: any) {
+ reject(error);
+ }
+ });
+};
+
+/** generates SHA-1 digest from supplied file (if not supported, falls back
+ to a 40-char base64 random id) */
+export const generateIdFromFile = async (file: File): Promise<FileId> => {
+ try {
+ const hashBuffer = await window.crypto.subtle.digest(
+ "SHA-1",
+ await blobToArrayBuffer(file),
+ );
+ return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
+ } catch (error: any) {
+ console.error(error);
+ // length 40 to align with the HEX length of SHA-1 (which is 160 bit)
+ return nanoid(40) as FileId;
+ }
+};
+
+/** async. For sync variant, use getDataURL_sync */
+export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const dataURL = reader.result as DataURL;
+ resolve(dataURL);
+ };
+ reader.onerror = (error) => reject(error);
+ reader.readAsDataURL(file);
+ });
+};
+
+export const getDataURL_sync = (
+ data: string | Uint8Array | ArrayBuffer,
+ mimeType: ValueOf<typeof MIME_TYPES>,
+): DataURL => {
+ return `data:${mimeType};base64,${stringToBase64(
+ toByteString(data),
+ true,
+ )}` as DataURL;
+};
+
+export const dataURLToFile = (dataURL: DataURL, filename = "") => {
+ const dataIndexStart = dataURL.indexOf(",");
+ const byteString = atob(dataURL.slice(dataIndexStart + 1));
+ const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0];
+
+ const ab = new ArrayBuffer(byteString.length);
+ const ia = new Uint8Array(ab);
+ for (let i = 0; i < byteString.length; i++) {
+ ia[i] = byteString.charCodeAt(i);
+ }
+ return new File([ab], filename, { type: mimeType });
+};
+
+export const dataURLToString = (dataURL: DataURL) => {
+ return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
+};
+
+export const resizeImageFile = async (
+ file: File,
+ opts: {
+ /** undefined indicates auto */
+ outputType?: typeof MIME_TYPES["jpg"];
+ maxWidthOrHeight: number;
+ },
+): Promise<File> => {
+ // SVG files shouldn't a can't be resized
+ if (file.type === MIME_TYPES.svg) {
+ return file;
+ }
+
+ const [pica, imageBlobReduce] = await Promise.all([
+ import("pica").then((res) => res.default),
+ // a wrapper for pica for better API
+ import("image-blob-reduce").then((res) => res.default),
+ ]);
+
+ // CRA's minification settings break pica in WebWorkers, so let's disable
+ // them for now
+ // https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513
+ const reduce = imageBlobReduce({
+ pica: pica({ features: ["js", "wasm"] }),
+ });
+
+ if (opts.outputType) {
+ const { outputType } = opts;
+ reduce._create_blob = function (env) {
+ return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => {
+ env.out_blob = blob;
+ return env;
+ });
+ };
+ }
+
+ if (!isSupportedImageFile(file)) {
+ throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
+ }
+
+ return new File(
+ [await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })],
+ file.name,
+ {
+ type: opts.outputType || file.type,
+ },
+ );
+};
+
+export const SVGStringToFile = (SVGString: string, filename: string = "") => {
+ return new File([new TextEncoder().encode(SVGString)], filename, {
+ type: MIME_TYPES.svg,
+ }) as File & { type: typeof MIME_TYPES.svg };
+};
+
+export const ImageURLToFile = async (
+ imageUrl: string,
+ filename: string = "",
+): Promise<File | undefined> => {
+ let response;
+ try {
+ response = await fetch(imageUrl);
+ } catch (error: any) {
+ throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" });
+ }
+
+ if (!response.ok) {
+ throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" });
+ }
+
+ const blob = await response.blob();
+
+ if (blob.type && isSupportedImageFile(blob)) {
+ const name = filename || blob.name || "";
+ return new File([blob], name, { type: blob.type });
+ }
+
+ throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
+};
+
+export const getFileFromEvent = async (
+ event: React.DragEvent<HTMLDivElement>,
+) => {
+ const file = event.dataTransfer.files.item(0);
+ const fileHandle = await getFileHandle(event);
+
+ return { file: file ? await normalizeFile(file) : null, fileHandle };
+};
+
+export const getFileHandle = async (
+ event: React.DragEvent<HTMLDivElement>,
+): Promise<FileSystemHandle | null> => {
+ if (nativeFileSystemSupported) {
+ try {
+ const item = event.dataTransfer.items[0];
+ const handle: FileSystemHandle | null =
+ (await (item as any).getAsFileSystemHandle()) || null;
+
+ return handle;
+ } catch (error: any) {
+ console.warn(error.name, error.message);
+ return null;
+ }
+ }
+ return null;
+};
+
+/**
+ * attempts to detect if a buffer is a valid image by checking its leading bytes
+ */
+const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
+ let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
+ null;
+
+ const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `;
+
+ // uint8 leading bytes
+ const headerBytes = {
+ // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
+ png: "137 80 78 71 13 10 26 10 ",
+ // https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
+ // jpg is a bit wonky. Checking the first three bytes should be enough,
+ // but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
+ jpg: "255 216 255 ",
+ // https://en.wikipedia.org/wiki/GIF#Example_GIF_file
+ gif: "71 73 70 56 57 97 ",
+ };
+
+ if (first8Bytes === headerBytes.png) {
+ mimeType = MIME_TYPES.png;
+ } else if (first8Bytes.startsWith(headerBytes.jpg)) {
+ mimeType = MIME_TYPES.jpg;
+ } else if (first8Bytes.startsWith(headerBytes.gif)) {
+ mimeType = MIME_TYPES.gif;
+ }
+ return mimeType;
+};
+
+export const createFile = (
+ blob: File | Blob | ArrayBuffer,
+ mimeType: ValueOf<typeof MIME_TYPES>,
+ name: string | undefined,
+) => {
+ return new File([blob], name || "", {
+ type: mimeType,
+ });
+};
+
+/** attempts to detect correct mimeType if none is set, or if an image
+ * has an incorrect extension.
+ * Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
+export const normalizeFile = async (file: File) => {
+ if (!file.type) {
+ if (file?.name?.endsWith(".excalidrawlib")) {
+ file = createFile(
+ await blobToArrayBuffer(file),
+ MIME_TYPES.excalidrawlib,
+ file.name,
+ );
+ } else if (file?.name?.endsWith(".excalidraw")) {
+ file = createFile(
+ await blobToArrayBuffer(file),
+ MIME_TYPES.excalidraw,
+ file.name,
+ );
+ } else {
+ const buffer = await blobToArrayBuffer(file);
+ const mimeType = getActualMimeTypeFromImage(buffer);
+ if (mimeType) {
+ file = createFile(buffer, mimeType, file.name);
+ }
+ }
+ // when the file is an image, make sure the extension corresponds to the
+ // actual mimeType (this is an edge case, but happens sometime)
+ } else if (isSupportedImageFile(file)) {
+ const buffer = await blobToArrayBuffer(file);
+ const mimeType = getActualMimeTypeFromImage(buffer);
+ if (mimeType && mimeType !== file.type) {
+ file = createFile(buffer, mimeType, file.name);
+ }
+ }
+
+ return file;
+};
+
+export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
+ if ("arrayBuffer" in blob) {
+ return blob.arrayBuffer();
+ }
+ // Safari
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ if (!event.target?.result) {
+ return reject(new Error("Couldn't convert blob to ArrayBuffer"));
+ }
+ resolve(event.target.result as ArrayBuffer);
+ };
+ reader.readAsArrayBuffer(blob);
+ });
+};
diff --git a/packages/excalidraw/data/encode.ts b/packages/excalidraw/data/encode.ts
new file mode 100644
index 0000000..15dfdb2
--- /dev/null
+++ b/packages/excalidraw/data/encode.ts
@@ -0,0 +1,413 @@
+import { deflate, inflate } from "pako";
+import { encryptData, decryptData } from "./encryption";
+
+// -----------------------------------------------------------------------------
+// byte (binary) strings
+// -----------------------------------------------------------------------------
+
+// Buffer-compatible implem.
+//
+// Note that in V8, spreading the uint8array (by chunks) into
+// `String.fromCharCode(...uint8array)` tends to be faster for large
+// strings/buffers, in case perf is needed in the future.
+export const toByteString = (data: string | Uint8Array | ArrayBuffer) => {
+ const bytes =
+ typeof data === "string"
+ ? new TextEncoder().encode(data)
+ : data instanceof Uint8Array
+ ? data
+ : new Uint8Array(data);
+ let bstring = "";
+ for (const byte of bytes) {
+ bstring += String.fromCharCode(byte);
+ }
+ return bstring;
+};
+
+const byteStringToArrayBuffer = (byteString: string) => {
+ const buffer = new ArrayBuffer(byteString.length);
+ const bufferView = new Uint8Array(buffer);
+ for (let i = 0, len = byteString.length; i < len; i++) {
+ bufferView[i] = byteString.charCodeAt(i);
+ }
+ return buffer;
+};
+
+const byteStringToString = (byteString: string) => {
+ return new TextDecoder("utf-8").decode(byteStringToArrayBuffer(byteString));
+};
+
+// -----------------------------------------------------------------------------
+// base64
+// -----------------------------------------------------------------------------
+
+/**
+ * @param isByteString set to true if already byte string to prevent bloat
+ * due to reencoding
+ */
+export const stringToBase64 = (str: string, isByteString = false) => {
+ return isByteString ? window.btoa(str) : window.btoa(toByteString(str));
+};
+
+// async to align with stringToBase64
+export const base64ToString = (base64: string, isByteString = false) => {
+ return isByteString
+ ? window.atob(base64)
+ : byteStringToString(window.atob(base64));
+};
+
+export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
+ if (typeof Buffer !== "undefined") {
+ // Node.js environment
+ return Buffer.from(base64, "base64").buffer;
+ }
+ // Browser environment
+ return byteStringToArrayBuffer(atob(base64));
+};
+
+// -----------------------------------------------------------------------------
+// base64url
+// -----------------------------------------------------------------------------
+
+export const base64urlToString = (str: string) => {
+ return window.atob(
+ // normalize base64URL to base64
+ str
+ .replace(/-/g, "+")
+ .replace(/_/g, "/")
+ .padEnd(str.length + ((4 - (str.length % 4)) % 4), "="),
+ );
+};
+
+// -----------------------------------------------------------------------------
+// text encoding
+// -----------------------------------------------------------------------------
+
+type EncodedData = {
+ encoded: string;
+ encoding: "bstring";
+ /** whether text is compressed (zlib) */
+ compressed: boolean;
+ /** version for potential migration purposes */
+ version?: string;
+};
+
+/**
+ * Encodes (and potentially compresses via zlib) text to byte string
+ */
+export const encode = ({
+ text,
+ compress,
+}: {
+ text: string;
+ /** defaults to `true`. If compression fails, falls back to bstring alone. */
+ compress?: boolean;
+}): EncodedData => {
+ let deflated!: string;
+ if (compress !== false) {
+ try {
+ deflated = toByteString(deflate(text));
+ } catch (error: any) {
+ console.error("encode: cannot deflate", error);
+ }
+ }
+ return {
+ version: "1",
+ encoding: "bstring",
+ compressed: !!deflated,
+ encoded: deflated || toByteString(text),
+ };
+};
+
+export const decode = (data: EncodedData): string => {
+ let decoded: string;
+
+ switch (data.encoding) {
+ case "bstring":
+ // if compressed, do not double decode the bstring
+ decoded = data.compressed
+ ? data.encoded
+ : byteStringToString(data.encoded);
+ break;
+ default:
+ throw new Error(`decode: unknown encoding "${data.encoding}"`);
+ }
+
+ if (data.compressed) {
+ return inflate(new Uint8Array(byteStringToArrayBuffer(decoded)), {
+ to: "string",
+ });
+ }
+
+ return decoded;
+};
+
+// -----------------------------------------------------------------------------
+// binary encoding
+// -----------------------------------------------------------------------------
+
+type FileEncodingInfo = {
+ /* version 2 is the version we're shipping the initial image support with.
+ version 1 was a PR version that a lot of people were using anyway.
+ Thus, if there are issues we can check whether they're not using the
+ unoffic version */
+ version: 1 | 2;
+ compression: "pako@1" | null;
+ encryption: "AES-GCM" | null;
+};
+
+// -----------------------------------------------------------------------------
+const CONCAT_BUFFERS_VERSION = 1;
+/** how many bytes we use to encode how many bytes the next chunk has.
+ * Corresponds to DataView setter methods (setUint32, setUint16, etc).
+ *
+ * NOTE ! values must not be changed, which would be backwards incompatible !
+ */
+const VERSION_DATAVIEW_BYTES = 4;
+const NEXT_CHUNK_SIZE_DATAVIEW_BYTES = 4;
+// -----------------------------------------------------------------------------
+
+const DATA_VIEW_BITS_MAP = { 1: 8, 2: 16, 4: 32 } as const;
+
+// getter
+function dataView(buffer: Uint8Array, bytes: 1 | 2 | 4, offset: number): number;
+// setter
+function dataView(
+ buffer: Uint8Array,
+ bytes: 1 | 2 | 4,
+ offset: number,
+ value: number,
+): Uint8Array;
+/**
+ * abstraction over DataView that serves as a typed getter/setter in case
+ * you're using constants for the byte size and want to ensure there's no
+ * discrepenancy in the encoding across refactors.
+ *
+ * DataView serves for an endian-agnostic handling of numbers in ArrayBuffers.
+ */
+function dataView(
+ buffer: Uint8Array,
+ bytes: 1 | 2 | 4,
+ offset: number,
+ value?: number,
+): Uint8Array | number {
+ if (value != null) {
+ if (value > Math.pow(2, DATA_VIEW_BITS_MAP[bytes]) - 1) {
+ throw new Error(
+ `attempting to set value higher than the allocated bytes (value: ${value}, bytes: ${bytes})`,
+ );
+ }
+ const method = `setUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
+ new DataView(buffer.buffer)[method](offset, value);
+ return buffer;
+ }
+ const method = `getUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
+ return new DataView(buffer.buffer)[method](offset);
+}
+
+// -----------------------------------------------------------------------------
+
+/**
+ * Resulting concatenated buffer has this format:
+ *
+ * [
+ * VERSION chunk (4 bytes)
+ * LENGTH chunk 1 (4 bytes)
+ * DATA chunk 1 (up to 2^32 bits)
+ * LENGTH chunk 2 (4 bytes)
+ * DATA chunk 2 (up to 2^32 bits)
+ * ...
+ * ]
+ *
+ * @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
+ */
+const concatBuffers = (...buffers: Uint8Array[]) => {
+ const bufferView = new Uint8Array(
+ VERSION_DATAVIEW_BYTES +
+ NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
+ buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0),
+ );
+
+ let cursor = 0;
+
+ // as the first chunk we'll encode the version for backwards compatibility
+ dataView(bufferView, VERSION_DATAVIEW_BYTES, cursor, CONCAT_BUFFERS_VERSION);
+ cursor += VERSION_DATAVIEW_BYTES;
+
+ for (const buffer of buffers) {
+ dataView(
+ bufferView,
+ NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
+ cursor,
+ buffer.byteLength,
+ );
+ cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
+
+ bufferView.set(buffer, cursor);
+ cursor += buffer.byteLength;
+ }
+
+ return bufferView;
+};
+
+/** can only be used on buffers created via `concatBuffers()` */
+const splitBuffers = (concatenatedBuffer: Uint8Array) => {
+ const buffers = [];
+
+ let cursor = 0;
+
+ // first chunk is the version
+ const version = dataView(
+ concatenatedBuffer,
+ NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
+ cursor,
+ );
+ // If version is outside of the supported versions, throw an error.
+ // This usually means the buffer wasn't encoded using this API, so we'd only
+ // waste compute.
+ if (version > CONCAT_BUFFERS_VERSION) {
+ throw new Error(`invalid version ${version}`);
+ }
+
+ cursor += VERSION_DATAVIEW_BYTES;
+
+ while (true) {
+ const chunkSize = dataView(
+ concatenatedBuffer,
+ NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
+ cursor,
+ );
+ cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
+
+ buffers.push(concatenatedBuffer.slice(cursor, cursor + chunkSize));
+ cursor += chunkSize;
+ if (cursor >= concatenatedBuffer.byteLength) {
+ break;
+ }
+ }
+
+ return buffers;
+};
+
+// helpers for (de)compressing data with JSON metadata including encryption
+// -----------------------------------------------------------------------------
+
+/** @private */
+const _encryptAndCompress = async (
+ data: Uint8Array | string,
+ encryptionKey: string,
+) => {
+ const { encryptedBuffer, iv } = await encryptData(
+ encryptionKey,
+ deflate(data),
+ );
+
+ return { iv, buffer: new Uint8Array(encryptedBuffer) };
+};
+
+/**
+ * The returned buffer has following format:
+ * `[]` refers to a buffers wrapper (see `concatBuffers`)
+ *
+ * [
+ * encodingMetadataBuffer,
+ * iv,
+ * [
+ * contentsMetadataBuffer
+ * contentsBuffer
+ * ]
+ * ]
+ */
+export const compressData = async <T extends Record<string, any> = never>(
+ dataBuffer: Uint8Array,
+ options: {
+ encryptionKey: string;
+ } & ([T] extends [never]
+ ? {
+ metadata?: T;
+ }
+ : {
+ metadata: T;
+ }),
+): Promise<Uint8Array> => {
+ const fileInfo: FileEncodingInfo = {
+ version: 2,
+ compression: "pako@1",
+ encryption: "AES-GCM",
+ };
+
+ const encodingMetadataBuffer = new TextEncoder().encode(
+ JSON.stringify(fileInfo),
+ );
+
+ const contentsMetadataBuffer = new TextEncoder().encode(
+ JSON.stringify(options.metadata || null),
+ );
+
+ const { iv, buffer } = await _encryptAndCompress(
+ concatBuffers(contentsMetadataBuffer, dataBuffer),
+ options.encryptionKey,
+ );
+
+ return concatBuffers(encodingMetadataBuffer, iv, buffer);
+};
+
+/** @private */
+const _decryptAndDecompress = async (
+ iv: Uint8Array,
+ decryptedBuffer: Uint8Array,
+ decryptionKey: string,
+ isCompressed: boolean,
+) => {
+ decryptedBuffer = new Uint8Array(
+ await decryptData(iv, decryptedBuffer, decryptionKey),
+ );
+
+ if (isCompressed) {
+ return inflate(decryptedBuffer);
+ }
+
+ return decryptedBuffer;
+};
+
+export const decompressData = async <T extends Record<string, any>>(
+ bufferView: Uint8Array,
+ options: { decryptionKey: string },
+) => {
+ // first chunk is encoding metadata (ignored for now)
+ const [encodingMetadataBuffer, iv, buffer] = splitBuffers(bufferView);
+
+ const encodingMetadata: FileEncodingInfo = JSON.parse(
+ new TextDecoder().decode(encodingMetadataBuffer),
+ );
+
+ try {
+ const [contentsMetadataBuffer, contentsBuffer] = splitBuffers(
+ await _decryptAndDecompress(
+ iv,
+ buffer,
+ options.decryptionKey,
+ !!encodingMetadata.compression,
+ ),
+ );
+
+ const metadata = JSON.parse(
+ new TextDecoder().decode(contentsMetadataBuffer),
+ ) as T;
+
+ return {
+ /** metadata source is always JSON so we can decode it here */
+ metadata,
+ /** data can be anything so the caller must decode it */
+ data: contentsBuffer,
+ };
+ } catch (error: any) {
+ console.error(
+ `Error during decompressing and decrypting the file.`,
+ encodingMetadata,
+ );
+ throw error;
+ }
+};
+
+// -----------------------------------------------------------------------------
diff --git a/packages/excalidraw/data/encryption.ts b/packages/excalidraw/data/encryption.ts
new file mode 100644
index 0000000..33e6899
--- /dev/null
+++ b/packages/excalidraw/data/encryption.ts
@@ -0,0 +1,93 @@
+import { ENCRYPTION_KEY_BITS } from "../constants";
+import { blobToArrayBuffer } from "./blob";
+
+export const IV_LENGTH_BYTES = 12;
+
+export const createIV = () => {
+ const arr = new Uint8Array(IV_LENGTH_BYTES);
+ return window.crypto.getRandomValues(arr);
+};
+
+export const generateEncryptionKey = async <
+ T extends "string" | "cryptoKey" = "string",
+>(
+ returnAs?: T,
+): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
+ const key = await window.crypto.subtle.generateKey(
+ {
+ name: "AES-GCM",
+ length: ENCRYPTION_KEY_BITS,
+ },
+ true, // extractable
+ ["encrypt", "decrypt"],
+ );
+ return (
+ returnAs === "cryptoKey"
+ ? key
+ : (await window.crypto.subtle.exportKey("jwk", key)).k
+ ) as T extends "cryptoKey" ? CryptoKey : string;
+};
+
+export const getCryptoKey = (key: string, usage: KeyUsage) =>
+ window.crypto.subtle.importKey(
+ "jwk",
+ {
+ alg: "A128GCM",
+ ext: true,
+ k: key,
+ key_ops: ["encrypt", "decrypt"],
+ kty: "oct",
+ },
+ {
+ name: "AES-GCM",
+ length: ENCRYPTION_KEY_BITS,
+ },
+ false, // extractable
+ [usage],
+ );
+
+export const encryptData = async (
+ key: string | CryptoKey,
+ data: Uint8Array | ArrayBuffer | Blob | File | string,
+): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
+ const importedKey =
+ typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
+ const iv = createIV();
+ const buffer: ArrayBuffer | Uint8Array =
+ typeof data === "string"
+ ? new TextEncoder().encode(data)
+ : data instanceof Uint8Array
+ ? data
+ : data instanceof Blob
+ ? await blobToArrayBuffer(data)
+ : data;
+
+ // We use symmetric encryption. AES-GCM is the recommended algorithm and
+ // includes checks that the ciphertext has not been modified by an attacker.
+ const encryptedBuffer = await window.crypto.subtle.encrypt(
+ {
+ name: "AES-GCM",
+ iv,
+ },
+ importedKey,
+ buffer as ArrayBuffer | Uint8Array,
+ );
+
+ return { encryptedBuffer, iv };
+};
+
+export const decryptData = async (
+ iv: Uint8Array,
+ encrypted: Uint8Array | ArrayBuffer,
+ privateKey: string,
+): Promise<ArrayBuffer> => {
+ const key = await getCryptoKey(privateKey, "decrypt");
+ return window.crypto.subtle.decrypt(
+ {
+ name: "AES-GCM",
+ iv,
+ },
+ key,
+ encrypted,
+ );
+};
diff --git a/packages/excalidraw/data/filesystem.ts b/packages/excalidraw/data/filesystem.ts
new file mode 100644
index 0000000..186d587
--- /dev/null
+++ b/packages/excalidraw/data/filesystem.ts
@@ -0,0 +1,104 @@
+import type { FileSystemHandle } from "browser-fs-access";
+import {
+ fileOpen as _fileOpen,
+ fileSave as _fileSave,
+ supported as nativeFileSystemSupported,
+} from "browser-fs-access";
+import { EVENT, MIME_TYPES } from "../constants";
+import { AbortError } from "../errors";
+import { debounce } from "../utils";
+
+type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
+
+const INPUT_CHANGE_INTERVAL_MS = 500;
+
+export const fileOpen = <M extends boolean | undefined = false>(opts: {
+ extensions?: FILE_EXTENSION[];
+ description: string;
+ multiple?: M;
+}): Promise<M extends false | undefined ? File : File[]> => {
+ // an unsafe TS hack, alas not much we can do AFAIK
+ type RetType = M extends false | undefined ? File : File[];
+
+ const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
+ mimeTypes.push(MIME_TYPES[type]);
+
+ return mimeTypes;
+ }, [] as string[]);
+
+ const extensions = opts.extensions?.reduce((acc, ext) => {
+ if (ext === "jpg") {
+ return acc.concat(".jpg", ".jpeg");
+ }
+ return acc.concat(`.${ext}`);
+ }, [] as string[]);
+
+ return _fileOpen({
+ description: opts.description,
+ extensions,
+ mimeTypes,
+ multiple: opts.multiple ?? false,
+ legacySetup: (resolve, reject, input) => {
+ const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
+ const focusHandler = () => {
+ checkForFile();
+ document.addEventListener(EVENT.KEYUP, scheduleRejection);
+ document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
+ scheduleRejection();
+ };
+ const checkForFile = () => {
+ // this hack might not work when expecting multiple files
+ if (input.files?.length) {
+ const ret = opts.multiple ? [...input.files] : input.files[0];
+ resolve(ret as RetType);
+ }
+ };
+ requestAnimationFrame(() => {
+ window.addEventListener(EVENT.FOCUS, focusHandler);
+ });
+ const interval = window.setInterval(() => {
+ checkForFile();
+ }, INPUT_CHANGE_INTERVAL_MS);
+ return (rejectPromise) => {
+ clearInterval(interval);
+ scheduleRejection.cancel();
+ window.removeEventListener(EVENT.FOCUS, focusHandler);
+ document.removeEventListener(EVENT.KEYUP, scheduleRejection);
+ document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
+ if (rejectPromise) {
+ // so that something is shown in console if we need to debug this
+ console.warn("Opening the file was canceled (legacy-fs).");
+ rejectPromise(new AbortError());
+ }
+ };
+ },
+ }) as Promise<RetType>;
+};
+
+export const fileSave = (
+ blob: Blob | Promise<Blob>,
+ opts: {
+ /** supply without the extension */
+ name: string;
+ /** file extension */
+ extension: FILE_EXTENSION;
+ mimeTypes?: string[];
+ description: string;
+ /** existing FileSystemHandle */
+ fileHandle?: FileSystemHandle | null;
+ },
+) => {
+ return _fileSave(
+ blob,
+ {
+ fileName: `${opts.name}.${opts.extension}`,
+ description: opts.description,
+ extensions: [`.${opts.extension}`],
+ mimeTypes: opts.mimeTypes,
+ },
+ opts.fileHandle,
+ );
+};
+
+export { nativeFileSystemSupported };
+export type { FileSystemHandle };
diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts
new file mode 100644
index 0000000..0359a9c
--- /dev/null
+++ b/packages/excalidraw/data/image.ts
@@ -0,0 +1,69 @@
+import decodePng from "png-chunks-extract";
+import tEXt from "png-chunk-text";
+import encodePng from "png-chunks-encode";
+import { encode, decode } from "./encode";
+import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
+import { blobToArrayBuffer } from "./blob";
+
+// -----------------------------------------------------------------------------
+// PNG
+// -----------------------------------------------------------------------------
+
+export const getTEXtChunk = async (
+ blob: Blob,
+): Promise<{ keyword: string; text: string } | null> => {
+ const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob)));
+ const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt");
+ if (metadataChunk) {
+ return tEXt.decode(metadataChunk.data);
+ }
+ return null;
+};
+
+export const encodePngMetadata = async ({
+ blob,
+ metadata,
+}: {
+ blob: Blob;
+ metadata: string;
+}) => {
+ const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob)));
+
+ const metadataChunk = tEXt.encode(
+ MIME_TYPES.excalidraw,
+ JSON.stringify(
+ encode({
+ text: metadata,
+ compress: true,
+ }),
+ ),
+ );
+ // insert metadata before last chunk (iEND)
+ chunks.splice(-1, 0, metadataChunk);
+
+ return new Blob([encodePng(chunks)], { type: MIME_TYPES.png });
+};
+
+export const decodePngMetadata = async (blob: Blob) => {
+ const metadata = await getTEXtChunk(blob);
+ if (metadata?.keyword === MIME_TYPES.excalidraw) {
+ try {
+ const encodedData = JSON.parse(metadata.text);
+ if (!("encoded" in encodedData)) {
+ // legacy, un-encoded scene JSON
+ if (
+ "type" in encodedData &&
+ encodedData.type === EXPORT_DATA_TYPES.excalidraw
+ ) {
+ return metadata.text;
+ }
+ throw new Error("FAILED");
+ }
+ return decode(encodedData);
+ } catch (error: any) {
+ console.error(error);
+ throw new Error("FAILED");
+ }
+ }
+ throw new Error("INVALID");
+};
diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts
new file mode 100644
index 0000000..04cdcaf
--- /dev/null
+++ b/packages/excalidraw/data/index.ts
@@ -0,0 +1,202 @@
+import {
+ copyBlobToClipboardAsPng,
+ copyTextToSystemClipboard,
+} from "../clipboard";
+import {
+ DEFAULT_EXPORT_PADDING,
+ DEFAULT_FILENAME,
+ IMAGE_MIME_TYPES,
+ isFirefox,
+ MIME_TYPES,
+} from "../constants";
+import { getNonDeletedElements } from "../element";
+import { isFrameLikeElement } from "../element/typeChecks";
+import type {
+ ExcalidrawElement,
+ ExcalidrawFrameLikeElement,
+ NonDeletedExcalidrawElement,
+} from "../element/types";
+import { getElementsOverlappingFrame } from "../frame";
+import { t } from "../i18n";
+import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { exportToCanvas, exportToSvg } from "../scene/export";
+import type { ExportType } from "../scene/types";
+import type { AppState, BinaryFiles } from "../types";
+import { cloneJSON } from "../utils";
+import { canvasToBlob } from "./blob";
+import type { FileSystemHandle } from "./filesystem";
+import { fileSave } from "./filesystem";
+import { serializeAsJSON } from "./json";
+
+export { loadFromBlob } from "./blob";
+export { loadFromJSON, saveAsJSON } from "./json";
+
+export type ExportedElements = readonly NonDeletedExcalidrawElement[] & {
+ _brand: "exportedElements";
+};
+
+export const prepareElementsForExport = (
+ elements: readonly ExcalidrawElement[],
+ { selectedElementIds }: Pick<AppState, "selectedElementIds">,
+ exportSelectionOnly: boolean,
+) => {
+ elements = getNonDeletedElements(elements);
+
+ const isExportingSelection =
+ exportSelectionOnly &&
+ isSomeElementSelected(elements, { selectedElementIds });
+
+ let exportingFrame: ExcalidrawFrameLikeElement | null = null;
+ let exportedElements = isExportingSelection
+ ? getSelectedElements(
+ elements,
+ { selectedElementIds },
+ {
+ includeBoundTextElement: true,
+ },
+ )
+ : elements;
+
+ if (isExportingSelection) {
+ if (
+ exportedElements.length === 1 &&
+ isFrameLikeElement(exportedElements[0])
+ ) {
+ exportingFrame = exportedElements[0];
+ exportedElements = getElementsOverlappingFrame(elements, exportingFrame);
+ } else if (exportedElements.length > 1) {
+ exportedElements = getSelectedElements(
+ elements,
+ { selectedElementIds },
+ {
+ includeBoundTextElement: true,
+ includeElementsInFrames: true,
+ },
+ );
+ }
+ }
+
+ return {
+ exportingFrame,
+ exportedElements: cloneJSON(exportedElements) as ExportedElements,
+ };
+};
+
+export const exportCanvas = async (
+ type: Omit<ExportType, "backend">,
+ elements: ExportedElements,
+ appState: AppState,
+ files: BinaryFiles,
+ {
+ exportBackground,
+ exportPadding = DEFAULT_EXPORT_PADDING,
+ viewBackgroundColor,
+ name = appState.name || DEFAULT_FILENAME,
+ fileHandle = null,
+ exportingFrame = null,
+ }: {
+ exportBackground: boolean;
+ exportPadding?: number;
+ viewBackgroundColor: string;
+ /** filename, if applicable */
+ name?: string;
+ fileHandle?: FileSystemHandle | null;
+ exportingFrame: ExcalidrawFrameLikeElement | null;
+ },
+) => {
+ if (elements.length === 0) {
+ throw new Error(t("alerts.cannotExportEmptyCanvas"));
+ }
+ if (type === "svg" || type === "clipboard-svg") {
+ const svgPromise = exportToSvg(
+ elements,
+ {
+ exportBackground,
+ exportWithDarkMode: appState.exportWithDarkMode,
+ viewBackgroundColor,
+ exportPadding,
+ exportScale: appState.exportScale,
+ exportEmbedScene: appState.exportEmbedScene && type === "svg",
+ },
+ files,
+ { exportingFrame },
+ );
+
+ if (type === "svg") {
+ return fileSave(
+ svgPromise.then((svg) => {
+ return new Blob([svg.outerHTML], { type: MIME_TYPES.svg });
+ }),
+ {
+ description: "Export to SVG",
+ name,
+ extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
+ mimeTypes: [IMAGE_MIME_TYPES.svg],
+ fileHandle,
+ },
+ );
+ } else if (type === "clipboard-svg") {
+ const svg = await svgPromise.then((svg) => svg.outerHTML);
+ try {
+ await copyTextToSystemClipboard(svg);
+ } catch (e) {
+ throw new Error(t("errors.copyToSystemClipboardFailed"));
+ }
+ return;
+ }
+ }
+
+ const tempCanvas = exportToCanvas(elements, appState, files, {
+ exportBackground,
+ viewBackgroundColor,
+ exportPadding,
+ exportingFrame,
+ });
+
+ if (type === "png") {
+ let blob = canvasToBlob(tempCanvas);
+
+ if (appState.exportEmbedScene) {
+ blob = blob.then((blob) =>
+ import("./image").then(({ encodePngMetadata }) =>
+ encodePngMetadata({
+ blob,
+ metadata: serializeAsJSON(elements, appState, files, "local"),
+ }),
+ ),
+ );
+ }
+
+ return fileSave(blob, {
+ description: "Export to PNG",
+ name,
+ extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
+ mimeTypes: [IMAGE_MIME_TYPES.png],
+ fileHandle,
+ });
+ } else if (type === "clipboard") {
+ try {
+ const blob = canvasToBlob(tempCanvas);
+ await copyBlobToClipboardAsPng(blob);
+ } catch (error: any) {
+ console.warn(error);
+ if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
+ throw new Error(t("canvasError.canvasTooBig"));
+ }
+ // TypeError *probably* suggests ClipboardItem not defined, which
+ // people on Firefox can enable through a flag, so let's tell them.
+ if (isFirefox && error.name === "TypeError") {
+ throw new Error(
+ `${t("alerts.couldNotCopyToClipboard")}\n\n${t(
+ "hints.firefox_clipboard_write",
+ )}`,
+ );
+ } else {
+ throw new Error(t("alerts.couldNotCopyToClipboard"));
+ }
+ }
+ } else {
+ // shouldn't happen
+ throw new Error("Unsupported export type");
+ }
+};
diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts
new file mode 100644
index 0000000..1270fd1
--- /dev/null
+++ b/packages/excalidraw/data/json.ts
@@ -0,0 +1,156 @@
+import { fileOpen, fileSave } from "./filesystem";
+import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
+import {
+ DEFAULT_FILENAME,
+ EXPORT_DATA_TYPES,
+ EXPORT_SOURCE,
+ MIME_TYPES,
+ VERSIONS,
+} from "../constants";
+import { clearElementsForDatabase, clearElementsForExport } from "../element";
+import type { ExcalidrawElement } from "../element/types";
+import type { AppState, BinaryFiles, LibraryItems } from "../types";
+import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob";
+
+import type {
+ ExportedDataState,
+ ImportedDataState,
+ ExportedLibraryData,
+ ImportedLibraryData,
+} from "./types";
+
+/**
+ * Strips out files which are only referenced by deleted elements
+ */
+const filterOutDeletedFiles = (
+ elements: readonly ExcalidrawElement[],
+ files: BinaryFiles,
+) => {
+ const nextFiles: BinaryFiles = {};
+ for (const element of elements) {
+ if (
+ !element.isDeleted &&
+ "fileId" in element &&
+ element.fileId &&
+ files[element.fileId]
+ ) {
+ nextFiles[element.fileId] = files[element.fileId];
+ }
+ }
+ return nextFiles;
+};
+
+export const serializeAsJSON = (
+ elements: readonly ExcalidrawElement[],
+ appState: Partial<AppState>,
+ files: BinaryFiles,
+ type: "local" | "database",
+): string => {
+ const data: ExportedDataState = {
+ type: EXPORT_DATA_TYPES.excalidraw,
+ version: VERSIONS.excalidraw,
+ source: EXPORT_SOURCE,
+ elements:
+ type === "local"
+ ? clearElementsForExport(elements)
+ : clearElementsForDatabase(elements),
+ appState:
+ type === "local"
+ ? cleanAppStateForExport(appState)
+ : clearAppStateForDatabase(appState),
+ files:
+ type === "local"
+ ? filterOutDeletedFiles(elements, files)
+ : // will be stripped from JSON
+ undefined,
+ };
+
+ return JSON.stringify(data, null, 2);
+};
+
+export const saveAsJSON = async (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ files: BinaryFiles,
+ /** filename */
+ name: string = appState.name || DEFAULT_FILENAME,
+) => {
+ const serialized = serializeAsJSON(elements, appState, files, "local");
+ const blob = new Blob([serialized], {
+ type: MIME_TYPES.excalidraw,
+ });
+
+ const fileHandle = await fileSave(blob, {
+ name,
+ extension: "excalidraw",
+ description: "Excalidraw file",
+ fileHandle: isImageFileHandle(appState.fileHandle)
+ ? null
+ : appState.fileHandle,
+ });
+ return { fileHandle };
+};
+
+export const loadFromJSON = async (
+ localAppState: AppState,
+ localElements: readonly ExcalidrawElement[] | null,
+) => {
+ const file = await fileOpen({
+ description: "Excalidraw files",
+ // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
+ // gets resolved. Else, iOS users cannot open `.excalidraw` files.
+ // extensions: ["json", "excalidraw", "png", "svg"],
+ });
+ return loadFromBlob(
+ await normalizeFile(file),
+ localAppState,
+ localElements,
+ file.handle,
+ );
+};
+
+export const isValidExcalidrawData = (data?: {
+ type?: any;
+ elements?: any;
+ appState?: any;
+}): data is ImportedDataState => {
+ return (
+ data?.type === EXPORT_DATA_TYPES.excalidraw &&
+ (!data.elements ||
+ (Array.isArray(data.elements) &&
+ (!data.appState || typeof data.appState === "object")))
+ );
+};
+
+export const isValidLibrary = (json: any): json is ImportedLibraryData => {
+ return (
+ typeof json === "object" &&
+ json &&
+ json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
+ (json.version === 1 || json.version === 2)
+ );
+};
+
+export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
+ const data: ExportedLibraryData = {
+ type: EXPORT_DATA_TYPES.excalidrawLibrary,
+ version: VERSIONS.excalidrawLibrary,
+ source: EXPORT_SOURCE,
+ libraryItems,
+ };
+ return JSON.stringify(data, null, 2);
+};
+
+export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
+ const serialized = serializeLibraryAsJSON(libraryItems);
+ await fileSave(
+ new Blob([serialized], {
+ type: MIME_TYPES.excalidrawlib,
+ }),
+ {
+ name: "library",
+ extension: "excalidrawlib",
+ description: "Excalidraw library file",
+ },
+ );
+};
diff --git a/packages/excalidraw/data/library.test.ts b/packages/excalidraw/data/library.test.ts
new file mode 100644
index 0000000..bbbfd5f
--- /dev/null
+++ b/packages/excalidraw/data/library.test.ts
@@ -0,0 +1,105 @@
+import { validateLibraryUrl } from "./library";
+
+describe("validateLibraryUrl", () => {
+ it("should validate hostname & pathname", () => {
+ // valid hostnames
+ // -------------------------------------------------------------------------
+ expect(
+ validateLibraryUrl("https://www.excalidraw.com", ["excalidraw.com"]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://excalidraw.com", ["excalidraw.com"]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://library.excalidraw.com", ["excalidraw.com"]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://library.excalidraw.com", [
+ "library.excalidraw.com",
+ ]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com/"]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://excalidraw.com", ["excalidraw.com/"]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com"]),
+ ).toBe(true);
+
+ // valid pathnames
+ // -------------------------------------------------------------------------
+ expect(
+ validateLibraryUrl("https://excalidraw.com/path", ["excalidraw.com"]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://excalidraw.com/path/", ["excalidraw.com"]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://excalidraw.com/specific/path", [
+ "excalidraw.com/specific/path",
+ ]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://excalidraw.com/specific/path/", [
+ "excalidraw.com/specific/path",
+ ]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://excalidraw.com/specific/path", [
+ "excalidraw.com/specific/path/",
+ ]),
+ ).toBe(true);
+ expect(
+ validateLibraryUrl("https://excalidraw.com/specific/path/other", [
+ "excalidraw.com/specific/path",
+ ]),
+ ).toBe(true);
+
+ // invalid hostnames
+ // -------------------------------------------------------------------------
+ expect(() =>
+ validateLibraryUrl("https://xexcalidraw.com", ["excalidraw.com"]),
+ ).toThrow();
+ expect(() =>
+ validateLibraryUrl("https://x-excalidraw.com", ["excalidraw.com"]),
+ ).toThrow();
+ expect(() =>
+ validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
+ ).toThrow();
+ expect(() =>
+ validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
+ ).toThrow();
+ expect(() =>
+ validateLibraryUrl("https://excalidraw.com.mx", ["excalidraw.com"]),
+ ).toThrow();
+ // protocol must be https
+ expect(() =>
+ validateLibraryUrl("http://excalidraw.com.mx", ["excalidraw.com"]),
+ ).toThrow();
+
+ // invalid pathnames
+ // -------------------------------------------------------------------------
+ expect(() =>
+ validateLibraryUrl("https://excalidraw.com/specific/other/path", [
+ "excalidraw.com/specific/path",
+ ]),
+ ).toThrow();
+ expect(() =>
+ validateLibraryUrl("https://excalidraw.com/specific/paths", [
+ "excalidraw.com/specific/path",
+ ]),
+ ).toThrow();
+ expect(() =>
+ validateLibraryUrl("https://excalidraw.com/specific/path-s", [
+ "excalidraw.com/specific/path",
+ ]),
+ ).toThrow();
+ expect(() =>
+ validateLibraryUrl("https://excalidraw.com/some/specific/path", [
+ "excalidraw.com/specific/path",
+ ]),
+ ).toThrow();
+ });
+});
diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts
new file mode 100644
index 0000000..1c23edc
--- /dev/null
+++ b/packages/excalidraw/data/library.ts
@@ -0,0 +1,978 @@
+import { loadLibraryFromBlob } from "./blob";
+import type {
+ LibraryItems,
+ LibraryItem,
+ ExcalidrawImperativeAPI,
+ LibraryItemsSource,
+ LibraryItems_anyVersion,
+} from "../types";
+import { restoreLibraryItems } from "./restore";
+import type App from "../components/App";
+import { atom, editorJotaiStore } from "../editor-jotai";
+import type { ExcalidrawElement } from "../element/types";
+import { getCommonBoundingBox } from "../element/bounds";
+import { AbortError } from "../errors";
+import { t } from "../i18n";
+import { useEffect, useRef } from "react";
+import {
+ URL_HASH_KEYS,
+ URL_QUERY_KEYS,
+ APP_NAME,
+ EVENT,
+ DEFAULT_SIDEBAR,
+ LIBRARY_SIDEBAR_TAB,
+} from "../constants";
+import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
+import {
+ arrayToMap,
+ cloneJSON,
+ preventUnload,
+ promiseTry,
+ resolvablePromise,
+} from "../utils";
+import type { MaybePromise } from "../utility-types";
+import { Emitter } from "../emitter";
+import { Queue } from "../queue";
+import { hashElementsVersion, hashString } from "../element";
+import { toValidURL } from "./url";
+
+/**
+ * format: hostname or hostname/pathname
+ *
+ * Both hostname and pathname are matched partially,
+ * hostname from the end, pathname from the start, with subdomain/path
+ * boundaries
+ **/
+const ALLOWED_LIBRARY_URLS = [
+ "excalidraw.com",
+ // when installing from github PRs
+ "raw.githubusercontent.com/excalidraw/excalidraw-libraries",
+];
+
+type LibraryUpdate = {
+ /** deleted library items since last onLibraryChange event */
+ deletedItems: Map<LibraryItem["id"], LibraryItem>;
+ /** newly added items in the library */
+ addedItems: Map<LibraryItem["id"], LibraryItem>;
+};
+
+// an object so that we can later add more properties to it without breaking,
+// such as schema version
+export type LibraryPersistedData = { libraryItems: LibraryItems };
+
+const onLibraryUpdateEmitter = new Emitter<
+ [update: LibraryUpdate, libraryItems: LibraryItems]
+>();
+
+export type LibraryAdatapterSource = "load" | "save";
+
+export interface LibraryPersistenceAdapter {
+ /**
+ * Should load data that were previously saved into the database using the
+ * `save` method. Should throw if saving fails.
+ *
+ * Will be used internally in multiple places, such as during save to
+ * in order to reconcile changes with latest store data.
+ */
+ load(metadata: {
+ /**
+ * Indicates whether we're loading data for save purposes, or reading
+ * purposes, in which case host app can implement more aggressive caching.
+ */
+ source: LibraryAdatapterSource;
+ }): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
+ /** Should persist to the database as is (do no change the data structure). */
+ save(libraryData: LibraryPersistedData): MaybePromise<void>;
+}
+
+export interface LibraryMigrationAdapter {
+ /**
+ * loads data from legacy data source. Returns `null` if no data is
+ * to be migrated.
+ */
+ load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
+
+ /** clears entire storage afterwards */
+ clear(): MaybePromise<void>;
+}
+
+export const libraryItemsAtom = atom<{
+ status: "loading" | "loaded";
+ /** indicates whether library is initialized with library items (has gone
+ * through at least one update). Used in UI. Specific to this atom only. */
+ isInitialized: boolean;
+ libraryItems: LibraryItems;
+}>({ status: "loaded", isInitialized: false, libraryItems: [] });
+
+const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
+ cloneJSON(libraryItems);
+
+/**
+ * checks if library item does not exist already in current library
+ */
+const isUniqueItem = (
+ existingLibraryItems: LibraryItems,
+ targetLibraryItem: LibraryItem,
+) => {
+ return !existingLibraryItems.find((libraryItem) => {
+ if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
+ return false;
+ }
+
+ // detect z-index difference by checking the excalidraw elements
+ // are in order
+ return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
+ return (
+ libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
+ libItemExcalidrawItem.versionNonce ===
+ targetLibraryItem.elements[idx].versionNonce
+ );
+ });
+ });
+};
+
+/** Merges otherItems into localItems. Unique items in otherItems array are
+ sorted first. */
+export const mergeLibraryItems = (
+ localItems: LibraryItems,
+ otherItems: LibraryItems,
+): LibraryItems => {
+ const newItems = [];
+ for (const item of otherItems) {
+ if (isUniqueItem(localItems, item)) {
+ newItems.push(item);
+ }
+ }
+
+ return [...newItems, ...localItems];
+};
+
+/**
+ * Returns { deletedItems, addedItems } maps of all added and deleted items
+ * since last onLibraryChange event.
+ *
+ * Host apps are recommended to diff with the latest state they have.
+ */
+const createLibraryUpdate = (
+ prevLibraryItems: LibraryItems,
+ nextLibraryItems: LibraryItems,
+): LibraryUpdate => {
+ const nextItemsMap = arrayToMap(nextLibraryItems);
+
+ const update: LibraryUpdate = {
+ deletedItems: new Map<LibraryItem["id"], LibraryItem>(),
+ addedItems: new Map<LibraryItem["id"], LibraryItem>(),
+ };
+
+ for (const item of prevLibraryItems) {
+ if (!nextItemsMap.has(item.id)) {
+ update.deletedItems.set(item.id, item);
+ }
+ }
+
+ const prevItemsMap = arrayToMap(prevLibraryItems);
+
+ for (const item of nextLibraryItems) {
+ if (!prevItemsMap.has(item.id)) {
+ update.addedItems.set(item.id, item);
+ }
+ }
+
+ return update;
+};
+
+class Library {
+ /** latest libraryItems */
+ private currLibraryItems: LibraryItems = [];
+ /** snapshot of library items since last onLibraryChange call */
+ private prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
+
+ private app: App;
+
+ constructor(app: App) {
+ this.app = app;
+ }
+
+ private updateQueue: Promise<LibraryItems>[] = [];
+
+ private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
+ return this.updateQueue[this.updateQueue.length - 1];
+ };
+
+ private notifyListeners = () => {
+ if (this.updateQueue.length > 0) {
+ editorJotaiStore.set(libraryItemsAtom, (s) => ({
+ status: "loading",
+ libraryItems: this.currLibraryItems,
+ isInitialized: s.isInitialized,
+ }));
+ } else {
+ editorJotaiStore.set(libraryItemsAtom, {
+ status: "loaded",
+ libraryItems: this.currLibraryItems,
+ isInitialized: true,
+ });
+ try {
+ const prevLibraryItems = this.prevLibraryItems;
+ this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
+
+ const nextLibraryItems = cloneLibraryItems(this.currLibraryItems);
+
+ this.app.props.onLibraryChange?.(nextLibraryItems);
+
+ // for internal use in `useHandleLibrary` hook
+ onLibraryUpdateEmitter.trigger(
+ createLibraryUpdate(prevLibraryItems, nextLibraryItems),
+ nextLibraryItems,
+ );
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ };
+
+ /** call on excalidraw instance unmount */
+ destroy = () => {
+ this.updateQueue = [];
+ this.currLibraryItems = [];
+ editorJotaiStore.set(libraryItemSvgsCache, new Map());
+ // TODO uncomment after/if we make jotai store scoped to each excal instance
+ // jotaiStore.set(libraryItemsAtom, {
+ // status: "loading",
+ // isInitialized: false,
+ // libraryItems: [],
+ // });
+ };
+
+ resetLibrary = () => {
+ return this.setLibrary([]);
+ };
+
+ /**
+ * @returns latest cloned libraryItems. Awaits all in-progress updates first.
+ */
+ getLatestLibrary = (): Promise<LibraryItems> => {
+ return new Promise(async (resolve) => {
+ try {
+ const libraryItems = await (this.getLastUpdateTask() ||
+ this.currLibraryItems);
+ if (this.updateQueue.length > 0) {
+ resolve(this.getLatestLibrary());
+ } else {
+ resolve(cloneLibraryItems(libraryItems));
+ }
+ } catch (error) {
+ return resolve(this.currLibraryItems);
+ }
+ });
+ };
+
+ // NOTE this is a high-level public API (exposed on ExcalidrawAPI) with
+ // a slight overhead (always restoring library items). For internal use
+ // where merging isn't needed, use `library.setLibrary()` directly.
+ updateLibrary = async ({
+ libraryItems,
+ prompt = false,
+ merge = false,
+ openLibraryMenu = false,
+ defaultStatus = "unpublished",
+ }: {
+ libraryItems: LibraryItemsSource;
+ merge?: boolean;
+ prompt?: boolean;
+ openLibraryMenu?: boolean;
+ defaultStatus?: "unpublished" | "published";
+ }): Promise<LibraryItems> => {
+ if (openLibraryMenu) {
+ this.app.setState({
+ openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
+ });
+ }
+
+ return this.setLibrary(() => {
+ return new Promise<LibraryItems>(async (resolve, reject) => {
+ try {
+ const source = await (typeof libraryItems === "function" &&
+ !(libraryItems instanceof Blob)
+ ? libraryItems(this.currLibraryItems)
+ : libraryItems);
+
+ let nextItems;
+
+ if (source instanceof Blob) {
+ nextItems = await loadLibraryFromBlob(source, defaultStatus);
+ } else {
+ nextItems = restoreLibraryItems(source, defaultStatus);
+ }
+ if (
+ !prompt ||
+ window.confirm(
+ t("alerts.confirmAddLibrary", {
+ numShapes: nextItems.length,
+ }),
+ )
+ ) {
+ if (prompt) {
+ // focus container if we've prompted. We focus conditionally
+ // lest `props.autoFocus` is disabled (in which case we should
+ // focus only on user action such as prompt confirm)
+ this.app.focusContainer();
+ }
+
+ if (merge) {
+ resolve(mergeLibraryItems(this.currLibraryItems, nextItems));
+ } else {
+ resolve(nextItems);
+ }
+ } else {
+ reject(new AbortError());
+ }
+ } catch (error: any) {
+ reject(error);
+ }
+ });
+ });
+ };
+
+ setLibrary = (
+ /**
+ * LibraryItems that will replace current items. Can be a function which
+ * will be invoked after all previous tasks are resolved
+ * (this is the prefered way to update the library to avoid race conditions,
+ * but you'll want to manually merge the library items in the callback
+ * - which is what we're doing in Library.importLibrary()).
+ *
+ * If supplied promise is rejected with AbortError, we swallow it and
+ * do not update the library.
+ */
+ libraryItems:
+ | LibraryItems
+ | Promise<LibraryItems>
+ | ((
+ latestLibraryItems: LibraryItems,
+ ) => LibraryItems | Promise<LibraryItems>),
+ ): Promise<LibraryItems> => {
+ const task = new Promise<LibraryItems>(async (resolve, reject) => {
+ try {
+ await this.getLastUpdateTask();
+
+ if (typeof libraryItems === "function") {
+ libraryItems = libraryItems(this.currLibraryItems);
+ }
+
+ this.currLibraryItems = cloneLibraryItems(await libraryItems);
+
+ resolve(this.currLibraryItems);
+ } catch (error: any) {
+ reject(error);
+ }
+ })
+ .catch((error) => {
+ if (error.name === "AbortError") {
+ console.warn("Library update aborted by user");
+ return this.currLibraryItems;
+ }
+ throw error;
+ })
+ .finally(() => {
+ this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
+ this.notifyListeners();
+ });
+
+ this.updateQueue.push(task);
+ this.notifyListeners();
+
+ return task;
+ };
+}
+
+export default Library;
+
+export const distributeLibraryItemsOnSquareGrid = (
+ libraryItems: LibraryItems,
+) => {
+ const PADDING = 50;
+ const ITEMS_PER_ROW = Math.ceil(Math.sqrt(libraryItems.length));
+
+ const resElements: ExcalidrawElement[] = [];
+
+ const getMaxHeightPerRow = (row: number) => {
+ const maxHeight = libraryItems
+ .slice(row * ITEMS_PER_ROW, row * ITEMS_PER_ROW + ITEMS_PER_ROW)
+ .reduce((acc, item) => {
+ const { height } = getCommonBoundingBox(item.elements);
+ return Math.max(acc, height);
+ }, 0);
+ return maxHeight;
+ };
+
+ const getMaxWidthPerCol = (targetCol: number) => {
+ let index = 0;
+ let currCol = 0;
+ let maxWidth = 0;
+ for (const item of libraryItems) {
+ if (index % ITEMS_PER_ROW === 0) {
+ currCol = 0;
+ }
+ if (currCol === targetCol) {
+ const { width } = getCommonBoundingBox(item.elements);
+ maxWidth = Math.max(maxWidth, width);
+ }
+ index++;
+ currCol++;
+ }
+ return maxWidth;
+ };
+
+ let colOffsetX = 0;
+ let rowOffsetY = 0;
+
+ let maxHeightCurrRow = 0;
+ let maxWidthCurrCol = 0;
+
+ let index = 0;
+ let col = 0;
+ let row = 0;
+
+ for (const item of libraryItems) {
+ if (index && index % ITEMS_PER_ROW === 0) {
+ rowOffsetY += maxHeightCurrRow + PADDING;
+ colOffsetX = 0;
+ col = 0;
+ row++;
+ }
+
+ if (col === 0) {
+ maxHeightCurrRow = getMaxHeightPerRow(row);
+ }
+ maxWidthCurrCol = getMaxWidthPerCol(col);
+
+ const { minX, minY, width, height } = getCommonBoundingBox(item.elements);
+ const offsetCenterX = (maxWidthCurrCol - width) / 2;
+ const offsetCenterY = (maxHeightCurrRow - height) / 2;
+ resElements.push(
+ // eslint-disable-next-line no-loop-func
+ ...item.elements.map((element) => ({
+ ...element,
+ x:
+ element.x +
+ // offset for column
+ colOffsetX +
+ // offset to center in given square grid
+ offsetCenterX -
+ // subtract minX so that given item starts at 0 coord
+ minX,
+ y:
+ element.y +
+ // offset for row
+ rowOffsetY +
+ // offset to center in given square grid
+ offsetCenterY -
+ // subtract minY so that given item starts at 0 coord
+ minY,
+ })),
+ );
+ colOffsetX += maxWidthCurrCol + PADDING;
+ index++;
+ col++;
+ }
+
+ return resElements;
+};
+
+export const validateLibraryUrl = (
+ libraryUrl: string,
+ /**
+ * @returns `true` if the URL is valid, throws otherwise.
+ */
+ validator:
+ | ((libraryUrl: string) => boolean)
+ | string[] = ALLOWED_LIBRARY_URLS,
+): true => {
+ if (
+ typeof validator === "function"
+ ? validator(libraryUrl)
+ : validator.some((allowedUrlDef) => {
+ const allowedUrl = new URL(
+ `https://${allowedUrlDef.replace(/^https?:\/\//, "")}`,
+ );
+
+ const { hostname, pathname } = new URL(libraryUrl);
+
+ return (
+ new RegExp(`(^|\\.)${allowedUrl.hostname}$`).test(hostname) &&
+ new RegExp(
+ `^${allowedUrl.pathname.replace(/\/+$/, "")}(/+|$)`,
+ ).test(pathname)
+ );
+ })
+ ) {
+ return true;
+ }
+
+ throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`);
+};
+
+export const parseLibraryTokensFromUrl = () => {
+ const libraryUrl =
+ // current
+ new URLSearchParams(window.location.hash.slice(1)).get(
+ URL_HASH_KEYS.addLibrary,
+ ) ||
+ // legacy, kept for compat reasons
+ new URLSearchParams(window.location.search).get(URL_QUERY_KEYS.addLibrary);
+ const idToken = libraryUrl
+ ? new URLSearchParams(window.location.hash.slice(1)).get("token")
+ : null;
+
+ return libraryUrl ? { libraryUrl, idToken } : null;
+};
+
+class AdapterTransaction {
+ static queue = new Queue();
+
+ static async getLibraryItems(
+ adapter: LibraryPersistenceAdapter,
+ source: LibraryAdatapterSource,
+ _queue = true,
+ ): Promise<LibraryItems> {
+ const task = () =>
+ new Promise<LibraryItems>(async (resolve, reject) => {
+ try {
+ const data = await adapter.load({ source });
+ resolve(restoreLibraryItems(data?.libraryItems || [], "published"));
+ } catch (error: any) {
+ reject(error);
+ }
+ });
+
+ if (_queue) {
+ return AdapterTransaction.queue.push(task);
+ }
+
+ return task();
+ }
+
+ static run = async <T>(
+ adapter: LibraryPersistenceAdapter,
+ fn: (transaction: AdapterTransaction) => Promise<T>,
+ ) => {
+ const transaction = new AdapterTransaction(adapter);
+ return AdapterTransaction.queue.push(() => fn(transaction));
+ };
+
+ // ------------------
+
+ private adapter: LibraryPersistenceAdapter;
+
+ constructor(adapter: LibraryPersistenceAdapter) {
+ this.adapter = adapter;
+ }
+
+ getLibraryItems(source: LibraryAdatapterSource) {
+ return AdapterTransaction.getLibraryItems(this.adapter, source, false);
+ }
+}
+
+let lastSavedLibraryItemsHash = 0;
+let librarySaveCounter = 0;
+
+export const getLibraryItemsHash = (items: LibraryItems) => {
+ return hashString(
+ items
+ .map((item) => {
+ return `${item.id}:${hashElementsVersion(item.elements)}`;
+ })
+ .sort()
+ .join(),
+ );
+};
+
+const persistLibraryUpdate = async (
+ adapter: LibraryPersistenceAdapter,
+ update: LibraryUpdate,
+): Promise<LibraryItems> => {
+ try {
+ librarySaveCounter++;
+
+ return await AdapterTransaction.run(adapter, async (transaction) => {
+ const nextLibraryItemsMap = arrayToMap(
+ await transaction.getLibraryItems("save"),
+ );
+
+ for (const [id] of update.deletedItems) {
+ nextLibraryItemsMap.delete(id);
+ }
+
+ const addedItems: LibraryItem[] = [];
+
+ // we want to merge current library items with the ones stored in the
+ // DB so that we don't lose any elements that for some reason aren't
+ // in the current editor library, which could happen when:
+ //
+ // 1. we haven't received an update deleting some elements
+ // (in which case it's still better to keep them in the DB lest
+ // it was due to a different reason)
+ // 2. we keep a single DB for all active editors, but the editors'
+ // libraries aren't synced or there's a race conditions during
+ // syncing
+ // 3. some other race condition, e.g. during init where emit updates
+ // for partial updates (e.g. you install a 3rd party library and
+ // init from DB only after — we emit events for both updates)
+ for (const [id, item] of update.addedItems) {
+ if (nextLibraryItemsMap.has(id)) {
+ // replace item with latest version
+ // TODO we could prefer the newer item instead
+ nextLibraryItemsMap.set(id, item);
+ } else {
+ // we want to prepend the new items with the ones that are already
+ // in DB to preserve the ordering we do in editor (newly added
+ // items are added to the beginning)
+ addedItems.push(item);
+ }
+ }
+
+ const nextLibraryItems = addedItems.concat(
+ Array.from(nextLibraryItemsMap.values()),
+ );
+
+ const version = getLibraryItemsHash(nextLibraryItems);
+
+ if (version !== lastSavedLibraryItemsHash) {
+ await adapter.save({ libraryItems: nextLibraryItems });
+ }
+
+ lastSavedLibraryItemsHash = version;
+
+ return nextLibraryItems;
+ });
+ } finally {
+ librarySaveCounter--;
+ }
+};
+
+export const useHandleLibrary = (
+ opts: {
+ excalidrawAPI: ExcalidrawImperativeAPI | null;
+ /**
+ * Return `true` if the library install url should be allowed.
+ * If not supplied, only the excalidraw.com base domain is allowed.
+ */
+ validateLibraryUrl?: (libraryUrl: string) => boolean;
+ } & (
+ | {
+ /** @deprecated we recommend using `opts.adapter` instead */
+ getInitialLibraryItems?: () => MaybePromise<LibraryItemsSource>;
+ }
+ | {
+ adapter: LibraryPersistenceAdapter;
+ /**
+ * Adapter that takes care of loading data from legacy data store.
+ * Supply this if you want to migrate data on initial load from legacy
+ * data store.
+ *
+ * Can be a different LibraryPersistenceAdapter.
+ */
+ migrationAdapter?: LibraryMigrationAdapter;
+ }
+ ),
+) => {
+ const { excalidrawAPI } = opts;
+
+ const optsRef = useRef(opts);
+ optsRef.current = opts;
+
+ const isLibraryLoadedRef = useRef(false);
+
+ useEffect(() => {
+ if (!excalidrawAPI) {
+ return;
+ }
+
+ // reset on editor remount (excalidrawAPI changed)
+ isLibraryLoadedRef.current = false;
+
+ const importLibraryFromURL = async ({
+ libraryUrl,
+ idToken,
+ }: {
+ libraryUrl: string;
+ idToken: string | null;
+ }) => {
+ const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
+ try {
+ libraryUrl = decodeURIComponent(libraryUrl);
+
+ libraryUrl = toValidURL(libraryUrl);
+
+ validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl);
+
+ const request = await fetch(libraryUrl);
+ const blob = await request.blob();
+ resolve(blob);
+ } catch (error: any) {
+ reject(error);
+ }
+ });
+
+ const shouldPrompt = idToken !== excalidrawAPI.id;
+
+ // wait for the tab to be focused before continuing in case we'll prompt
+ // for confirmation
+ await (shouldPrompt && document.hidden
+ ? new Promise<void>((resolve) => {
+ window.addEventListener("focus", () => resolve(), {
+ once: true,
+ });
+ })
+ : null);
+
+ try {
+ await excalidrawAPI.updateLibrary({
+ libraryItems: libraryPromise,
+ prompt: shouldPrompt,
+ merge: true,
+ defaultStatus: "published",
+ openLibraryMenu: true,
+ });
+ } catch (error: any) {
+ excalidrawAPI.updateScene({
+ appState: {
+ errorMessage: error.message,
+ },
+ });
+ throw error;
+ } finally {
+ if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
+ const hash = new URLSearchParams(window.location.hash.slice(1));
+ hash.delete(URL_HASH_KEYS.addLibrary);
+ window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
+ } else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
+ const query = new URLSearchParams(window.location.search);
+ query.delete(URL_QUERY_KEYS.addLibrary);
+ window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
+ }
+ }
+ };
+ const onHashChange = (event: HashChangeEvent) => {
+ event.preventDefault();
+ const libraryUrlTokens = parseLibraryTokensFromUrl();
+ if (libraryUrlTokens) {
+ event.stopImmediatePropagation();
+ // If hash changed and it contains library url, import it and replace
+ // the url to its previous state (important in case of collaboration
+ // and similar).
+ // Using history API won't trigger another hashchange.
+ window.history.replaceState({}, "", event.oldURL);
+
+ importLibraryFromURL(libraryUrlTokens);
+ }
+ };
+
+ // -------------------------------------------------------------------------
+ // ---------------------------------- init ---------------------------------
+ // -------------------------------------------------------------------------
+
+ const libraryUrlTokens = parseLibraryTokensFromUrl();
+
+ if (libraryUrlTokens) {
+ importLibraryFromURL(libraryUrlTokens);
+ }
+
+ // ------ (A) init load (legacy) -------------------------------------------
+ if (
+ "getInitialLibraryItems" in optsRef.current &&
+ optsRef.current.getInitialLibraryItems
+ ) {
+ console.warn(
+ "useHandleLibrar `opts.getInitialLibraryItems` is deprecated. Use `opts.adapter` instead.",
+ );
+
+ Promise.resolve(optsRef.current.getInitialLibraryItems())
+ .then((libraryItems) => {
+ excalidrawAPI.updateLibrary({
+ libraryItems,
+ // merge with current library items because we may have already
+ // populated it (e.g. by installing 3rd party library which can
+ // happen before the DB data is loaded)
+ merge: true,
+ });
+ })
+ .catch((error: any) => {
+ console.error(
+ `UseHandeLibrary getInitialLibraryItems failed: ${error?.message}`,
+ );
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // --------------------------------------------------------- init load -----
+ // -------------------------------------------------------------------------
+
+ // ------ (B) data source adapter ------------------------------------------
+
+ if ("adapter" in optsRef.current && optsRef.current.adapter) {
+ const adapter = optsRef.current.adapter;
+ const migrationAdapter = optsRef.current.migrationAdapter;
+
+ const initDataPromise = resolvablePromise<LibraryItems | null>();
+
+ // migrate from old data source if needed
+ // (note, if `migrate` function is defined, we always migrate even
+ // if the data has already been migrated. In that case it'll be a no-op,
+ // though with several unnecessary steps — we will still load latest
+ // DB data during the `persistLibraryChange()` step)
+ // -----------------------------------------------------------------------
+ if (migrationAdapter) {
+ initDataPromise.resolve(
+ promiseTry(migrationAdapter.load)
+ .then(async (libraryData) => {
+ let restoredData: LibraryItems | null = null;
+ try {
+ // if no library data to migrate, assume no migration needed
+ // and skip persisting to new data store, as well as well
+ // clearing the old store via `migrationAdapter.clear()`
+ if (!libraryData) {
+ return AdapterTransaction.getLibraryItems(adapter, "load");
+ }
+
+ restoredData = restoreLibraryItems(
+ libraryData.libraryItems || [],
+ "published",
+ );
+
+ // we don't queue this operation because it's running inside
+ // a promise that's running inside Library update queue itself
+ const nextItems = await persistLibraryUpdate(
+ adapter,
+ createLibraryUpdate([], restoredData),
+ );
+ try {
+ await migrationAdapter.clear();
+ } catch (error: any) {
+ console.error(
+ `couldn't delete legacy library data: ${error.message}`,
+ );
+ }
+ // migration suceeded, load migrated data
+ return nextItems;
+ } catch (error: any) {
+ console.error(
+ `couldn't migrate legacy library data: ${error.message}`,
+ );
+ // migration failed, load data from previous store, if any
+ return restoredData;
+ }
+ })
+ // errors caught during `migrationAdapter.load()`
+ .catch((error: any) => {
+ console.error(`error during library migration: ${error.message}`);
+ // as a default, load latest library from current data source
+ return AdapterTransaction.getLibraryItems(adapter, "load");
+ }),
+ );
+ } else {
+ initDataPromise.resolve(
+ promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"),
+ );
+ }
+
+ // load initial (or migrated) library
+ excalidrawAPI
+ .updateLibrary({
+ libraryItems: initDataPromise.then((libraryItems) => {
+ const _libraryItems = libraryItems || [];
+ lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems);
+ return _libraryItems;
+ }),
+ // merge with current library items because we may have already
+ // populated it (e.g. by installing 3rd party library which can
+ // happen before the DB data is loaded)
+ merge: true,
+ })
+ .finally(() => {
+ isLibraryLoadedRef.current = true;
+ });
+ }
+ // ---------------------------------------------- data source datapter -----
+
+ window.addEventListener(EVENT.HASHCHANGE, onHashChange);
+ return () => {
+ window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
+ };
+ }, [
+ // important this useEffect only depends on excalidrawAPI so it only reruns
+ // on editor remounts (the excalidrawAPI changes)
+ excalidrawAPI,
+ ]);
+
+ // This effect is run without excalidrawAPI dependency so that host apps
+ // can run this hook outside of an active editor instance and the library
+ // update queue/loop survives editor remounts
+ //
+ // This effect is still only meant to be run if host apps supply an persitence
+ // adapter. If we don't have access to it, it the update listener doesn't
+ // do anything.
+ useEffect(
+ () => {
+ // on update, merge with current library items and persist
+ // -----------------------------------------------------------------------
+ const unsubOnLibraryUpdate = onLibraryUpdateEmitter.on(
+ async (update, nextLibraryItems) => {
+ const isLoaded = isLibraryLoadedRef.current;
+ // we want to operate with the latest adapter, but we don't want this
+ // effect to rerun on every adapter change in case host apps' adapter
+ // isn't stable
+ const adapter =
+ ("adapter" in optsRef.current && optsRef.current.adapter) || null;
+ try {
+ if (adapter) {
+ if (
+ // if nextLibraryItems hash identical to previously saved hash,
+ // exit early, even if actual upstream state ends up being
+ // different (e.g. has more data than we have locally), as it'd
+ // be low-impact scenario.
+ lastSavedLibraryItemsHash !==
+ getLibraryItemsHash(nextLibraryItems)
+ ) {
+ await persistLibraryUpdate(adapter, update);
+ }
+ }
+ } catch (error: any) {
+ console.error(
+ `couldn't persist library update: ${error.message}`,
+ update,
+ );
+
+ // currently we only show error if an editor is loaded
+ if (isLoaded && optsRef.current.excalidrawAPI) {
+ optsRef.current.excalidrawAPI.updateScene({
+ appState: {
+ errorMessage: t("errors.saveLibraryError"),
+ },
+ });
+ }
+ }
+ },
+ );
+
+ const onUnload = (event: Event) => {
+ if (librarySaveCounter) {
+ preventUnload(event);
+ }
+ };
+
+ window.addEventListener(EVENT.BEFORE_UNLOAD, onUnload);
+
+ return () => {
+ window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload);
+ unsubOnLibraryUpdate();
+ lastSavedLibraryItemsHash = 0;
+ librarySaveCounter = 0;
+ };
+ },
+ [
+ // this effect must not have any deps so it doesn't rerun
+ ],
+ );
+};
diff --git a/packages/excalidraw/data/reconcile.ts b/packages/excalidraw/data/reconcile.ts
new file mode 100644
index 0000000..fa4cff8
--- /dev/null
+++ b/packages/excalidraw/data/reconcile.ts
@@ -0,0 +1,118 @@
+import throttle from "lodash.throttle";
+import { ENV } from "../constants";
+import type { OrderedExcalidrawElement } from "../element/types";
+import {
+ orderByFractionalIndex,
+ syncInvalidIndices,
+ validateFractionalIndices,
+} from "../fractionalIndex";
+import type { AppState } from "../types";
+import type { MakeBrand } from "../utility-types";
+import { arrayToMap } from "../utils";
+
+export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
+ MakeBrand<"ReconciledElement">;
+
+export type RemoteExcalidrawElement = OrderedExcalidrawElement &
+ MakeBrand<"RemoteExcalidrawElement">;
+
+const shouldDiscardRemoteElement = (
+ localAppState: AppState,
+ local: OrderedExcalidrawElement | undefined,
+ remote: RemoteExcalidrawElement,
+): boolean => {
+ if (
+ local &&
+ // local element is being edited
+ (local.id === localAppState.editingTextElement?.id ||
+ local.id === localAppState.resizingElement?.id ||
+ local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
+ // local element is newer
+ local.version > remote.version ||
+ // resolve conflicting edits deterministically by taking the one with
+ // the lowest versionNonce
+ (local.version === remote.version &&
+ local.versionNonce < remote.versionNonce))
+ ) {
+ return true;
+ }
+ return false;
+};
+
+const validateIndicesThrottled = throttle(
+ (
+ orderedElements: readonly OrderedExcalidrawElement[],
+ localElements: readonly OrderedExcalidrawElement[],
+ remoteElements: readonly RemoteExcalidrawElement[],
+ ) => {
+ if (
+ import.meta.env.DEV ||
+ import.meta.env.MODE === ENV.TEST ||
+ window?.DEBUG_FRACTIONAL_INDICES
+ ) {
+ // create new instances due to the mutation
+ const elements = syncInvalidIndices(
+ orderedElements.map((x) => ({ ...x })),
+ );
+
+ validateFractionalIndices(elements, {
+ // throw in dev & test only, to remain functional on `DEBUG_FRACTIONAL_INDICES`
+ shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST,
+ includeBoundTextValidation: true,
+ reconciliationContext: {
+ localElements,
+ remoteElements,
+ },
+ });
+ }
+ },
+ 1000 * 60,
+ { leading: true, trailing: false },
+);
+
+export const reconcileElements = (
+ localElements: readonly OrderedExcalidrawElement[],
+ remoteElements: readonly RemoteExcalidrawElement[],
+ localAppState: AppState,
+): ReconciledExcalidrawElement[] => {
+ const localElementsMap = arrayToMap(localElements);
+ const reconciledElements: OrderedExcalidrawElement[] = [];
+ const added = new Set<string>();
+
+ // process remote elements
+ for (const remoteElement of remoteElements) {
+ if (!added.has(remoteElement.id)) {
+ const localElement = localElementsMap.get(remoteElement.id);
+ const discardRemoteElement = shouldDiscardRemoteElement(
+ localAppState,
+ localElement,
+ remoteElement,
+ );
+
+ if (localElement && discardRemoteElement) {
+ reconciledElements.push(localElement);
+ added.add(localElement.id);
+ } else {
+ reconciledElements.push(remoteElement);
+ added.add(remoteElement.id);
+ }
+ }
+ }
+
+ // process remaining local elements
+ for (const localElement of localElements) {
+ if (!added.has(localElement.id)) {
+ reconciledElements.push(localElement);
+ added.add(localElement.id);
+ }
+ }
+
+ const orderedElements = orderByFractionalIndex(reconciledElements);
+
+ validateIndicesThrottled(orderedElements, localElements, remoteElements);
+
+ // de-duplicate indices
+ syncInvalidIndices(orderedElements);
+
+ return orderedElements as ReconciledExcalidrawElement[];
+};
diff --git a/packages/excalidraw/data/resave.ts b/packages/excalidraw/data/resave.ts
new file mode 100644
index 0000000..6249184
--- /dev/null
+++ b/packages/excalidraw/data/resave.ts
@@ -0,0 +1,41 @@
+import type { ExcalidrawElement } from "../element/types";
+import type { AppState, BinaryFiles } from "../types";
+import { exportCanvas, prepareElementsForExport } from ".";
+import { getFileHandleType, isImageFileHandleType } from "./blob";
+
+export const resaveAsImageWithScene = async (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ files: BinaryFiles,
+ name: string,
+) => {
+ const { exportBackground, viewBackgroundColor, fileHandle } = appState;
+
+ const fileHandleType = getFileHandleType(fileHandle);
+
+ if (!fileHandle || !isImageFileHandleType(fileHandleType)) {
+ throw new Error(
+ "fileHandle should exist and should be of type svg or png when resaving",
+ );
+ }
+ appState = {
+ ...appState,
+ exportEmbedScene: true,
+ };
+
+ const { exportedElements, exportingFrame } = prepareElementsForExport(
+ elements,
+ appState,
+ false,
+ );
+
+ await exportCanvas(fileHandleType, exportedElements, appState, files, {
+ exportBackground,
+ viewBackgroundColor,
+ name,
+ fileHandle,
+ exportingFrame,
+ });
+
+ return { fileHandle };
+};
diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts
new file mode 100644
index 0000000..c4e45b0
--- /dev/null
+++ b/packages/excalidraw/data/restore.ts
@@ -0,0 +1,813 @@
+import type {
+ ExcalidrawArrowElement,
+ ExcalidrawElbowArrowElement,
+ ExcalidrawElement,
+ ExcalidrawElementType,
+ ExcalidrawLinearElement,
+ ExcalidrawSelectionElement,
+ ExcalidrawTextElement,
+ FixedPointBinding,
+ FontFamilyValues,
+ NonDeletedSceneElementsMap,
+ OrderedExcalidrawElement,
+ PointBinding,
+ StrokeRoundness,
+} from "../element/types";
+import type { AppState, BinaryFiles, LibraryItem } from "../types";
+import type { ImportedDataState, LegacyAppState } from "./types";
+import {
+ getNonDeletedElements,
+ getNormalizedDimensions,
+ isInvisiblySmallElement,
+ refreshTextDimensions,
+} from "../element";
+import {
+ isArrowElement,
+ isElbowArrow,
+ isFixedPointBinding,
+ isLinearElement,
+ isTextElement,
+ isUsingAdaptiveRadius,
+} from "../element/typeChecks";
+import { randomId } from "../random";
+import {
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_TEXT_ALIGN,
+ DEFAULT_VERTICAL_ALIGN,
+ FONT_FAMILY,
+ ROUNDNESS,
+ DEFAULT_SIDEBAR,
+ DEFAULT_ELEMENT_PROPS,
+ DEFAULT_GRID_SIZE,
+ DEFAULT_GRID_STEP,
+} from "../constants";
+import { getDefaultAppState } from "../appState";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import { bumpVersion } from "../element/mutateElement";
+import { getUpdatedTimestamp, updateActiveTool } from "../utils";
+import { arrayToMap } from "../utils";
+import type { MarkOptional, Mutable } from "../utility-types";
+import { getContainerElement } from "../element/textElement";
+import { normalizeLink } from "./url";
+import { syncInvalidIndices } from "../fractionalIndex";
+import { getSizeFromPoints } from "../points";
+import { getLineHeight } from "../fonts";
+import { normalizeFixedPoint } from "../element/binding";
+import {
+ getNormalizedGridSize,
+ getNormalizedGridStep,
+ getNormalizedZoom,
+} from "../scene";
+import type { LocalPoint, Radians } from "@excalidraw/math";
+import { isFiniteNumber, pointFrom } from "@excalidraw/math";
+import { detectLineHeight } from "../element/textMeasurements";
+import {
+ updateElbowArrowPoints,
+ validateElbowPoints,
+} from "../element/elbowArrow";
+
+type RestoredAppState = Omit<
+ AppState,
+ "offsetTop" | "offsetLeft" | "width" | "height"
+>;
+
+export const AllowedExcalidrawActiveTools: Record<
+ AppState["activeTool"]["type"],
+ boolean
+> = {
+ selection: true,
+ text: true,
+ rectangle: true,
+ diamond: true,
+ ellipse: true,
+ line: true,
+ image: true,
+ arrow: true,
+ freedraw: true,
+ eraser: false,
+ custom: true,
+ frame: true,
+ embeddable: true,
+ hand: true,
+ laser: false,
+ magicframe: false,
+};
+
+export type RestoredDataState = {
+ elements: OrderedExcalidrawElement[];
+ appState: RestoredAppState;
+ files: BinaryFiles;
+};
+
+const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
+ if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
+ return FONT_FAMILY[
+ fontFamilyName as keyof typeof FONT_FAMILY
+ ] as FontFamilyValues;
+ }
+ return DEFAULT_FONT_FAMILY;
+};
+
+const repairBinding = <T extends ExcalidrawLinearElement>(
+ element: T,
+ binding: PointBinding | FixedPointBinding | null,
+): T extends ExcalidrawElbowArrowElement
+ ? FixedPointBinding | null
+ : PointBinding | FixedPointBinding | null => {
+ if (!binding) {
+ return null;
+ }
+
+ const focus = binding.focus || 0;
+
+ if (isElbowArrow(element)) {
+ const fixedPointBinding:
+ | ExcalidrawElbowArrowElement["startBinding"]
+ | ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
+ ? {
+ ...binding,
+ focus,
+ fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
+ }
+ : null;
+
+ return fixedPointBinding;
+ }
+
+ return {
+ ...binding,
+ focus,
+ } as T extends ExcalidrawElbowArrowElement
+ ? FixedPointBinding | null
+ : PointBinding | FixedPointBinding | null;
+};
+
+const restoreElementWithProperties = <
+ T extends Required<Omit<ExcalidrawElement, "customData">> & {
+ customData?: ExcalidrawElement["customData"];
+ /** @deprecated */
+ boundElementIds?: readonly ExcalidrawElement["id"][];
+ /** @deprecated */
+ strokeSharpness?: StrokeRoundness;
+ },
+ K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
+>(
+ element: T,
+ extra: Pick<
+ T,
+ // This extra Pick<T, keyof K> ensure no excess properties are passed.
+ // @ts-ignore TS complains here but type checks the call sites fine.
+ keyof K
+ > &
+ Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
+): T => {
+ const base: Pick<T, keyof ExcalidrawElement> = {
+ type: extra.type || element.type,
+ // all elements must have version > 0 so getSceneVersion() will pick up
+ // newly added elements
+ version: element.version || 1,
+ versionNonce: element.versionNonce ?? 0,
+ index: element.index ?? null,
+ isDeleted: element.isDeleted ?? false,
+ id: element.id || randomId(),
+ fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
+ strokeWidth: element.strokeWidth || DEFAULT_ELEMENT_PROPS.strokeWidth,
+ strokeStyle: element.strokeStyle ?? DEFAULT_ELEMENT_PROPS.strokeStyle,
+ roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
+ opacity:
+ element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
+ angle: element.angle || (0 as Radians),
+ x: extra.x ?? element.x ?? 0,
+ y: extra.y ?? element.y ?? 0,
+ strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
+ backgroundColor:
+ element.backgroundColor || DEFAULT_ELEMENT_PROPS.backgroundColor,
+ width: element.width || 0,
+ height: element.height || 0,
+ seed: element.seed ?? 1,
+ groupIds: element.groupIds ?? [],
+ frameId: element.frameId ?? null,
+ roundness: element.roundness
+ ? element.roundness
+ : element.strokeSharpness === "round"
+ ? {
+ // for old elements that would now use adaptive radius algo,
+ // use legacy algo instead
+ type: isUsingAdaptiveRadius(element.type)
+ ? ROUNDNESS.LEGACY
+ : ROUNDNESS.PROPORTIONAL_RADIUS,
+ }
+ : null,
+ boundElements: element.boundElementIds
+ ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
+ : element.boundElements ?? [],
+ updated: element.updated ?? getUpdatedTimestamp(),
+ link: element.link ? normalizeLink(element.link) : null,
+ locked: element.locked ?? false,
+ };
+
+ if ("customData" in element || "customData" in extra) {
+ base.customData =
+ "customData" in extra ? extra.customData : element.customData;
+ }
+
+ return {
+ // spread the original element properties to not lose unknown ones
+ // for forward-compatibility
+ ...element,
+ // normalized properties
+ ...base,
+ ...getNormalizedDimensions(base),
+ ...extra,
+ } as unknown as T;
+};
+
+const restoreElement = (
+ element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
+): typeof element | null => {
+ element = { ...element };
+
+ switch (element.type) {
+ case "text":
+ // temp fix: cleanup legacy obsidian-excalidraw attribute else it'll
+ // conflict when porting between the apps
+ delete (element as any).rawText;
+
+ let fontSize = element.fontSize;
+ let fontFamily = element.fontFamily;
+ if ("font" in element) {
+ const [fontPx, _fontFamily]: [string, string] = (
+ element as any
+ ).font.split(" ");
+ fontSize = parseFloat(fontPx);
+ fontFamily = getFontFamilyByName(_fontFamily);
+ }
+ const text = (typeof element.text === "string" && element.text) || "";
+
+ // line-height might not be specified either when creating elements
+ // programmatically, or when importing old diagrams.
+ // For the latter we want to detect the original line height which
+ // will likely differ from our per-font fixed line height we now use,
+ // to maintain backward compatibility.
+ const lineHeight =
+ element.lineHeight ||
+ (element.height
+ ? // detect line-height from current element height and font-size
+ detectLineHeight(element)
+ : // no element height likely means programmatic use, so default
+ // to a fixed line height
+ getLineHeight(element.fontFamily));
+ element = restoreElementWithProperties(element, {
+ fontSize,
+ fontFamily,
+ text,
+ textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
+ verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
+ containerId: element.containerId ?? null,
+ originalText: element.originalText || text,
+ autoResize: element.autoResize ?? true,
+ lineHeight,
+ });
+
+ // if empty text, mark as deleted. We keep in array
+ // for data integrity purposes (collab etc.)
+ if (!text && !element.isDeleted) {
+ element = { ...element, originalText: text, isDeleted: true };
+ element = bumpVersion(element);
+ }
+
+ return element;
+ case "freedraw": {
+ return restoreElementWithProperties(element, {
+ points: element.points,
+ lastCommittedPoint: null,
+ simulatePressure: element.simulatePressure,
+ pressures: element.pressures,
+ });
+ }
+ case "image":
+ return restoreElementWithProperties(element, {
+ status: element.status || "pending",
+ fileId: element.fileId,
+ scale: element.scale || [1, 1],
+ crop: element.crop ?? null,
+ });
+ case "line":
+ // @ts-ignore LEGACY type
+ // eslint-disable-next-line no-fallthrough
+ case "draw":
+ const { startArrowhead = null, endArrowhead = null } = element;
+ let x = element.x;
+ let y = element.y;
+ let points = // migrate old arrow model to new one
+ !Array.isArray(element.points) || element.points.length < 2
+ ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
+ : element.points;
+
+ if (points[0][0] !== 0 || points[0][1] !== 0) {
+ ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
+ }
+
+ return restoreElementWithProperties(element, {
+ type:
+ (element.type as ExcalidrawElementType | "draw") === "draw"
+ ? "line"
+ : element.type,
+ startBinding: repairBinding(element, element.startBinding),
+ endBinding: repairBinding(element, element.endBinding),
+ lastCommittedPoint: null,
+ startArrowhead,
+ endArrowhead,
+ points,
+ x,
+ y,
+ ...getSizeFromPoints(points),
+ });
+ case "arrow": {
+ const { startArrowhead = null, endArrowhead = "arrow" } = element;
+ let x: number | undefined = element.x;
+ let y: number | undefined = element.y;
+ let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
+ !Array.isArray(element.points) || element.points.length < 2
+ ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
+ : element.points;
+
+ if (points[0][0] !== 0 || points[0][1] !== 0) {
+ ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
+ }
+
+ const base = {
+ type: element.type,
+ startBinding: repairBinding(element, element.startBinding),
+ endBinding: repairBinding(element, element.endBinding),
+ lastCommittedPoint: null,
+ startArrowhead,
+ endArrowhead,
+ points,
+ x,
+ y,
+ elbowed: (element as ExcalidrawArrowElement).elbowed,
+ ...getSizeFromPoints(points),
+ } as const;
+
+ // TODO: Separate arrow from linear element
+ return isElbowArrow(element)
+ ? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
+ ...base,
+ elbowed: true,
+ startBinding: repairBinding(element, element.startBinding),
+ endBinding: repairBinding(element, element.endBinding),
+ fixedSegments: element.fixedSegments,
+ startIsSpecial: element.startIsSpecial,
+ endIsSpecial: element.endIsSpecial,
+ })
+ : restoreElementWithProperties(element as ExcalidrawArrowElement, base);
+ }
+
+ // generic elements
+ case "ellipse":
+ case "rectangle":
+ case "diamond":
+ case "iframe":
+ case "embeddable":
+ return restoreElementWithProperties(element, {});
+ case "magicframe":
+ case "frame":
+ return restoreElementWithProperties(element, {
+ name: element.name ?? null,
+ });
+
+ // Don't use default case so as to catch a missing an element type case.
+ // We also don't want to throw, but instead return void so we filter
+ // out these unsupported elements from the restored array.
+ }
+ return null;
+};
+
+/**
+ * Repairs container element's boundElements array by removing duplicates and
+ * fixing containerId of bound elements if not present. Also removes any
+ * bound elements that do not exist in the elements array.
+ *
+ * NOTE mutates elements.
+ */
+const repairContainerElement = (
+ container: Mutable<ExcalidrawElement>,
+ elementsMap: Map<string, Mutable<ExcalidrawElement>>,
+) => {
+ if (container.boundElements) {
+ // copy because we're not cloning on restore, and we don't want to mutate upstream
+ const boundElements = container.boundElements.slice();
+
+ // dedupe bindings & fix boundElement.containerId if not set already
+ const boundIds = new Set<ExcalidrawElement["id"]>();
+ container.boundElements = boundElements.reduce(
+ (
+ acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
+ binding,
+ ) => {
+ const boundElement = elementsMap.get(binding.id);
+ if (boundElement && !boundIds.has(binding.id)) {
+ boundIds.add(binding.id);
+
+ if (boundElement.isDeleted) {
+ return acc;
+ }
+
+ acc.push(binding);
+
+ if (
+ isTextElement(boundElement) &&
+ // being slightly conservative here, preserving existing containerId
+ // if defined, lest boundElements is stale
+ !boundElement.containerId
+ ) {
+ (boundElement as Mutable<ExcalidrawTextElement>).containerId =
+ container.id;
+ }
+ }
+ return acc;
+ },
+ [],
+ );
+ }
+};
+
+/**
+ * Repairs target bound element's container's boundElements array,
+ * or removes contaienrId if container does not exist.
+ *
+ * NOTE mutates elements.
+ */
+const repairBoundElement = (
+ boundElement: Mutable<ExcalidrawTextElement>,
+ elementsMap: Map<string, Mutable<ExcalidrawElement>>,
+) => {
+ const container = boundElement.containerId
+ ? elementsMap.get(boundElement.containerId)
+ : null;
+
+ if (!container) {
+ boundElement.containerId = null;
+ return;
+ }
+
+ if (boundElement.isDeleted) {
+ return;
+ }
+
+ if (
+ container.boundElements &&
+ !container.boundElements.find((binding) => binding.id === boundElement.id)
+ ) {
+ // copy because we're not cloning on restore, and we don't want to mutate upstream
+ const boundElements = (
+ container.boundElements || (container.boundElements = [])
+ ).slice();
+ boundElements.push({ type: "text", id: boundElement.id });
+ container.boundElements = boundElements;
+ }
+};
+
+/**
+ * Remove an element's frameId if its containing frame is non-existent
+ *
+ * NOTE mutates elements.
+ */
+const repairFrameMembership = (
+ element: Mutable<ExcalidrawElement>,
+ elementsMap: Map<string, Mutable<ExcalidrawElement>>,
+) => {
+ if (element.frameId) {
+ const containingFrame = elementsMap.get(element.frameId);
+
+ if (!containingFrame) {
+ element.frameId = null;
+ }
+ }
+};
+
+export const restoreElements = (
+ elements: ImportedDataState["elements"],
+ /** NOTE doesn't serve for reconciliation */
+ localElements: readonly ExcalidrawElement[] | null | undefined,
+ opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
+): OrderedExcalidrawElement[] => {
+ // used to detect duplicate top-level element ids
+ const existingIds = new Set<string>();
+ const localElementsMap = localElements ? arrayToMap(localElements) : null;
+ const restoredElements = syncInvalidIndices(
+ (elements || []).reduce((elements, element) => {
+ // filtering out selection, which is legacy, no longer kept in elements,
+ // and causing issues if retained
+ if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
+ let migratedElement: ExcalidrawElement | null = restoreElement(element);
+ if (migratedElement) {
+ const localElement = localElementsMap?.get(element.id);
+ if (localElement && localElement.version > migratedElement.version) {
+ migratedElement = bumpVersion(
+ migratedElement,
+ localElement.version,
+ );
+ }
+ if (existingIds.has(migratedElement.id)) {
+ migratedElement = { ...migratedElement, id: randomId() };
+ }
+ existingIds.add(migratedElement.id);
+
+ elements.push(migratedElement);
+ }
+ }
+ return elements;
+ }, [] as ExcalidrawElement[]),
+ );
+
+ if (!opts?.repairBindings) {
+ return restoredElements;
+ }
+
+ // repair binding. Mutates elements.
+ const restoredElementsMap = arrayToMap(restoredElements);
+ for (const element of restoredElements) {
+ if (element.frameId) {
+ repairFrameMembership(element, restoredElementsMap);
+ }
+
+ if (isTextElement(element) && element.containerId) {
+ repairBoundElement(element, restoredElementsMap);
+ } else if (element.boundElements) {
+ repairContainerElement(element, restoredElementsMap);
+ }
+
+ if (opts.refreshDimensions && isTextElement(element)) {
+ Object.assign(
+ element,
+ refreshTextDimensions(
+ element,
+ getContainerElement(element, restoredElementsMap),
+ restoredElementsMap,
+ ),
+ );
+ }
+
+ if (isLinearElement(element)) {
+ if (
+ element.startBinding &&
+ (!restoredElementsMap.has(element.startBinding.elementId) ||
+ !isArrowElement(element))
+ ) {
+ (element as Mutable<ExcalidrawLinearElement>).startBinding = null;
+ }
+ if (
+ element.endBinding &&
+ (!restoredElementsMap.has(element.endBinding.elementId) ||
+ !isArrowElement(element))
+ ) {
+ (element as Mutable<ExcalidrawLinearElement>).endBinding = null;
+ }
+ }
+ }
+
+ // NOTE (mtolmacs): Temporary fix for extremely large arrows
+ // Need to iterate again so we have attached text nodes in elementsMap
+ return restoredElements.map((element) => {
+ if (
+ isElbowArrow(element) &&
+ element.startBinding == null &&
+ element.endBinding == null &&
+ !validateElbowPoints(element.points)
+ ) {
+ return {
+ ...element,
+ ...updateElbowArrowPoints(
+ element,
+ restoredElementsMap as NonDeletedSceneElementsMap,
+ {
+ points: [
+ pointFrom<LocalPoint>(0, 0),
+ element.points[element.points.length - 1],
+ ],
+ },
+ ),
+ index: element.index,
+ };
+ }
+
+ if (
+ isElbowArrow(element) &&
+ element.startBinding &&
+ element.endBinding &&
+ element.startBinding.elementId === element.endBinding.elementId &&
+ element.points.length > 1 &&
+ element.points.some(
+ ([rx, ry]) => Math.abs(rx) > 1e6 || Math.abs(ry) > 1e6,
+ )
+ ) {
+ console.error("Fixing self-bound elbow arrow", element.id);
+ const boundElement = restoredElementsMap.get(
+ element.startBinding.elementId,
+ );
+ if (!boundElement) {
+ console.error(
+ "Bound element not found",
+ element.startBinding.elementId,
+ );
+ return element;
+ }
+
+ return {
+ ...element,
+ x: boundElement.x + boundElement.width / 2,
+ y: boundElement.y - 5,
+ width: boundElement.width,
+ height: boundElement.height,
+ points: [
+ pointFrom<LocalPoint>(0, 0),
+ pointFrom<LocalPoint>(0, -10),
+ pointFrom<LocalPoint>(boundElement.width / 2 + 5, -10),
+ pointFrom<LocalPoint>(
+ boundElement.width / 2 + 5,
+ boundElement.height / 2 + 5,
+ ),
+ ],
+ };
+ }
+
+ return element;
+ });
+};
+
+const coalesceAppStateValue = <
+ T extends keyof ReturnType<typeof getDefaultAppState>,
+>(
+ key: T,
+ appState: Exclude<ImportedDataState["appState"], null | undefined>,
+ defaultAppState: ReturnType<typeof getDefaultAppState>,
+) => {
+ const value = appState[key];
+ // NOTE the value! assertion is needed in TS 4.5.5 (fixed in newer versions)
+ return value !== undefined ? value! : defaultAppState[key];
+};
+
+const LegacyAppStateMigrations: {
+ [K in keyof LegacyAppState]: (
+ ImportedDataState: Exclude<ImportedDataState["appState"], null | undefined>,
+ defaultAppState: ReturnType<typeof getDefaultAppState>,
+ ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
+} = {
+ isSidebarDocked: (appState, defaultAppState) => {
+ return [
+ "defaultSidebarDockedPreference",
+ appState.isSidebarDocked ??
+ coalesceAppStateValue(
+ "defaultSidebarDockedPreference",
+ appState,
+ defaultAppState,
+ ),
+ ];
+ },
+};
+
+export const restoreAppState = (
+ appState: ImportedDataState["appState"],
+ localAppState: Partial<AppState> | null | undefined,
+): RestoredAppState => {
+ appState = appState || {};
+ const defaultAppState = getDefaultAppState();
+ const nextAppState = {} as typeof defaultAppState;
+
+ // first, migrate all legacy AppState properties to new ones. We do it
+ // in one go before migrate the rest of the properties in case the new ones
+ // depend on checking any other key (i.e. they are coupled)
+ for (const legacyKey of Object.keys(
+ LegacyAppStateMigrations,
+ ) as (keyof typeof LegacyAppStateMigrations)[]) {
+ if (legacyKey in appState) {
+ const [nextKey, nextValue] = LegacyAppStateMigrations[legacyKey](
+ appState,
+ defaultAppState,
+ );
+ (nextAppState as any)[nextKey] = nextValue;
+ }
+ }
+
+ for (const [key, defaultValue] of Object.entries(defaultAppState) as [
+ keyof typeof defaultAppState,
+ any,
+ ][]) {
+ // if AppState contains a legacy key, prefer that one and migrate its
+ // value to the new one
+ const suppliedValue = appState[key];
+
+ const localValue = localAppState ? localAppState[key] : undefined;
+ (nextAppState as any)[key] =
+ suppliedValue !== undefined
+ ? suppliedValue
+ : localValue !== undefined
+ ? localValue
+ : defaultValue;
+ }
+
+ return {
+ ...nextAppState,
+ cursorButton: localAppState?.cursorButton || "up",
+ // reset on fresh restore so as to hide the UI button if penMode not active
+ penDetected:
+ localAppState?.penDetected ??
+ (appState.penMode ? appState.penDetected ?? false : false),
+ activeTool: {
+ ...updateActiveTool(
+ defaultAppState,
+ nextAppState.activeTool.type &&
+ AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
+ ? nextAppState.activeTool
+ : { type: "selection" },
+ ),
+ lastActiveTool: null,
+ locked: nextAppState.activeTool.locked ?? false,
+ },
+ // Migrates from previous version where appState.zoom was a number
+ zoom: {
+ value: getNormalizedZoom(
+ isFiniteNumber(appState.zoom)
+ ? appState.zoom
+ : appState.zoom?.value ?? defaultAppState.zoom.value,
+ ),
+ },
+ openSidebar:
+ // string (legacy)
+ typeof (appState.openSidebar as any as string) === "string"
+ ? { name: DEFAULT_SIDEBAR.name }
+ : nextAppState.openSidebar,
+ gridSize: getNormalizedGridSize(
+ isFiniteNumber(appState.gridSize) ? appState.gridSize : DEFAULT_GRID_SIZE,
+ ),
+ gridStep: getNormalizedGridStep(
+ isFiniteNumber(appState.gridStep) ? appState.gridStep : DEFAULT_GRID_STEP,
+ ),
+ editingFrame: null,
+ };
+};
+
+export const restore = (
+ data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
+ /**
+ * Local AppState (`this.state` or initial state from localStorage) so that we
+ * don't overwrite local state with default values (when values not
+ * explicitly specified).
+ * Supply `null` if you can't get access to it.
+ */
+ localAppState: Partial<AppState> | null | undefined,
+ localElements: readonly ExcalidrawElement[] | null | undefined,
+ elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
+): RestoredDataState => {
+ return {
+ elements: restoreElements(data?.elements, localElements, elementsConfig),
+ appState: restoreAppState(data?.appState, localAppState || null),
+ files: data?.files || {},
+ };
+};
+
+const restoreLibraryItem = (libraryItem: LibraryItem) => {
+ const elements = restoreElements(
+ getNonDeletedElements(libraryItem.elements),
+ null,
+ );
+ return elements.length ? { ...libraryItem, elements } : null;
+};
+
+export const restoreLibraryItems = (
+ libraryItems: ImportedDataState["libraryItems"] = [],
+ defaultStatus: LibraryItem["status"],
+) => {
+ const restoredItems: LibraryItem[] = [];
+ for (const item of libraryItems) {
+ // migrate older libraries
+ if (Array.isArray(item)) {
+ const restoredItem = restoreLibraryItem({
+ status: defaultStatus,
+ elements: item,
+ id: randomId(),
+ created: Date.now(),
+ });
+ if (restoredItem) {
+ restoredItems.push(restoredItem);
+ }
+ } else {
+ const _item = item as MarkOptional<
+ LibraryItem,
+ "id" | "status" | "created"
+ >;
+ const restoredItem = restoreLibraryItem({
+ ..._item,
+ id: _item.id || randomId(),
+ status: _item.status || defaultStatus,
+ created: _item.created || Date.now(),
+ });
+ if (restoredItem) {
+ restoredItems.push(restoredItem);
+ }
+ }
+ }
+ return restoredItems;
+};
diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts
new file mode 100644
index 0000000..a36af0c
--- /dev/null
+++ b/packages/excalidraw/data/transform.test.ts
@@ -0,0 +1,970 @@
+import { vi } from "vitest";
+import type { ExcalidrawElementSkeleton } from "./transform";
+import { convertToExcalidrawElements } from "./transform";
+import type { ExcalidrawArrowElement } from "../element/types";
+import { pointFrom } from "@excalidraw/math";
+
+const opts = { regenerateIds: false };
+
+describe("Test Transform", () => {
+ it("should generate id unless opts.regenerateIds is set to false explicitly", () => {
+ const elements = [
+ {
+ type: "rectangle",
+ x: 100,
+ y: 100,
+ id: "rect-1",
+ },
+ ];
+ let data = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ );
+ expect(data.length).toBe(1);
+ expect(data[0].id).toBe("id0");
+
+ data = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+ expect(data[0].id).toBe("rect-1");
+ });
+
+ it("should transform regular shapes", () => {
+ const elements = [
+ {
+ type: "rectangle",
+ x: 100,
+ y: 100,
+ },
+ {
+ type: "ellipse",
+ x: 100,
+ y: 250,
+ },
+ {
+ type: "diamond",
+ x: 100,
+ y: 400,
+ },
+ {
+ type: "rectangle",
+ x: 300,
+ y: 100,
+ width: 200,
+ height: 100,
+ backgroundColor: "#c0eb75",
+ strokeWidth: 2,
+ },
+ {
+ type: "ellipse",
+ x: 300,
+ y: 250,
+ width: 200,
+ height: 100,
+ backgroundColor: "#ffc9c9",
+ strokeStyle: "dotted",
+ fillStyle: "solid",
+ strokeWidth: 2,
+ },
+ {
+ type: "diamond",
+ x: 300,
+ y: 400,
+ width: 200,
+ height: 100,
+ backgroundColor: "#a5d8ff",
+ strokeColor: "#1971c2",
+ strokeStyle: "dashed",
+ fillStyle: "cross-hatch",
+ strokeWidth: 2,
+ },
+ ];
+
+ convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ ).forEach((ele) => {
+ expect(ele).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+
+ it("should transform text element", () => {
+ const elements = [
+ {
+ type: "text",
+ x: 100,
+ y: 100,
+ text: "HELLO WORLD!",
+ },
+ {
+ type: "text",
+ x: 100,
+ y: 150,
+ text: "STYLED HELLO WORLD!",
+ fontSize: 20,
+ strokeColor: "#5f3dc4",
+ },
+ ];
+ convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ ).forEach((ele) => {
+ expect(ele).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+
+ it("should transform linear elements", () => {
+ const elements = [
+ {
+ type: "arrow",
+ x: 100,
+ y: 20,
+ },
+ {
+ type: "arrow",
+ x: 450,
+ y: 20,
+ startArrowhead: "dot",
+ endArrowhead: "triangle",
+ strokeColor: "#1971c2",
+ strokeWidth: 2,
+ },
+ {
+ type: "line",
+ x: 100,
+ y: 60,
+ },
+ {
+ type: "line",
+ x: 450,
+ y: 60,
+ strokeColor: "#2f9e44",
+ strokeWidth: 2,
+ strokeStyle: "dotted",
+ },
+ ];
+ const excalidrawElements = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+
+ expect(excalidrawElements.length).toBe(4);
+
+ excalidrawElements.forEach((ele) => {
+ expect(ele).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+
+ it("should transform to text containers when label provided", () => {
+ const elements = [
+ {
+ type: "rectangle",
+ x: 100,
+ y: 100,
+ label: {
+ text: "RECTANGLE TEXT CONTAINER",
+ },
+ },
+ {
+ type: "ellipse",
+ x: 500,
+ y: 100,
+ width: 200,
+ label: {
+ text: "ELLIPSE TEXT CONTAINER",
+ },
+ },
+ {
+ type: "diamond",
+ x: 100,
+ y: 150,
+ width: 280,
+ label: {
+ text: "DIAMOND\nTEXT CONTAINER",
+ },
+ },
+ {
+ type: "diamond",
+ x: 100,
+ y: 400,
+ width: 300,
+ backgroundColor: "#fff3bf",
+ strokeWidth: 2,
+ label: {
+ text: "STYLED DIAMOND TEXT CONTAINER",
+ strokeColor: "#099268",
+ fontSize: 20,
+ },
+ },
+ {
+ type: "rectangle",
+ x: 500,
+ y: 300,
+ width: 200,
+ strokeColor: "#c2255c",
+ label: {
+ text: "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
+ textAlign: "left",
+ verticalAlign: "top",
+ fontSize: 20,
+ },
+ },
+ {
+ type: "ellipse",
+ x: 500,
+ y: 500,
+ strokeColor: "#f08c00",
+ backgroundColor: "#ffec99",
+ width: 200,
+ label: {
+ text: "STYLED ELLIPSE TEXT CONTAINER",
+ strokeColor: "#c2255c",
+ },
+ },
+ ];
+ const excalidrawElements = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+
+ expect(excalidrawElements.length).toBe(12);
+
+ excalidrawElements.forEach((ele) => {
+ expect(ele).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+
+ it("should transform to labelled arrows when label provided for arrows", () => {
+ const elements = [
+ {
+ type: "arrow",
+ x: 100,
+ y: 100,
+ label: {
+ text: "LABELED ARROW",
+ },
+ },
+ {
+ type: "arrow",
+ x: 100,
+ y: 200,
+ label: {
+ text: "STYLED LABELED ARROW",
+ strokeColor: "#099268",
+ fontSize: 20,
+ },
+ },
+ {
+ type: "arrow",
+ x: 100,
+ y: 300,
+ strokeColor: "#1098ad",
+ strokeWidth: 2,
+ label: {
+ text: "ANOTHER STYLED LABELLED ARROW",
+ },
+ },
+ {
+ type: "arrow",
+ x: 100,
+ y: 400,
+ strokeColor: "#1098ad",
+ strokeWidth: 2,
+ label: {
+ text: "ANOTHER STYLED LABELLED ARROW",
+ strokeColor: "#099268",
+ },
+ },
+ ];
+ const excalidrawElements = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+
+ expect(excalidrawElements.length).toBe(8);
+
+ excalidrawElements.forEach((ele) => {
+ expect(ele).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+
+ describe("Test Frames", () => {
+ const elements: ExcalidrawElementSkeleton[] = [
+ {
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ strokeWidth: 2,
+ id: "1",
+ },
+ {
+ type: "diamond",
+ x: 120,
+ y: 20,
+ backgroundColor: "#fff3bf",
+ strokeWidth: 2,
+ label: {
+ text: "HELLO EXCALIDRAW",
+ strokeColor: "#099268",
+ fontSize: 30,
+ },
+ id: "2",
+ },
+ ];
+
+ it("should transform frames and update frame ids when regenerated", () => {
+ const elementsSkeleton: ExcalidrawElementSkeleton[] = [
+ ...elements,
+ {
+ type: "frame",
+ children: ["1", "2"],
+ name: "My frame",
+ },
+ ];
+ const excalidrawElements = convertToExcalidrawElements(
+ elementsSkeleton,
+ opts,
+ );
+ expect(excalidrawElements.length).toBe(4);
+
+ excalidrawElements.forEach((ele) => {
+ expect(ele).toMatchObject({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+
+ it("should consider user defined frame dimensions over calculated when provided", () => {
+ const elementsSkeleton: ExcalidrawElementSkeleton[] = [
+ ...elements,
+ {
+ type: "frame",
+ children: ["1", "2"],
+ name: "My frame",
+ width: 800,
+ height: 100,
+ },
+ ];
+ const excalidrawElements = convertToExcalidrawElements(
+ elementsSkeleton,
+ opts,
+ );
+ const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
+ expect(frame.width).toBe(800);
+ expect(frame.height).toBe(100);
+ });
+
+ it("should consider user defined frame coordinates calculated when provided", () => {
+ const elementsSkeleton: ExcalidrawElementSkeleton[] = [
+ ...elements,
+ {
+ type: "frame",
+ children: ["1", "2"],
+ name: "My frame",
+ x: 100,
+ y: 300,
+ },
+ ];
+ const excalidrawElements = convertToExcalidrawElements(
+ elementsSkeleton,
+ opts,
+ );
+ const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
+ expect(frame.x).toBe(100);
+ expect(frame.y).toBe(300);
+ });
+ });
+
+ describe("Test arrow bindings", () => {
+ it("should bind arrows to shapes when start / end provided without ids", () => {
+ const elements = [
+ {
+ type: "arrow",
+ x: 255,
+ y: 239,
+ label: {
+ text: "HELLO WORLD!!",
+ },
+ start: {
+ type: "rectangle",
+ },
+ end: {
+ type: "ellipse",
+ },
+ },
+ ];
+ const excalidrawElements = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+
+ expect(excalidrawElements.length).toBe(4);
+ const [arrow, text, rectangle, ellipse] = excalidrawElements;
+ expect(arrow).toMatchObject({
+ type: "arrow",
+ x: 255,
+ y: 239,
+ boundElements: [{ id: text.id, type: "text" }],
+ startBinding: {
+ elementId: rectangle.id,
+ focus: 0,
+ gap: 1,
+ },
+ endBinding: {
+ elementId: ellipse.id,
+ focus: -0,
+ },
+ });
+
+ expect(text).toMatchObject({
+ x: 240,
+ y: 226.5,
+ type: "text",
+ text: "HELLO WORLD!!",
+ containerId: arrow.id,
+ });
+
+ expect(rectangle).toMatchObject({
+ x: 155,
+ y: 189,
+ type: "rectangle",
+ boundElements: [
+ {
+ id: arrow.id,
+ type: "arrow",
+ },
+ ],
+ });
+
+ expect(ellipse).toMatchObject({
+ x: 355,
+ y: 189,
+ type: "ellipse",
+ boundElements: [
+ {
+ id: arrow.id,
+ type: "arrow",
+ },
+ ],
+ });
+
+ excalidrawElements.forEach((ele) => {
+ expect(ele).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+
+ it("should bind arrows to text when start / end provided without ids", () => {
+ const elements = [
+ {
+ type: "arrow",
+ x: 255,
+ y: 239,
+ label: {
+ text: "HELLO WORLD!!",
+ },
+ start: {
+ type: "text",
+ text: "HEYYYYY",
+ },
+ end: {
+ type: "text",
+ text: "WHATS UP ?",
+ },
+ },
+ ];
+
+ const excalidrawElements = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+
+ expect(excalidrawElements.length).toBe(4);
+ const [arrow, text1, text2, text3] = excalidrawElements;
+
+ expect(arrow).toMatchObject({
+ type: "arrow",
+ x: 255,
+ y: 239,
+ boundElements: [{ id: text1.id, type: "text" }],
+ startBinding: {
+ elementId: text2.id,
+ focus: 0,
+ gap: 1,
+ },
+ endBinding: {
+ elementId: text3.id,
+ focus: -0,
+ },
+ });
+
+ expect(text1).toMatchObject({
+ x: 240,
+ y: 226.5,
+ type: "text",
+ text: "HELLO WORLD!!",
+ containerId: arrow.id,
+ });
+
+ expect(text2).toMatchObject({
+ x: 185,
+ y: 226.5,
+ type: "text",
+ boundElements: [
+ {
+ id: arrow.id,
+ type: "arrow",
+ },
+ ],
+ });
+
+ expect(text3).toMatchObject({
+ x: 355,
+ y: 226.5,
+ type: "text",
+ boundElements: [
+ {
+ id: arrow.id,
+ type: "arrow",
+ },
+ ],
+ });
+
+ excalidrawElements.forEach((ele) => {
+ expect(ele).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+
+ it("should bind arrows to existing shapes when start / end provided with ids", () => {
+ const elements = [
+ {
+ type: "ellipse",
+ id: "ellipse-1",
+ strokeColor: "#66a80f",
+ x: 630,
+ y: 316,
+ width: 300,
+ height: 300,
+ backgroundColor: "#d8f5a2",
+ },
+ {
+ type: "diamond",
+ id: "diamond-1",
+ strokeColor: "#9c36b5",
+ width: 140,
+ x: 96,
+ y: 400,
+ },
+ {
+ type: "arrow",
+ x: 247,
+ y: 420,
+ width: 395,
+ height: 35,
+ strokeColor: "#1864ab",
+ start: {
+ type: "rectangle",
+ width: 300,
+ height: 300,
+ },
+ end: {
+ id: "ellipse-1",
+ },
+ },
+ {
+ type: "arrow",
+ x: 227,
+ y: 450,
+ width: 400,
+ strokeColor: "#e67700",
+ start: {
+ id: "diamond-1",
+ },
+ end: {
+ id: "ellipse-1",
+ },
+ },
+ ];
+
+ const excalidrawElements = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+
+ expect(excalidrawElements.length).toBe(5);
+
+ excalidrawElements.forEach((ele) => {
+ expect(ele).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+
+ it("should bind arrows to existing text elements when start / end provided with ids", () => {
+ const elements = [
+ {
+ x: 100,
+ y: 239,
+ type: "text",
+ text: "HEYYYYY",
+ id: "text-1",
+ strokeColor: "#c2255c",
+ },
+ {
+ type: "text",
+ id: "text-2",
+ x: 560,
+ y: 239,
+ text: "Whats up ?",
+ },
+ {
+ type: "arrow",
+ x: 255,
+ y: 239,
+ label: {
+ text: "HELLO WORLD!!",
+ },
+ start: {
+ id: "text-1",
+ },
+ end: {
+ id: "text-2",
+ },
+ },
+ ];
+
+ const excalidrawElements = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+
+ expect(excalidrawElements.length).toBe(4);
+
+ excalidrawElements.forEach((ele) => {
+ expect(ele).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+
+ it("should bind arrows to existing elements if ids are correct", () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementationOnce(() => void 0);
+ const elements = [
+ {
+ x: 100,
+ y: 239,
+ type: "text",
+ text: "HEYYYYY",
+ id: "text-1",
+ strokeColor: "#c2255c",
+ },
+ {
+ type: "rectangle",
+ x: 560,
+ y: 139,
+ id: "rect-1",
+ width: 100,
+ height: 200,
+ backgroundColor: "#bac8ff",
+ },
+ {
+ type: "arrow",
+ x: 255,
+ y: 239,
+ label: {
+ text: "HELLO WORLD!!",
+ },
+ start: {
+ id: "text-13",
+ },
+ end: {
+ id: "rect-11",
+ },
+ },
+ ];
+
+ const excalidrawElements = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+
+ expect(excalidrawElements.length).toBe(4);
+ const [, , arrow, text] = excalidrawElements;
+ expect(arrow).toMatchObject({
+ type: "arrow",
+ x: 255,
+ y: 239,
+ boundElements: [
+ {
+ id: text.id,
+ type: "text",
+ },
+ ],
+ startBinding: null,
+ endBinding: null,
+ });
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
+ expect(consoleErrorSpy).toHaveBeenNthCalledWith(
+ 1,
+ "No element for start binding with id text-13 found",
+ );
+ expect(consoleErrorSpy).toHaveBeenNthCalledWith(
+ 2,
+ "No element for end binding with id rect-11 found",
+ );
+ });
+
+ it("should bind when ids referenced before the element data", () => {
+ const elements = [
+ {
+ type: "arrow",
+ x: 255,
+ y: 239,
+ end: {
+ id: "rect-1",
+ },
+ },
+ {
+ type: "rectangle",
+ x: 560,
+ y: 139,
+ id: "rect-1",
+ width: 100,
+ height: 200,
+ backgroundColor: "#bac8ff",
+ },
+ ];
+ const excalidrawElements = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+ expect(excalidrawElements.length).toBe(2);
+ const [arrow, rect] = excalidrawElements;
+ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
+ elementId: "rect-1",
+ focus: -0,
+ gap: 14,
+ });
+ expect(rect.boundElements).toStrictEqual([
+ {
+ id: arrow.id,
+ type: "arrow",
+ },
+ ]);
+ });
+ });
+
+ it("should not allow duplicate ids", () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementationOnce(() => void 0);
+ const elements = [
+ {
+ type: "rectangle",
+ x: 300,
+ y: 100,
+ id: "rect-1",
+ width: 100,
+ height: 200,
+ },
+
+ {
+ type: "rectangle",
+ x: 100,
+ y: 200,
+ id: "rect-1",
+ width: 100,
+ height: 200,
+ },
+ ];
+ const excalidrawElements = convertToExcalidrawElements(
+ elements as ExcalidrawElementSkeleton[],
+ opts,
+ );
+
+ expect(excalidrawElements.length).toBe(1);
+ expect(excalidrawElements[0]).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ });
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ "Duplicate id found for rect-1",
+ );
+ });
+
+ it("should contains customData if provided", () => {
+ const rawData = [
+ {
+ type: "rectangle",
+ x: 100,
+ y: 100,
+ customData: { createdBy: "user01" },
+ },
+ ];
+ const convertedElements = convertToExcalidrawElements(
+ rawData as ExcalidrawElementSkeleton[],
+ opts,
+ );
+ expect(convertedElements[0].customData).toStrictEqual({
+ createdBy: "user01",
+ });
+ });
+
+ it("should transform the elements correctly when linear elements have single point", () => {
+ const elements: ExcalidrawElementSkeleton[] = [
+ {
+ id: "B",
+ type: "rectangle",
+ groupIds: ["subgraph_group_B"],
+ x: 0,
+ y: 0,
+ width: 166.03125,
+ height: 163,
+ label: {
+ groupIds: ["subgraph_group_B"],
+ text: "B",
+ fontSize: 20,
+ verticalAlign: "top",
+ },
+ },
+ {
+ id: "A",
+ type: "rectangle",
+ groupIds: ["subgraph_group_A"],
+ x: 364.546875,
+ y: 0,
+ width: 120.265625,
+ height: 114,
+ label: {
+ groupIds: ["subgraph_group_A"],
+ text: "A",
+ fontSize: 20,
+ verticalAlign: "top",
+ },
+ },
+ {
+ id: "Alice",
+ type: "rectangle",
+ groupIds: ["subgraph_group_A"],
+ x: 389.546875,
+ y: 35,
+ width: 70.265625,
+ height: 44,
+ strokeWidth: 2,
+ label: {
+ groupIds: ["subgraph_group_A"],
+ text: "Alice",
+ fontSize: 20,
+ },
+ link: null,
+ },
+ {
+ id: "Bob",
+ type: "rectangle",
+ groupIds: ["subgraph_group_B"],
+ x: 54.76953125,
+ y: 35,
+ width: 56.4921875,
+ height: 44,
+ strokeWidth: 2,
+ label: {
+ groupIds: ["subgraph_group_B"],
+ text: "Bob",
+ fontSize: 20,
+ },
+ link: null,
+ },
+ {
+ id: "Bob_Alice",
+ type: "arrow",
+ groupIds: [],
+ x: 111.262,
+ y: 57,
+ strokeWidth: 2,
+ points: [pointFrom(0, 0), pointFrom(272.985, 0)],
+ label: {
+ text: "How are you?",
+ fontSize: 20,
+ groupIds: [],
+ },
+ roundness: {
+ type: 2,
+ },
+ start: {
+ id: "Bob",
+ },
+ end: {
+ id: "Alice",
+ },
+ },
+ {
+ id: "Bob_B",
+ type: "arrow",
+ groupIds: [],
+ x: 77.017,
+ y: 79,
+ strokeWidth: 2,
+ points: [pointFrom(0, 0)],
+ label: {
+ text: "Friendship",
+ fontSize: 20,
+ groupIds: [],
+ },
+ roundness: {
+ type: 2,
+ },
+ start: {
+ id: "Bob",
+ },
+ end: {
+ id: "B",
+ },
+ },
+ ];
+
+ const excalidrawElements = convertToExcalidrawElements(elements, opts);
+ expect(excalidrawElements.length).toBe(12);
+ excalidrawElements.forEach((ele) => {
+ expect(ele).toMatchSnapshot({
+ seed: expect.any(Number),
+ versionNonce: expect.any(Number),
+ id: expect.any(String),
+ });
+ });
+ });
+});
diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts
new file mode 100644
index 0000000..023740c
--- /dev/null
+++ b/packages/excalidraw/data/transform.ts
@@ -0,0 +1,791 @@
+import {
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_FONT_SIZE,
+ TEXT_ALIGN,
+ VERTICAL_ALIGN,
+} from "../constants";
+import {
+ getCommonBounds,
+ newElement,
+ newLinearElement,
+ redrawTextBoundingBox,
+} from "../element";
+import { bindLinearElement } from "../element/binding";
+import type { ElementConstructorOpts } from "../element/newElement";
+import {
+ newArrowElement,
+ newFrameElement,
+ newImageElement,
+ newMagicFrameElement,
+ newTextElement,
+} from "../element/newElement";
+import type {
+ ElementsMap,
+ ExcalidrawArrowElement,
+ ExcalidrawBindableElement,
+ ExcalidrawElement,
+ ExcalidrawFrameElement,
+ ExcalidrawFreeDrawElement,
+ ExcalidrawGenericElement,
+ ExcalidrawIframeLikeElement,
+ ExcalidrawImageElement,
+ ExcalidrawLinearElement,
+ ExcalidrawMagicFrameElement,
+ ExcalidrawSelectionElement,
+ ExcalidrawTextElement,
+ FileId,
+ FontFamilyValues,
+ NonDeletedSceneElementsMap,
+ TextAlign,
+ VerticalAlign,
+} from "../element/types";
+import type { MarkOptional } from "../utility-types";
+import {
+ arrayToMap,
+ assertNever,
+ cloneJSON,
+ getFontString,
+ isDevEnv,
+ toBrandedType,
+} from "../utils";
+import { getSizeFromPoints } from "../points";
+import { randomId } from "../random";
+import { syncInvalidIndices } from "../fractionalIndex";
+import { getLineHeight } from "../fonts";
+import { isArrowElement } from "../element/typeChecks";
+import { pointFrom, type LocalPoint } from "@excalidraw/math";
+import { measureText, normalizeText } from "../element/textMeasurements";
+
+export type ValidLinearElement = {
+ type: "arrow" | "line";
+ x: number;
+ y: number;
+ label?: {
+ text: string;
+ fontSize?: number;
+ fontFamily?: FontFamilyValues;
+ textAlign?: TextAlign;
+ verticalAlign?: VerticalAlign;
+ } & MarkOptional<ElementConstructorOpts, "x" | "y">;
+ end?:
+ | (
+ | (
+ | {
+ type: Exclude<
+ ExcalidrawBindableElement["type"],
+ | "image"
+ | "text"
+ | "frame"
+ | "magicframe"
+ | "embeddable"
+ | "iframe"
+ >;
+ id?: ExcalidrawGenericElement["id"];
+ }
+ | {
+ id: ExcalidrawGenericElement["id"];
+ type?: Exclude<
+ ExcalidrawBindableElement["type"],
+ | "image"
+ | "text"
+ | "frame"
+ | "magicframe"
+ | "embeddable"
+ | "iframe"
+ >;
+ }
+ )
+ | ((
+ | {
+ type: "text";
+ text: string;
+ }
+ | {
+ type?: "text";
+ id: ExcalidrawTextElement["id"];
+ text: string;
+ }
+ ) &
+ Partial<ExcalidrawTextElement>)
+ ) &
+ MarkOptional<ElementConstructorOpts, "x" | "y">;
+ start?:
+ | (
+ | (
+ | {
+ type: Exclude<
+ ExcalidrawBindableElement["type"],
+ | "image"
+ | "text"
+ | "frame"
+ | "magicframe"
+ | "embeddable"
+ | "iframe"
+ >;
+ id?: ExcalidrawGenericElement["id"];
+ }
+ | {
+ id: ExcalidrawGenericElement["id"];
+ type?: Exclude<
+ ExcalidrawBindableElement["type"],
+ | "image"
+ | "text"
+ | "frame"
+ | "magicframe"
+ | "embeddable"
+ | "iframe"
+ >;
+ }
+ )
+ | ((
+ | {
+ type: "text";
+ text: string;
+ }
+ | {
+ type?: "text";
+ id: ExcalidrawTextElement["id"];
+ text: string;
+ }
+ ) &
+ Partial<ExcalidrawTextElement>)
+ ) &
+ MarkOptional<ElementConstructorOpts, "x" | "y">;
+} & Partial<ExcalidrawLinearElement>;
+
+export type ValidContainer =
+ | {
+ type: Exclude<ExcalidrawGenericElement["type"], "selection">;
+ id?: ExcalidrawGenericElement["id"];
+ label?: {
+ text: string;
+ fontSize?: number;
+ fontFamily?: FontFamilyValues;
+ textAlign?: TextAlign;
+ verticalAlign?: VerticalAlign;
+ } & MarkOptional<ElementConstructorOpts, "x" | "y">;
+ } & ElementConstructorOpts;
+
+export type ExcalidrawElementSkeleton =
+ | Extract<
+ Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
+ ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
+ >
+ | ({
+ type: Extract<ExcalidrawLinearElement["type"], "line">;
+ x: number;
+ y: number;
+ } & Partial<ExcalidrawLinearElement>)
+ | ValidContainer
+ | ValidLinearElement
+ | ({
+ type: "text";
+ text: string;
+ x: number;
+ y: number;
+ id?: ExcalidrawTextElement["id"];
+ } & Partial<ExcalidrawTextElement>)
+ | ({
+ type: Extract<ExcalidrawImageElement["type"], "image">;
+ x: number;
+ y: number;
+ fileId: FileId;
+ } & Partial<ExcalidrawImageElement>)
+ | ({
+ type: "frame";
+ children: readonly ExcalidrawElement["id"][];
+ name?: string;
+ } & Partial<ExcalidrawFrameElement>)
+ | ({
+ type: "magicframe";
+ children: readonly ExcalidrawElement["id"][];
+ name?: string;
+ } & Partial<ExcalidrawMagicFrameElement>);
+
+const DEFAULT_LINEAR_ELEMENT_PROPS = {
+ width: 100,
+ height: 0,
+};
+
+const DEFAULT_DIMENSION = 100;
+
+const bindTextToContainer = (
+ container: ExcalidrawElement,
+ textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
+ elementsMap: ElementsMap,
+) => {
+ const textElement: ExcalidrawTextElement = newTextElement({
+ x: 0,
+ y: 0,
+ textAlign: TEXT_ALIGN.CENTER,
+ verticalAlign: VERTICAL_ALIGN.MIDDLE,
+ ...textProps,
+ containerId: container.id,
+ strokeColor: textProps.strokeColor || container.strokeColor,
+ });
+
+ Object.assign(container, {
+ boundElements: (container.boundElements || []).concat({
+ type: "text",
+ id: textElement.id,
+ }),
+ });
+
+ redrawTextBoundingBox(textElement, container, elementsMap);
+ return [container, textElement] as const;
+};
+
+const bindLinearElementToElement = (
+ linearElement: ExcalidrawArrowElement,
+ start: ValidLinearElement["start"],
+ end: ValidLinearElement["end"],
+ elementStore: ElementStore,
+ elementsMap: NonDeletedSceneElementsMap,
+): {
+ linearElement: ExcalidrawLinearElement;
+ startBoundElement?: ExcalidrawElement;
+ endBoundElement?: ExcalidrawElement;
+} => {
+ let startBoundElement;
+ let endBoundElement;
+
+ Object.assign(linearElement, {
+ startBinding: linearElement?.startBinding || null,
+ endBinding: linearElement.endBinding || null,
+ });
+
+ if (start) {
+ const width = start?.width ?? DEFAULT_DIMENSION;
+ const height = start?.height ?? DEFAULT_DIMENSION;
+
+ let existingElement;
+ if (start.id) {
+ existingElement = elementStore.getElement(start.id);
+ if (!existingElement) {
+ console.error(`No element for start binding with id ${start.id} found`);
+ }
+ }
+
+ const startX = start.x || linearElement.x - width;
+ const startY = start.y || linearElement.y - height / 2;
+ const startType = existingElement ? existingElement.type : start.type;
+
+ if (startType) {
+ if (startType === "text") {
+ let text = "";
+ if (existingElement && existingElement.type === "text") {
+ text = existingElement.text;
+ } else if (start.type === "text") {
+ text = start.text;
+ }
+ if (!text) {
+ console.error(
+ `No text found for start binding text element for ${linearElement.id}`,
+ );
+ }
+ startBoundElement = newTextElement({
+ x: startX,
+ y: startY,
+ type: "text",
+ ...existingElement,
+ ...start,
+ text,
+ });
+ // to position the text correctly when coordinates not provided
+ Object.assign(startBoundElement, {
+ x: start.x || linearElement.x - startBoundElement.width,
+ y: start.y || linearElement.y - startBoundElement.height / 2,
+ });
+ } else {
+ switch (startType) {
+ case "rectangle":
+ case "ellipse":
+ case "diamond": {
+ startBoundElement = newElement({
+ x: startX,
+ y: startY,
+ width,
+ height,
+ ...existingElement,
+ ...start,
+ type: startType,
+ });
+ break;
+ }
+ default: {
+ assertNever(
+ linearElement as never,
+ `Unhandled element start type "${start.type}"`,
+ true,
+ );
+ }
+ }
+ }
+
+ bindLinearElement(
+ linearElement,
+ startBoundElement as ExcalidrawBindableElement,
+ "start",
+ elementsMap,
+ );
+ }
+ }
+ if (end) {
+ const height = end?.height ?? DEFAULT_DIMENSION;
+ const width = end?.width ?? DEFAULT_DIMENSION;
+
+ let existingElement;
+ if (end.id) {
+ existingElement = elementStore.getElement(end.id);
+ if (!existingElement) {
+ console.error(`No element for end binding with id ${end.id} found`);
+ }
+ }
+ const endX = end.x || linearElement.x + linearElement.width;
+ const endY = end.y || linearElement.y - height / 2;
+ const endType = existingElement ? existingElement.type : end.type;
+
+ if (endType) {
+ if (endType === "text") {
+ let text = "";
+ if (existingElement && existingElement.type === "text") {
+ text = existingElement.text;
+ } else if (end.type === "text") {
+ text = end.text;
+ }
+
+ if (!text) {
+ console.error(
+ `No text found for end binding text element for ${linearElement.id}`,
+ );
+ }
+ endBoundElement = newTextElement({
+ x: endX,
+ y: endY,
+ type: "text",
+ ...existingElement,
+ ...end,
+ text,
+ });
+ // to position the text correctly when coordinates not provided
+ Object.assign(endBoundElement, {
+ y: end.y || linearElement.y - endBoundElement.height / 2,
+ });
+ } else {
+ switch (endType) {
+ case "rectangle":
+ case "ellipse":
+ case "diamond": {
+ endBoundElement = newElement({
+ x: endX,
+ y: endY,
+ width,
+ height,
+ ...existingElement,
+ ...end,
+ type: endType,
+ });
+ break;
+ }
+ default: {
+ assertNever(
+ linearElement as never,
+ `Unhandled element end type "${endType}"`,
+ true,
+ );
+ }
+ }
+ }
+
+ bindLinearElement(
+ linearElement,
+ endBoundElement as ExcalidrawBindableElement,
+ "end",
+ elementsMap,
+ );
+ }
+ }
+
+ // Safe check to early return for single point
+ if (linearElement.points.length < 2) {
+ return {
+ linearElement,
+ startBoundElement,
+ endBoundElement,
+ };
+ }
+
+ // Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
+ const endPointIndex = linearElement.points.length - 1;
+ const delta = 0.5;
+
+ const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
+
+ // left to right so shift the arrow towards right
+ if (
+ linearElement.points[endPointIndex][0] >
+ linearElement.points[endPointIndex - 1][0]
+ ) {
+ newPoints[0][0] = delta;
+ newPoints[endPointIndex][0] -= delta;
+ }
+
+ // right to left so shift the arrow towards left
+ if (
+ linearElement.points[endPointIndex][0] <
+ linearElement.points[endPointIndex - 1][0]
+ ) {
+ newPoints[0][0] = -delta;
+ newPoints[endPointIndex][0] += delta;
+ }
+ // top to bottom so shift the arrow towards top
+ if (
+ linearElement.points[endPointIndex][1] >
+ linearElement.points[endPointIndex - 1][1]
+ ) {
+ newPoints[0][1] = delta;
+ newPoints[endPointIndex][1] -= delta;
+ }
+
+ // bottom to top so shift the arrow towards bottom
+ if (
+ linearElement.points[endPointIndex][1] <
+ linearElement.points[endPointIndex - 1][1]
+ ) {
+ newPoints[0][1] = -delta;
+ newPoints[endPointIndex][1] += delta;
+ }
+
+ Object.assign(linearElement, { points: newPoints });
+
+ return {
+ linearElement,
+ startBoundElement,
+ endBoundElement,
+ };
+};
+
+class ElementStore {
+ excalidrawElements = new Map<string, ExcalidrawElement>();
+
+ add = (ele?: ExcalidrawElement) => {
+ if (!ele) {
+ return;
+ }
+
+ this.excalidrawElements.set(ele.id, ele);
+ };
+
+ getElements = () => {
+ return syncInvalidIndices(Array.from(this.excalidrawElements.values()));
+ };
+
+ getElementsMap = () => {
+ return toBrandedType<NonDeletedSceneElementsMap>(
+ arrayToMap(this.getElements()),
+ );
+ };
+
+ getElement = (id: string) => {
+ return this.excalidrawElements.get(id);
+ };
+}
+
+export const convertToExcalidrawElements = (
+ elementsSkeleton: ExcalidrawElementSkeleton[] | null,
+ opts?: { regenerateIds: boolean },
+) => {
+ if (!elementsSkeleton) {
+ return [];
+ }
+ const elements = cloneJSON(elementsSkeleton);
+ const elementStore = new ElementStore();
+ const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
+ const oldToNewElementIdMap = new Map<string, string>();
+
+ // Create individual elements
+ for (const element of elements) {
+ let excalidrawElement: ExcalidrawElement;
+ const originalId = element.id;
+ if (opts?.regenerateIds !== false) {
+ Object.assign(element, { id: randomId() });
+ }
+
+ switch (element.type) {
+ case "rectangle":
+ case "ellipse":
+ case "diamond": {
+ const width =
+ element?.label?.text && element.width === undefined
+ ? 0
+ : element?.width || DEFAULT_DIMENSION;
+ const height =
+ element?.label?.text && element.height === undefined
+ ? 0
+ : element?.height || DEFAULT_DIMENSION;
+ excalidrawElement = newElement({
+ ...element,
+ width,
+ height,
+ });
+
+ break;
+ }
+ case "line": {
+ const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
+ const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
+ excalidrawElement = newLinearElement({
+ width,
+ height,
+ points: [pointFrom(0, 0), pointFrom(width, height)],
+ ...element,
+ });
+
+ break;
+ }
+ case "arrow": {
+ const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
+ const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
+ excalidrawElement = newArrowElement({
+ width,
+ height,
+ endArrowhead: "arrow",
+ points: [pointFrom(0, 0), pointFrom(width, height)],
+ ...element,
+ type: "arrow",
+ });
+
+ Object.assign(
+ excalidrawElement,
+ getSizeFromPoints(excalidrawElement.points),
+ );
+ break;
+ }
+ case "text": {
+ const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
+ const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
+ const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
+ const text = element.text ?? "";
+ const normalizedText = normalizeText(text);
+ const metrics = measureText(
+ normalizedText,
+ getFontString({ fontFamily, fontSize }),
+ lineHeight,
+ );
+
+ excalidrawElement = newTextElement({
+ width: metrics.width,
+ height: metrics.height,
+ fontFamily,
+ fontSize,
+ ...element,
+ });
+ break;
+ }
+ case "image": {
+ excalidrawElement = newImageElement({
+ width: element?.width || DEFAULT_DIMENSION,
+ height: element?.height || DEFAULT_DIMENSION,
+ ...element,
+ });
+
+ break;
+ }
+ case "frame": {
+ excalidrawElement = newFrameElement({
+ x: 0,
+ y: 0,
+ ...element,
+ });
+ break;
+ }
+ case "magicframe": {
+ excalidrawElement = newMagicFrameElement({
+ x: 0,
+ y: 0,
+ ...element,
+ });
+ break;
+ }
+ case "freedraw":
+ case "iframe":
+ case "embeddable": {
+ excalidrawElement = element;
+ break;
+ }
+
+ default: {
+ excalidrawElement = element;
+ assertNever(
+ element,
+ `Unhandled element type "${(element as any).type}"`,
+ true,
+ );
+ }
+ }
+ const existingElement = elementStore.getElement(excalidrawElement.id);
+ if (existingElement) {
+ console.error(`Duplicate id found for ${excalidrawElement.id}`);
+ } else {
+ elementStore.add(excalidrawElement);
+ elementsWithIds.set(excalidrawElement.id, element);
+ if (originalId) {
+ oldToNewElementIdMap.set(originalId, excalidrawElement.id);
+ }
+ }
+ }
+
+ const elementsMap = elementStore.getElementsMap();
+ // Add labels and arrow bindings
+ for (const [id, element] of elementsWithIds) {
+ const excalidrawElement = elementStore.getElement(id)!;
+
+ switch (element.type) {
+ case "rectangle":
+ case "ellipse":
+ case "diamond":
+ case "arrow": {
+ if (element.label?.text) {
+ let [container, text] = bindTextToContainer(
+ excalidrawElement,
+ element?.label,
+ elementsMap,
+ );
+ elementStore.add(container);
+ elementStore.add(text);
+
+ if (isArrowElement(container)) {
+ const originalStart =
+ element.type === "arrow" ? element?.start : undefined;
+ const originalEnd =
+ element.type === "arrow" ? element?.end : undefined;
+ if (originalStart && originalStart.id) {
+ const newStartId = oldToNewElementIdMap.get(originalStart.id);
+ if (newStartId) {
+ Object.assign(originalStart, { id: newStartId });
+ }
+ }
+ if (originalEnd && originalEnd.id) {
+ const newEndId = oldToNewElementIdMap.get(originalEnd.id);
+ if (newEndId) {
+ Object.assign(originalEnd, { id: newEndId });
+ }
+ }
+ const { linearElement, startBoundElement, endBoundElement } =
+ bindLinearElementToElement(
+ container,
+ originalStart,
+ originalEnd,
+ elementStore,
+ elementsMap,
+ );
+ container = linearElement;
+ elementStore.add(linearElement);
+ elementStore.add(startBoundElement);
+ elementStore.add(endBoundElement);
+ }
+ } else {
+ switch (element.type) {
+ case "arrow": {
+ const { start, end } = element;
+ if (start && start.id) {
+ const newStartId = oldToNewElementIdMap.get(start.id);
+ Object.assign(start, { id: newStartId });
+ }
+ if (end && end.id) {
+ const newEndId = oldToNewElementIdMap.get(end.id);
+ Object.assign(end, { id: newEndId });
+ }
+ const { linearElement, startBoundElement, endBoundElement } =
+ bindLinearElementToElement(
+ excalidrawElement as ExcalidrawArrowElement,
+ start,
+ end,
+ elementStore,
+ elementsMap,
+ );
+
+ elementStore.add(linearElement);
+ elementStore.add(startBoundElement);
+ elementStore.add(endBoundElement);
+ break;
+ }
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ // Once all the excalidraw elements are created, we can add frames since we
+ // need to calculate coordinates and dimensions of frame which is possible after all
+ // frame children are processed.
+ for (const [id, element] of elementsWithIds) {
+ if (element.type !== "frame" && element.type !== "magicframe") {
+ continue;
+ }
+ const frame = elementStore.getElement(id);
+
+ if (!frame) {
+ throw new Error(`Excalidraw element with id ${id} doesn't exist`);
+ }
+ const childrenElements: ExcalidrawElement[] = [];
+
+ element.children.forEach((id) => {
+ const newElementId = oldToNewElementIdMap.get(id);
+ if (!newElementId) {
+ throw new Error(`Element with ${id} wasn't mapped correctly`);
+ }
+
+ const elementInFrame = elementStore.getElement(newElementId);
+ if (!elementInFrame) {
+ throw new Error(`Frame element with id ${newElementId} doesn't exist`);
+ }
+ Object.assign(elementInFrame, { frameId: frame.id });
+
+ elementInFrame?.boundElements?.forEach((boundElement) => {
+ const ele = elementStore.getElement(boundElement.id);
+ if (!ele) {
+ throw new Error(
+ `Bound element with id ${boundElement.id} doesn't exist`,
+ );
+ }
+ Object.assign(ele, { frameId: frame.id });
+ childrenElements.push(ele);
+ });
+
+ childrenElements.push(elementInFrame);
+ });
+
+ let [minX, minY, maxX, maxY] = getCommonBounds(childrenElements);
+
+ const PADDING = 10;
+ minX = minX - PADDING;
+ minY = minY - PADDING;
+ maxX = maxX + PADDING;
+ maxY = maxY + PADDING;
+
+ const frameX = frame?.x || minX;
+ const frameY = frame?.y || minY;
+ const frameWidth = frame?.width || maxX - minX;
+ const frameHeight = frame?.height || maxY - minY;
+
+ Object.assign(frame, {
+ x: frameX,
+ y: frameY,
+ width: frameWidth,
+ height: frameHeight,
+ });
+ if (
+ isDevEnv() &&
+ element.children.length &&
+ (frame?.x || frame?.y || frame?.width || frame?.height)
+ ) {
+ console.info(
+ "User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
+ );
+ }
+ }
+
+ return elementStore.getElements();
+};
diff --git a/packages/excalidraw/data/types.ts b/packages/excalidraw/data/types.ts
new file mode 100644
index 0000000..f5f5535
--- /dev/null
+++ b/packages/excalidraw/data/types.ts
@@ -0,0 +1,59 @@
+import type { ExcalidrawElement } from "../element/types";
+import type {
+ AppState,
+ BinaryFiles,
+ LibraryItems,
+ LibraryItems_anyVersion,
+} from "../types";
+import type { cleanAppStateForExport } from "../appState";
+import type { VERSIONS } from "../constants";
+
+export interface ExportedDataState {
+ type: string;
+ version: number;
+ source: string;
+ elements: readonly ExcalidrawElement[];
+ appState: ReturnType<typeof cleanAppStateForExport>;
+ files: BinaryFiles | undefined;
+}
+
+/**
+ * Map of legacy AppState keys, with values of:
+ * [<legacy type>, <new AppState proeprty>]
+ *
+ * This is a helper type used in downstream abstractions.
+ * Don't consume on its own.
+ */
+export type LegacyAppState = {
+ /** @deprecated #6213 TODO remove 23-06-01 */
+ isSidebarDocked: [boolean, "defaultSidebarDockedPreference"];
+};
+
+export interface ImportedDataState {
+ type?: string;
+ version?: number;
+ source?: string;
+ elements?: readonly ExcalidrawElement[] | null;
+ appState?: Readonly<
+ Partial<
+ AppState & {
+ [T in keyof LegacyAppState]: LegacyAppState[T][0];
+ }
+ >
+ > | null;
+ scrollToContent?: boolean;
+ libraryItems?: LibraryItems_anyVersion;
+ files?: BinaryFiles;
+}
+
+export interface ExportedLibraryData {
+ type: string;
+ version: typeof VERSIONS.excalidrawLibrary;
+ source: string;
+ libraryItems: LibraryItems;
+}
+
+export interface ImportedLibraryData extends Partial<ExportedLibraryData> {
+ /** @deprecated v1 */
+ library?: LibraryItems;
+}
diff --git a/packages/excalidraw/data/url.test.tsx b/packages/excalidraw/data/url.test.tsx
new file mode 100644
index 0000000..9a40aad
--- /dev/null
+++ b/packages/excalidraw/data/url.test.tsx
@@ -0,0 +1,31 @@
+import { normalizeLink } from "./url";
+
+describe("normalizeLink", () => {
+ // NOTE not an extensive XSS test suite, just to check if we're not
+ // regressing in sanitization
+ it("should sanitize links", () => {
+ expect(
+ // eslint-disable-next-line no-script-url
+ normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
+ // eslint-disable-next-line no-script-url
+ `javascript:`,
+ ),
+ ).toBe(false);
+ expect(normalizeLink("ola")).toBe("ola");
+ expect(normalizeLink(" ola")).toBe("ola");
+
+ expect(normalizeLink("https://www.excalidraw.com")).toBe(
+ "https://www.excalidraw.com",
+ );
+ expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
+ expect(normalizeLink("/ola")).toBe("/ola");
+ expect(normalizeLink("http://test")).toBe("http://test");
+ expect(normalizeLink("ftp://test")).toBe("ftp://test");
+ expect(normalizeLink("file://")).toBe("file://");
+ expect(normalizeLink("file://")).toBe("file://");
+ expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
+ expect(normalizeLink("[[test]]")).toBe("[[test]]");
+ expect(normalizeLink("<test>")).toBe("<test>");
+ expect(normalizeLink("test&")).toBe("test&");
+ });
+});
diff --git a/packages/excalidraw/data/url.ts b/packages/excalidraw/data/url.ts
new file mode 100644
index 0000000..2ab553b
--- /dev/null
+++ b/packages/excalidraw/data/url.ts
@@ -0,0 +1,36 @@
+import { sanitizeUrl } from "@braintree/sanitize-url";
+import { escapeDoubleQuotes } from "../utils";
+
+export const normalizeLink = (link: string) => {
+ link = link.trim();
+ if (!link) {
+ return link;
+ }
+ return sanitizeUrl(escapeDoubleQuotes(link));
+};
+
+export const isLocalLink = (link: string | null) => {
+ return !!(link?.includes(location.origin) || link?.startsWith("/"));
+};
+
+/**
+ * Returns URL sanitized and safe for usage in places such as
+ * iframe's src attribute or <a> href attributes.
+ */
+export const toValidURL = (link: string) => {
+ link = normalizeLink(link);
+
+ // make relative links into fully-qualified urls
+ if (link.startsWith("/")) {
+ return `${location.origin}${link}`;
+ }
+
+ try {
+ new URL(link);
+ } catch {
+ // if link does not parse as URL, assume invalid and return blank page
+ return "about:blank";
+ }
+
+ return link;
+};