Browse Source

feat: support exporting json to excalidraw plus (#3678)

* feat: support exporting json to excalidraw plus

* add Firebase Storage rules to codebase

* factor the onClick handler out

* move excal icon to icons.tsx

* handle export error
vanilla_orig
David Luzar 3 years ago
parent
commit
a2e1199907
No account linked to committer's email address

+ 3
- 0
firebase-project/firebase.json View File

@@ -2,5 +2,8 @@
2 2
   "firestore": {
3 3
     "rules": "firestore.rules",
4 4
     "indexes": "firestore.indexes.json"
5
+  },
6
+  "storage": {
7
+    "rules": "storage.rules"
5 8
   }
6 9
 }

+ 12
- 0
firebase-project/storage.rules View File

@@ -0,0 +1,12 @@
1
+rules_version = '2';
2
+service firebase.storage {
3
+  match /b/{bucket}/o {
4
+    match /{migrations} {
5
+      match /{scenes}/{scene} {
6
+      	allow get, write: if true;
7
+        // redundant, but let's be explicit'
8
+        allow list: if false;
9
+      }
10
+    }
11
+  }
12
+}

+ 4
- 1
src/components/icons.tsx View File

@@ -24,7 +24,10 @@ type Opts = {
24 24
   mirror?: true;
25 25
 } & React.SVGProps<SVGSVGElement>;
26 26
 
