diff --git a/public/index.html b/public/index.html index 7eb19194..d8b856eb 100644 --- a/public/index.html +++ b/public/index.html @@ -8,6 +8,8 @@ content="width=device-width, initial-scale=1, shrink-to-fit=no" /> + + { if (window.confirm("This will clear the whole canvas. Are you sure?")) { + // 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; updateData(null); } }} diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index ee843a64..02ddaa03 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -40,7 +40,7 @@ export const actionChangeExportBackground: Action = { export const actionSaveScene: Action = { name: "saveScene", perform: (elements, appState, value) => { - saveAsJSON(elements, appState); + saveAsJSON(elements, appState).catch(err => console.error(err)); return {}; }, PanelComponent: ({ updateData }) => ( @@ -70,9 +70,11 @@ export const actionLoadScene: Action = { title="Load" aria-label="Load" onClick={() => { - loadFromJSON().then(({ elements, appState }) => { - updateData({ elements: elements, appState: appState }); - }); + loadFromJSON() + .then(({ elements, appState }) => { + updateData({ elements: elements, appState: appState }); + }) + .catch(err => console.error(err)); }} /> ) diff --git a/src/scene/data.ts b/src/scene/data.ts index a9a49859..6df1b2fc 100644 --- a/src/scene/data.ts +++ b/src/scene/data.ts @@ -13,6 +13,11 @@ import nanoid from "nanoid"; const LOCAL_STORAGE_KEY = "excalidraw"; const LOCAL_STORAGE_KEY_STATE = "excalidraw-state"; +// 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"); @@ -24,12 +29,54 @@ function saveFile(name: string, data: string) { 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; } -export function saveAsJSON( +export async function saveAsJSON( elements: readonly ExcalidrawElement[], appState: AppState ) { @@ -40,46 +87,86 @@ export function saveAsJSON( appState: appState }); - saveFile( - `${appState.name}.json`, - "data:text/plain;charset=utf-8," + encodeURIComponent(serialized) - ); + const name = `${appState.name}.json`; + if ("chooseFileSystemEntries" in window) { + await saveFileNative( + name, + new Blob([serialized], { type: "application/json" }) + ); + } else { + saveFile( + name, + "data:text/plain;charset=utf-8," + encodeURIComponent(serialized) + ); + } } -export function loadFromJSON() { - 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; +export async function loadFromJSON() { + const updateAppState = (contents: string) => { + const defaultAppState = getDefaultAppState(); + let elements = []; + let appState = defaultAppState; + try { + const data = JSON.parse(contents); + elements = data.elements || []; + appState = { ...defaultAppState, ...data.appState }; + } catch (e) { + // Do nothing because elements array is already empty } - - reader.readAsText(input.files![0], "utf8"); + return { elements, appState }; }; - input.click(); - - return new Promise(resolve => { - reader.onloadend = () => { - if (reader.readyState === FileReader.DONE) { - const defaultAppState = getDefaultAppState(); - let elements = []; - let appState = defaultAppState; - try { - const data = JSON.parse(reader.result as string); - elements = data.elements || []; - appState = { ...defaultAppState, ...data.appState }; - } catch (e) { - // Do nothing because elements array is already empty - } + 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)); + } + }; + }); + } } export function getExportCanvasPreview( @@ -135,7 +222,7 @@ export function getExportCanvasPreview( return tempCanvas; } -export function exportCanvas( +export async function exportCanvas( type: ExportType, elements: readonly ExcalidrawElement[], canvas: HTMLCanvasElement, @@ -197,7 +284,16 @@ export function exportCanvas( ); if (type === "png") { - saveFile(`${name}.png`, tempCanvas.toDataURL("image/png")); + const fileName = `${name}.png`; + if ("chooseFileSystemEntries" in window) { + tempCanvas.toBlob(async blob => { + if (blob) { + await saveFileNative(fileName, blob); + } + }); + } else { + saveFile(fileName, tempCanvas.toDataURL("image/png")); + } } else if (type === "clipboard") { try { tempCanvas.toBlob(async function(blob) {