Jelajahi Sumber

Import and export library from/to a file (#1940)

Co-authored-by: dwelle <luzar.david@gmail.com>
Mohammed Salman 4 tahun lalu
induk
melakukan
ee8fa6aaad

+ 1 - 8
src/actions/actionExport.tsx

@@ -7,14 +7,7 @@ import { t } from "../i18n";
 import useIsMobile from "../is-mobile";
 import { register } from "./register";
 import { KEYS } from "../keys";
-
-const muteFSAbortError = (error?: Error) => {
-  // if user cancels, ignore the error
-  if (error?.name === "AbortError") {
-    return;
-  }
-  throw error;
-};
+import { muteFSAbortError } from "../utils";
 
 export const actionChangeProjectName = register({
   name: "changeProjectName",

+ 18 - 1
src/components/App.tsx

@@ -143,6 +143,7 @@ import { actionFinalize, actionDeleteSelected } from "../actions";
 import {
   restoreUsernameFromLocalStorage,
   saveUsernameToLocalStorage,
+  loadLibrary,
 } from "../data/localStorage";
 
 import throttle from "lodash.throttle";
@@ -153,6 +154,7 @@ import {
   isElementInGroup,
   getSelectedGroupIdForElement,
 } from "../groups";
+import { Library } from "../data/library";
 
 /**
  * @param func handler taking at most single parameter (event).
@@ -3206,7 +3208,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
   private handleCanvasOnDrop = (event: React.DragEvent<HTMLCanvasElement>) => {
     const libraryShapes = event.dataTransfer.getData(
-      "application/vnd.excalidraw.json",
+      "application/vnd.excalidrawlib+json",
     );
     if (libraryShapes !== "") {
       this.addElementsFromPasteOrLibrary(
@@ -3237,6 +3239,17 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         .catch((error) => {
           this.setState({ isLoading: false, errorMessage: error.message });
         });
+    } else if (
+      file?.type === "application/vnd.excalidrawlib+json" ||
+      file?.name.endsWith(".excalidrawlib")
+    ) {
+      Library.importLibrary(file)
+        .then(() => {
+          this.setState({ isLibraryOpen: false });
+        })
+        .catch((error) =>
+          this.setState({ isLoading: false, errorMessage: error.message }),
+        );
     } else {
       this.setState({
         isLoading: false,
@@ -3484,6 +3497,7 @@ declare global {
       setState: React.Component<any, AppState>["setState"];
       history: SceneHistory;
       app: InstanceType<typeof App>;
+      library: ReturnType<typeof loadLibrary>;
     };
   }
 }
@@ -3506,6 +3520,9 @@ if (
     history: {
       get: () => history,
     },
+    library: {
+      get: () => loadLibrary(),
+    },
   });
 }
 

+ 57 - 14
src/components/LayerUI.tsx

@@ -9,12 +9,8 @@ import { showSelectedShapeActions } from "../element";
 import { calculateScrollCenter, getSelectedElements } from "../scene";
 import { exportCanvas } from "../data";
 
-import { AppState, LibraryItems } from "../types";
-import {
-  NonDeletedExcalidrawElement,
-  ExcalidrawElement,
-  NonDeleted,
-} from "../element/types";
+import { AppState, LibraryItems, LibraryItem } from "../types";
+import { NonDeletedExcalidrawElement } from "../element/types";
 
 import { ActionManager } from "../actions/manager";
 import { Island } from "./Island";
@@ -37,13 +33,16 @@ import { ErrorDialog } from "./ErrorDialog";
 import { ShortcutsDialog } from "./ShortcutsDialog";
 import { LoadingMessage } from "./LoadingMessage";
 import { CLASSES } from "../constants";
-import { shield } from "./icons";
+import { shield, exportFile, load } from "./icons";
 import { GitHubCorner } from "./GitHubCorner";
 import { Tooltip } from "./Tooltip";
 
 import "./LayerUI.scss";
 import { LibraryUnit } from "./LibraryUnit";
 import { loadLibrary, saveLibrary } from "../data/localStorage";
+import { ToolButton } from "./ToolButton";
+import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
+import { muteFSAbortError } from "../utils";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -55,7 +54,7 @@ interface LayerUIProps {
   onUsernameChange: (username: string) => void;
   onRoomDestroy: () => void;
   onLockToggle: () => void;
-  onInsertShape: (elements: readonly NonDeleted<ExcalidrawElement>[]) => void;
+  onInsertShape: (elements: LibraryItem) => void;
   zenModeEnabled: boolean;
   toggleZenMode: () => void;
   lng: string;
@@ -95,13 +94,15 @@ const LibraryMenuItems = ({
   onAddToLibrary,
   onInsertShape,
   pendingElements,
+  setAppState,
 }: {
   library: LibraryItems;
-  pendingElements: NonDeleted<ExcalidrawElement>[];
+  pendingElements: LibraryItem;
   onClickOutside: (event: MouseEvent) => void;
   onRemoveFromLibrary: (index: number) => void;
-  onInsertShape: (elements: readonly NonDeleted<ExcalidrawElement>[]) => void;
-  onAddToLibrary: (elements: NonDeleted<ExcalidrawElement>[]) => void;
+  onInsertShape: (elements: LibraryItem) => void;
+  onAddToLibrary: (elements: LibraryItem) => void;
+  setAppState: any;
 }) => {
   const isMobile = useIsMobile();
   const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
@@ -110,6 +111,44 @@ const LibraryMenuItems = ({
   const rows = [];
   let addedPendingElements = false;
 
+  rows.push(
+    <Stack.Row align="center" gap={1} key={"actions"}>
+      <ToolButton
+        key="import"
+        type="button"
+        title={t("buttons.load")}
+        aria-label={t("buttons.load")}
+        icon={load}
+        onClick={() => {
+          importLibraryFromJSON()
+            .then(() => {
+              // Maybe we should close and open the menu so that the items get updated.
+              // But for now we just close the menu.
+              setAppState({ isLibraryOpen: false });
+            })
+            .catch(muteFSAbortError)
+            .catch((error) => {
+              setAppState({ errorMessage: error.message });
+            });
+        }}
+      />
+      <ToolButton
+        key="export"
+        type="button"
+        title={t("buttons.export")}
+        aria-label={t("buttons.export")}
+        icon={exportFile}
+        onClick={() => {
+          saveLibraryAsJSON()
+            .catch(muteFSAbortError)
+            .catch((error) => {
+              setAppState({ errorMessage: error.message });
+            });
+        }}
+      />
+    </Stack.Row>,
+  );
+
   for (let row = 0; row < numRows; row++) {
     const i = CELLS_PER_ROW * row;
     const children = [];
@@ -156,11 +195,13 @@ const LibraryMenu = ({
   onInsertShape,
   pendingElements,
   onAddToLibrary,
+  setAppState,
 }: {
-  pendingElements: NonDeleted<ExcalidrawElement>[];
+  pendingElements: LibraryItem;
   onClickOutside: (event: MouseEvent) => void;
-  onInsertShape: (elements: readonly NonDeleted<ExcalidrawElement>[]) => void;
+  onInsertShape: (elements: LibraryItem) => void;
   onAddToLibrary: () => void;
+  setAppState: any;
 }) => {
   const ref = useRef<HTMLDivElement | null>(null);
   useOnClickOutside(ref, onClickOutside);
@@ -202,7 +243,7 @@ const LibraryMenu = ({
   }, []);
 
   const addToLibrary = useCallback(
-    async (elements: NonDeleted<ExcalidrawElement>[]) => {
+    async (elements: LibraryItem) => {
       const items = await loadLibrary();
       const nextItems = [...items, elements];
       onAddToLibrary();
@@ -226,6 +267,7 @@ const LibraryMenu = ({
           onAddToLibrary={addToLibrary}
           onInsertShape={onInsertShape}
           pendingElements={pendingElements}
+          setAppState={setAppState}
         />
       )}
     </Island>
@@ -372,6 +414,7 @@ const LayerUI = ({
       onClickOutside={closeLibrary}
       onInsertShape={onInsertShape}
       onAddToLibrary={deselectItems}
+      setAppState={setAppState}
     />
   ) : null;
 

+ 4 - 4
src/components/LibraryUnit.tsx

@@ -1,11 +1,11 @@
 import React, { useRef, useEffect, useState } from "react";
 import { exportToSvg } from "../scene/export";
-import { ExcalidrawElement, NonDeleted } from "../element/types";
 import { close } from "../components/icons";
 
 import "./LibraryUnit.scss";
 import { t } from "../i18n";
 import useIsMobile from "../is-mobile";
+import { LibraryItem } from "../types";
 
 // fa-plus
 const PLUS_ICON = (
@@ -20,8 +20,8 @@ export const LibraryUnit = ({
   onRemoveFromLibrary,
   onClick,
 }: {
-  elements?: NonDeleted<ExcalidrawElement>[];
-  pendingElements?: NonDeleted<ExcalidrawElement>[];
+  elements?: LibraryItem;
+  pendingElements?: LibraryItem;
   onRemoveFromLibrary: () => void;
   onClick: () => void;
 }) => {
@@ -75,7 +75,7 @@ export const LibraryUnit = ({
         onDragStart={(event) => {
           setIsHovered(false);
           event.dataTransfer.setData(
-            "application/vnd.excalidraw.json",
+            "application/vnd.excalidrawlib+json",
             JSON.stringify(elements),
           );
         }}

+ 24 - 9
src/data/blob.ts

@@ -2,17 +2,11 @@ import { getDefaultAppState, cleanAppStateForExport } from "../appState";
 import { restore } from "./restore";
 import { t } from "../i18n";
 import { AppState } from "../types";
+import { LibraryData } from "./types";
 import { calculateScrollCenter } from "../scene";
 
-/**
- * @param blob
- * @param appState if provided, used for centering scroll to restored scene
- */
-export const loadFromBlob = async (blob: any, appState?: AppState) => {
-  if (blob.handle) {
-    (window as any).handle = blob.handle;
-  }
-  let contents;
+const loadFileContents = async (blob: any) => {
+  let contents: string;
   if ("text" in Blob) {
     contents = await blob.text();
   } else {
@@ -26,7 +20,19 @@ export const loadFromBlob = async (blob: any, appState?: AppState) => {
       };
     });
   }
+  return contents;
+};
+
+/**
+ * @param blob
+ * @param appState if provided, used for centering scroll to restored scene
+ */
+export const loadFromBlob = async (blob: any, appState?: AppState) => {
+  if (blob.handle) {
+    (window as any).handle = blob.handle;
+  }
 
+  const contents = await loadFileContents(blob);
   const defaultAppState = getDefaultAppState();
   let elements = [];
   let _appState = appState || defaultAppState;
@@ -47,3 +53,12 @@ export const loadFromBlob = async (blob: any, appState?: AppState) => {
 
   return restore(elements, _appState);
 };
+
+export const loadLibraryFromBlob = async (blob: any) => {
+  const contents = await loadFileContents(blob);
+  const data: LibraryData = JSON.parse(contents);
+  if (data.type !== "excalidrawlib") {
+    throw new Error(t("alerts.couldNotLoadInvalidFile"));
+  }
+  return data;
+};

+ 33 - 0
src/data/json.ts

@@ -4,6 +4,8 @@ import { cleanAppStateForExport } from "../appState";
 
 import { fileOpen, fileSave } from "browser-nativefs";
 import { loadFromBlob } from "./blob";
+import { loadLibrary } from "./localStorage";
+import { Library } from "./library";
 
 export const serializeAsJSON = (
   elements: readonly ExcalidrawElement[],
@@ -50,3 +52,34 @@ export const loadFromJSON = async (appState: AppState) => {
   });
   return loadFromBlob(blob, appState);
 };
+
+export const saveLibraryAsJSON = async () => {
+  const library = await loadLibrary();
+  const serialized = JSON.stringify(
+    {
+      type: "excalidrawlib",
+      version: 1,
+      library,
+    },
+    null,
+    2,
+  );
+  const fileName = `library.excalidrawlib`;
+  const blob = new Blob([serialized], {
+    type: "application/vnd.excalidrawlib+json",
+  });
+  await fileSave(blob, {
+    fileName,
+    description: "Excalidraw library file",
+    extensions: ["excalidrawlib"],
+  });
+};
+
+export const importLibraryFromJSON = async () => {
+  const blob = await fileOpen({
+    description: "Excalidraw library files",
+    extensions: ["json", "excalidrawlib"],
+    mimeTypes: ["application/json"],
+  });
+  Library.importLibrary(blob);
+};

+ 43 - 0
src/data/library.ts

@@ -0,0 +1,43 @@
+import { loadLibraryFromBlob } from "./blob";
+import { LibraryItems, LibraryItem } from "../types";
+import { loadLibrary, saveLibrary } from "./localStorage";
+
+export class Library {
+  /** imports library (currently merges, removing duplicates) */
+  static async importLibrary(blob: any) {
+    const libraryFile = await loadLibraryFromBlob(blob);
+    if (!libraryFile || !libraryFile.library) {
+      return;
+    }
+
+    /**
+     * checks if library item does not exist already in current library
+     */
+    const isUniqueitem = (
+      existingLibraryItems: LibraryItems,
+      targetLibraryItem: LibraryItem,
+    ) => {
+      return !existingLibraryItems.find((libraryItem) => {
+        if (libraryItem.length !== targetLibraryItem.length) {
+          return false;
+        }
+
+        // detect z-index difference by checking the excalidraw elements
+        //  are in order
+        return libraryItem.every((libItemExcalidrawItem, idx) => {
+          return (
+            libItemExcalidrawItem.id === targetLibraryItem[idx].id &&
+            libItemExcalidrawItem.versionNonce ===
+              targetLibraryItem[idx].versionNonce
+          );
+        });
+      });
+    };
+
+    const existingLibraryItems = await loadLibrary();
+    const filtered = libraryFile.library!.filter((libraryItem) =>
+      isUniqueitem(existingLibraryItems, libraryItem),
+    );
+    saveLibrary([...existingLibraryItems, ...filtered]);
+  }
+}

+ 1 - 1
src/data/localStorage.ts

@@ -21,7 +21,7 @@ export const loadLibrary = (): Promise<LibraryItems> => {
         return resolve([]);
       }
 
-      const items = (JSON.parse(data) as ExcalidrawElement[][]).map(
+      const items = (JSON.parse(data) as LibraryItems).map(
         (elements) => restore(elements, null).elements,
       ) as Mutable<LibraryItems>;
 

+ 8 - 1
src/data/types.ts

@@ -1,5 +1,5 @@
 import { ExcalidrawElement } from "../element/types";
-import { AppState } from "../types";
+import { AppState, LibraryItems } from "../types";
 
 export interface DataState {
   type?: string;
@@ -8,3 +8,10 @@ export interface DataState {
   elements: readonly ExcalidrawElement[];
   appState: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
 }
+
+export interface LibraryData {
+  type?: string;
+  version?: number;
+  source?: string;
+  library?: LibraryItems;
+}

+ 2 - 1
src/types.ts

@@ -108,7 +108,8 @@ export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSour
   _brand: "socketUpdateData";
 };
 
-export type LibraryItems = readonly NonDeleted<ExcalidrawElement>[][];
+export type LibraryItem = NonDeleted<ExcalidrawElement>[];
+export type LibraryItems = readonly LibraryItem[];
 
 export interface ExcalidrawProps {
   width: number;

+ 8 - 0
src/utils.ts

@@ -246,3 +246,11 @@ export function tupleToCoors(
   const [x, y] = xyTuple;
   return { x, y };
 }
+
+/** use as a rejectionHandler to mute filesystem Abort errors */
+export const muteFSAbortError = (error?: Error) => {
+  if (error?.name === "AbortError") {
+    return;
+  }
+  throw error;
+};