27
-const createIcon = (d: string | React.ReactNode, opts: number | Opts = 512) => {
27
+export const createIcon = (
28
+  d: string | React.ReactNode,
29
+  opts: number | Opts = 512,
30
+) => {
28 31
   const { width = 512, height = width, mirror, style } =
29 32
     typeof opts === "number" ? ({ width: opts } as Opts) : opts;
30 33
   return (

+ 92
- 0
src/excalidraw-app/components/ExportToExcalidrawPlus.tsx View File

@@ -0,0 +1,92 @@
1
+import React from "react";
2
+import { Card } from "../../components/Card";
3
+import { ToolButton } from "../../components/ToolButton";
4
+import { serializeAsJSON } from "../../data/json";
5
+import { getImportedKey, createIV, generateEncryptionKey } from "../data";
6
+import { loadFirebaseStorage } from "../data/firebase";
7
+import { NonDeletedExcalidrawElement } from "../../element/types";
8
+import { AppState } from "../../types";
9
+import { nanoid } from "nanoid";
10
+import { t } from "../../i18n";
11
+import { excalidrawPlusIcon } from "./icons";
12
+
13
+const encryptData = async (
14
+  key: string,
15
+  json: string,
16
+): Promise<{ blob: Blob; iv: Uint8Array }> => {
17
+  const importedKey = await getImportedKey(key, "encrypt");
18
+  const iv = createIV();
19
+  const encoded = new TextEncoder().encode(json);
20
+  const ciphertext = await window.crypto.subtle.encrypt(
21
+    {
22
+      name: "AES-GCM",
23
+      iv,
24
+    },
25
+    importedKey,
26
+    encoded,
27
+  );
28
+
29
+  return { blob: new Blob([new Uint8Array(ciphertext)]), iv };
30
+};
31
+
32
+const exportToExcalidrawPlus = async (
33
+  elements: readonly NonDeletedExcalidrawElement[],
34
+  appState: AppState,
35
+) => {
36
+  const firebase = await loadFirebaseStorage();
37
+
38
+  const id = `${nanoid(12)}`;
39
+
40
+  const key = (await generateEncryptionKey())!;
41
+  const encryptedData = await encryptData(
42
+    key,
43
+    serializeAsJSON(elements, appState),
44
+  );
45
+
46
+  const blob = new Blob([encryptedData.iv, encryptedData.blob], {
47
+    type: "application/octet-stream",
48
+  });
49
+
50
+  await firebase
51
+    .storage()
52
+    .ref(`/migrations/scenes/${id}`)
53
+    .put(blob, {
54
+      customMetadata: {
55
+        data: JSON.stringify({ version: 1, name: appState.name }),
56
+        created: Date.now().toString(),
57
+      },
58
+    });
59
+
60
+  window.open(`https://plus.excalidraw.com/import?excalidraw=${id},${key}`);
61
+};
62
+
63
+export const ExportToExcalidrawPlus: React.FC<{
64
+  elements: readonly NonDeletedExcalidrawElement[];
65
+  appState: AppState;
66
+  onError: (error: Error) => void;
67
+}> = ({ elements, appState, onError }) => {
68
+  return (
69
+    <Card color="indigo">
70
+      <div className="Card-icon">{excalidrawPlusIcon}</div>
71
+      <h2>Excalidraw+</h2>
72
+      <div className="Card-details">
73
+        {t("exportDialog.excalidrawplus_description")}
74
+      </div>
75
+      <ToolButton
76
+        className="Card-button"
77
+        type="button"
78
+        title={t("exportDialog.excalidrawplus_button")}
79
+        aria-label={t("exportDialog.excalidrawplus_button")}
80
+        showAriaLabel={true}
81
+        onClick={async () => {
82
+          try {
83
+            await exportToExcalidrawPlus(elements, appState);
84
+          } catch (error) {
85
+            console.error(error);
86
+            onError(new Error(t("exportDialog.excalidrawplus_exportError")));
87
+          }
88
+        }}
89
+      />
90
+    </Card>
91
+  );
92
+};

+ 19
- 0
src/excalidraw-app/components/icons.tsx
File diff suppressed because it is too large
View File


+ 35
- 7
src/excalidraw-app/data/firebase.ts View File

@@ -5,15 +5,19 @@ import { getSceneVersion } from "../../element";
5 5
 import Portal from "../collab/Portal";
6 6
 import { restoreElements } from "../../data/restore";
7 7
 
8
+// private
9
+// -----------------------------------------------------------------------------
10
+
8 11
 let firebasePromise: Promise<
9 12
   typeof import("firebase/app").default
10 13
 > | null = null;
14
+let firestorePromise: Promise<any> | null = null;
15
+let firebseStoragePromise: Promise<any> | null = null;
11 16
 
12
-const loadFirebase = async () => {
17
+const _loadFirebase = async () => {
13 18
   const firebase = (
14 19
     await import(/* webpackChunkName: "firebase" */ "firebase/app")
15 20
   ).default;
16
-  await import(/* webpackChunkName: "firestore" */ "firebase/firestore");
17 21
 
18 22
   const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
19 23
   firebase.initializeApp(firebaseConfig);
@@ -21,13 +25,37 @@ const loadFirebase = async () => {
21 25
   return firebase;
22 26
 };
23 27
 
24
-const getFirebase = async (): Promise<
28
+const _getFirebase = async (): Promise<
25 29
   typeof import("firebase/app").default
26 30
 > => {
27 31
   if (!firebasePromise) {
28
-    firebasePromise = loadFirebase();
32
+    firebasePromise = _loadFirebase();
33
+  }
34
+  return firebasePromise;
35
+};
36
+
37
+// -----------------------------------------------------------------------------
38
+
39
+const loadFirestore = async () => {
40
+  const firebase = await _getFirebase();
41
+  if (!firestorePromise) {
42
+    firestorePromise = import(
43
+      /* webpackChunkName: "firestore" */ "firebase/firestore"
44
+    );
45
+    await firestorePromise;
29 46
   }
30
-  return await firebasePromise!;
47
+  return firebase;
48
+};
49
+
50
+export const loadFirebaseStorage = async () => {
51
+  const firebase = await _getFirebase();
52
+  if (!firebseStoragePromise) {
53
+    firebseStoragePromise = import(
54
+      /* webpackChunkName: "storage" */ "firebase/storage"
55
+    );
56
+    await firebseStoragePromise;
57
+  }
58
+  return firebase;
31 59
 };
32 60
 
33 61
 interface FirebaseStoredScene {
@@ -108,7 +136,7 @@ export const saveToFirebase = async (
108 136
     return true;
109 137
   }
110 138
 
111
-  const firebase = await getFirebase();
139
+  const firebase = await loadFirestore();
112 140
   const sceneVersion = getSceneVersion(elements);
113 141
   const { ciphertext, iv } = await encryptElements(roomKey, elements);
114 142
 
@@ -150,7 +178,7 @@ export const loadFromFirebase = async (
150 178
   roomKey: string,
151 179
   socket: SocketIOClient.Socket | null,
152 180
 ): Promise<readonly ExcalidrawElement[] | null> => {
153
-  const firebase = await getFirebase();
181
+  const firebase = await loadFirestore();
154 182
   const db = firebase.firestore();
155 183
 
156 184
   const docRef = db.collection("scenes").doc(roomId);

+ 2
- 2
src/excalidraw-app/data/index.ts View File

@@ -17,7 +17,7 @@ const generateRandomID = async () => {
17 17
   return Array.from(arr, byteToHex).join("");
18 18
 };
19 19
 
20
-const generateEncryptionKey = async () => {
20
+export const generateEncryptionKey = async () => {
21 21
   const key = await window.crypto.subtle.generateKey(
22 22
     {
23 23
       name: "AES-GCM",
@@ -176,7 +176,7 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
176 176
     [usage],
177 177
   );
178 178
 
179
-const decryptImported = async (
179
+export const decryptImported = async (
180 180
   iv: ArrayBuffer,
181 181
   encrypted: ArrayBuffer,
182 182
   privateKey: string,

+ 16
- 0
src/excalidraw-app/index.tsx View File

@@ -56,6 +56,7 @@ import { Tooltip } from "../components/Tooltip";
56 56
 import { shield } from "../components/icons";
57 57
 
58 58
 import "./index.scss";
59
+import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
59 60
 
60 61
 const languageDetector = new LanguageDetector();
61 62
 languageDetector.init({
@@ -428,6 +429,21 @@ const ExcalidrawWrapper = () => {
428 429
           canvasActions: {
429 430
             export: {
430 431
               onExportToBackend,
432
+              renderCustomUI: (elements, appState) => {
433
+                return (
434
+                  <ExportToExcalidrawPlus
435
+                    elements={elements}
436
+                    appState={appState}
437
+                    onError={(error) => {
438
+                      excalidrawAPI?.updateScene({
439
+                        appState: {
440
+                          errorMessage: error.message,
441
+                        },
442
+                      });
443
+                    }}
444
+                  />
445
+                );
446
+              },
431 447
             },
432 448
           },
433 449
         }}

+ 4
- 1
src/locales/en.json View File

@@ -225,7 +225,10 @@
225 225
     "disk_button": "Save to file",
226 226
     "link_title": "Shareable link",
227 227
     "link_details": "Export as a read-only link.",
228
-    "link_button": "Export to Link"
228
+    "link_button": "Export to Link",
229
+    "excalidrawplus_description": "Save the scene to your Excalidraw+ workspace.",
230
+    "excalidrawplus_button": "Export",
231
+    "excalidrawplus_exportError": "Couldn't export to Excalidraw+ at this moment..."
229 232
   },
230 233
   "helpDialog": {
231 234
     "blog": "Read our blog",

Loading…
Cancel
Save