From ee8fa6aaade79354c056ae8b81eb7a8a3c47be7f Mon Sep 17 00:00:00 2001 From: Mohammed Salman Date: Mon, 27 Jul 2020 15:29:19 +0300 Subject: [PATCH] Import and export library from/to a file (#1940) Co-authored-by: dwelle --- src/actions/actionExport.tsx | 9 +---- src/components/App.tsx | 19 ++++++++- src/components/LayerUI.tsx | 71 +++++++++++++++++++++++++++------- src/components/LibraryUnit.tsx | 8 ++-- src/data/blob.ts | 33 +++++++++++----- src/data/json.ts | 33 ++++++++++++++++ src/data/library.ts | 43 ++++++++++++++++++++ src/data/localStorage.ts | 2 +- src/data/types.ts | 9 ++++- src/types.ts | 3 +- src/utils.ts | 8 ++++ 11 files changed, 199 insertions(+), 39 deletions(-) create mode 100644 src/data/library.ts diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index 5258f87a..02d43739 100644 --- a/src/actions/actionExport.tsx +++ b/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", diff --git a/src/components/App.tsx b/src/components/App.tsx index 2ffb91c5..244aef79 100644 --- a/src/components/App.tsx +++ b/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 { private handleCanvasOnDrop = (event: React.DragEvent) => { 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 { .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["setState"]; history: SceneHistory; app: InstanceType; + library: ReturnType; }; } } @@ -3506,6 +3520,9 @@ if ( history: { get: () => history, }, + library: { + get: () => loadLibrary(), + }, }); } diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 88eb482e..e56cc5d1 100644 --- a/src/components/LayerUI.tsx +++ b/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[]) => 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[]; + pendingElements: LibraryItem; onClickOutside: (event: MouseEvent) => void; onRemoveFromLibrary: (index: number) => void; - onInsertShape: (elements: readonly NonDeleted[]) => void; - onAddToLibrary: (elements: NonDeleted[]) => 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( + + { + 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 }); + }); + }} + /> + { + saveLibraryAsJSON() + .catch(muteFSAbortError) + .catch((error) => { + setAppState({ errorMessage: error.message }); + }); + }} + /> + , + ); + 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[]; + pendingElements: LibraryItem; onClickOutside: (event: MouseEvent) => void; - onInsertShape: (elements: readonly NonDeleted[]) => void; + onInsertShape: (elements: LibraryItem) => void; onAddToLibrary: () => void; + setAppState: any; }) => { const ref = useRef(null); useOnClickOutside(ref, onClickOutside); @@ -202,7 +243,7 @@ const LibraryMenu = ({ }, []); const addToLibrary = useCallback( - async (elements: NonDeleted[]) => { + 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} /> )} @@ -372,6 +414,7 @@ const LayerUI = ({ onClickOutside={closeLibrary} onInsertShape={onInsertShape} onAddToLibrary={deselectItems} + setAppState={setAppState} /> ) : null; diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index bcf1959f..f8616256 100644 --- a/src/components/LibraryUnit.tsx +++ b/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[]; - pendingElements?: NonDeleted[]; + 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), ); }} diff --git a/src/data/blob.ts b/src/data/blob.ts index 9a87355d..e5dc0063 100644 --- a/src/data/blob.ts +++ b/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; +}; diff --git a/src/data/json.ts b/src/data/json.ts index 42a78861..5c78afc1 100644 --- a/src/data/json.ts +++ b/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); +}; diff --git a/src/data/library.ts b/src/data/library.ts new file mode 100644 index 00000000..832e7d76 --- /dev/null +++ b/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]); + } +} diff --git a/src/data/localStorage.ts b/src/data/localStorage.ts index ec8aa08f..04a391e6 100644 --- a/src/data/localStorage.ts +++ b/src/data/localStorage.ts @@ -21,7 +21,7 @@ export const loadLibrary = (): Promise => { 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; diff --git a/src/data/types.ts b/src/data/types.ts index f29e7d82..49526b4b 100644 --- a/src/data/types.ts +++ b/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 | null; } + +export interface LibraryData { + type?: string; + version?: number; + source?: string; + library?: LibraryItems; +} diff --git a/src/types.ts b/src/types.ts index fe20212b..c9c6fa26 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,7 +108,8 @@ export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSour _brand: "socketUpdateData"; }; -export type LibraryItems = readonly NonDeleted[][]; +export type LibraryItem = NonDeleted[]; +export type LibraryItems = readonly LibraryItem[]; export interface ExcalidrawProps { width: number; diff --git a/src/utils.ts b/src/utils.ts index 0ac8e063..865652d1 100644 --- a/src/utils.ts +++ b/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; +};