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": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
"indexes": "firestore.indexes.json"
|
"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;
|
mirror?: true;
|
||||||
} & React.SVGProps<SVGSVGElement>;
|
} & 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 } =
|
const { width = 512, height = width, mirror, style } =
|
||||||
typeof opts === "number" ? ({ width: opts } as Opts) : opts;
|
typeof opts === "number" ? ({ width: opts } as Opts) : opts;
|
||||||
return (
|
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 Portal from "../collab/Portal";
|
||||||
import { restoreElements } from "../../data/restore";
|
import { restoreElements } from "../../data/restore";
|
||||||
|
|
||||||
|
// private
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
let firebasePromise: Promise<
|
let firebasePromise: Promise<
|
||||||
typeof import("firebase/app").default
|
typeof import("firebase/app").default
|
||||||
> | null = null;
|
> | null = null;
|
||||||
|
let firestorePromise: Promise<any> | null = null;
|
||||||
|
let firebseStoragePromise: Promise<any> | null = null;
|
||||||
|
|
||||||
const loadFirebase = async () => {
|
const _loadFirebase = async () => {
|
||||||
const firebase = (
|
const firebase = (
|
||||||
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
await import(/* webpackChunkName: "firebase" */ "firebase/app")
|
||||||
).default;
|
).default;
|
||||||
await import(/* webpackChunkName: "firestore" */ "firebase/firestore");
|
|
||||||
|
|
||||||
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
|
const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
|
||||||
firebase.initializeApp(firebaseConfig);
|
firebase.initializeApp(firebaseConfig);
|
||||||
@ -21,13 +25,37 @@ const loadFirebase = async () => {
|
|||||||
return firebase;
|
return firebase;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFirebase = async (): Promise<
|
const _getFirebase = async (): Promise<
|
||||||
typeof import("firebase/app").default
|
typeof import("firebase/app").default
|
||||||
> => {
|
> => {
|
||||||
if (!firebasePromise) {
|
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 {
|
interface FirebaseStoredScene {
|
||||||
@ -108,7 +136,7 @@ export const saveToFirebase = async (
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firebase = await getFirebase();
|
const firebase = await loadFirestore();
|
||||||
const sceneVersion = getSceneVersion(elements);
|
const sceneVersion = getSceneVersion(elements);
|
||||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||||
|
|
||||||
@ -150,7 +178,7 @@ export const loadFromFirebase = async (
|
|||||||
roomKey: string,
|
roomKey: string,
|
||||||
socket: SocketIOClient.Socket | null,
|
socket: SocketIOClient.Socket | null,
|
||||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
): Promise<readonly ExcalidrawElement[] | null> => {
|
||||||
const firebase = await getFirebase();
|
const firebase = await loadFirestore();
|
||||||
const db = firebase.firestore();
|
const db = firebase.firestore();
|
||||||
|
|
||||||
const docRef = db.collection("scenes").doc(roomId);
|
const docRef = db.collection("scenes").doc(roomId);
|
||||||
|
@ -17,7 +17,7 @@ const generateRandomID = async () => {
|
|||||||
return Array.from(arr, byteToHex).join("");
|
return Array.from(arr, byteToHex).join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateEncryptionKey = async () => {
|
export const generateEncryptionKey = async () => {
|
||||||
const key = await window.crypto.subtle.generateKey(
|
const key = await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: "AES-GCM",
|
||||||
@ -176,7 +176,7 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
|
|||||||
[usage],
|
[usage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const decryptImported = async (
|
export const decryptImported = async (
|
||||||
iv: ArrayBuffer,
|
iv: ArrayBuffer,
|
||||||
encrypted: ArrayBuffer,
|
encrypted: ArrayBuffer,
|
||||||
privateKey: string,
|
privateKey: string,
|
||||||
|
@ -56,6 +56,7 @@ import { Tooltip } from "../components/Tooltip";
|
|||||||
import { shield } from "../components/icons";
|
import { shield } from "../components/icons";
|
||||||
|
|
||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
|
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
|
||||||
|
|
||||||
const languageDetector = new LanguageDetector();
|
const languageDetector = new LanguageDetector();
|
||||||
languageDetector.init({
|
languageDetector.init({
|
||||||
@ -428,6 +429,21 @@ const ExcalidrawWrapper = () => {
|
|||||||
canvasActions: {
|
canvasActions: {
|
||||||
export: {
|
export: {
|
||||||
onExportToBackend,
|
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",
|
"disk_button": "Save to file",
|
||||||
"link_title": "Shareable link",
|
"link_title": "Shareable link",
|
||||||
"link_details": "Export as a read-only 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": {
|
"helpDialog": {
|
||||||
"blog": "Read our blog",
|
"blog": "Read our blog",
|
||||||
|
Loading…
Reference in New Issue
Block a user