Explorar o código

Add Native File System API saving/exporting and opening (#388)

* Add Native File System API saving/exporting

* Add Native File System API opening

* Add origin trial token placeholder

* Reuse an opened file handle for better saving experience

* Fix file handle reuse to only kick in for Excalidraw files

* Remove reference
Thomas Steiner %!s(int64=5) %!d(string=hai) anos
pai
achega
7ddc206b8c
Modificáronse 4 ficheiros con 143 adicións e 39 borrados
  1. 2 0
      public/index.html
  2. 4 0
      src/actions/actionCanvas.tsx
  3. 6 4
      src/actions/actionExport.tsx
  4. 131 35
      src/scene/data.ts

+ 2 - 0
public/index.html

@@ -8,6 +8,8 @@
       content="width=device-width, initial-scale=1, shrink-to-fit=no"
     />
     <meta name="theme-color" content="#000000" />
+    <meta http-equiv="origin-trial" content="AvB+3K1LqGdVR+XcHhjpdM0yl5/RtR/v7MIO/nbgNnLZHJ5yMYos9kZAQiuc0EEZne4d9CzHhF2sk2fUPOylcgUAAABceyJvcmlnaW4iOiJodHRwczovL3d3dy5leGNhbGlkcmF3LmNvbTo0NDMiLCJmZWF0dXJlIjoiTmF0aXZlRmlsZVN5c3RlbSIsImV4cGlyeSI6MTU4Mjg3ODU4NH0=">
+
     <!-- General tags -->
     <meta
       name="description"

+ 4 - 0
src/actions/actionCanvas.tsx

@@ -39,6 +39,10 @@ export const actionClearCanvas: Action = {
       aria-label="Clear the canvas & reset background color"
       onClick={() => {
         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);
         }
       }}

+ 6 - 4
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));
       }}
     />
   )

+ 131 - 35
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 <a> 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<DataState>(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<DataState>(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<DataState>(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) {