Procházet zdrojové kódy

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
David Luzar před 4 roky
rodič
revize
a2e1199907

+ 3 - 0
firebase-project/firebase.json

@@ -2,5 +2,8 @@
   "firestore": {
     "rules": "firestore.rules",
     "indexes": "firestore.indexes.json"
+  },
+  "storage": {
+    "rules": "storage.rules"
   }
 }

+ 12 - 0
firebase-project/storage.rules

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

+ 4 - 1
src/components/icons.tsx

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

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

@@ -0,0 +1,92 @@
+import React from "react";
+import { Card } from "../../components/Card";
+import { ToolButton } from "../../components/ToolButton";
+import { serializeAsJSON } from "../../data/json";
+import { getImportedKey, createIV, generateEncryptionKey } from "../data";
+import { loadFirebaseStorage } from "../data/firebase";
+import { NonDeletedExcalidrawElement } from "../../element/types";
+import { AppState } from "../../types";
+import { nanoid } from "nanoid";
+import { t } from "../../i18n";
+import { excalidrawPlusIcon } from "./icons";
+
+const encryptData = async (
+  key: string,
+  json: string,
+): Promise<{ blob: Blob; iv: Uint8Array }> => {
+  const importedKey = await getImportedKey(key, "encrypt");
+  const iv = createIV();
+  const encoded = new TextEncoder().encode(json);
+  const ciphertext = await window.crypto.subtle.encrypt(
+    {
+      name: "AES-GCM",
+      iv,
+    },
+    importedKey,
+    encoded,
+  );
+
+  return { blob: new Blob([new Uint8Array(ciphertext)]), iv };
+};
+
+const exportToExcalidrawPlus = async (
+  elements: readonly NonDeletedExcalidrawElement[],
+  appState: AppState,
+) => {
+  const firebase = await loadFirebaseStorage();
+
+  const id = `${nanoid(12)}`;
+
+  const key = (await generateEncryptionKey())!;
+  const encryptedData = await encryptData(
+    key,
+    serializeAsJSON(elements, appState),
+  );
+
+  const blob = new Blob([encryptedData.iv, encryptedData.blob], {
+    type: "application/octet-stream",
+  });
+
+  await firebase
+    .storage()
+    .ref(`/migrations/scenes/${id}`)
+    .put(blob, {
+      customMetadata: {
+        data: JSON.stringify({ version: 1, name: appState.name }),
+        created: Date.now().toString(),
+      },
+    });
+
+  window.open(`https://plus.excalidraw.com/import?excalidraw=${id},${key}`);
+};
+
+export const ExportToExcalidrawPlus: React.FC<{
+  elements: readonly NonDeletedExcalidrawElement[];
+  appState: AppState;
+  onError: (error: Error) => void;
+}> = ({ elements, appState, onError }) => {
+  return (
+    <Card color="indigo">
+      <div className="Card-icon">{excalidrawPlusIcon}</div>
+      <h2>Excalidraw+</h2>
+      <div className="Card-details">
+        {t("exportDialog.excalidrawplus_description")}
+      </div>
+      <ToolButton
+        className="Card-button"
+        type="button"
+        title={t("exportDialog.excalidrawplus_button")}
+        aria-label={t("exportDialog.excalidrawplus_button")}
+        showAriaLabel={true}
+        onClick={async () => {
+          try {
+            await exportToExcalidrawPlus(elements, appState);
+          } catch (error) {
+            console.error(error);
+            onError(new Error(t("exportDialog.excalidrawplus_exportError")));
+          }
+        }}
+      />
+    </Card>
+  );
+};

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 9 - 0
src/excalidraw-app/components/icons.tsx


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

@@ -5,15 +5,19 @@ import { getSceneVersion } from "../../element";
 import Portal from "../collab/Portal";
 import { restoreElements } from "../../data/restore";
 
+// private
+// -----------------------------------------------------------------------------
+
 let firebasePromise: Promise<
   typeof import("firebase/app").default
 > | null = null;
+let firestorePromise: Promise<any> | null = null;
+let firebseStoragePromise: Promise<any> | null = null;
 
-const loadFirebase = async () => {
+const _loadFirebase = async () => {
   const firebase = (
     await import(/* webpackChunkName: "firebase" */ "firebase/app")
   ).default;
-  await import(/* webpackChunkName: "firestore" */ "firebase/firestore");
 
   const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
   firebase.initializeApp(firebaseConfig);
@@ -21,13 +25,37 @@ const loadFirebase = async () => {
   return firebase;
 };
 
-const getFirebase = async (): Promise<
+const _getFirebase = async (): Promise<
   typeof import("firebase/app").default
 > => {
   if (!firebasePromise) {
-    firebasePromise = loadFirebase();
+    firebasePromise = _loadFirebase();
+  }
+  return firebasePromise;
+};
+
+// -----------------------------------------------------------------------------
+
+const loadFirestore = async () => {
+  const firebase = await _getFirebase();
+  if (!firestorePromise) {
+    firestorePromise = import(
+      /* webpackChunkName: "firestore" */ "firebase/firestore"
+    );
+    await firestorePromise;
   }
-  return await firebasePromise!;
+  return firebase;
+};
+
+export const loadFirebaseStorage = async () => {
+  const firebase = await _getFirebase();
+  if (!firebseStoragePromise) {
+    firebseStoragePromise = import(
+      /* webpackChunkName: "storage" */ "firebase/storage"
+    );
+    await firebseStoragePromise;
+  }
+  return firebase;
 };
 
 interface FirebaseStoredScene {
@@ -108,7 +136,7 @@ export const saveToFirebase = async (
     return true;
   }
 
-  const firebase = await getFirebase();
+  const firebase = await loadFirestore();
   const sceneVersion = getSceneVersion(elements);
   const { ciphertext, iv } = await encryptElements(roomKey, elements);
 
@@ -150,7 +178,7 @@ export const loadFromFirebase = async (
   roomKey: string,
   socket: SocketIOClient.Socket | null,
 ): Promise<readonly ExcalidrawElement[] | null> => {
-  const firebase = await getFirebase();
+  const firebase = await loadFirestore();
   const db = firebase.firestore();
 
   const docRef = db.collection("scenes").doc(roomId);

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

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

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

@@ -56,6 +56,7 @@ import { Tooltip } from "../components/Tooltip";
 import { shield } from "../components/icons";
 
 import "./index.scss";
+import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
 
 const languageDetector = new LanguageDetector();
 languageDetector.init({
@@ -428,6 +429,21 @@ const ExcalidrawWrapper = () => {
           canvasActions: {
             export: {
               onExportToBackend,
+              renderCustomUI: (elements, appState) => {
+                return (
+                  <ExportToExcalidrawPlus
+                    elements={elements}
+                    appState={appState}
+                    onError={(error) => {
+                      excalidrawAPI?.updateScene({
+                        appState: {
+                          errorMessage: error.message,
+                        },
+                      });
+                    }}
+                  />
+                );
+              },
             },
           },
         }}

+ 4 - 1
src/locales/en.json

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů