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
This commit is contained in:
David Luzar 2021-06-01 14:05:09 +02:00 committed by GitHub
parent c08e9c4172
commit a2e1199907
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 187 additions and 11 deletions

View File

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

View File

@ -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;
}
}
}
}

View File

@ -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 (

View File

@ -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>
);
};

File diff suppressed because one or more lines are too long

View File

@ -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 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<readonly ExcalidrawElement[] | null> => {
const firebase = await getFirebase();
const firebase = await loadFirestore();
const db = firebase.firestore();
const docRef = db.collection("scenes").doc(roomId);

View File

@ -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,

View File

@ -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,
},
});
}}
/>
);
},
},
},
}}

View File

@ -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",