Browse Source

feat: Allow publishing libraries from UI (#4115)

* feat: Allow publishing libraries from UI

* Add status for each library item and show publish only for unpublished libs

* Add publish library dialog

* Pass the data to publish the library

* pass lib blob

* Handle old and new libraries when importing

* Better error handling

* Show publish success when library submitted for review

* don't close library when publish success dialog open

* Support multiple libs deletion and publish

* Set status to published once library submitted for review

* Save  to LS after library published

* unique key for publish and delete

* fix layout shift when hover and also highlight selected library items

* design improvements

* migrate old library to the new one

* fix

* fix tests

* use i18n

* Support submit type in toolbutton

* Use html5 form validation, add asteriks for required fields, add twitter handle, mark github handle optional

* Add twitter handle in form state

* revert html5 validation as fetch is giving some issues :/

* clarify types around LibraryItems

* Add website optional field

* event.preventDefault to make htm5 form validationw work

* improve png generation by drawing a bounding box rect and aligining pngs to support multiple libs png

* remove ts-ignore

* add placeholders for fields

* decrease clickable area for checkbox by 0.5em

* add checkbox background color

* rename `items` to `elements`

* improve checkbox hit area

* show selected library items in publish dialog

* decrease dimensions by 3px to improve jerky experience when opening/closing library menu

* Don't close publish dialog when clicked outside

* Show selected library actions only when any library item selected and use icons instead of button

* rename library to libraryItems in excalidrawLib and added migration

* change icon and swap bg/color

* use blue brand color for hover/selected states

* prompt for confirmation when deleting library items

* separate unpublished items from published

* factor `LibraryMenu` into own file

* i18n and minor fixes for unpublished items

* fix not rendering empty cells when library empty

* don't render published section if empty and unpublished is not

* Add edit name functionality for library items

* fix

* edit lib name with onchange/blur

* bump library version

* prefer response error message

* add library urls to ENV vars

* mark lib item name as required

* Use input only for lib item name

* better error validation for lib items

* fix label styling for lib items

* design and i18n fixes

* Save publish dialog data to local storage and clear once published

* Add a note about MIT License

* Add note for guidelines

* Add tooltip for publish button

* Show spinner in submit button when submission is in progress

* assign id for older lib items when installed and set status as published for all lib when installed

* update export icon and support export library for selected items

* move LibraryMenuItems into its own component as its best to keep one comp per file

* fix spec

* Refactoring the library actions for reusablility

* show only load when items not present

* close on click outside in publish dialog

* ad dialog description and tweak copy

* vertically center input labels

* align input styles

* move author name input to other usernames

* rename param

* inline to simplify

* fix to not inline `undefined` class names

* fix version & include only latest lib schema in library export type

* await response callback

* refactor types

* refactor

* i18n

* align casing & tweaks

* move ls logic to publishLibrary

* support removal of item inside publish dialog

* fix labels for trash icon when items selected

* replace window.confirm for removal libs with confirm dialog

* fix input/textarea styling

* move library item menu scss to its own file

* use blue for load and cyan for publish

* reduce margin for submit and make submit => Submit

* Make library items header sticky

* move publish icon to left so there is no jerkiness when unpublish items selected

* update url

* fix grid gap between lib items

* Mark older items imported from initial data as unpublished

* add text to publish button on non-mobile

* add items counter

* fix test

* show personal and excal libs sections and personal goes first

* show toast on adding to library via contextMenu

* Animate plus icon and not the pending item

* fix snap

* use i18n when no item in publish dialog

* tweak style of new lib item

* show empty cells for both sections and set status as published for installed libs

* fix

* push selected item first in unpublished section

* set status as published for imported from webiste but unpublished for json

* Add items to the begining of library

* add `created` library item attr

* fix test

* use `defaultValue` instead of `value`

* fix dark theme styles

* fix toggle button not closing library

* close library menu on Escape

* tweak publish dialog item remove style

* fix remove icon in publish dialog

Co-authored-by: dwelle <luzar.david@gmail.com>
Aakansha Doshi 3 years ago
parent
commit
84d1d9993c
44 changed files with 1864 additions and 499 deletions
  1. 3 1
      .env
  2. 6 1
      .env.production
  3. 36 12
      src/actions/actionAddToLibrary.ts
  4. 1 13
      src/align.ts
  5. 6 3
      src/components/App.tsx
  6. 3 2
      src/components/CheckboxItem.tsx
  7. 3 0
      src/components/Dialog.tsx
  8. 0 36
      src/components/LayerUI.scss
  9. 14 322
      src/components/LayerUI.tsx
  10. 55 0
      src/components/LibraryMenu.scss
  11. 287 0
      src/components/LibraryMenu.tsx
  12. 102 0
      src/components/LibraryMenuItems.scss
  13. 322 0
      src/components/LibraryMenuItems.tsx
  14. 59 15
      src/components/LibraryUnit.scss
  15. 25 24
      src/components/LibraryUnit.tsx
  16. 6 2
      src/components/Modal.tsx
  17. 1 1
      src/components/PasteChartDialog.tsx
  18. 1 0
      src/components/ProjectName.tsx
  19. 92 0
      src/components/PublishLibrary.scss
  20. 430 0
      src/components/PublishLibrary.tsx
  21. 66 0
      src/components/SingleLibraryItem.scss
  22. 99 0
      src/components/SingleLibraryItem.tsx
  23. 0 18
      src/components/TextInput.scss
  24. 17 3
      src/components/ToolButton.tsx
  25. 9 0
      src/components/icons.tsx
  26. 21 0
      src/css/styles.scss
  27. 2 1
      src/css/theme.scss
  28. 5 6
      src/data/json.ts
  29. 22 15
      src/data/library.ts
  30. 33 1
      src/data/restore.ts
  31. 8 5
      src/data/types.ts
  32. 15 0
      src/element/bounds.ts
  33. 1 0
      src/element/textWysiwyg.tsx
  34. 1 6
      src/excalidraw-app/collab/RoomDialog.scss
  35. 2 0
      src/excalidraw-app/collab/RoomDialog.tsx
  36. 2 0
      src/global.d.ts
  37. 5 2
      src/i18n.ts
  38. 59 2
      src/locales/en.json
  39. 2 2
      src/packages/utils.ts
  40. 2 2
      src/tests/__snapshots__/contextmenu.test.tsx.snap
  41. 3 2
      src/tests/contextmenu.test.tsx
  42. 6 1
      src/tests/library.test.tsx
  43. 18 1
      src/types.ts
  44. 14 0
      src/utils.ts

+ 3 - 1
.env

@@ -1,6 +1,8 @@
 REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
 REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
 
-# dev values
+REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
+REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
+
 REACT_APP_SOCKET_SERVER_URL=http://localhost:3000
 REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'

+ 6 - 1
.env.production

@@ -1,6 +1,11 @@
 REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
 REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
 
-REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
+REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
+REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
+
 REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
 REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
+
+# production-only vars
+REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13

+ 36 - 12
src/actions/actionAddToLibrary.ts

@@ -2,22 +2,46 @@ import { register } from "./register";
 import { getSelectedElements } from "../scene";
 import { getNonDeletedElements } from "../element";
 import { deepCopyElement } from "../element/newElement";
+import { randomId } from "../random";
+import { t } from "../i18n";
 
 export const actionAddToLibrary = register({
   name: "addToLibrary",
   perform: (elements, appState, _, app) => {
-    const selectedElements = getSelectedElements(
-      getNonDeletedElements(elements),
-      appState,
-    );
-
-    app.library.loadLibrary().then((items) => {
-      app.library.saveLibrary([
-        ...items,
-        selectedElements.map(deepCopyElement),
-      ]);
-    });
-    return false;
+    return app.library
+      .loadLibrary()
+      .then((items) => {
+        return app.library.saveLibrary([
+          {
+            id: randomId(),
+            status: "unpublished",
+            elements: getSelectedElements(
+              getNonDeletedElements(elements),
+              appState,
+            ).map(deepCopyElement),
+            created: Date.now(),
+          },
+          ...items,
+        ]);
+      })
+      .then(() => {
+        return {
+          commitToHistory: false,
+          appState: {
+            ...appState,
+            toastMessage: t("toast.addedToLibrary"),
+          },
+        };
+      })
+      .catch((error) => {
+        return {
+          commitToHistory: false,
+          appState: {
+            ...appState,
+            errorMessage: error.message,
+          },
+        };
+      });
   },
   contextItemLabel: "labels.addToLibrary",
 });

+ 1 - 13
src/align.ts

@@ -1,13 +1,6 @@
 import { ExcalidrawElement } from "./element/types";
 import { newElementWith } from "./element/mutateElement";
-import { getCommonBounds } from "./element";
-
-interface Box {
-  minX: number;
-  minY: number;
-  maxX: number;
-  maxY: number;
-}
+import { Box, getCommonBoundingBox } from "./element/bounds";
 
 export interface Alignment {
   position: "start" | "center" | "end";
@@ -88,8 +81,3 @@ const calculateTranslation = (
       (groupBoundingBox[min] + groupBoundingBox[max]) / 2,
   };
 };
-
-const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
-  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
-  return { minX, minY, maxX, maxY };
-};

+ 6 - 3
src/components/App.tsx

