Browse Source

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
vanilla_orig
Thomas Steiner 5 years ago
parent
commit
5d3867d8ac
No account linked to committer's email address

+ 3
- 3
package-lock.json View File

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

+ 1
- 1
package.json View File

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

+ 27
- 3
src/actions/actionExport.tsx View File

1
 import React from "react";
1
 import React from "react";
2
 import { ProjectName } from "../components/ProjectName";
2
 import { ProjectName } from "../components/ProjectName";
3
 import { saveAsJSON, loadFromJSON } from "../data";
3
 import { saveAsJSON, loadFromJSON } from "../data";
4
-import { load, save } from "../components/icons";
4
+import { load, save, saveAs } from "../components/icons";
5
 import { ToolButton } from "../components/ToolButton";
5
 import { ToolButton } from "../components/ToolButton";
6
 import { t } from "../i18n";
6
 import { t } from "../i18n";
7
 import useIsMobile from "../is-mobile";
7
 import useIsMobile from "../is-mobile";
65
 export const actionSaveScene = register({
65
 export const actionSaveScene = register({
66
   name: "saveScene",
66
   name: "saveScene",
67
   perform: (elements, appState, value) => {
67
   perform: (elements, appState, value) => {
68
-    saveAsJSON(elements, appState).catch((error) => console.error(error));
68
+    saveAsJSON(elements, appState, (window as any).handle).catch((error) =>
69
+      console.error(error),
70
+    );
69
     return { commitToHistory: false };
71
     return { commitToHistory: false };
70
   },
72
   },
71
   keyTest: (event) => {
73
   keyTest: (event) => {
72
-    return event.key === "s" && event[KEYS.CTRL_OR_CMD];
74
+    return event.key === "s" && event[KEYS.CTRL_OR_CMD] && !event.shiftKey;
73
   },
75
   },
74
   PanelComponent: ({ updateData }) => (
76
   PanelComponent: ({ updateData }) => (
75
     <ToolButton
77
     <ToolButton
83
   ),
85
   ),
84
 });
86
 });
85
 
87
 
