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) {