From 5950fa9a40ea9aa6a78ae9e79c01395ba0647e63 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Tue, 13 Oct 2020 14:47:07 +0200 Subject: [PATCH] support embedding scene data to PNG/SVG (#2219) Co-authored-by: Lipis --- CHANGELOG.md | 3 + package-lock.json | 32 +++++++ package.json | 3 + src/actions/actionExport.tsx | 20 +++++ src/actions/types.ts | 1 + src/appState.ts | 2 + src/base64.ts | 40 +++++++++ src/components/App.tsx | 28 ++++++- src/components/ExportDialog.tsx | 1 + src/components/LibraryUnit.tsx | 3 +- src/constants.ts | 5 ++ src/data/blob.ts | 59 +++++++++---- src/data/index.ts | 28 ++++++- src/data/json.ts | 7 +- src/global.d.ts | 22 +++++ src/index-node.ts | 2 +- src/locales/en.json | 6 +- src/scene/export.ts | 10 ++- .../regressionTests.test.tsx.snap | 83 +++++++++++++++++++ src/types.ts | 1 + 20 files changed, 329 insertions(+), 27 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/base64.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a6506e9a --- /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 fb5105a0..3419fbe7 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 a2ae1cb3..628c64cb 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 5e5b8106..6af01e87 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 9fdcb954..aa944eef 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 8914ac48..54843f17 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 00000000..78e464fd --- /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 13298f0a..a800b09f 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 384c7cd0..52ceba1b 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -156,6 +156,7 @@ const ExportModal = ({ {actionManager.renderAction("changeExportBackground")} + {actionManager.renderAction("changeExportEmbedScene")} {someElementIsSelected && (