diff --git a/package-lock.json b/package-lock.json index 09e322d9..82b6cac2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2721,6 +2721,11 @@ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, + "browser-nativefs": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/browser-nativefs/-/browser-nativefs-0.0.5.tgz", + "integrity": "sha512-0yS+D32qmIgg7YAUpaSfLEMfG6Co5ajPhbCT7agHsF6PuF6p7VVFNT5x8yAEWLAfPJHyNW/1nxNL54JZLzn6jg==" + }, "browser-process-hrtime": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz", @@ -3054,7 +3059,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -3072,11 +3078,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3089,15 +3097,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -3200,7 +3211,8 @@ }, "inherits": { "version": "2.0.4", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -3210,6 +3222,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3222,17 +3235,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.9.0", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -3249,6 +3265,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -3329,7 +3346,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -3339,6 +3357,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -3414,7 +3433,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -3444,6 +3464,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -3461,6 +3482,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -3499,11 +3521,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.1.1", - "bundled": true + "bundled": true, + "optional": true } } }, @@ -7687,7 +7711,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -7705,11 +7730,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7722,15 +7749,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -7833,7 +7863,8 @@ }, "inherits": { "version": "2.0.4", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -7843,6 +7874,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -7855,17 +7887,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.9.0", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -7882,6 +7917,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -7962,7 +7998,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -7972,6 +8009,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -8047,7 +8085,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -8077,6 +8116,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -8094,6 +8134,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -8132,11 +8173,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.1.1", - "bundled": true + "bundled": true, + "optional": true } } } diff --git a/package.json b/package.json index 481957a3..af3d60f7 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "not op_mini all" ], "dependencies": { + "browser-nativefs": "0.0.5", "i18next": "19.0.3", "i18next-browser-languagedetector": "4.0.1", "i18next-xhr-backend": "3.2.2", @@ -61,5 +62,10 @@ "test:app": "react-scripts test --env=jsdom --passWithNoTests", "test:code": "npm run prettier -- --list-different" }, - "version": "1.0.0" + "version": "1.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/excalidraw/excalidraw.git" + } } diff --git a/src/scene/browser-native.d.ts b/src/scene/browser-native.d.ts new file mode 100644 index 00000000..59630fc0 --- /dev/null +++ b/src/scene/browser-native.d.ts @@ -0,0 +1 @@ +declare module "browser-nativefs"; diff --git a/src/scene/data.ts b/src/scene/data.ts index 0db34f30..01c636ed 100644 --- a/src/scene/data.ts +++ b/src/scene/data.ts @@ -6,70 +6,26 @@ import { AppState } from "../types"; import { ExportType } from "./types"; import { getExportCanvasPreview } from "./getExportCanvasPreview"; import nanoid from "nanoid"; +import { fileOpenPromise, fileSavePromise } from "browser-nativefs"; const LOCAL_STORAGE_KEY = "excalidraw"; const LOCAL_STORAGE_KEY_STATE = "excalidraw-state"; const BACKEND_POST = "https://json.excalidraw.com/api/v1/post/"; const BACKEND_GET = "https://json.excalidraw.com/api/v1/"; +let fileOpen: Function; +let fileSave: Function; + +(async () => { + fileOpen = (await fileOpenPromise).default; + fileSave = (await fileSavePromise).default; +})(); + // TODO: Defined globally, since file handles aren't yet serializable. // Once `FileSystemFileHandle` can be serialized, make this // part of `AppState`. (window as any).handle = null; -function saveFile(name: string, data: string) { - // create a temporary elem which we'll use to download the image - const link = document.createElement("a"); - link.setAttribute("download", name); - link.setAttribute("href", data); - link.click(); - - // clean up - link.remove(); -} - -async function saveFileNative(name: string, data: Blob) { - const options = { - type: "saveFile", - accepts: [ - { - description: `Excalidraw ${ - data.type === "image/png" ? "image" : "file" - }`, - extensions: [data.type.split("/")[1]], - mimeTypes: [data.type] - } - ] - }; - try { - let handle; - if (data.type === "application/json") { - // For Excalidraw files (i.e., `application/json` files): - // If it exists, write back to a previously opened file. - // Else, create a new file. - if ((window as any).handle) { - handle = (window as any).handle; - } else { - handle = await (window as any).chooseFileSystemEntries(options); - (window as any).handle = handle; - } - } else { - // For image export files (i.e., `image/png` files): - // Always create a new file. - handle = await (window as any).chooseFileSystemEntries(options); - } - const writer = await handle.createWriter(); - await writer.truncate(0); - await writer.write(0, data, data.type); - await writer.close(); - } catch (err) { - if (err.name !== "AbortError") { - console.error(err.name, err.message); - } - throw err; - } -} - interface DataState { elements: readonly ExcalidrawElement[]; appState: AppState; @@ -94,17 +50,14 @@ export async function saveAsJSON( const serialized = serializeAsJSON(elements, appState); const name = `${appState.name}.json`; - if ("chooseFileSystemEntries" in window) { - await saveFileNative( - name, - new Blob([serialized], { type: "application/json" }) - ); - } else { - saveFile( - name, - "data:application/json;charset=utf-8," + encodeURIComponent(serialized) - ); - } + await fileSave( + new Blob([serialized], { type: "application/json" }), + { + fileName: name, + description: "Excalidraw file" + }, + (window as any).handle + ); } export async function loadFromJSON() { @@ -122,57 +75,34 @@ export async function loadFromJSON() { return { elements, appState }; }; - if ("chooseFileSystemEntries" in window) { - try { - (window as any).handle = await (window as any).chooseFileSystemEntries({ - accepts: [ - { - description: "Excalidraw files", - extensions: ["json"], - mimeTypes: ["application/json"] - } - ] - }); - const file = await (window as any).handle.getFile(); - const contents = await file.text(); - const { elements, appState } = updateAppState(contents); - return new Promise(resolve => { - resolve(restore(elements, appState)); - }); - } catch (err) { - if (err.name !== "AbortError") { - console.error(err.name, err.message); - } - throw err; - } - } else { - const input = document.createElement("input"); - const reader = new FileReader(); - input.type = "file"; - input.accept = ".json"; - - input.onchange = () => { - if (!input.files!.length) { - alert("A file was not selected."); - return; - } - - reader.readAsText(input.files![0], "utf8"); - }; - - input.click(); - - return new Promise(resolve => { - reader.onloadend = () => { - if (reader.readyState === FileReader.DONE) { - const { elements, appState } = updateAppState( - reader.result as string - ); - resolve(restore(elements, appState)); - } - }; - }); + const blob = await fileOpen({ + description: "Excalidraw files", + extensions: ["json"], + mimeTypes: ["application/json"] + }); + if (blob.handle) { + (window as any).handle = blob.handle; } + let contents; + if ("text" in Blob) { + contents = await blob.text(); + } else { + contents = await (async () => { + return new Promise(resolve => { + const reader = new FileReader(); + reader.readAsText(blob, "utf8"); + reader.onloadend = () => { + if (reader.readyState === FileReader.DONE) { + resolve(reader.result as string); + } + }; + }); + })(); + } + const { elements, appState } = updateAppState(contents); + return new Promise(resolve => { + resolve(restore(elements, appState)); + }); } export async function exportToBackend( @@ -246,15 +176,14 @@ export async function exportCanvas( if (type === "png") { const fileName = `${name}.png`; - if ("chooseFileSystemEntries" in window) { - tempCanvas.toBlob(async (blob: any) => { - if (blob) { - await saveFileNative(fileName, blob); - } - }); - } else { - saveFile(fileName, tempCanvas.toDataURL("image/png")); - } + tempCanvas.toBlob(async (blob: any) => { + if (blob) { + await fileSave(blob, { + fileName: fileName, + description: "Excalidraw image" + }); + } + }); } else if (type === "clipboard") { try { tempCanvas.toBlob(async function(blob: any) {