소스 검색

fix: abstract and fix legacy fs (#4032)

David Luzar 3 년 전
부모
커밋
54739cd2df
13개의 변경된 파일179개의 추가작업 그리고 50개의 파일을 삭제
  1. 2 2
      package.json
  2. 2 2
      src/actions/actionExport.tsx
  3. 3 3
      src/components/App.tsx
  4. 3 2
      src/components/ImageExportDialog.tsx
  5. 3 2
      src/components/JSONExportDialog.tsx
  6. 2 1
      src/constants.ts
  7. 1 1
      src/data/blob.ts
  8. 122 0
      src/data/filesystem.ts
  9. 8 12
      src/data/index.ts
  10. 19 19
      src/data/json.ts
  11. 7 0
      src/errors.ts
  12. 2 1
      src/types.ts
  13. 5 5
      yarn.lock

+ 2 - 2
package.json

@@ -19,6 +19,7 @@
     ]
   },
   "dependencies": {
+    "@dwelle/browser-fs-access": "0.21.1",
     "@sentry/browser": "6.2.5",
     "@sentry/integrations": "6.2.5",
     "@testing-library/jest-dom": "5.11.10",
@@ -27,7 +28,6 @@
     "@types/react": "17.0.3",
     "@types/react-dom": "17.0.3",
     "@types/socket.io-client": "1.4.36",
-    "browser-fs-access": "0.20.5",
     "clsx": "1.1.1",
     "firebase": "8.3.3",
     "i18next-browser-languagedetector": "6.1.0",
@@ -76,7 +76,7 @@
   },
   "jest": {
     "transformIgnorePatterns": [
-      "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
+      "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
     ],
     "resetMocks": false
   },

+ 2 - 2
src/actions/actionExport.tsx

@@ -12,7 +12,6 @@ import { t } from "../i18n";
 import { useIsMobile } from "../components/App";
 import { KEYS } from "../keys";
 import { register } from "./register";
-import { supported as fsSupported } from "browser-fs-access";
 import { CheckboxItem } from "../components/CheckboxItem";
 import { getExportSize } from "../scene/export";
 import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES } from "../constants";
@@ -20,6 +19,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { getNonDeletedElements } from "../element";
 import { ActiveFile } from "../components/ActiveFile";
 import { isImageFileHandle } from "../data/blob";