88
+export const actionSaveAsScene = register({
89
+  name: "saveAsScene",
90
+  perform: (elements, appState, value) => {
91
+    saveAsJSON(elements, appState, null).catch((error) => console.error(error));
92
+    return { commitToHistory: false };
93
+  },
94
+  keyTest: (event) => {
95
+    return event.key === "s" && event.shiftKey && event[KEYS.CTRL_OR_CMD];
96
+  },
97
+  PanelComponent: ({ updateData }) => (
98
+    <ToolButton
99
+      type="button"
100
+      icon={saveAs}
101
+      title={t("buttons.saveAs")}
102
+      aria-label={t("buttons.saveAs")}
103
+      showAriaLabel={useIsMobile()}
104
+      hidden={!("chooseFileSystemEntries" in window)}
105
+      onClick={() => updateData(null)}
106
+    />
107
+  ),
108
+});
109
+
86
 export const actionLoadScene = register({
110
 export const actionLoadScene = register({
87
   name: "loadScene",
111
   name: "loadScene",
88
   perform: (
112
   perform: (

+ 1
- 0
src/actions/index.ts View File

34
   actionChangeProjectName,
34
   actionChangeProjectName,
35
   actionChangeExportBackground,
35
   actionChangeExportBackground,
36
   actionSaveScene,
36
   actionSaveScene,
37
+  actionSaveAsScene,
37
   actionLoadScene,
38
   actionLoadScene,
38
 } from "./actionExport";
39
 } from "./actionExport";
39
 
40
 

+ 1
- 0
src/actions/types.ts View File

43
   | "changeExportBackground"
43
   | "changeExportBackground"
44
   | "changeShouldAddWatermark"
44
   | "changeShouldAddWatermark"
45
   | "saveScene"
45
   | "saveScene"
46
+  | "saveAsScene"
46
   | "loadScene"
47
   | "loadScene"
47
   | "duplicateSelection"
48
   | "duplicateSelection"
48
   | "deleteSelectedElements"
49
   | "deleteSelectedElements"

+ 1
- 0
src/components/LayerUI.tsx View File

130
           <Stack.Row gap={1} justifyContent="space-between">
130
           <Stack.Row gap={1} justifyContent="space-between">
131
             {actionManager.renderAction("loadScene")}
131
             {actionManager.renderAction("loadScene")}
132
             {actionManager.renderAction("saveScene")}
132
             {actionManager.renderAction("saveScene")}
133
+            {actionManager.renderAction("saveAsScene")}
133
             {renderExportDialog()}
134
             {renderExportDialog()}
134
             {actionManager.renderAction("clearCanvas")}
135
             {actionManager.renderAction("clearCanvas")}
135
             <RoomDialog
136
             <RoomDialog

+ 1
- 0
src/components/MobileMenu.tsx View File

84
               <Stack.Col gap={4}>
84
               <Stack.Col gap={4}>
85
                 {actionManager.renderAction("loadScene")}
85
                 {actionManager.renderAction("loadScene")}
86
                 {actionManager.renderAction("saveScene")}
86
                 {actionManager.renderAction("saveScene")}
87
+                {actionManager.renderAction("saveAsScene")}
87
                 {exportButton}
88
                 {exportButton}
88
                 {actionManager.renderAction("clearCanvas")}
89
                 {actionManager.renderAction("clearCanvas")}
89
                 <RoomDialog
90
                 <RoomDialog

+ 8
- 4
src/components/ToolButton.tsx View File

16
   size?: ToolIconSize;
16
   size?: ToolIconSize;
17
   keyBindingLabel?: string;
17
   keyBindingLabel?: string;
18
   showAriaLabel?: boolean;
18
   showAriaLabel?: boolean;
19
+  hidden?: boolean;
19
   visible?: boolean;
20
   visible?: boolean;
20
   selected?: boolean;
21
   selected?: boolean;
21
   className?: string;
22
   className?: string;
44
   if (props.type === "button") {
45
   if (props.type === "button") {
45
     return (
46
     return (
46
       <button
47
       <button
47
-        className={`ToolIcon_type_button ToolIcon ${sizeCn}${
48
-          props.selected ? " ToolIcon--selected" : ""
49
-        } ${props.className} ${
50
-          props.visible
48
+        className={`ToolIcon_type_button ${
49
+          !props.hidden ? "ToolIcon" : ""
50
+        } ${sizeCn}${props.selected ? " ToolIcon--selected" : ""} ${
51
+          props.className
52
+        } ${
53
+          props.visible && !props.hidden
51
             ? "ToolIcon_type_button--show"
54
             ? "ToolIcon_type_button--show"
52
             : "ToolIcon_type_button--hide"
55
             : "ToolIcon_type_button--hide"
53
         }`}
56
         }`}
57
+        hidden={props.hidden}
54
         title={props.title}
58
         title={props.title}
55
         aria-label={props["aria-label"]}
59
         aria-label={props["aria-label"]}
56
         type="button"
60
         type="button"

+ 5
- 0
src/components/icons.tsx View File

39
   { width: 448, height: 512 },
39
   { width: 448, height: 512 },
40
 );
40
 );
41
 
41
 
42
+export const saveAs = createIcon(
43
+  "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",
44
+  { width: 448, height: 512 },
45
+);
46
+
42
 export const load = createIcon(
47
 export const load = createIcon(
43
   "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",
48
   "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",
44
   { width: 576, height: 512, mirror: true },
49
   { width: 576, height: 512, mirror: true },

+ 25
- 15
src/data/json.ts View File

24
 export const saveAsJSON = async (
24
 export const saveAsJSON = async (
25
   elements: readonly ExcalidrawElement[],
25
   elements: readonly ExcalidrawElement[],
26
   appState: AppState,
26
   appState: AppState,
27
+  fileHandle: any,
27
 ) => {
28
 ) => {
28
   const serialized = serializeAsJSON(elements, appState);
29
   const serialized = serializeAsJSON(elements, appState);
29
-
30
-  const name = `${appState.name}.excalidraw`;
31
-  await fileSave(
32
-    new Blob([serialized], {
33
-      type: /\b(iPad|iPhone|iPod)\b/.test(navigator.userAgent)
34
-        ? "application/json"
35
-        : "application/vnd.excalidraw+json",
36
-    }),
37
-    {
38
-      fileName: name,
39
-      description: "Excalidraw file",
40
-    },
41
-    (window as any).handle,
42
-  );
30
+  const blob = new Blob([serialized], {
31
+    type: "application/json",
32
+  });
33
+  // Either "Save as" or non-supporting browser
34
+  if (!fileHandle) {
35
+    const name = `${appState.name}.excalidraw`;
36
+    const handle = await fileSave(
37
+      blob,
38
+      {
39
+        fileName: name,
40
+        description: "Excalidraw file",
41
+        extensions: ["excalidraw"],
42
+      },
43
+      fileHandle,
44
+    );
45
+    (window as any).handle = handle;
46
+    return;
47
+  }
48
+  // "Save"
49
+  const writable = await fileHandle.createWritable();
50
+  await writable.write(blob);
51
+  await writable.close();
43
 };
52
 };
53
+
44
 export const loadFromJSON = async () => {
54
 export const loadFromJSON = async () => {
45
   const blob = await fileOpen({
55
   const blob = await fileOpen({
46
     description: "Excalidraw files",
56
     description: "Excalidraw files",
47
     extensions: ["json", "excalidraw"],
57
     extensions: ["json", "excalidraw"],
48
-    mimeTypes: ["application/json", "application/vnd.excalidraw+json"],
58
+    mimeTypes: ["application/json"],
49
   });
59
   });
50
   return loadFromBlob(blob);
60
   return loadFromBlob(blob);
51
 };
61
 };

+ 1
- 0
src/locales/en.json View File

71
     "copyToClipboard": "Copy to clipboard",
71
     "copyToClipboard": "Copy to clipboard",
72
     "copyPngToClipboard": "Copy PNG to clipboard",
72
     "copyPngToClipboard": "Copy PNG to clipboard",
73
     "save": "Save",
73
     "save": "Save",
74
+    "saveAs": "Save as",
74
     "load": "Load",
75
     "load": "Load",
75
     "getShareableLink": "Get shareable link",
76
     "getShareableLink": "Get shareable link",
76
     "close": "Close",
77
     "close": "Close",

Loading…
Cancel
Save