@@ -72,7 +72,7 @@ import {
 import { loadFromBlob } from "../data";
 import { isValidLibrary } from "../data/json";
 import Library from "../data/library";
-import { restore, restoreElements } from "../data/restore";
+import { restore, restoreElements, restoreLibraryItems } from "../data/restore";
 import {
   dragNewElement,
   dragSelectedElements,
@@ -658,7 +658,7 @@ class App extends React.Component<AppProps, AppState> {
           t("alerts.confirmAddLibrary", { numShapes: json.library.length }),
         )
       ) {
-        await this.library.importLibrary(blob);
+        await this.library.importLibrary(blob, "published");
         // hack to rerender the library items after import
         if (this.state.isLibraryOpen) {
           this.setState({ isLibraryOpen: false });
@@ -732,7 +732,10 @@ class App extends React.Component<AppProps, AppState> {
     try {
       initialData = (await this.props.initialData) || null;
       if (initialData?.libraryItems) {
-        this.libraryItemsFromStorage = initialData.libraryItems;
+        this.libraryItemsFromStorage = restoreLibraryItems(
+          initialData.libraryItems,
+          "unpublished",
+        ) as LibraryItems;
       }
     } catch (error: any) {
       console.error(error);

+ 3 - 2
src/components/CheckboxItem.tsx

@@ -7,10 +7,11 @@ import "./CheckboxItem.scss";
 export const CheckboxItem: React.FC<{
   checked: boolean;
   onChange: (checked: boolean) => void;
-}> = ({ children, checked, onChange }) => {
+  className?: string;
+}> = ({ children, checked, onChange, className }) => {
   return (
     <div
-      className={clsx("Checkbox", { "is-checked": checked })}
+      className={clsx("Checkbox", className, { "is-checked": checked })}
       onClick={(event) => {
         onChange(!checked);
         (

+ 3 - 0
src/components/Dialog.tsx

@@ -18,7 +18,9 @@ export interface DialogProps {
   title: React.ReactNode;
   autofocus?: boolean;
   theme?: AppState["theme"];
+  closeOnClickOutside?: boolean;
 }
+
 export const Dialog = (props: DialogProps) => {
   const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
   const [lastActiveElement] = useState(document.activeElement);
@@ -82,6 +84,7 @@ export const Dialog = (props: DialogProps) => {
       maxWidth={props.small ? 550 : 800}
       onCloseRequest={onClose}
       theme={props.theme}
+      closeOnClickOutside={props.closeOnClickOutside}
     >
       <Island ref={setIslandNode}>
         <h2 id={`${id}-dialog-title`} className="Dialog__title">

+ 0 - 36
src/components/LayerUI.scss

@@ -1,42 +1,6 @@
 @import "open-color/open-color";
 
 .excalidraw {
-  .layer-ui__library {
-    margin: auto;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-
-    .layer-ui__library-header {
-      display: flex;
-      align-items: center;
-      width: 100%;
-      margin: 2px 0;
-
-      button {
-        // 2px from the left to account for focus border of left-most button
-        margin: 0 2px;
-      }
-
-      a {
-        margin-inline-start: auto;
-        // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
-        padding-inline-end: 18px;
-        white-space: nowrap;
-      }
-    }
-  }
-
-  .layer-ui__library-message {
-    padding: 10px 20px;
-    max-width: 200px;
-  }
-
-  .layer-ui__library-items {
-    max-height: 50vh;
-    overflow: auto;
-  }
-
   .layer-ui__wrapper {
     z-index: var(--zIndex-layerUI);
 

+ 14 - 322
src/components/LayerUI.tsx

@@ -1,29 +1,15 @@
 import clsx from "clsx";
-import React, {
-  RefObject,
-  useCallback,
-  useEffect,
-  useRef,
-  useState,
-} from "react";
+import React, { useCallback } from "react";
 import { ActionManager } from "../actions/manager";
 import { CLASSES } from "../constants";
 import { exportCanvas } from "../data";
-import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
 import { isTextElement, showSelectedShapeActions } from "../element";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { Language, t } from "../i18n";
 import { useIsMobile } from "../components/App";
 import { calculateScrollCenter, getSelectedElements } from "../scene";
 import { ExportType } from "../scene/types";
-import {
-  AppProps,
-  AppState,
-  ExcalidrawProps,
-  BinaryFiles,
-  LibraryItem,
-  LibraryItems,
-} from "../types";
+import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
 import { muteFSAbortError } from "../utils";
 import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
 import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
@@ -32,10 +18,8 @@ import { ErrorDialog } from "./ErrorDialog";
 import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
 import { FixedSideContainer } from "./FixedSideContainer";
 import { HintViewer } from "./HintViewer";
-import { exportFile, load, trash } from "./icons";
 import { Island } from "./Island";
 import "./LayerUI.scss";
-import { LibraryUnit } from "./LibraryUnit";
 import { LoadingMessage } from "./LoadingMessage";
 import { LockButton } from "./LockButton";
 import { MobileMenu } from "./MobileMenu";
@@ -43,13 +27,13 @@ import { PasteChartDialog } from "./PasteChartDialog";
 import { Section } from "./Section";
 import { HelpDialog } from "./HelpDialog";
 import Stack from "./Stack";
-import { ToolButton } from "./ToolButton";
 import { Tooltip } from "./Tooltip";
 import { UserList } from "./UserList";
 import Library from "../data/library";
 import { JSONExportDialog } from "./JSONExportDialog";
 import { LibraryButton } from "./LibraryButton";
 import { isImageFileHandle } from "../data/blob";
+import { LibraryMenu } from "./LibraryMenu";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -81,302 +65,6 @@ interface LayerUIProps {
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
 }
 
-const useOnClickOutside = (
-  ref: RefObject<HTMLElement>,
-  cb: (event: MouseEvent) => void,
-) => {
-  useEffect(() => {
-    const listener = (event: MouseEvent) => {
-      if (!ref.current) {
-        return;
-      }
-
-      if (
-        event.target instanceof Element &&
-        (ref.current.contains(event.target) ||
-          !document.body.contains(event.target))
-      ) {
-        return;
-      }
-
-      cb(event);
-    };
-    document.addEventListener("pointerdown", listener, false);
-
-    return () => {
-      document.removeEventListener("pointerdown", listener);
-    };
-  }, [ref, cb]);
-};
-
-const LibraryMenuItems = ({
-  libraryItems,
-  onRemoveFromLibrary,
-  onAddToLibrary,
-  onInsertShape,
-  pendingElements,
-  theme,
-  setAppState,
-  setLibraryItems,
-  libraryReturnUrl,
-  focusContainer,
-  library,
-  files,
-  id,
-}: {
-  libraryItems: LibraryItems;
-  pendingElements: LibraryItem;
-  onRemoveFromLibrary: (index: number) => void;
-  onInsertShape: (elements: LibraryItem) => void;
-  onAddToLibrary: (elements: LibraryItem) => void;
-  theme: AppState["theme"];
-  files: BinaryFiles;
-  setAppState: React.Component<any, AppState>["setState"];
-  setLibraryItems: (library: LibraryItems) => void;
-  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
-  focusContainer: () => void;
-  library: Library;
-  id: string;
-}) => {
-  const isMobile = useIsMobile();
-  const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
-  const CELLS_PER_ROW = isMobile ? 4 : 6;
-  const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
-  const rows = [];
-  let addedPendingElements = false;
-
-  const referrer =
-    libraryReturnUrl || window.location.origin + window.location.pathname;
-
-  rows.push(
-    <div className="layer-ui__library-header" key="library-header">
-      <ToolButton
-        key="import"
-        type="button"
-        title={t("buttons.load")}
-        aria-label={t("buttons.load")}
-        icon={load}
-        onClick={() => {
-          importLibraryFromJSON(library)
-            .then(() => {
-              // Close and then open to get the libraries updated
-              setAppState({ isLibraryOpen: false });
-              setAppState({ isLibraryOpen: true });
-            })
-            .catch(muteFSAbortError)
-            .catch((error) => {
-              setAppState({ errorMessage: error.message });
-            });
-        }}
-      />
-      {!!libraryItems.length && (
-        <>
-          <ToolButton
-            key="export"
-            type="button"
-            title={t("buttons.export")}
-            aria-label={t("buttons.export")}
-            icon={exportFile}
-            onClick={() => {
-              saveLibraryAsJSON(library)
-                .catch(muteFSAbortError)
-                .catch((error) => {
-                  setAppState({ errorMessage: error.message });
-                });
-            }}
-          />
-          <ToolButton
-            key="reset"
-            type="button"
-            title={t("buttons.resetLibrary")}
-            aria-label={t("buttons.resetLibrary")}
-            icon={trash}
-            onClick={() => {
-              if (window.confirm(t("alerts.resetLibrary"))) {
-                library.resetLibrary();
-                setLibraryItems([]);
-                focusContainer();
-              }
-            }}
-          />
-        </>
-      )}
-      <a
-        href={`https://libraries.excalidraw.com?target=${
-          window.name || "_blank"
-        }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
-        target="_excalidraw_libraries"
-      >
-        {t("labels.libraries")}
-      </a>
-    </div>,
-  );
-
-  for (let row = 0; row < numRows; row++) {
-    const y = CELLS_PER_ROW * row;
-    const children = [];
-    for (let x = 0; x < CELLS_PER_ROW; x++) {
-      const shouldAddPendingElements: boolean =
-        pendingElements.length > 0 &&
-        !addedPendingElements &&
-        y + x >= libraryItems.length;
-      addedPendingElements = addedPendingElements || shouldAddPendingElements;
-
-      children.push(
-        <Stack.Col key={x}>
-          <LibraryUnit
-            elements={libraryItems[y + x]}
-            files={files}
-            pendingElements={
-              shouldAddPendingElements ? pendingElements : undefined
-            }
-            onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
-            onClick={
-              shouldAddPendingElements
-                ? onAddToLibrary.bind(null, pendingElements)
-                : onInsertShape.bind(null, libraryItems[y + x])
-            }
-          />
-        </Stack.Col>,
-      );
-    }
-    rows.push(
-      <Stack.Row align="center" gap={1} key={row}>
-        {children}
-      </Stack.Row>,
-    );
-  }
-
-  return (
-    <Stack.Col align="start" gap={1} className="layer-ui__library-items">
-      {rows}
-    </Stack.Col>
-  );
-};
-
-const LibraryMenu = ({
-  onClickOutside,
-  onInsertShape,
-  pendingElements,
-  onAddToLibrary,
-  theme,
-  setAppState,
-  files,
-  libraryReturnUrl,
-  focusContainer,
-  library,
-  id,
-}: {
-  pendingElements: LibraryItem;
-  onClickOutside: (event: MouseEvent) => void;
-  onInsertShape: (elements: LibraryItem) => void;
-  onAddToLibrary: () => void;
-  theme: AppState["theme"];
-  files: BinaryFiles;
-  setAppState: React.Component<any, AppState>["setState"];
-  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
-  focusContainer: () => void;
-  library: Library;
-  id: string;
-}) => {
-  const ref = useRef<HTMLDivElement | null>(null);
-  useOnClickOutside(ref, (event) => {
-    // If click on the library icon, do nothing.
-    if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
-      return;
-    }
-    onClickOutside(event);
-  });
-
-  const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
-
-  const [loadingState, setIsLoading] = useState<
-    "preloading" | "loading" | "ready"
-  >("preloading");
-
-  const loadingTimerRef = useRef<number | null>(null);
-
-  useEffect(() => {
-    Promise.race([
-      new Promise((resolve) => {
-        loadingTimerRef.current = window.setTimeout(() => {
-          resolve("loading");
-        }, 100);
-      }),
-      library.loadLibrary().then((items) => {
-        setLibraryItems(items);
-        setIsLoading("ready");
-      }),
-    ]).then((data) => {
-      if (data === "loading") {
-        setIsLoading("loading");
-      }
-    });
-    return () => {
-      clearTimeout(loadingTimerRef.current!);
-    };
-  }, [library]);
-
-  const removeFromLibrary = useCallback(
-    async (indexToRemove) => {
-      const items = await library.loadLibrary();
-      const nextItems = items.filter((_, index) => index !== indexToRemove);
-      library.saveLibrary(nextItems).catch((error) => {
-        setLibraryItems(items);
-        setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
-      });
-      setLibraryItems(nextItems);
-    },
-    [library, setAppState],
-  );
-
-  const addToLibrary = useCallback(
-    async (elements: LibraryItem) => {
-      if (elements.some((element) => element.type === "image")) {
-        return setAppState({
-          errorMessage: "Support for adding images to the library coming soon!",
-        });
-      }
-
-      const items = await library.loadLibrary();
-      const nextItems = [...items, elements];
-      onAddToLibrary();
-      library.saveLibrary(nextItems).catch((error) => {
-        setLibraryItems(items);
-        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
-      });
-      setLibraryItems(nextItems);
-    },
-    [onAddToLibrary, library, setAppState],
-  );
-
-  return loadingState === "preloading" ? null : (
-    <Island padding={1} ref={ref} className="layer-ui__library">
-      {loadingState === "loading" ? (
-        <div className="layer-ui__library-message">
-          {t("labels.libraryLoadingMessage")}
-        </div>
-      ) : (
-        <LibraryMenuItems
-          libraryItems={libraryItems}
-          onRemoveFromLibrary={removeFromLibrary}
-          onAddToLibrary={addToLibrary}
-          onInsertShape={onInsertShape}
-          pendingElements={pendingElements}
-          setAppState={setAppState}
-          setLibraryItems={setLibraryItems}
-          libraryReturnUrl={libraryReturnUrl}
-          focusContainer={focusContainer}
-          library={library}
-          theme={theme}
-          files={files}
-          id={id}
-        />
-      )}
-    </Island>
-  );
-};
-
 const LayerUI = ({
   actionManager,
   appState,
@@ -561,12 +249,15 @@ const LayerUI = ({
     </Section>
   );
 
-  const closeLibrary = useCallback(
-    (event) => {
-      setAppState({ isLibraryOpen: false });
-    },
-    [setAppState],
-  );
+  const closeLibrary = useCallback(() => {
+    const isDialogOpen = !!document.querySelector(".Dialog");
+
+    // Prevent closing if any dialog is open
+    if (isDialogOpen) {
+      return;
+    }
+    setAppState({ isLibraryOpen: false });
+  }, [setAppState]);
 
   const deselectItems = useCallback(() => {
     setAppState({
@@ -578,7 +269,7 @@ const LayerUI = ({
   const libraryMenu = appState.isLibraryOpen ? (
     <LibraryMenu
       pendingElements={getSelectedElements(elements, appState)}
-      onClickOutside={closeLibrary}
+      onClose={closeLibrary}
       onInsertShape={onInsertElements}
       onAddToLibrary={deselectItems}
       setAppState={setAppState}
@@ -588,6 +279,7 @@ const LayerUI = ({
       theme={appState.theme}
       files={files}
       id={id}
+      appState={appState}
     />
   ) : null;
 

+ 55 - 0
src/components/LibraryMenu.scss

@@ -0,0 +1,55 @@
+@import "open-color/open-color";
+
+.excalidraw {
+  .layer-ui__library {
+    margin: auto;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .layer-ui__library-header {
+      display: flex;
+      align-items: center;
+      width: 100%;
+      margin: 2px 0;
+
+      button {
+        // 2px from the left to account for focus border of left-most button
+        margin: 0 2px;
+      }
+
+      a {
+        margin-inline-start: auto;
+        // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
+        padding-inline-end: 18px;
+        white-space: nowrap;
+      }
+    }
+  }
+
+  .layer-ui__library-message {
+    padding: 10px 20px;
+    max-width: 200px;
+  }
+
+  .publish-library-success {
+    .Dialog__content {
+      display: flex;
+      flex-direction: column;
+    }
+
+    &-close.ToolIcon_type_button {
+      background-color: $oc-blue-6;
+      align-self: flex-end;
+      &:hover {
+        background-color: $oc-blue-8;
+      }
+      .ToolIcon__icon {
+        width: auto;
+        font-size: 1rem;
+        color: $oc-white;
+        padding: 0 0.5rem;
+      }
+    }
+  }
+}

+ 287 - 0
src/components/LibraryMenu.tsx

@@ -0,0 +1,287 @@
+import { useRef, useState, useEffect, useCallback, RefObject } from "react";
+import Library from "../data/library";
+import { t } from "../i18n";
+import { randomId } from "../random";
+import {
+  LibraryItems,
+  LibraryItem,
+  AppState,
+  BinaryFiles,
+  ExcalidrawProps,
+} from "../types";
+import { Dialog } from "./Dialog";
+import { Island } from "./Island";
+import PublishLibrary from "./PublishLibrary";
+import { ToolButton } from "./ToolButton";
+
+import "./LibraryMenu.scss";
+import LibraryMenuItems from "./LibraryMenuItems";
+import { EVENT } from "../constants";
+import { KEYS } from "../keys";
+
+const useOnClickOutside = (
+  ref: RefObject<HTMLElement>,
+  cb: (event: MouseEvent) => void,
+) => {
+  useEffect(() => {
+    const listener = (event: MouseEvent) => {
+      if (!ref.current) {
+        return;
+      }
+
+      if (
+        event.target instanceof Element &&
+        (ref.current.contains(event.target) ||
+          !document.body.contains(event.target))
+      ) {
+        return;
+      }
+
+      cb(event);
+    };
+    document.addEventListener("pointerdown", listener, false);
+
+    return () => {
+      document.removeEventListener("pointerdown", listener);
+    };
+  }, [ref, cb]);
+};
+
+const getSelectedItems = (
+  libraryItems: LibraryItems,
+  selectedItems: LibraryItem["id"][],
+) => libraryItems.filter((item) => selectedItems.includes(item.id));
+
+export const LibraryMenu = ({
+  onClose,
+  onInsertShape,
+  pendingElements,
+  onAddToLibrary,
+  theme,
+  setAppState,
+  files,
+  libraryReturnUrl,
+  focusContainer,
+  library,
+  id,
+  appState,
+}: {
+  pendingElements: LibraryItem["elements"];
+  onClose: () => void;
+  onInsertShape: (elements: LibraryItem["elements"]) => void;
+  onAddToLibrary: () => void;
+  theme: AppState["theme"];
+  files: BinaryFiles;
+  setAppState: React.Component<any, AppState>["setState"];
+  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
+  focusContainer: () => void;
+  library: Library;
+  id: string;
+  appState: AppState;
+}) => {
+  const ref = useRef<HTMLDivElement | null>(null);
+
+  useOnClickOutside(ref, (event) => {
+    // If click on the library icon, do nothing.
+    if ((event.target as Element).closest(".ToolIcon__library")) {
+      return;
+    }
+    onClose();
+  });
+
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === KEYS.ESCAPE) {
+        onClose();
+      }
+    };
+    document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
+    return () => {
+      document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
+    };
+  }, [onClose]);
+
+  const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
+
+  const [loadingState, setIsLoading] = useState<
+    "preloading" | "loading" | "ready"
+  >("preloading");
+  const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
+  const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
+    useState(false);
+  const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
+    url: string;
+    authorName: string;
+  }>(null);
+  const loadingTimerRef = useRef<number | null>(null);
+
+  useEffect(() => {
+    Promise.race([
+      new Promise((resolve) => {
+        loadingTimerRef.current = window.setTimeout(() => {
+          resolve("loading");
+        }, 100);
+      }),
+      library.loadLibrary().then((items) => {
+        setLibraryItems(items);
+        setIsLoading("ready");
+      }),
+    ]).then((data) => {
+      if (data === "loading") {
+        setIsLoading("loading");
+      }
+    });
+    return () => {
+      clearTimeout(loadingTimerRef.current!);
+    };
+  }, [library]);
+
+  const removeFromLibrary = useCallback(async () => {
+    const items = await library.loadLibrary();
+
+    const nextItems = items.filter((item) => !selectedItems.includes(item.id));
+    library.saveLibrary(nextItems).catch((error) => {
+      setLibraryItems(items);
+      setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
+    });
+    setSelectedItems([]);
+    setLibraryItems(nextItems);
+  }, [library, setAppState, selectedItems, setSelectedItems]);
+
+  const resetLibrary = useCallback(() => {
+    library.resetLibrary();
+    setLibraryItems([]);
+    focusContainer();
+  }, [library, focusContainer]);
+
+  const addToLibrary = useCallback(
+    async (elements: LibraryItem["elements"]) => {
+      if (elements.some((element) => element.type === "image")) {
+        return setAppState({
+          errorMessage: "Support for adding images to the library coming soon!",
+        });
+      }
+      const items = await library.loadLibrary();
+      const nextItems: LibraryItems = [
+        {
+          status: "unpublished",
+          elements,
+          id: randomId(),
+          created: Date.now(),
+        },
+        ...items,
+      ];
+      onAddToLibrary();
+      library.saveLibrary(nextItems).catch((error) => {
+        setLibraryItems(items);
+        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
+      });
+      setLibraryItems(nextItems);
+    },
+    [onAddToLibrary, library, setAppState],
+  );
+
+  const renderPublishSuccess = useCallback(() => {
+    return (
+      <Dialog
+        onCloseRequest={() => setPublishLibSuccess(null)}
+        title={t("publishSuccessDialog.title")}
+        className="publish-library-success"
+        small={true}
+      >
+        <p>
+          {t("publishSuccessDialog.content", {
+            authorName: publishLibSuccess!.authorName,
+          })}{" "}
+          <a
+            href={publishLibSuccess?.url}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            {t("publishSuccessDialog.link")}
+          </a>
+        </p>
+        <ToolButton
+          type="button"
+          title={t("buttons.close")}
+          aria-label={t("buttons.close")}
+          label={t("buttons.close")}
+          onClick={() => setPublishLibSuccess(null)}
+          data-testid="publish-library-success-close"
+          className="publish-library-success-close"
+        />
+      </Dialog>
+    );
+  }, [setPublishLibSuccess, publishLibSuccess]);
+
+  const onPublishLibSuccess = useCallback(
+    (data) => {
+      setShowPublishLibraryDialog(false);
+      setPublishLibSuccess({ url: data.url, authorName: data.authorName });
+      const nextLibItems = libraryItems.slice();
+      nextLibItems.forEach((libItem) => {
+        if (selectedItems.includes(libItem.id)) {
+          libItem.status = "published";
+        }
+      });
+      library.saveLibrary(nextLibItems);
+      setLibraryItems(nextLibItems);
+    },
+    [
+      setShowPublishLibraryDialog,
+      setPublishLibSuccess,
+      libraryItems,
+      selectedItems,
+      library,
+    ],
+  );
+
+  return loadingState === "preloading" ? null : (
+    <Island padding={1} ref={ref} className="layer-ui__library">
+      {showPublishLibraryDialog && (
+        <PublishLibrary
+          onClose={() => setShowPublishLibraryDialog(false)}
+          libraryItems={getSelectedItems(libraryItems, selectedItems)}
+          appState={appState}
+          onSuccess={onPublishLibSuccess}
+          onError={(error) => window.alert(error)}
+          updateItemsInStorage={() => library.saveLibrary(libraryItems)}
+          onRemove={(id: string) =>
+            setSelectedItems(selectedItems.filter((_id) => _id !== id))
+          }
+        />
+      )}
+      {publishLibSuccess && renderPublishSuccess()}
+
+      {loadingState === "loading" ? (
+        <div className="layer-ui__library-message">
+          {t("labels.libraryLoadingMessage")}
+        </div>
+      ) : (
+        <LibraryMenuItems
+          libraryItems={libraryItems}
+          onRemoveFromLibrary={removeFromLibrary}
+          onAddToLibrary={addToLibrary}
+          onInsertShape={onInsertShape}
+          pendingElements={pendingElements}
+          setAppState={setAppState}
+          libraryReturnUrl={libraryReturnUrl}
+          library={library}
+          theme={theme}
+          files={files}
+          id={id}
+          selectedItems={selectedItems}
+          onToggle={(id) => {
+            if (!selectedItems.includes(id)) {
+              setSelectedItems([...selectedItems, id]);
+            } else {
+              setSelectedItems(selectedItems.filter((_id) => _id !== id));
+            }
+          }}
+          onPublish={() => setShowPublishLibraryDialog(true)}
+          resetLibrary={resetLibrary}
+        />
+      )}
+    </Island>
+  );
+};

+ 102 - 0
src/components/LibraryMenuItems.scss

@@ -0,0 +1,102 @@
+@import "open-color/open-color";
+
+.excalidraw {
+  .library-menu-items-container {
+    .library-actions {
+      display: flex;
+
+      button .library-actions-counter {
+        position: absolute;
+        right: 2px;
+        bottom: 2px;
+        border-radius: 50%;
+        width: 1em;
+        height: 1em;
+        padding: 1px;
+        font-size: 0.7rem;
+        background: #fff;
+      }
+
+      &--remove {
+        background-color: $oc-red-7;
+        &:hover {
+          background-color: $oc-red-8;
+        }
+        &:active {
+          background-color: $oc-red-9;
+        }
+        svg {
+          color: $oc-white;
+        }
+        .library-actions-counter {
+          color: $oc-red-7;
+        }
+      }
+
+      &--export {
+        background-color: $oc-lime-5;
+
+        &:hover {
+          background-color: $oc-lime-7;
+        }
+
+        &:active {
+          background-color: $oc-lime-8;
+        }
+        svg {
+          color: $oc-white;
+        }
+        .library-actions-counter {
+          color: $oc-lime-5;
+        }
+      }
+
+      &--publish {
+        background-color: $oc-cyan-6;
+        &:hover {
+          background-color: $oc-cyan-7;
+        }
+        &:active {
+          background-color: $oc-cyan-9;
+        }
+        svg {
+          color: $oc-white;
+        }
+        label {
+          margin-left: -0.2em;
+          margin-right: 1.1em;
+          color: $oc-white;
+          font-size: 0.86em;
+        }
+        .library-actions-counter {
+          color: $oc-cyan-6;
+        }
+      }
+
+      &--load {
+        background-color: $oc-blue-6;
+        &:hover {
+          background-color: $oc-blue-7;
+        }
+        &:active {
+          background-color: $oc-blue-9;
+        }
+        svg {
+          color: $oc-white;
+        }
+      }
+    }
+    &__items {
+      max-height: 50vh;
+      overflow: auto;
+      margin-top: 0.5rem;
+    }
+
+    .separator {
+      font-weight: 500;
+      font-size: 0.9rem;
+      margin: 0.6em 0.2em;
+      color: var(--text-primary-color);
+    }
+  }
+}

+ 322 - 0
src/components/LibraryMenuItems.tsx

@@ -0,0 +1,322 @@
+import { chunk } from "lodash";
+import { useCallback, useState } from "react";
+import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
+import Library from "../data/library";
+import { ExcalidrawElement, NonDeleted } from "../element/types";
+import { t } from "../i18n";
+import {
+  AppState,
+  BinaryFiles,
+  ExcalidrawProps,
+  LibraryItem,
+  LibraryItems,
+} from "../types";
+import { muteFSAbortError } from "../utils";
+import { useIsMobile } from "./App";
+import ConfirmDialog from "./ConfirmDialog";
+import { exportToFileIcon, load, publishIcon, trash } from "./icons";
+import { LibraryUnit } from "./LibraryUnit";
+import Stack from "./Stack";
+import { ToolButton } from "./ToolButton";
+import { Tooltip } from "./Tooltip";
+
+import "./LibraryMenuItems.scss";
+
+const LibraryMenuItems = ({
+  libraryItems,
+  onRemoveFromLibrary,
+  onAddToLibrary,
+  onInsertShape,
+  pendingElements,
+  theme,
+  setAppState,
+  libraryReturnUrl,
+  library,
+  files,
+  id,
+  selectedItems,
+  onToggle,
+  onPublish,
+  resetLibrary,
+}: {
+  libraryItems: LibraryItems;
+  pendingElements: LibraryItem["elements"];
+  onRemoveFromLibrary: () => void;
+  onInsertShape: (elements: LibraryItem["elements"]) => void;
+  onAddToLibrary: (elements: LibraryItem["elements"]) => void;
+  theme: AppState["theme"];
+  files: BinaryFiles;
+  setAppState: React.Component<any, AppState>["setState"];
+  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
+  library: Library;
+  id: string;
+  selectedItems: LibraryItem["id"][];
+  onToggle: (id: LibraryItem["id"]) => void;
+  onPublish: () => void;
+  resetLibrary: () => void;
+}) => {
+  const renderRemoveLibAlert = useCallback(() => {
+    const content = selectedItems.length
+      ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
+      : t("alerts.resetLibrary");
+    const title = selectedItems.length
+      ? t("confirmDialog.removeItemsFromLib")
+      : t("confirmDialog.resetLibrary");
+    return (
+      <ConfirmDialog
+        onConfirm={() => {
+          if (selectedItems.length) {
+            onRemoveFromLibrary();
+          } else {
+            resetLibrary();
+          }
+          setShowRemoveLibAlert(false);
+        }}
+        onCancel={() => {
+          setShowRemoveLibAlert(false);
+        }}
+        title={title}
+      >
+        <p>{content}</p>
+      </ConfirmDialog>
+    );
+  }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
+
+  const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
+
+  const isMobile = useIsMobile();
+
+  const renderLibraryActions = () => {
+    const itemsSelected = !!selectedItems.length;
+    const items = itemsSelected
+      ? libraryItems.filter((item) => selectedItems.includes(item.id))
+      : libraryItems;
+    const resetLabel = itemsSelected
+      ? t("buttons.remove")
+      : t("buttons.resetLibrary");
+    return (
+      <div className="library-actions">
+        {(!itemsSelected || !isMobile) && (
+          <ToolButton
+            key="import"
+            type="button"
+            title={t("buttons.load")}
+            aria-label={t("buttons.load")}
+            icon={load}
+            onClick={() => {
+              importLibraryFromJSON(library)
+                .then(() => {
+                  // Close and then open to get the libraries updated
+                  setAppState({ isLibraryOpen: false });
+                  setAppState({ isLibraryOpen: true });
+                })
+                .catch(muteFSAbortError)
+                .catch((error) => {
+                  setAppState({ errorMessage: error.message });
+                });
+            }}
+            className="library-actions--load"
+          />
+        )}
+        {!!items.length && (
+          <>
+            <ToolButton
+              key="export"
+              type="button"
+              title={t("buttons.export")}
+              aria-label={t("buttons.export")}
+              icon={exportToFileIcon}
+              onClick={async () => {
+                const libraryItems = itemsSelected
+                  ? items
+                  : await library.loadLibrary();
+                saveLibraryAsJSON(libraryItems)
+                  .catch(muteFSAbortError)
+                  .catch((error) => {
+                    setAppState({ errorMessage: error.message });
+                  });
+              }}
+              className="library-actions--export"
+            >
+              {selectedItems.length > 0 && (
+                <span className="library-actions-counter">
+                  {selectedItems.length}
+                </span>
+              )}
+            </ToolButton>
+            <ToolButton
+              key="reset"
+              type="button"
+              title={resetLabel}
+              aria-label={resetLabel}
+              icon={trash}
+              onClick={() => setShowRemoveLibAlert(true)}
+              className="library-actions--remove"
+            >
+              {selectedItems.length > 0 && (
+                <span className="library-actions-counter">
+                  {selectedItems.length}
+                </span>
+              )}
+            </ToolButton>
+          </>
+        )}
+        {itemsSelected && !isPublished && (
+          <Tooltip label={t("hints.publishLibrary")}>
+            <ToolButton
+              type="button"
+              aria-label={t("buttons.publishLibrary")}
+              label={t("buttons.publishLibrary")}
+              icon={publishIcon}
+              className="library-actions--publish"
+              onClick={onPublish}
+            >
+              {!isMobile && <label>{t("buttons.publishLibrary")}</label>}
+              {selectedItems.length > 0 && (
+                <span className="library-actions-counter">
+                  {selectedItems.length}
+                </span>
+              )}
+            </ToolButton>
+          </Tooltip>
+        )}
+      </div>
+    );
+  };
+
+  const CELLS_PER_ROW = isMobile ? 4 : 6;
+
+  const referrer =
+    libraryReturnUrl || window.location.origin + window.location.pathname;
+  const isPublished = selectedItems.some(
+    (id) => libraryItems.find((item) => item.id === id)?.status === "published",
+  );
+
+  const createLibraryItemCompo = (params: {
+    item:
+      | LibraryItem
+      | /* pending library item */ {
+          id: null;
+          elements: readonly NonDeleted<ExcalidrawElement>[];
+        }
+      | null;
+    onClick?: () => void;
+    key: string;
+  }) => {
+    return (
+      <Stack.Col key={params.key}>
+        <LibraryUnit
+          elements={params.item?.elements}
+          files={files}
+          isPending={!params.item?.id && !!params.item?.elements}
+          onClick={params.onClick || (() => {})}
+          id={params.item?.id || null}
+          selected={!!params.item?.id && selectedItems.includes(params.item.id)}
+          onToggle={() => {
+            if (params.item?.id) {
+              onToggle(params.item.id);
+            }
+          }}
+        />
+      </Stack.Col>
+    );
+  };
+
+  const renderLibrarySection = (
+    items: (
+      | LibraryItem
+      | /* pending library item */ {
+          id: null;
+          elements: readonly NonDeleted<ExcalidrawElement>[];
+        }
+    )[],
+  ) => {
+    const _items = items.map((item) => {
+      if (item.id) {
+        return createLibraryItemCompo({
+          item,
+          onClick: () => onInsertShape(item.elements),
+          key: item.id,
+        });
+      }
+      return createLibraryItemCompo({
+        key: "__pending__item__",
+        item,
+        onClick: () => onAddToLibrary(pendingElements),
+      });
+    });
+
+    // ensure we render all empty cells if no items are present
+    let rows = chunk(_items, CELLS_PER_ROW);
+    if (!rows.length) {
+      rows = [[]];
+    }
+
+    return rows.map((rowItems, index, rows) => {
+      if (index === rows.length - 1) {
+        // pad row with empty cells
+        rowItems = rowItems.concat(
+          new Array(CELLS_PER_ROW - rowItems.length)
+            .fill(null)
+            .map((_, index) => {
+              return createLibraryItemCompo({
+                key: `empty_${index}`,
+                item: null,
+              });
+            }),
+        );
+      }
+      return (
+        <Stack.Row align="center" gap={1} key={index}>
+          {rowItems}
+        </Stack.Row>
+      );
+    });
+  };
+
+  const publishedItems = libraryItems.filter(
+    (item) => item.status === "published",
+  );
+  const unpublishedItems = [
+    // append pending library item
+    ...(pendingElements.length
+      ? [{ id: null, elements: pendingElements }]
+      : []),
+    ...libraryItems.filter((item) => item.status !== "published"),
+  ];
+
+  return (
+    <div className="library-menu-items-container">
+      {showRemoveLibAlert && renderRemoveLibAlert()}
+      <div className="layer-ui__library-header" key="library-header">
+        {renderLibraryActions()}
+        <a
+          href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
+            window.name || "_blank"
+          }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
+          target="_excalidraw_libraries"
+        >
+          {t("labels.libraries")}
+        </a>
+      </div>
+      <Stack.Col
+        className="library-menu-items-container__items"
+        align="start"
+        gap={1}
+      >
+        <>
+          <div className="separator">{t("labels.personalLib")}</div>
+          {renderLibrarySection(unpublishedItems)}
+        </>
+
+        <>
+          <div className="separator">{t("labels.excalidrawLib")} </div>
+
+          {renderLibrarySection(publishedItems)}
+        </>
+      </Stack.Col>
+    </div>
+  );
+};
+
+export default LibraryMenuItems;

+ 59 - 15
src/components/LibraryUnit.scss

@@ -1,3 +1,5 @@
+@import "../css/variables.module";
+
 .excalidraw {
   .library-unit {
     align-items: center;
@@ -7,6 +9,20 @@
     position: relative;
     width: 63px;
     height: 63px; // match width
+
+    &--hover {
+      box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
+      border-color: $oc-blue-5;
+    }
+
+    &--selected {
+      box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
+      border-color: $oc-blue-8;
+    }
+  }
+
+  &.theme--dark .library-unit {
+    border-color: rgb(48, 48, 48);
   }
 
   .library-unit__dragger {
@@ -22,9 +38,9 @@
     max-width: 100%;
   }
 
-  .library-unit__removeFromLibrary,
-  .library-unit__removeFromLibrary:hover,
-  .library-unit__removeFromLibrary:active {
+  .library-unit__checkbox-container,
+  .library-unit__checkbox-container:hover,
+  .library-unit__checkbox-container:active {
     align-items: center;
     background: none;
     border: none;
@@ -32,10 +48,35 @@
     display: flex;
     justify-content: center;
     margin: 0;
-    padding: 0;
+    padding: 0.5rem;
     position: absolute;
-    right: 5px;
-    top: 5px;
+    left: 2rem;
+    bottom: 2rem;
+    cursor: pointer;
+
+    input {
+      cursor: pointer;
+    }
+  }
+
+  .library-unit__checkbox {
+    position: absolute;
+    left: 2.3rem;
+    bottom: 2.3rem;
+
+    .Checkbox-box {
+      width: 13px;
+      height: 13px;
+      border-radius: 2px;
+      margin: 0.5em 0.5em 0.2em 0.2em;
+      background-color: $oc-blue-1;
+    }
+
+    &.Checkbox:hover {
+      .Checkbox-box {
+        background-color: $oc-blue-2;
+      }
+    }
   }
 
   .library-unit__removeFromLibrary > svg {
@@ -43,29 +84,32 @@
     width: 16px;
   }
 
-  .library-unit__pulse {
+  .library-unit__adder {
     transform: scale(1);
-    animation: library-unit__pulse-animation 1s ease-in infinite;
+    animation: library-unit__adder-animation 1s ease-in infinite;
   }
 
   .library-unit__adder {
     position: absolute;
-    left: 50%;
-    top: 50%;
-    width: 20px;
-    height: 20px;
+    left: 40%;
+    top: 40%;
+    width: 2rem;
+    height: 2rem;
     margin-left: -10px;
     margin-top: -10px;
     pointer-events: none;
   }
+  .library-unit--hover .library-unit__adder {
+    color: $oc-blue-7;
+  }
 
   .library-unit__active {
     cursor: pointer;
   }
 
-  @keyframes library-unit__pulse-animation {
+  @keyframes library-unit__adder-animation {
     0% {
-      transform: scale(0.95);
+      transform: scale(0.85);
     }
 
     50% {
@@ -73,7 +117,7 @@
     }
 
     100% {
-      transform: scale(0.95);
+      transform: scale(0.85);
     }
   }
 }

+ 25 - 24
src/components/LibraryUnit.tsx

@@ -1,13 +1,12 @@
 import clsx from "clsx";
 import oc from "open-color";
 import { useEffect, useRef, useState } from "react";
-import { close } from "../components/icons";
 import { MIME_TYPES } from "../constants";
-import { t } from "../i18n";
 import { useIsMobile } from "../components/App";
 import { exportToSvg } from "../scene/export";
 import { BinaryFiles, LibraryItem } from "../types";
 import "./LibraryUnit.scss";
+import { CheckboxItem } from "./CheckboxItem";
 
 // fa-plus
 const PLUS_ICON = (
@@ -20,17 +19,21 @@ const PLUS_ICON = (
 );
 
 export const LibraryUnit = ({
+  id,
   elements,
   files,
-  pendingElements,
-  onRemoveFromLibrary,
+  isPending,
   onClick,
+  selected,
+  onToggle,
 }: {
-  elements?: LibraryItem;
+  id: LibraryItem["id"] | /** for pending item */ null;
+  elements?: LibraryItem["elements"];
   files: BinaryFiles;
-  pendingElements?: LibraryItem;
-  onRemoveFromLibrary: () => void;
+  isPending?: boolean;
   onClick: () => void;
+  selected: boolean;
+  onToggle: (id: string) => void;
 }) => {
   const ref = useRef<HTMLDivElement | null>(null);
   useEffect(() => {
@@ -40,12 +43,11 @@ export const LibraryUnit = ({
     }
 
     (async () => {
-      const elementsToRender = elements || pendingElements;
-      if (!elementsToRender) {
+      if (!elements) {
         return;
       }
       const svg = await exportToSvg(
-        elementsToRender,
+        elements,
         {
           exportBackground: false,
           viewBackgroundColor: oc.white,
@@ -58,30 +60,31 @@ export const LibraryUnit = ({
     return () => {
       node.innerHTML = "";
     };
-  }, [elements, pendingElements, files]);
+  }, [elements, files]);
 
   const [isHovered, setIsHovered] = useState(false);
   const isMobile = useIsMobile();
-
-  const adder = (isHovered || isMobile) && pendingElements && (
+  const adder = isPending && (
     <div className="library-unit__adder">{PLUS_ICON}</div>
   );
 
   return (
     <div
       className={clsx("library-unit", {
-        "library-unit__active": elements || pendingElements,
+        "library-unit__active": elements,
+        "library-unit--hover": elements && isHovered,
+        "library-unit--selected": selected,
       })}
       onMouseEnter={() => setIsHovered(true)}
       onMouseLeave={() => setIsHovered(false)}
     >
       <div
         className={clsx("library-unit__dragger", {
-          "library-unit__pulse": !!pendingElements,
+          "library-unit__pulse": !!isPending,
         })}
         ref={ref}
         draggable={!!elements}
-        onClick={!!elements || !!pendingElements ? onClick : undefined}
+        onClick={!!elements || !!isPending ? onClick : undefined}
         onDragStart={(event) => {
           setIsHovered(false);
           event.dataTransfer.setData(
@@ -91,14 +94,12 @@ export const LibraryUnit = ({
         }}
       />
       {adder}
-      {elements && (isHovered || isMobile) && (
-        <button
-          className="library-unit__removeFromLibrary"
-          aria-label={t("labels.removeFromLibrary")}
-          onClick={onRemoveFromLibrary}
-        >
-          {close}
-        </button>
+      {id && elements && (isHovered || isMobile || selected) && (
+        <CheckboxItem
+          checked={selected}
+          onChange={() => onToggle(id)}
+          className="library-unit__checkbox"
+        />
       )}
     </div>
   );

+ 6 - 2
src/components/Modal.tsx

@@ -15,8 +15,9 @@ export const Modal = (props: {
   onCloseRequest(): void;
   labelledBy: string;
   theme?: AppState["theme"];
+  closeOnClickOutside?: boolean;
 }) => {
-  const { theme = THEME.LIGHT } = props;
+  const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
   const modalRoot = useBodyRoot(theme);
 
   if (!modalRoot) {
@@ -39,7 +40,10 @@ export const Modal = (props: {
       onKeyDown={handleKeydown}
       aria-labelledby={props.labelledBy}
     >
-      <div className="Modal__background" onClick={props.onCloseRequest}></div>
+      <div
+        className="Modal__background"
+        onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
+      ></div>
       <div
         className="Modal__content"
         style={{ "--max-width": `${props.maxWidth}px` }}

+ 1 - 1
src/components/PasteChartDialog.tsx

@@ -82,7 +82,7 @@ export const PasteChartDialog = ({
   appState: AppState;
   onClose: () => void;
   setAppState: React.Component<any, AppState>["setState"];
-  onInsertChart: (elements: LibraryItem) => void;
+  onInsertChart: (elements: LibraryItem["elements"]) => void;
 }) => {
   const handleClose = React.useCallback(() => {
     if (onClose) {

+ 1 - 0
src/components/ProjectName.tsx

@@ -42,6 +42,7 @@ export const ProjectName = (props: Props) => {
       </label>
       {props.isNameEditable ? (
         <input
+          type="text"
           className="TextInput"
           onBlur={handleBlur}
           onKeyDown={handleKeyDown}

+ 92 - 0
src/components/PublishLibrary.scss

@@ -0,0 +1,92 @@
+@import "../css/variables.module";
+
+.excalidraw {
+  .publish-library {
+    &__fields {
+      display: flex;
+      flex-direction: column;
+
+      label {
+        padding: 1em;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        span {
+          font-weight: 500;
+          font-size: 1rem;
+          color: $oc-gray-6;
+        }
+        input,
+        textarea {
+          width: 70%;
+          padding: 0.6em;
+          font-family: var(--ui-font);
+        }
+
+        .required {
+          color: $oc-red-8;
+          margin: 0.2rem;
+        }
+      }
+    }
+
+    &__buttons {
+      display: flex;
+      padding: 0.2rem 0;
+      justify-content: flex-end;
+
+      .ToolIcon__icon {
+        min-width: 2.5rem;
+        width: auto;
+        font-size: 1rem;
+      }
+
+      .ToolIcon_type_button {
+        margin-left: 1rem;
+        padding: 0 0.5rem;
+      }
+
+      &--confirm.ToolIcon_type_button {
+        background-color: $oc-blue-6;
+
+        &:hover {
+          background-color: $oc-blue-8;
+        }
+      }
+
+      &--cancel.ToolIcon_type_button {
+        background-color: $oc-gray-5;
+        &:hover {
+          background-color: $oc-gray-6;
+        }
+      }
+
+      .ToolIcon__icon {
+        color: $oc-white;
+        .Spinner {
+          --spinner-color: #fff;
+          svg {
+            padding: 0.5rem;
+          }
+        }
+      }
+    }
+
+    .selected-library-items {
+      display: flex;
+      padding: 0 0.8rem;
+      flex-wrap: wrap;
+
+      .single-library-item-wrapper {
+        width: 9rem;
+      }
+    }
+
+    &-note {
+      padding: 1em;
+      font-style: italic;
+      font-size: 14px;
+      display: block;
+    }
+  }
+}

+ 430 - 0
src/components/PublishLibrary.tsx

@@ -0,0 +1,430 @@
+import { ReactNode, useCallback, useEffect, useState } from "react";
+import oc from "open-color";
+
+import { Dialog } from "./Dialog";
+import { t } from "../i18n";
+
+import { ToolButton } from "./ToolButton";
+
+import { AppState, LibraryItems, LibraryItem } from "../types";
+import { exportToBlob } from "../packages/utils";
+import { EXPORT_DATA_TYPES, EXPORT_SOURCE } from "../constants";
+import { ExportedLibraryData } from "../data/types";
+
+import "./PublishLibrary.scss";
+import { ExcalidrawElement } from "../element/types";
+import { newElement } from "../element";
+import { mutateElement } from "../element/mutateElement";
+import { getCommonBoundingBox } from "../element/bounds";
+import SingleLibraryItem from "./SingleLibraryItem";
+
+interface PublishLibraryDataParams {
+  authorName: string;
+  githubHandle: string;
+  name: string;
+  description: string;
+  twitterHandle: string;
+  website: string;
+}
+
+const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
+
+const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
+  try {
+    localStorage.setItem(
+      LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
+      JSON.stringify(data),
+    );
+  } catch (error: any) {
+    // Unable to access window.localStorage
+    console.error(error);
+  }
+};
+
+const importPublishLibDataFromStorage = () => {
+  try {
+    const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
+    if (data) {
+      return JSON.parse(data);
+    }
+  } catch (error: any) {
+    // Unable to access localStorage
+    console.error(error);
+  }
+
+  return null;
+};
+
+const PublishLibrary = ({
+  onClose,
+  libraryItems,
+  appState,
+  onSuccess,
+  onError,
+  updateItemsInStorage,
+  onRemove,
+}: {
+  onClose: () => void;
+  libraryItems: LibraryItems;
+  appState: AppState;
+  onSuccess: (data: {
+    url: string;
+    authorName: string;
+    items: LibraryItems;
+  }) => void;
+
+  onError: (error: Error) => void;
+  updateItemsInStorage: (items: LibraryItems) => void;
+  onRemove: (id: string) => void;
+}) => {
+  const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
+    authorName: "",
+    githubHandle: "",
+    name: "",
+    description: "",
+    twitterHandle: "",
+    website: "",
+  });
+
+  const [isSubmitting, setIsSubmitting] = useState(false);
+
+  useEffect(() => {
+    const data = importPublishLibDataFromStorage();
+    if (data) {
+      setLibraryData(data);
+    }
+  }, []);
+
+  const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
+    libraryItems.slice(),
+  );
+
+  useEffect(() => {
+    setClonedLibItems(libraryItems.slice());
+  }, [libraryItems]);
+
+  const onInputChange = (event: any) => {
+    setLibraryData({
+      ...libraryData,
+      [event.target.name]: event.target.value,
+    });
+  };
+
+  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
+    event.preventDefault();
+    setIsSubmitting(true);
+    const erroredLibItems: LibraryItem[] = [];
+    let isError = false;
+    clonedLibItems.forEach((libItem) => {
+      let error = "";
+      if (!libItem.name) {
+        error = t("publishDialog.errors.required");
+        isError = true;
+      } else if (!/^[a-zA-Z\s]+$/i.test(libItem.name)) {
+        error = t("publishDialog.errors.letter&Spaces");
+        isError = true;
+      }
+      erroredLibItems.push({ ...libItem, error });
+    });
+
+    if (isError) {
+      setClonedLibItems(erroredLibItems);
+      setIsSubmitting(false);
+      return;
+    }
+    const elements: ExcalidrawElement[] = [];
+    const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
+    clonedLibItems.forEach((libItem) => {
+      const boundingBox = getCommonBoundingBox(libItem.elements);
+      const width = boundingBox.maxX - boundingBox.minX + 30;
+      const height = boundingBox.maxY - boundingBox.minY + 30;
+      const offset = {
+        x: prevBoundingBox.maxX - boundingBox.minX,
+        y: prevBoundingBox.maxY - boundingBox.minY,
+      };
+
+      const itemsWithUpdatedCoords = libItem.elements.map((element) => {
+        element = mutateElement(element, {
+          x: element.x + offset.x + 15,
+          y: element.y + offset.y + 15,
+        });
+        return element;
+      });
+      const items = [
+        ...itemsWithUpdatedCoords,
+        newElement({
+          type: "rectangle",
+          width,
+          height,
+          x: prevBoundingBox.maxX,
+          y: prevBoundingBox.maxY,
+          strokeColor: "#ced4da",
+          backgroundColor: "transparent",
+          strokeStyle: "solid",
+          opacity: 100,
+          roughness: 0,
+          strokeSharpness: "sharp",
+          fillStyle: "solid",
+          strokeWidth: 1,
+        }),
+      ];
+      elements.push(...items);
+      prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30;
+    });
+    const png = await exportToBlob({
+      elements,
+      mimeType: "image/png",
+      appState: {
+        ...appState,
+        viewBackgroundColor: oc.white,
+        exportBackground: true,
+      },
+      files: null,
+    });
+
+    const libContent: ExportedLibraryData = {
+      type: EXPORT_DATA_TYPES.excalidrawLibrary,
+      version: 2,
+      source: EXPORT_SOURCE,
+      libraryItems: clonedLibItems,
+    };
+    const content = JSON.stringify(libContent, null, 2);
+    const lib = new Blob([content], { type: "application/json" });
+
+    const formData = new FormData();
+    formData.append("excalidrawLib", lib);
+    formData.append("excalidrawPng", png!);
+    formData.append("title", libraryData.name);
+    formData.append("authorName", libraryData.authorName);
+    formData.append("githubHandle", libraryData.githubHandle);
+    formData.append("name", libraryData.name);
+    formData.append("description", libraryData.description);
+    formData.append("twitterHandle", libraryData.twitterHandle);
+    formData.append("website", libraryData.website);
+
+    fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
+      method: "post",
+      body: formData,
+    })
+      .then(
+        (response) => {
+          if (response.ok) {
+            return response.json().then(({ url }) => {
+              // flush data from local storage
+              localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
+              onSuccess({
+                url,
+                authorName: libraryData.authorName,
+                items: clonedLibItems,
+              });
+            });
+          }
+          return response
+            .json()
+            .catch(() => {
+              throw new Error(response.statusText || "something went wrong");
+            })
+            .then((error) => {
+              throw new Error(
+                error.message || response.statusText || "something went wrong",
+              );
+            });
+        },
+        (err) => {
+          console.error(err);
+          onError(err);
+          setIsSubmitting(false);
+        },
+      )
+      .catch((err) => {
+        console.error(err);
+        onError(err);
+        setIsSubmitting(false);
+      });
+  };
+
+  const renderLibraryItems = () => {
+    const items: ReactNode[] = [];
+    clonedLibItems.forEach((libItem, index) => {
+      items.push(
+        <div className="single-library-item-wrapper" key={index}>
+          <SingleLibraryItem
+            libItem={libItem}
+            appState={appState}
+            index={index}
+            onChange={(val, index) => {
+              const items = clonedLibItems.slice();
+              items[index].name = val;
+              setClonedLibItems(items);
+            }}
+            onRemove={onRemove}
+          />
+        </div>,
+      );
+    });
+    return <div className="selected-library-items">{items}</div>;
+  };
+
+  const onDialogClose = useCallback(() => {
+    updateItemsInStorage(clonedLibItems);
+    savePublishLibDataToStorage(libraryData);
+    onClose();
+  }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
+
+  const shouldRenderForm = !!libraryItems.length;
+  return (
+    <Dialog
+      onCloseRequest={onDialogClose}
+      title={t("publishDialog.title")}
+      className="publish-library"
+    >
+      {shouldRenderForm ? (
+        <form onSubmit={onSubmit}>
+          <div className="publish-library-note">
+            {t("publishDialog.noteDescription.pre")}
+            <a
+              href="https://libraries.excalidraw.com"
+              target="_blank"
+              rel="noopener noreferrer"
+            >
+              {t("publishDialog.noteDescription.link")}
+            </a>{" "}
+            {t("publishDialog.noteDescription.post")}
+          </div>
+          <span className="publish-library-note">
+            {t("publishDialog.noteGuidelines.pre")}
+            <a
+              href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
+              target="_blank"
+              rel="noopener noreferrer"
+            >
+              {t("publishDialog.noteGuidelines.link")}
+            </a>
+            {t("publishDialog.noteGuidelines.post")}
+          </span>
+
+          <div className="publish-library-note">
+            {t("publishDialog.noteItems")}
+          </div>
+          {renderLibraryItems()}
+          <div className="publish-library__fields">
+            <label>
+              <div>
+                <span>{t("publishDialog.libraryName")}</span>
+                <span aria-hidden="true" className="required">
+                  *
+                </span>
+              </div>
+              <input
+                type="text"
+                name="name"
+                required
+                value={libraryData.name}
+                onChange={onInputChange}
+                placeholder={t("publishDialog.placeholder.libraryName")}
+              />
+            </label>
+            <label style={{ alignItems: "flex-start" }}>
+              <div>
+                <span>{t("publishDialog.libraryDesc")}</span>
+                <span aria-hidden="true" className="required">
+                  *
+                </span>
+              </div>
+              <textarea
+                name="description"
+                rows={4}
+                required
+                value={libraryData.description}
+                onChange={onInputChange}
+                placeholder={t("publishDialog.placeholder.libraryDesc")}
+              />
+            </label>
+            <label>
+              <div>
+                <span>{t("publishDialog.authorName")}</span>
+                <span aria-hidden="true" className="required">
+                  *
+                </span>
+              </div>
+              <input
+                type="text"
+                name="authorName"
+                required
+                value={libraryData.authorName}
+                onChange={onInputChange}
+                placeholder={t("publishDialog.placeholder.authorName")}
+              />
+            </label>
+            <label>
+              <span>{t("publishDialog.githubUsername")}</span>
+              <input
+                type="text"
+                name="githubHandle"
+                value={libraryData.githubHandle}
+                onChange={onInputChange}
+                placeholder={t("publishDialog.placeholder.githubHandle")}
+              />
+            </label>
+            <label>
+              <span>{t("publishDialog.twitterUsername")}</span>
+              <input
+                type="text"
+                name="twitterHandle"
+                value={libraryData.twitterHandle}
+                onChange={onInputChange}
+                placeholder={t("publishDialog.placeholder.twitterHandle")}
+              />
+            </label>
+            <label>
+              <span>{t("publishDialog.website")}</span>
+              <input
+                type="text"
+                name="website"
+                value={libraryData.website}
+                onChange={onInputChange}
+                placeholder={t("publishDialog.placeholder.website")}
+              />
+            </label>
+            <span className="publish-library-note">
+              {t("publishDialog.noteLicense.pre")}
+              <a
+                href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
+                {t("publishDialog.noteLicense.link")}
+              </a>
+              {t("publishDialog.noteLicense.post")}
+            </span>
+          </div>
+          <div className="publish-library__buttons">
+            <ToolButton
+              type="button"
+              title={t("buttons.cancel")}
+              aria-label={t("buttons.cancel")}
+              label={t("buttons.cancel")}
+              onClick={onDialogClose}
+              data-testid="cancel-clear-canvas-button"
+              className="publish-library__buttons--cancel"
+            />
+            <ToolButton
+              type="submit"
+              title={t("buttons.submit")}
+              aria-label={t("buttons.submit")}
+              label={t("buttons.submit")}
+              className="publish-library__buttons--confirm"
+              isLoading={isSubmitting}
+            />
+          </div>
+        </form>
+      ) : (
+        <p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
+          {t("publishDialog.atleastOneLibItem")}
+        </p>
+      )}
+    </Dialog>
+  );
+};
+
+export default PublishLibrary;

+ 66 - 0
src/components/SingleLibraryItem.scss

@@ -0,0 +1,66 @@
+@import "../css/variables.module";
+
+.excalidraw {
+  .single-library-item {
+    position: relative;
+    &__svg {
+      width: 7.5rem;
+      height: 7.5rem;
+      border: 1px solid var(--button-gray-2);
+      margin: 0.3rem;
+      svg {
+        width: 100%;
+        height: 100%;
+      }
+    }
+
+    .ToolIcon__icon {
+      background-color: $oc-white;
+      width: auto;
+      height: auto;
+      margin: 0 0.5rem;
+    }
+    .ToolIcon,
+    .ToolIcon_type_button:hover {
+      background-color: white;
+    }
+    .required,
+    .error {
+      color: $oc-red-8;
+      font-weight: bold;
+      font-size: 1rem;
+      margin: 0.2rem;
+    }
+    .error {
+      font-weight: 500;
+      margin: 0;
+      padding: 0.3em 0;
+    }
+
+    &--remove {
+      position: absolute;
+      top: 0.2rem;
+      right: 1.3rem;
+
+      .ToolIcon__icon {
+        margin: 0;
+      }
+      .ToolIcon__icon {
+        background-color: $oc-red-6;
+        &:hover {
+          background-color: $oc-red-7;
+        }
+        &:active {
+          background-color: $oc-red-8;
+        }
+      }
+      svg {
+        color: $oc-white;
+        padding: 0.26rem;
+        border-radius: 0.3em;
+        width: 1rem;
+        height: 1rem;
+      }
+    }
+  }
+}

+ 99 - 0
src/components/SingleLibraryItem.tsx

@@ -0,0 +1,99 @@
+import oc from "open-color";
+import { useEffect, useRef } from "react";
+import { t } from "../i18n";
+import { exportToSvg } from "../packages/utils";
+import { AppState, LibraryItem } from "../types";
+import { close } from "./icons";
+
+import "./SingleLibraryItem.scss";
+import { ToolButton } from "./ToolButton";
+
+const SingleLibraryItem = ({
+  libItem,
+  appState,
+  index,
+  onChange,
+  onRemove,
+}: {
+  libItem: LibraryItem;
+  appState: AppState;
+  index: number;
+  onChange: (val: string, index: number) => void;
+  onRemove: (id: string) => void;
+}) => {
+  const svgRef = useRef<HTMLDivElement | null>(null);
+  const inputRef = useRef<HTMLInputElement | null>(null);
+
+  useEffect(() => {
+    const node = svgRef.current;
+    if (!node) {
+      return;
+    }
+    (async () => {
+      const svg = await exportToSvg({
+        elements: libItem.elements,
+        appState: {
+          ...appState,
+          viewBackgroundColor: oc.white,
+          exportBackground: true,
+        },
+        files: null,
+      });
+      node.innerHTML = svg.outerHTML;
+    })();
+  }, [libItem.elements, appState]);
+
+  return (
+    <div className="single-library-item">
+      <div ref={svgRef} className="single-library-item__svg" />
+      <ToolButton
+        aria-label={t("buttons.remove")}
+        type="button"
+        icon={close}
+        className="single-library-item--remove"
+        onClick={onRemove.bind(null, libItem.id)}
+        title={t("buttons.remove")}
+      />
+      <div
+        style={{
+          display: "flex",
+          margin: "0.8rem 0.3rem",
+          width: "100%",
+          fontSize: "14px",
+          fontWeight: 500,
+          flexDirection: "column",
+        }}
+      >
+        <label
+          style={{
+            display: "flex",
+            justifyContent: "space-between",
+            flexDirection: "column",
+          }}
+        >
+          <div style={{ padding: "0.5em 0" }}>
+            <span style={{ fontWeight: 500, color: oc.gray[6] }}>
+              {t("publishDialog.itemName")}
+            </span>
+            <span aria-hidden="true" className="required">
+              *
+            </span>
+          </div>
+          <input
+            type="text"
+            ref={inputRef}
+            style={{ width: "80%", padding: "0.2rem" }}
+            defaultValue={libItem.name}
+            placeholder="Item name"
+            onChange={(event) => {
+              onChange(event.target.value, index);
+            }}
+          />
+        </label>
+        <span className="error">{libItem.error}</span>
+      </div>
+    </div>
+  );
+};
+
+export default SingleLibraryItem;

+ 0 - 18
src/components/TextInput.scss

@@ -2,24 +2,6 @@
 
 .excalidraw {
   .TextInput {
-    color: var(--text-primary-color);
     display: inline-block;
-    border: 1.5px solid var(--button-gray-1);
-    line-height: 1;
-    padding: 0.75rem;
-    white-space: nowrap;
-    border-radius: var(--space-factor);
-    background-color: var(--input-bg-color);
-
-    &:not(:focus) {
-      &:hover {
-        background-color: var(--input-hover-bg-color);
-      }
-    }
-
-    &:focus {
-      outline: none;
-      box-shadow: 0 0 0 2px var(--focus-highlight-color);
-    }
   }
 }

+ 17 - 3
src/components/ToolButton.tsx

@@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
   visible?: boolean;
   selected?: boolean;
   className?: string;
+  isLoading?: boolean;
 };
 
 type ToolButtonProps =
@@ -34,6 +35,11 @@ type ToolButtonProps =
       onClick?(event: React.MouseEvent): void;
     })
   | (ToolButtonBaseProps & {
+      type: "submit";
+      children?: React.ReactNode;
+      onClick?(event: React.MouseEvent): void;
+    })
+  | (ToolButtonBaseProps & {
       type: "icon";
       children?: React.ReactNode;
       onClick?(): void;
@@ -82,7 +88,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
 
   const lastPointerTypeRef = useRef<PointerType | null>(null);
 
-  if (props.type === "button" || props.type === "icon") {
+  if (
+    props.type === "button" ||
+    props.type === "icon" ||
+    props.type === "submit"
+  ) {
+    const type = (props.type === "icon" ? "button" : props.type) as
+      | "button"
+      | "submit";
     return (
       <button
         className={clsx(
@@ -102,10 +115,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
         hidden={props.hidden}
         title={props.title}
         aria-label={props["aria-label"]}
-        type="button"
+        type={type}
         onClick={onClick}
         ref={innerRef}
-        disabled={isLoading}
+        disabled={isLoading || props.isLoading}
       >
         {(props.icon || props.label) && (
           <div className="ToolIcon__icon" aria-hidden="true">
@@ -115,6 +128,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
                 {props.keyBindingLabel}
               </span>
             )}
+            {props.isLoading && <Spinner />}
           </div>
         )}
         {props.showAriaLabel && (

+ 9 - 0
src/components/icons.tsx

@@ -85,6 +85,7 @@ export const clipboard = createIcon(
 
 export const trash = createIcon(
   "M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z",
+
   { width: 448, height: 512 },
 );
 
@@ -882,3 +883,11 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
     { width: 448, height: 512 },
   ),
 );
+
+export const publishIcon = createIcon(
+  <path
+    d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
+    fill="currentColor"
+  />,
+  { width: 640, height: 512 },
+);

+ 21 - 0
src/css/styles.scss

@@ -517,6 +517,27 @@
     }
   }
 
+  input[type="text"],
+  textarea:not(.excalidraw-wysiwyg) {
+    color: var(--text-primary-color);
+    border: 1.5px solid var(--input-border-color);
+    padding: 0.75rem;
+    white-space: nowrap;
+    border-radius: var(--space-factor);
+    background-color: var(--input-bg-color);
+
+    &:not(:focus) {
+      &:hover {
+        background-color: var(--input-hover-bg-color);
+      }
+    }
+
+    &:focus {
+      outline: none;
+      box-shadow: 0 0 0 2px var(--focus-highlight-color);
+    }
+  }
+
   @media print {
     .App-bottom-bar,
     .FixedSideContainer,

+ 2 - 1
src/css/theme.scss

@@ -16,7 +16,7 @@
   --icon-green-fill-color: #{$oc-green-9};
   --default-bg-color: #{$oc-white};
   --input-bg-color: #{$oc-white};
-  --input-border-color: #{$oc-gray-3};
+  --input-border-color: #{$oc-gray-4};
   --input-hover-bg-color: #{$oc-gray-1};
   --input-label-color: #{$oc-gray-7};
   --island-bg-color: rgba(255, 255, 255, 0.96);
@@ -64,6 +64,7 @@
     --input-label-color: #{$oc-gray-2};
     --island-bg-color: rgba(30, 30, 30, 0.98);
     --keybinding-color: #{$oc-gray-6};
+    --link-color: #{$oc-blue-4};
     --overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
     --popup-bg-color: #2c2c2c;
     --popup-secondary-bg-color: #222;

+ 5 - 6
src/data/json.ts

@@ -3,7 +3,7 @@ import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
 import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
 import { clearElementsForDatabase, clearElementsForExport } from "../element";
 import { ExcalidrawElement } from "../element/types";
-import { AppState, BinaryFiles } from "../types";
+import { AppState, BinaryFiles, LibraryItems } from "../types";
 import { isImageFileHandle, loadFromBlob } from "./blob";
 
 import {
@@ -114,17 +114,16 @@ export const isValidLibrary = (json: any) => {
     typeof json === "object" &&
     json &&
     json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
-    json.version === 1
+    (json.version === 1 || json.version === 2)
   );
 };
 
-export const saveLibraryAsJSON = async (library: Library) => {
-  const libraryItems = await library.loadLibrary();
+export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
   const data: ExportedLibraryData = {
     type: EXPORT_DATA_TYPES.excalidrawLibrary,
-    version: 1,
+    version: 2,
     source: EXPORT_SOURCE,
-    library: libraryItems,
+    libraryItems,
   };
   const serialized = JSON.stringify(data, null, 2);
   await fileSave(

+ 22 - 15
src/data/library.ts

@@ -1,6 +1,6 @@
 import { loadLibraryFromBlob } from "./blob";
 import { LibraryItems, LibraryItem } from "../types";
-import { restoreElements } from "./restore";
+import { restoreElements, restoreLibraryItems } from "./restore";
 import { getNonDeletedElements } from "../element";
 import type App from "../components/App";
 
@@ -18,14 +18,16 @@ class Library {
   };
 
   restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
-    const elements = getNonDeletedElements(restoreElements(libraryItem, null));
-    return elements.length ? elements : null;
+    const elements = getNonDeletedElements(
+      restoreElements(libraryItem.elements, null),
+    );
+    return elements.length ? { ...libraryItem, elements } : null;
   };
 
   /** imports library (currently merges, removing duplicates) */
-  async importLibrary(blob: Blob) {
+  async importLibrary(blob: Blob, defaultStatus = "unpublished") {
     const libraryFile = await loadLibraryFromBlob(blob);
-    if (!libraryFile || !libraryFile.library) {
+    if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
       return;
     }
 
@@ -37,17 +39,17 @@ class Library {
       targetLibraryItem: LibraryItem,
     ) => {
       return !existingLibraryItems.find((libraryItem) => {
-        if (libraryItem.length !== targetLibraryItem.length) {
+        if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
           return false;
         }
 
         // detect z-index difference by checking the excalidraw elements
         // are in order
-        return libraryItem.every((libItemExcalidrawItem, idx) => {
+        return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
           return (
-            libItemExcalidrawItem.id === targetLibraryItem[idx].id &&
+            libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
             libItemExcalidrawItem.versionNonce ===
-              targetLibraryItem[idx].versionNonce
+              targetLibraryItem.elements[idx].versionNonce
           );
         });
       });
@@ -55,15 +57,20 @@ class Library {
 
     const existingLibraryItems = await this.loadLibrary();
 
-    const filtered = libraryFile.library!.reduce((acc, libraryItem) => {
-      const restoredItem = this.restoreLibraryItem(libraryItem);
+    const library = libraryFile.libraryItems || libraryFile.library || [];
+    const restoredLibItems = restoreLibraryItems(
+      library,
+      defaultStatus as "published" | "unpublished",
+    );
+    const filteredItems = [];
+    for (const item of restoredLibItems) {
+      const restoredItem = this.restoreLibraryItem(item as LibraryItem);
       if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
-        acc.push(restoredItem);
+        filteredItems.push(restoredItem);
       }
-      return acc;
-    }, [] as Mutable<LibraryItems>);
+    }
 
-    await this.saveLibrary([...existingLibraryItems, ...filtered]);
+    await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
   }
 
   loadLibrary = (): Promise<LibraryItems> => {

+ 33 - 1
src/data/restore.ts

@@ -3,7 +3,12 @@ import {
   ExcalidrawSelectionElement,
   FontFamilyValues,
 } from "../element/types";
-import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
+import {
+  AppState,
+  BinaryFiles,
+  LibraryItem,
+  NormalizedZoomValue,
+} from "../types";
 import { ImportedDataState } from "./types";
 import {
   getElementMap,
@@ -273,3 +278,30 @@ export const restore = (
     files: data?.files || {},
   };
 };
+
+export const restoreLibraryItems = (
+  libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
+  defaultStatus: LibraryItem["status"],
+) => {
+  const restoredItems: LibraryItem[] = [];
+  for (const item of libraryItems) {
+    // migrate older libraries
+    if (Array.isArray(item)) {
+      restoredItems.push({
+        status: defaultStatus,
+        elements: item,
+        id: randomId(),
+        created: Date.now(),
+      });
+    } else {
+      const _item = item as MarkOptional<LibraryItem, "id" | "status">;
+      restoredItems.push({
+        ..._item,
+        id: _item.id || randomId(),
+        status: _item.status || defaultStatus,
+        created: _item.created || Date.now(),
+      });
+    }
+  }
+  return restoredItems;
+};

+ 8 - 5
src/data/types.ts

@@ -1,5 +1,5 @@
 import { ExcalidrawElement } from "../element/types";
-import { AppState, BinaryFiles, LibraryItems } from "../types";
+import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
 import type { cleanAppStateForExport } from "../appState";
 
 export interface ExportedDataState {
@@ -18,15 +18,18 @@ export interface ImportedDataState {
   elements?: readonly ExcalidrawElement[] | null;
   appState?: Readonly<Partial<AppState>> | null;
   scrollToContent?: boolean;
-  libraryItems?: LibraryItems;
+  libraryItems?: LibraryItems | LibraryItems_v1;
   files?: BinaryFiles;
 }
 
 export interface ExportedLibraryData {
   type: string;
-  version: number;
+  version: 2;
   source: string;
-  library: LibraryItems;
+  libraryItems: LibraryItems;
 }
 
-export interface ImportedLibraryData extends Partial<ExportedLibraryData> {}
+export interface ImportedLibraryData extends Partial<ExportedLibraryData> {
+  /** @deprecated v1 */
+  library?: LibraryItems;
+}

+ 15 - 0
src/element/bounds.ts

@@ -3,6 +3,7 @@ import {
   ExcalidrawLinearElement,
   Arrowhead,
   ExcalidrawFreeDrawElement,
+  NonDeleted,
 } from "./types";
 import { distance2d, rotate } from "../math";
 import rough from "roughjs/bin/rough";
@@ -513,3 +514,17 @@ export const getClosestElementBounds = (
 
   return getElementBounds(closestElement);
 };
+
+export interface Box {
+  minX: number;
+  minY: number;
+  maxX: number;
+  maxY: number;
+}
+
+export const getCommonBoundingBox = (
+  elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
+): Box => {
+  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+  return { minX, minY, maxX, maxY };
+};

+ 1 - 0
src/element/textWysiwyg.tsx

@@ -108,6 +108,7 @@ export const textWysiwyg = ({
   editable.dataset.type = "wysiwyg";
   // prevent line wrapping on Safari
   editable.wrap = "off";
+  editable.classList.add("excalidraw-wysiwyg");
 
   Object.assign(editable.style, {
     position: "absolute",

+ 1 - 6
src/excalidraw-app/collab/RoomDialog.scss

@@ -6,7 +6,7 @@
     margin: 1.5em 0;
   }
 
-  .RoomDialog-link {
+  input.RoomDialog-link {
     color: var(--text-primary-color);
     min-width: 0;
     flex: 1 1 auto;
@@ -14,8 +14,6 @@
     display: inline-block;
     cursor: pointer;
     border: none;
-    height: 2.5rem;
-    line-height: 2.5rem;
     padding: 0 0.5rem;
     white-space: nowrap;
     border-radius: var(--space-factor);
@@ -55,10 +53,7 @@
       margin-top: 0.5em;
       margin-inline-start: 0;
     }
-    height: 2.5rem;
     font-size: 1em;
-    line-height: 1.5;
-    padding: 0 0.5rem;
   }
 
   .RoomDialog-sessionStartButtonContainer {

+ 2 - 0
src/excalidraw-app/collab/RoomDialog.tsx

@@ -124,6 +124,7 @@ const RoomDialog = ({
                 />
               </Stack.Row>
               <input
+                type="text"
                 value={activeRoomLink}
                 readOnly={true}
                 className="RoomDialog-link"
@@ -136,6 +137,7 @@ const RoomDialog = ({
                 {t("labels.yourName")}
               </label>
               <input
+                type="text"
                 id="username"
                 value={username || ""}
                 className="RoomDialog-username TextInput"

+ 2 - 0
src/global.d.ts

@@ -50,6 +50,8 @@ type MarkNonNullable<T, K extends keyof T> = {
   [P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
 } & { [P in keyof T]: T[P] };
 
+type NonOptional<T> = Exclude<T, undefined>;
+
 // PNG encoding/decoding
 // -----------------------------------------------------------------------------
 type TEXtChunk = { name: "tEXt"; data: Uint8Array };

+ 5 - 2
src/i18n.ts

@@ -105,7 +105,10 @@ const findPartsForData = (data: any, parts: string[]) => {
   return data;
 };
 
-export const t = (path: string, replacement?: { [key: string]: string }) => {
+export const t = (
+  path: string,
+  replacement?: { [key: string]: string | number },
+) => {
   if (currentLang.code.startsWith(TEST_LANG_CODE)) {
     const name = replacement
       ? `${path}(${JSON.stringify(replacement).slice(1, -1)})`
@@ -123,7 +126,7 @@ export const t = (path: string, replacement?: { [key: string]: string }) => {
 
   if (replacement) {
     for (const key in replacement) {
-      translation = translation.replace(`{{${key}}}`, replacement[key]);
+      translation = translation.replace(`{{${key}}}`, String(replacement[key]));
     }
   }
   return translation;

+ 59 - 2
src/locales/en.json

@@ -100,7 +100,9 @@
     "share": "Share",
     "showStroke": "Show stroke color picker",
     "showBackground": "Show background color picker",
-    "toggleTheme": "Toggle theme"
+    "toggleTheme": "Toggle theme",
+    "personalLib": "Personal Library",
+    "excalidrawLib": "Excalidraw Library"
   },
   "buttons": {
     "clearReset": "Reset the canvas",
@@ -136,6 +138,9 @@
     "exitZenMode": "Exit zen mode",
     "cancel": "Cancel",
     "clear": "Clear",
+    "remove": "Remove",
+    "publishLibrary": "Publish",
+    "submit": "Submit",
     "confirm": "Confirm"
   },
   "alerts": {
@@ -158,6 +163,7 @@
     "cannotRestoreFromImage": "Scene couldn't be restored from this image file",
     "invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
     "resetLibrary": "This will clear your library. Are you sure?",
+    "removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
     "invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled."
   },
   "errors": {
@@ -200,7 +206,8 @@
     "lineEditor_info": "Double-click or press Enter to edit points",
     "lineEditor_pointSelected": "Press Delete to remove point, CtrlOrCmd+D to duplicate, or drag to move",
     "lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points",
-    "placeImage": "Click to place the image, or click and drag to set its size manually"
+    "placeImage": "Click to place the image, or click and drag to set its size manually",
+    "publishLibrary": "Publish your own library"
   },
   "canvasError": {
     "cannotShowPreview": "Cannot show preview",
@@ -270,6 +277,55 @@
   "clearCanvasDialog": {
     "title": "Clear canvas"
   },
+  "publishDialog": {
+    "title": "Publish library",
+    "itemName": "Item name",
+    "authorName": "Author name",
+    "githubUsername": "GitHub username",
+    "twitterUsername": "Twitter username",
+    "libraryName": "Library name",
+    "libraryDesc": "Library description",
+    "website": "Website",
+    "placeholder": {
+      "authorName": "Your name or username",
+      "libraryName": "Name of your library",
+      "libraryDesc": "Description of your library to help people understand its usage",
+      "githubHandle": "Github handle (optional), so you can edit the library once submitted for review",
+      "twitterHandle": "Twitter username (optional), so we know who to credit when promoting over Twitter",
+      "website": "Link to your personal website or elsewhere (optional)"
+    },
+    "errors": {
+      "required": "Required",
+      "letter&Spaces": "Only letters and spaces allowed"
+    },
+    "noteDescription": {
+      "pre": "Submit your library to be included in the ",
+      "link": "public library repository",
+      "post": "for other people to use in their drawings."
+    },
+    "noteGuidelines": {
+      "pre": "The library needs to be manually approved first. Please read the ",
+      "link": "guidelines",
+      "post": " before submitting. You will need a GitHub account to communicate and make changes if requested, but it is not strictly required."
+    },
+    "noteLicense": {
+      "pre": "By submitting, you agree the library will be published under the ",
+      "link": "MIT License, ",
+      "post": "which in short means anyone can use them without restrictions."
+    },
+    "noteItems": "Each library item must have its own name so it's filterable. The following library items will be included:",
+    "atleastOneLibItem": "Please select at least one library item to get started"
+  },
+
+  "publishSuccessDialog": {
+    "title": "Library submitted",
+    "content": "Thank you {{authorName}}. Your library has been submitted for review. You can track the status",
+    "link": "here"
+  },
+  "confirmDialog": {
+    "resetLibrary": "Reset library",
+    "removeItemsFromLib": "Remove selected items from library"
+  },
   "encrypted": {
     "tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them.",
     "link": "Blog post on end-to-end encryption in Excalidraw"
@@ -290,6 +346,7 @@
     "width": "Width"
   },
   "toast": {
+    "addedToLibrary": "Added to library",
     "copyStyles": "Copied styles.",
     "copyToClipboard": "Copied to clipboard.",
     "copyToClipboardAsPng": "Copied {{exportSelection}} to clipboard as PNG\n({{exportColorScheme}})",

+ 2 - 2
src/packages/utils.ts

@@ -4,13 +4,13 @@ import {
 } from "../scene/export";
 import { getDefaultAppState } from "../appState";
 import { AppState, BinaryFiles } from "../types";
-import { ExcalidrawElement } from "../element/types";
+import { ExcalidrawElement, NonDeleted } from "../element/types";
 import { getNonDeletedElements } from "../element";
 import { restore } from "../data/restore";
 import { MIME_TYPES } from "../constants";
 
 type ExportOpts = {
-  elements: readonly ExcalidrawElement[];
+  elements: readonly NonDeleted<ExcalidrawElement>[];
   appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
   files: BinaryFiles | null;
   getDimensions?: (

+ 2 - 2
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -66,7 +66,7 @@ Object {
   "startBoundElement": null,
   "suggestedBindings": Array [],
   "theme": "light",
-  "toastMessage": null,
+  "toastMessage": "Added to library",
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -166,7 +166,7 @@ Object {
 
 exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of elements 1`] = `1`;
 
-exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of renders 1`] = `9`;
+exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of renders 1`] = `10`;
 
 exports[`contextMenu element selecting 'Bring forward' in context menu brings element forward: [end of test] appState 1`] = `
 Object {

+ 3 - 2
src/tests/contextmenu.test.tsx

@@ -20,6 +20,7 @@ import { copiedStyles } from "../actions/actionStyles";
 import { API } from "./helpers/api";
 import { setDateTimeForTests } from "../utils";
 import { t } from "../i18n";
+import { LibraryItem } from "../types";
 
 const checkpoint = (name: string) => {
   expect(renderScene.mock.calls.length).toMatchSnapshot(
@@ -392,8 +393,8 @@ describe("contextMenu element", () => {
     await waitFor(() => {
       const library = localStorage.getItem("excalidraw-library");
       expect(library).not.toBeNull();
-      const addedElement = JSON.parse(library!)[0][0];
-      expect(addedElement).toEqual(h.elements[0]);
+      const addedElement = JSON.parse(library!)[0] as LibraryItem;
+      expect(addedElement.elements[0]).toEqual(h.elements[0]);
     });
   });
 

+ 6 - 1
src/tests/library.test.tsx

@@ -20,7 +20,12 @@ describe("library", () => {
     );
     await waitFor(async () => {
       expect(await h.app.library.loadLibrary()).toEqual([
-        [expect.objectContaining({ id: "A" })],
+        {
+          status: "unpublished",
+          elements: [expect.objectContaining({ id: "A" })],
+          id: "id0",
+          created: expect.any(Number),
+        },
       ]);
     });
   });

+ 18 - 1
src/types.ts

@@ -178,8 +178,25 @@ export declare class GestureEvent extends UIEvent {
   readonly scale: number;
 }
 
-export type LibraryItem = readonly NonDeleted<ExcalidrawElement>[];
+// libraries
+// -----------------------------------------------------------------------------
+/** @deprecated legacy: do not use outside of migration paths */
+export type LibraryItem_v1 = readonly NonDeleted<ExcalidrawElement>[];
+/** @deprecated legacy: do not use outside of migration paths */
+export type LibraryItems_v1 = readonly LibraryItem_v1[];
+
+/** v2 library item */
+export type LibraryItem = {
+  id: string;
+  status: "published" | "unpublished";
+  elements: readonly NonDeleted<ExcalidrawElement>[];
+  /** timestamp in epoch (ms) */
+  created: number;
+  name?: string;
+  error?: string;
+};
 export type LibraryItems = readonly LibraryItem[];
+// -----------------------------------------------------------------------------
 
 // NOTE ready/readyPromise props are optional for host apps' sake (our own
 // implem guarantees existence)

+ 14 - 0
src/utils.ts

@@ -150,6 +150,20 @@ export const debounce = <T extends any[]>(
   return ret;
 };
 
+// https://github.com/lodash/lodash/blob/es/chunk.js
+export const chunk = <T extends any>(array: T[], size: number): T[][] => {
+  if (!array.length || size < 1) {
+    return [];
+  }
+  let index = 0;
+  let resIndex = 0;
+  const result = Array(Math.ceil(array.length / size));
+  while (index < array.length) {
+    result[resIndex++] = array.slice(index, (index += size));
+  }
+  return result;
+};
+
 export const selectNode = (node: Element) => {
   const selection = window.getSelection();
   if (selection) {