+import { nativeFileSystemSupported } from "../data/filesystem";
 
 export const actionChangeProjectName = register({
   name: "changeProjectName",
@@ -193,7 +193,7 @@ export const actionSaveFileToDisk = register({
       title={t("buttons.saveAs")}
       aria-label={t("buttons.saveAs")}
       showAriaLabel={useIsMobile()}
-      hidden={!fsSupported}
+      hidden={!nativeFileSystemSupported}
       onClick={() => updateData(null)}
       data-testid="save-as-button"
     />

+ 3 - 3
src/components/App.tsx

@@ -2,7 +2,6 @@ import React, { useContext } from "react";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import rough from "roughjs/bin/rough";
 import clsx from "clsx";
-import { supported as fsSupported } from "browser-fs-access";
 import { nanoid } from "nanoid";
 
 import {
@@ -195,6 +194,7 @@ import LayerUI from "./LayerUI";
 import { Stats } from "./Stats";
 import { Toast } from "./Toast";
 import { actionToggleViewMode } from "../actions/actionToggleViewMode";
+import { nativeFileSystemSupported } from "../data/filesystem";
 
 const IsMobileContext = React.createContext(false);
 export const useIsMobile = () => useContext(IsMobileContext);
@@ -3833,7 +3833,7 @@ class App extends React.Component<AppProps, AppState> {
     try {
       const file = event.dataTransfer.files[0];
       if (file?.type === "image/png" || file?.type === "image/svg+xml") {
-        if (fsSupported) {
+        if (nativeFileSystemSupported) {
           try {
             // This will only work as of Chrome 86,
             // but can be safely ignored on older releases.
@@ -3893,7 +3893,7 @@ class App extends React.Component<AppProps, AppState> {
       // default: assume an Excalidraw file regardless of extension/MimeType
     } else {
       this.setState({ isLoading: true });
-      if (fsSupported) {
+      if (nativeFileSystemSupported) {
         try {
           // This will only work as of Chrome 86,
           // but can be safely ignored on older releases.

+ 3 - 2
src/components/ImageExportDialog.tsx

@@ -15,10 +15,10 @@ import { clipboard, exportImage } from "./icons";
 import Stack from "./Stack";
 import { ToolButton } from "./ToolButton";
 import "./ExportDialog.scss";
-import { supported as fsSupported } from "browser-fs-access";
 import OpenColor from "open-color";
 import { CheckboxItem } from "./CheckboxItem";
 import { DEFAULT_EXPORT_PADDING } from "../constants";
+import { nativeFileSystemSupported } from "../data/filesystem";
 
 const supportsContextFilters =
   "filter" in document.createElement("canvas").getContext("2d")!;
@@ -182,7 +182,8 @@ const ImageExportModal = ({
           margin: ".6em 0",
         }}
       >
-        {!fsSupported && actionManager.renderAction("changeProjectName")}
+        {!nativeFileSystemSupported &&
+          actionManager.renderAction("changeProjectName")}
       </div>
       <Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
         <ExportButton

+ 3 - 2
src/components/JSONExportDialog.tsx

@@ -11,7 +11,7 @@ import { actionSaveFileToDisk } from "../actions/actionExport";
 import { Card } from "./Card";
 
 import "./ExportDialog.scss";
-import { supported as fsSupported } from "browser-fs-access";
+import { nativeFileSystemSupported } from "../data/filesystem";
 
 export type ExportCB = (
   elements: readonly NonDeletedExcalidrawElement[],
@@ -42,7 +42,8 @@ const JSONExportModal = ({
             <h2>{t("exportDialog.disk_title")}</h2>
             <div className="Card-details">
               {t("exportDialog.disk_details")}
-              {!fsSupported && actionManager.renderAction("changeProjectName")}
+              {!nativeFileSystemSupported &&
+                actionManager.renderAction("changeProjectName")}
             </div>
             <ToolButton
               className="Card-button"

+ 2 - 1
src/constants.ts

@@ -35,6 +35,7 @@ export enum EVENT {
   MOUSE_MOVE = "mousemove",
   RESIZE = "resize",
   UNLOAD = "unload",
+  FOCUS = "focus",
   BLUR = "blur",
   DRAG_OVER = "dragover",
   DROP = "drop",
@@ -84,7 +85,7 @@ export const GRID_SIZE = 20; // TODO make it configurable?
 export const MIME_TYPES = {
   excalidraw: "application/vnd.excalidraw+json",
   excalidrawlib: "application/vnd.excalidrawlib+json",
-};
+} as const;
 
 export const EXPORT_DATA_TYPES = {
   excalidraw: "excalidraw",

+ 1 - 1
src/data/blob.ts

@@ -1,4 +1,3 @@
-import { FileSystemHandle } from "browser-fs-access";
 import { cleanAppStateForExport } from "../appState";
 import { EXPORT_DATA_TYPES } from "../constants";
 import { clearElementsForExport } from "../element";
@@ -7,6 +6,7 @@ import { CanvasError } from "../errors";
 import { t } from "../i18n";
 import { calculateScrollCenter } from "../scene";
 import { AppState } from "../types";
+import { FileSystemHandle } from "./filesystem";
 import { isValidExcalidrawData } from "./json";
 import { restore } from "./restore";
 import { ImportedLibraryData } from "./types";

+ 122 - 0
src/data/filesystem.ts

@@ -0,0 +1,122 @@
+import {
+  FileWithHandle,
+  fileOpen as _fileOpen,
+  fileSave as _fileSave,
+  FileSystemHandle,
+  supported as nativeFileSystemSupported,
+} from "@dwelle/browser-fs-access";
+import { EVENT, MIME_TYPES } from "../constants";
+import { AbortError } from "../errors";
+import { debounce } from "../utils";
+
+type FILE_EXTENSION =
+  | "jpg"
+  | "png"
+  | "svg"
+  | "json"
+  | "excalidraw"
+  | "excalidrawlib";
+
+const FILE_TYPE_TO_MIME_TYPE: Record<FILE_EXTENSION, string> = {
+  jpg: "image/jpeg",
+  png: "image/png",
+  svg: "image/svg+xml",
+  json: "application/json",
+  excalidraw: MIME_TYPES.excalidraw,
+  excalidrawlib: MIME_TYPES.excalidrawlib,
+};
+
+const INPUT_CHANGE_INTERVAL_MS = 500;
+
+export const fileOpen = <M extends boolean | undefined = false>(opts: {
+  extensions?: FILE_EXTENSION[];
+  description?: string;
+  multiple?: M;
+}): Promise<
+  M extends false | undefined ? FileWithHandle : FileWithHandle[]
+> => {
+  // an unsafe TS hack, alas not much we can do AFAIK
+  type RetType = M extends false | undefined
+    ? FileWithHandle
+    : FileWithHandle[];
+
+  const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
+    mimeTypes.push(FILE_TYPE_TO_MIME_TYPE[type]);
+
+    return mimeTypes;
+  }, [] as string[]);
+
+  const extensions = opts.extensions?.reduce((acc, ext) => {
+    if (ext === "jpg") {
+      return acc.concat(".jpg", ".jpeg");
+    }
+    return acc.concat(`.${ext}`);
+  }, [] as string[]);
+
+  return _fileOpen({
+    description: opts.description,
+    extensions,
+    mimeTypes,
+    multiple: opts.multiple ?? false,
+    legacySetup: (resolve, reject, input) => {
+      const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
+      const focusHandler = () => {
+        checkForFile();
+        document.addEventListener(EVENT.KEYUP, scheduleRejection);
+        document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
+        scheduleRejection();
+      };
+      const checkForFile = () => {
+        // this hack might not work when expecting multiple files
+        if (input.files?.length) {
+          const ret = opts.multiple ? [...input.files] : input.files[0];
+          resolve(ret as RetType);
+        }
+      };
+      requestAnimationFrame(() => {
+        window.addEventListener(EVENT.FOCUS, focusHandler);
+      });
+      const interval = window.setInterval(() => {
+        checkForFile();
+      }, INPUT_CHANGE_INTERVAL_MS);
+      return (rejectPromise) => {
+        clearInterval(interval);
+        scheduleRejection.cancel();
+        window.removeEventListener(EVENT.FOCUS, focusHandler);
+        document.removeEventListener(EVENT.KEYUP, scheduleRejection);
+        document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
+        if (rejectPromise) {
+          // so that something is shown in console if we need to debug this
+          console.warn("Opening the file was canceled (legacy-fs).");
+          rejectPromise(new AbortError());
+        }
+      };
+    },
+  }) as Promise<RetType>;
+};
+
+export const fileSave = (
+  blob: Blob,
+  opts: {
+    /** supply without the extension */
+    name: string;
+    /** file extension */
+    extension: FILE_EXTENSION;
+    description?: string;
+    /** existing FileSystemHandle */
+    fileHandle?: FileSystemHandle | null;
+  },
+) => {
+  return _fileSave(
+    blob,
+    {
+      fileName: `${opts.name}.${opts.extension}`,
+      description: opts.description,
+      extensions: [`.${opts.extension}`],
+    },
+    opts.fileHandle,
+  );
+};
+
+export type { FileSystemHandle };
+export { nativeFileSystemSupported };

+ 8 - 12
src/data/index.ts

@@ -1,4 +1,3 @@
-import { fileSave, FileSystemHandle } from "browser-fs-access";
 import {
   copyBlobToClipboardAsPng,
   copyTextToSystemClipboard,
@@ -10,6 +9,7 @@ import { exportToCanvas, exportToSvg } from "../scene/export";
 import { ExportType } from "../scene/types";
 import { AppState } from "../types";
 import { canvasToBlob } from "./blob";
+import { fileSave, FileSystemHandle } from "./filesystem";
 import { serializeAsJSON } from "./json";
 
 export { loadFromBlob } from "./blob";
@@ -49,10 +49,10 @@ export const exportCanvas = async (
       return await fileSave(
         new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }),
         {
-          fileName: `${name}.svg`,
-          extensions: [".svg"],
+          name,
+          extension: "svg",
+          fileHandle,
         },
-        fileHandle,
       );
     } else if (type === "clipboard-svg") {
       await copyTextToSystemClipboard(tempSvg.outerHTML);
@@ -71,7 +71,6 @@ export const exportCanvas = async (
   tempCanvas.remove();
 
   if (type === "png") {
-    const fileName = `${name}.png`;
     if (appState.exportEmbedScene) {
       blob = await (
         await import(/* webpackChunkName: "image" */ "./image")
@@ -81,14 +80,11 @@ export const exportCanvas = async (
       });
     }
 
-    return await fileSave(
-      blob,
-      {
-        fileName,
-        extensions: [".png"],
-      },
+    return await fileSave(blob, {
+      name,
+      extension: "png",
       fileHandle,
-    );
+    });
   } else if (type === "clipboard") {
     try {
       await copyBlobToClipboardAsPng(blob);

+ 19 - 19
src/data/json.ts

@@ -1,4 +1,4 @@
-import { fileOpen, fileSave } from "browser-fs-access";
+import { fileOpen, fileSave } from "./filesystem";
 import { cleanAppStateForExport } from "../appState";
 import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
 import { clearElementsForExport } from "../element";
@@ -37,15 +37,14 @@ export const saveAsJSON = async (
     type: MIME_TYPES.excalidraw,
   });
 
-  const fileHandle = await fileSave(
-    blob,
-    {
-      fileName: `${appState.name}.excalidraw`,
-      description: "Excalidraw file",
-      extensions: [".excalidraw"],
-    },
-    isImageFileHandle(appState.fileHandle) ? null : appState.fileHandle,
-  );
+  const fileHandle = await fileSave(blob, {
+    name: appState.name,
+    extension: "excalidraw",
+    description: "Excalidraw file",
+    fileHandle: isImageFileHandle(appState.fileHandle)
+      ? null
+      : appState.fileHandle,
+  });
   return { fileHandle };
 };
 
@@ -101,15 +100,16 @@ export const saveLibraryAsJSON = async (library: Library) => {
     library: libraryItems,
   };
   const serialized = JSON.stringify(data, null, 2);
-  const fileName = "library.excalidrawlib";
-  const blob = new Blob([serialized], {
-    type: MIME_TYPES.excalidrawlib,
-  });
-  await fileSave(blob, {
-    fileName,
-    description: "Excalidraw library file",
-    extensions: [".excalidrawlib"],
-  });
+  await fileSave(
+    new Blob([serialized], {
+      type: MIME_TYPES.excalidrawlib,
+    }),
+    {
+      name: "library",
+      extension: "excalidrawlib",
+      description: "Excalidraw library file",
+    },
+  );
 };
 
 export const importLibraryFromJSON = async (library: Library) => {

+ 7 - 0
src/errors.ts

@@ -1,4 +1,5 @@
 type CANVAS_ERROR_NAMES = "CANVAS_ERROR" | "CANVAS_POSSIBLY_TOO_BIG";
+
 export class CanvasError extends Error {
   constructor(
     message: string = "Couldn't export canvas.",
@@ -9,3 +10,9 @@ export class CanvasError extends Error {
     this.message = message;
   }
 }
+
+export class AbortError extends DOMException {
+  constructor(message: string = "Request Aborted") {
+    super(message, "AbortError");
+  }
+}

+ 2 - 1
src/types.ts

@@ -23,6 +23,7 @@ import { Language } from "./i18n";
 import { ClipboardData } from "./clipboard";
 import { isOverScrollBars } from "./scene";
 import { MaybeTransformHandleType } from "./element/transformHandles";
+import { FileSystemHandle } from "./data/filesystem";
 
 export type Point = Readonly<RoughPoint>;
 
@@ -112,7 +113,7 @@ export type AppState = {
   offsetLeft: number;
 
   isLibraryOpen: boolean;
-  fileHandle: import("browser-fs-access").FileSystemHandle | null;
+  fileHandle: FileSystemHandle | null;
   collaborators: Map<string, Collaborator>;
   showStats: boolean;
   currentChartType: ChartType;

+ 5 - 5
yarn.lock

@@ -1062,6 +1062,11 @@
     enabled "2.0.x"
     kuler "^2.0.0"
 
+"@dwelle/browser-fs-access@0.21.1":
+  version "0.21.1"
+  resolved "https://registry.yarnpkg.com/@dwelle/browser-fs-access/-/browser-fs-access-0.21.1.tgz#46a9c1c95a8b8da3887d95136dc8c1f65830cfa7"
+  integrity sha512-ryAWrTdFgB2IjUooBcKz2bSrVsAUqtjctLK6ByFGbqx7qxk+kqpjA4J54uiMcbvaJ17N/cYeserA6uxBIWIdsg==
+
 "@eslint/eslintrc@^0.4.0":
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.0.tgz#99cc0a0584d72f1df38b900fb062ba995f395547"
@@ -3218,11 +3223,6 @@ brorand@^1.0.1, brorand@^1.1.0:
   version "1.1.0"
   resolved "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz"
 
-browser-fs-access@0.20.5:
-  version "0.20.5"
-  resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.20.5.tgz#16bea029f6dc14787c8b394360f32f3b8cf06f50"
-  integrity sha512-ROPZ9ZYC4gptm0JRH/DgTm9dDLzUrOksBw8VMcUm7TINyaan5KUJPkklEurl0WTapfuy5T85GSP6bRmX/BpbnA==
-
 browser-process-hrtime@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz"