mirror of
https://github.com/excalidraw/excalidraw.git
synced 2024-11-02 03:25:53 +01:00
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:
parent
c08e9c4172
commit
a2e1199907
@ -2,5 +2,8 @@
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"storage": {
|
||||
"rules": "storage.rules"
|
||||
}
|
||||
}
|
||||
|
12
firebase-project/storage.rules
Normal file
12
firebase-project/storage.rules
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
src/excalidraw-app/components/ExportToExcalidrawPlus.tsx
Normal file
92
src/excalidraw-app/components/ExportToExcalidrawPlus.tsx
Normal 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>
|
||||
);
|
||||
};
|
19
src/excalidraw-app/components/icons.tsx
Normal file
19
src/excalidraw-app/components/icons.tsx
Normal file
File diff suppressed because one or more lines are too long
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user