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

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Mohammed Salman 2020-07-27 15:29:19 +03:00 committed by GitHub
parent 7eff6893c5
commit ee8fa6aaad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 199 additions and 39 deletions

View File

@ -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",

View File

@ -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(),
},
});
}

View File

@ -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;

View File

@ -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),
);
}}

View File

@ -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;
};

View File

@ -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
src/data/library.ts Normal file
View File

@ -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]);
}
}

View File

@ -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>;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
};