diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000..a6506e9a0f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 2020-10-13
+
+- Added ability to embed scene source into exported PNG/SVG files so you can import the scene from them (open via `Load` button or drag & drop). #2219
diff --git a/package-lock.json b/package-lock.json
index fb5105a023..3419fbe7e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5901,6 +5901,11 @@
}
}
},
+ "crc-32": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz",
+ "integrity": "sha1-aj02h/W67EH36bmf4ZU6Ll0Zd14="
+ },
"crc32-stream": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-3.0.1.tgz",
@@ -17341,6 +17346,28 @@
"resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz",
"integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA=="
},
+ "png-chunk-text": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz",
+ "integrity": "sha1-HGAG2ONLpHHTjhycVLP1PhCF4Y8="
+ },
+ "png-chunks-encode": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz",
+ "integrity": "sha1-2epeNcru7XgmWMGre6+npe2xqHg=",
+ "requires": {
+ "crc-32": "^0.3.0",
+ "sliced": "^1.0.1"
+ }
+ },
+ "png-chunks-extract": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz",
+ "integrity": "sha1-+tSpBeZmUhlzUcZeNbksZDEeRy0=",
+ "requires": {
+ "crc-32": "^0.3.0"
+ }
+ },
"pnp-webpack-plugin": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz",
@@ -20125,6 +20152,11 @@
}
}
},
+ "sliced": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz",
+ "integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E="
+ },
"snapdragon": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
diff --git a/package.json b/package.json
index a2ae1cb3ba..628c64cbb3 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,9 @@
"nanoid": "2.1.11",
"node-sass": "4.14.1",
"open-color": "1.7.0",
+ "png-chunk-text": "1.0.0",
+ "png-chunks-encode": "1.0.0",
+ "png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0",
"pwacompat": "2.0.17",
"react": "16.13.1",
diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx
index 5e5b81060e..6af01e8785 100644
--- a/src/actions/actionExport.tsx
+++ b/src/actions/actionExport.tsx
@@ -43,6 +43,26 @@ export const actionChangeExportBackground = register({
),
});
+export const actionChangeExportEmbedScene = register({
+ name: "changeExportEmbedScene",
+ perform: (_elements, appState, value) => {
+ return {
+ appState: { ...appState, exportEmbedScene: value },
+ commitToHistory: false,
+ };
+ },
+ PanelComponent: ({ appState, updateData }) => (
+
+ ),
+});
+
export const actionChangeShouldAddWatermark = register({
name: "changeShouldAddWatermark",
perform: (_elements, appState, value) => {
diff --git a/src/actions/types.ts b/src/actions/types.ts
index 9fdcb954c8..aa944eefb0 100644
--- a/src/actions/types.ts
+++ b/src/actions/types.ts
@@ -44,6 +44,7 @@ export type ActionName =
| "finalize"
| "changeProjectName"
| "changeExportBackground"
+ | "changeExportEmbedScene"
| "changeShouldAddWatermark"
| "saveScene"
| "saveAsScene"
diff --git a/src/appState.ts b/src/appState.ts
index 8914ac4857..54843f1772 100644
--- a/src/appState.ts
+++ b/src/appState.ts
@@ -25,6 +25,7 @@ export const getDefaultAppState = (): Omit<
elementType: "selection",
elementLocked: false,
exportBackground: true,
+ exportEmbedScene: false,
shouldAddWatermark: false,
currentItemStrokeColor: oc.black,
currentItemBackgroundColor: "transparent",
@@ -112,6 +113,7 @@ const APP_STATE_STORAGE_CONF = (<
elementType: { browser: true, export: false },
errorMessage: { browser: false, export: false },
exportBackground: { browser: true, export: false },
+ exportEmbedScene: { browser: true, export: false },
gridSize: { browser: true, export: true },
height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
diff --git a/src/base64.ts b/src/base64.ts
new file mode 100644
index 0000000000..78e464fd6b
--- /dev/null
+++ b/src/base64.ts
@@ -0,0 +1,40 @@
+// `btoa(unescape(encodeURIComponent(str)))` hack doesn't work in edge cases and
+// `unescape` API shouldn't be used anyway.
+// This implem is ~10x faster than using fromCharCode in a loop (in Chrome).
+const stringToByteString = (str: string): Promise => {
+ return new Promise((resolve, reject) => {
+ const blob = new Blob([new TextEncoder().encode(str)]);
+ const reader = new FileReader();
+ reader.onload = function (event) {
+ if (!event.target || typeof event.target.result !== "string") {
+ return reject(new Error("couldn't convert to byte string"));
+ }
+ resolve(event.target.result);
+ };
+ reader.readAsBinaryString(blob);
+ });
+};
+
+function byteStringToArrayBuffer(byteString: string) {
+ const buffer = new ArrayBuffer(byteString.length);
+ const bufferView = new Uint8Array(buffer);
+ for (let i = 0, len = byteString.length; i < len; i++) {
+ bufferView[i] = byteString.charCodeAt(i);
+ }
+ return buffer;
+}
+
+const byteStringToString = (byteString: string) => {
+ return new TextDecoder("utf-8").decode(byteStringToArrayBuffer(byteString));
+};
+
+// -----------------------------------------------------------------------------
+
+export const stringToBase64 = async (str: string) => {
+ return btoa(await stringToByteString(str));
+};
+
+// async to align with stringToBase64
+export const base64ToString = async (base64: string) => {
+ return byteStringToString(atob(base64));
+};
diff --git a/src/components/App.tsx b/src/components/App.tsx
index 13298f0a95..a800b09f95 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -125,6 +125,7 @@ import {
DEFAULT_VERTICAL_ALIGN,
GRID_SIZE,
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
+ MIME_TYPES,
} from "../constants";
import {
INITIAL_SCENE_UPDATE_TIMEOUT,
@@ -3788,9 +3789,28 @@ class App extends React.Component {
private handleCanvasOnDrop = async (
event: React.DragEvent,
) => {
- const libraryShapes = event.dataTransfer.getData(
- "application/vnd.excalidrawlib+json",
- );
+ try {
+ const file = event.dataTransfer.files[0];
+ if (file?.type === "image/png" || file?.type === "image/svg+xml") {
+ const { elements, appState } = await loadFromBlob(file, this.state);
+ this.syncActionResult({
+ elements,
+ appState: {
+ ...(appState || this.state),
+ isLoading: false,
+ },
+ commitToHistory: true,
+ });
+ return;
+ }
+ } catch (error) {
+ return this.setState({
+ isLoading: false,
+ errorMessage: error.message,
+ });
+ }
+
+ const libraryShapes = event.dataTransfer.getData(MIME_TYPES.excalidraw);
if (libraryShapes !== "") {
this.addElementsFromPasteOrLibrary(
JSON.parse(libraryShapes),
@@ -3835,7 +3855,7 @@ class App extends React.Component {
this.setState({ isLoading: false, errorMessage: error.message });
});
} else if (
- file?.type === "application/vnd.excalidrawlib+json" ||
+ file?.type === MIME_TYPES.excalidrawlib ||
file?.name.endsWith(".excalidrawlib")
) {
Library.importLibrary(file)
diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx
index 384c7cd025..52ceba1b7e 100644
--- a/src/components/ExportDialog.tsx
+++ b/src/components/ExportDialog.tsx
@@ -156,6 +156,7 @@ const ExportModal = ({
{actionManager.renderAction("changeExportBackground")}
+ {actionManager.renderAction("changeExportEmbedScene")}
{someElementIsSelected && (