ソースを参照

Implement Save without re-prompt and Save as (#1709)

* Implement Save without re-prompt and Save as
Fixes #1668

* Add save-as icon

* Make .excalidraw the default extension

* Only show save as button on supporting browsers
Thomas Steiner 4 年 前
コミット
5d3867d8ac

+ 3 - 3
package-lock.json

@@ -3306,9 +3306,9 @@
       "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8="
     },
     "browser-nativefs": {
-      "version": "0.8.1",
-      "resolved": "https://registry.npmjs.org/browser-nativefs/-/browser-nativefs-0.8.1.tgz",
-      "integrity": "sha512-5XQTR6eg+/hDBVoOKbCnCqUzhD7IP5RG6jCe+J+EaTHo8EnDxjEj3mod3BiEBc/4NfTLEMbrMzUPPY64KwnmNw=="
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/browser-nativefs/-/browser-nativefs-0.8.2.tgz",
+      "integrity": "sha512-x1dYA6lkpaLZcvvbQ1+/SSDR9H/fbzlcnKi3BDCvEe3fr3HzV5finUMX8fJspzCmPuP7fGLVO8S3UZ8RhQseFw=="
     },
     "browser-process-hrtime": {
       "version": "1.0.0",

+ 1 - 1
package.json

@@ -28,7 +28,7 @@
     "@types/react": "16.9.35",
     "@types/react-dom": "16.9.8",
     "@types/socket.io-client": "1.4.33",
-    "browser-nativefs": "0.8.1",
+    "browser-nativefs": "0.8.2",
     "i18next-browser-languagedetector": "4.2.0",
     "lodash.throttle": "4.1.1",
     "nanoid": "2.1.11",

+ 27 - 3
src/actions/actionExport.tsx

@@ -1,7 +1,7 @@
 import React from "react";
 import { ProjectName } from "../components/ProjectName";
 import { saveAsJSON, loadFromJSON } from "../data";
-import { load, save } from "../components/icons";
+import { load, save, saveAs } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 import { t } from "../i18n";
 import useIsMobile from "../is-mobile";
@@ -65,11 +65,13 @@ export const actionChangeShouldAddWatermark = register({
 export const actionSaveScene = register({
   name: "saveScene",
   perform: (elements, appState, value) => {
-    saveAsJSON(elements, appState).catch((error) => console.error(error));
+    saveAsJSON(elements, appState, (window as any).handle).catch((error) =>
+      console.error(error),
+    );
     return { commitToHistory: false };
   },
   keyTest: (event) => {
-    return event.key === "s" && event[KEYS.CTRL_OR_CMD];
+    return event.key === "s" && event[KEYS.CTRL_OR_CMD] && !event.shiftKey;
   },
   PanelComponent: ({ updateData }) => (
     <ToolButton
@@ -83,6 +85,28 @@ export const actionSaveScene = register({
   ),
 });
 
+export const actionSaveAsScene = register({
+  name: "saveAsScene",
+  perform: (elements, appState, value) => {
+    saveAsJSON(elements, appState, null).catch((error) => console.error(error));
+    return { commitToHistory: false };
+  },
+  keyTest: (event) => {
+    return event.key === "s" && event.shiftKey && event[KEYS.CTRL_OR_CMD];
+  },
+  PanelComponent: ({ updateData }) => (
+    <ToolButton
+      type="button"
+      icon={saveAs}
+      title={t("buttons.saveAs")}
+      aria-label={t("buttons.saveAs")}
+      showAriaLabel={useIsMobile()}
+      hidden={!("chooseFileSystemEntries" in window)}
+      onClick={() => updateData(null)}
+    />
+  ),
+});
+
 export const actionLoadScene = register({
   name: "loadScene",
   perform: (

+ 1 - 0
src/actions/index.ts

@@ -34,6 +34,7 @@ export {
   actionChangeProjectName,
   actionChangeExportBackground,
   actionSaveScene,
+  actionSaveAsScene,
   actionLoadScene,
 } from "./actionExport";
 

+ 1 - 0
src/actions/types.ts

@@ -43,6 +43,7 @@ export type ActionName =
   | "changeExportBackground"
   | "changeShouldAddWatermark"
   | "saveScene"
+  | "saveAsScene"
   | "loadScene"
   | "duplicateSelection"
   | "deleteSelectedElements"

+ 1 - 0
src/components/LayerUI.tsx

@@ -130,6 +130,7 @@ const LayerUI = ({
           <Stack.Row gap={1} justifyContent="space-between">
             {actionManager.renderAction("loadScene")}
             {actionManager.renderAction("saveScene")}
+            {actionManager.renderAction("saveAsScene")}
             {renderExportDialog()}
             {actionManager.renderAction("clearCanvas")}
             <RoomDialog

+ 1 - 0
src/components/MobileMenu.tsx

@@ -84,6 +84,7 @@ export const MobileMenu = ({
               <Stack.Col gap={4}>
                 {actionManager.renderAction("loadScene")}
                 {actionManager.renderAction("saveScene")}
+                {actionManager.renderAction("saveAsScene")}
                 {exportButton}
                 {actionManager.renderAction("clearCanvas")}
                 <RoomDialog

+ 8 - 4
src/components/ToolButton.tsx

@@ -16,6 +16,7 @@ type ToolButtonBaseProps = {
   size?: ToolIconSize;
   keyBindingLabel?: string;
   showAriaLabel?: boolean;
+  hidden?: boolean;
   visible?: boolean;
   selected?: boolean;
   className?: string;
@@ -44,13 +45,16 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
   if (props.type === "button") {
     return (
       <button
-        className={`ToolIcon_type_button ToolIcon ${sizeCn}${
-          props.selected ? " ToolIcon--selected" : ""
-        } ${props.className} ${
-          props.visible
+        className={`ToolIcon_type_button ${
+          !props.hidden ? "ToolIcon" : ""
+        } ${sizeCn}${props.selected ? " ToolIcon--selected" : ""} ${
+          props.className
+        } ${
+          props.visible && !props.hidden
             ? "ToolIcon_type_button--show"
             : "ToolIcon_type_button--hide"
         }`}
+        hidden={props.hidden}
         title={props.title}
         aria-label={props["aria-label"]}
         type="button"

+ 5 - 0
src/components/icons.tsx

@@ -39,6 +39,11 @@ export const save = createIcon(
   { width: 448, height: 512 },
 );
 
+export const saveAs = createIcon(
+  "M252 54L203 8a28 27 0 00-20-8H28C12 0 0 12 0 27v195c0 15 12 26 28 26h204c15 0 28-11 28-26V73a28 27 0 00-8-19zM130 213c-21 0-37-16-37-36 0-19 16-35 37-35 20 0 37 16 37 35 0 20-17 36-37 36zm56-169v56c0 4-4 6-7 6H44c-4 0-7-2-7-6V42c0-4 3-7 7-7h133l4 2 3 2a7 7 0 012 5z M296 201l87 95-188 205-78 9c-10 1-19-8-18-20l9-84zm141-14l-41-44a31 31 0 00-46 0l-38 41 87 95 38-42c13-14 13-36 0-50z",
+  { width: 448, height: 512 },
+);
+
 export const load = createIcon(
   "M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z",
   { width: 576, height: 512, mirror: true },

+ 25 - 15
src/data/json.ts

@@ -24,28 +24,38 @@ export const serializeAsJSON = (
 export const saveAsJSON = async (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
+  fileHandle: any,
 ) => {
   const serialized = serializeAsJSON(elements, appState);
-
-  const name = `${appState.name}.excalidraw`;
-  await fileSave(
-    new Blob([serialized], {
-      type: /\b(iPad|iPhone|iPod)\b/.test(navigator.userAgent)
-        ? "application/json"
-        : "application/vnd.excalidraw+json",
-    }),
-    {
-      fileName: name,
-      description: "Excalidraw file",
-    },
-    (window as any).handle,
-  );
+  const blob = new Blob([serialized], {
+    type: "application/json",
+  });
+  // Either "Save as" or non-supporting browser
+  if (!fileHandle) {
+    const name = `${appState.name}.excalidraw`;
+    const handle = await fileSave(
+      blob,
+      {
+        fileName: name,
+        description: "Excalidraw file",
+        extensions: ["excalidraw"],
+      },
+      fileHandle,
+    );
+    (window as any).handle = handle;
+    return;
+  }
+  // "Save"
+  const writable = await fileHandle.createWritable();
+  await writable.write(blob);
+  await writable.close();
 };
+
 export const loadFromJSON = async () => {
   const blob = await fileOpen({
     description: "Excalidraw files",
     extensions: ["json", "excalidraw"],
-    mimeTypes: ["application/json", "application/vnd.excalidraw+json"],
+    mimeTypes: ["application/json"],
   });
   return loadFromBlob(blob);
 };

+ 1 - 0
src/locales/en.json

@@ -71,6 +71,7 @@
     "copyToClipboard": "Copy to clipboard",
     "copyPngToClipboard": "Copy PNG to clipboard",
     "save": "Save",
+    "saveAs": "Save as",
     "load": "Load",
     "getShareableLink": "Get shareable link",
     "close": "Close",