From a2e1199907c9b4090a009bdcf8f85a62b06859e6 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Tue, 1 Jun 2021 14:05:09 +0200 Subject: [PATCH] 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 --- firebase-project/firebase.json | 3 + firebase-project/storage.rules | 12 +++ src/components/icons.tsx | 5 +- .../components/ExportToExcalidrawPlus.tsx | 92 +++++++++++++++++++ src/excalidraw-app/components/icons.tsx | 19 ++++ src/excalidraw-app/data/firebase.ts | 42 +++++++-- src/excalidraw-app/data/index.ts | 4 +- src/excalidraw-app/index.tsx | 16 ++++ src/locales/en.json | 5 +- 9 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 firebase-project/storage.rules create mode 100644 src/excalidraw-app/components/ExportToExcalidrawPlus.tsx create mode 100644 src/excalidraw-app/components/icons.tsx diff --git a/firebase-project/firebase.json b/firebase-project/firebase.json index d4d918a83..3facb51c7 100644 --- a/firebase-project/firebase.json +++ b/firebase-project/firebase.json @@ -2,5 +2,8 @@ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" + }, + "storage": { + "rules": "storage.rules" } } diff --git a/firebase-project/storage.rules b/firebase-project/storage.rules new file mode 100644 index 000000000..7d1ab153b --- /dev/null +++ b/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; + } + } + } +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index dfd81a939..a55d5ed05 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -24,7 +24,10 @@ type Opts = { mirror?: true; } & React.SVGProps; -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 ( diff --git a/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx b/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx new file mode 100644 index 000000000..36c7e7937 --- /dev/null +++ b/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 ( + +
{excalidrawPlusIcon}
+

Excalidraw+

+
+ {t("exportDialog.excalidrawplus_description")} +
+ { + try { + await exportToExcalidrawPlus(elements, appState); + } catch (error) { + console.error(error); + onError(new Error(t("exportDialog.excalidrawplus_exportError"))); + } + }} + /> +
+ ); +}; diff --git a/src/excalidraw-app/components/icons.tsx b/src/excalidraw-app/components/icons.tsx new file mode 100644 index 000000000..5e701ce8a --- /dev/null +++ b/src/excalidraw-app/components/icons.tsx @@ -0,0 +1,19 @@ +import { createIcon } from "../../components/icons"; + +export const excalidrawPlusIcon = createIcon( + <> + + + + , + { width: 89, height: 131, style: { transform: "translateX(4px)" } }, +); diff --git a/src/excalidraw-app/data/firebase.ts b/src/excalidraw-app/data/firebase.ts index 57148319a..66fdd8716 100644 --- a/src/excalidraw-app/data/firebase.ts +++ b/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 | null = null; +let firebseStoragePromise: Promise | 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 await firebasePromise!; + return firebasePromise; +}; + +// ----------------------------------------------------------------------------- + +const loadFirestore = async () => { + const firebase = await _getFirebase(); + if (!firestorePromise) { + firestorePromise = import( + /* webpackChunkName: "firestore" */ "firebase/firestore" + ); + await firestorePromise; + } + 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 => { - const firebase = await getFirebase(); + const firebase = await loadFirestore(); const db = firebase.firestore(); const docRef = db.collection("scenes").doc(roomId); diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts index 6715f6b8f..e50dc4cff 100644 --- a/src/excalidraw-app/data/index.ts +++ b/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, diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 267793cee..1d71dfe67 100644 --- a/src/excalidraw-app/index.tsx +++ b/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 ( + { + excalidrawAPI?.updateScene({ + appState: { + errorMessage: error.message, + }, + }); + }} + /> + ); + }, }, }, }} diff --git a/src/locales/en.json b/src/locales/en.json index 3980ef7e7..891456a00 100644 --- a/src/locales/en.json +++ b/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",