ソースを参照

Library MVP (#1787)

Co-authored-by: dwelle <luzar.david@gmail.com>
Pete Hunt 5 年 前
コミット
6428b59ccb

+ 23 - 0
src/actions/actionAddToLibrary.ts

@@ -0,0 +1,23 @@
+import { register } from "./register";
+import { getSelectedElements } from "../scene";
+import { getNonDeletedElements } from "../element";
+import { deepCopyElement } from "../element/newElement";
+import { loadLibrary, saveLibrary } from "../data/localStorage";
+
+export const actionAddToLibrary = register({
+  name: "addToLibrary",
+  perform: (elements, appState) => {
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+    );
+
+    loadLibrary().then((items) => {
+      saveLibrary([...items, selectedElements.map(deepCopyElement)]);
+    });
+
+    return false;
+  },
+  contextMenuOrder: 6,
+  contextItemLabel: "labels.addToLibrary",
+});

+ 2 - 0
src/actions/index.ts

@@ -49,3 +49,5 @@ export {
 export { actionGroup, actionUngroup } from "./actionGroup";
 
 export { actionGoToCollaborator } from "./actionNavigate";
+
+export { actionAddToLibrary } from "./actionAddToLibrary";

+ 2 - 1
src/actions/types.ts

@@ -62,7 +62,8 @@ export type ActionName =
   | "toggleShortcuts"
   | "group"
   | "ungroup"
-  | "goToCollaborator";
+  | "goToCollaborator"
+  | "addToLibrary";
 
 export interface Action {
   name: ActionName;

+ 2 - 0
src/appState.ts

@@ -58,6 +58,7 @@ export const getDefaultAppState = (): AppState => {
     selectedGroupIds: {},
     width: window.innerWidth,
     height: window.innerHeight,
+    isLibraryOpen: false,
   };
 };
 
@@ -76,6 +77,7 @@ export const clearAppStateForLocalStorage = (appState: AppState) => {
     errorMessage,
     showShortcutsDialog,
     editingLinearElement,
+    isLibraryOpen,
     ...exportedState
   } = appState;
   return exportedState;

+ 23 - 2
src/components/Actions.tsx

@@ -85,12 +85,21 @@ export const SelectedShapeActions = ({
   );
 };
 
+const LIBRARY_ICON = (
+  // fa-th-large
+  <svg viewBox="0 0 512 512">
+    <path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
+  </svg>
+);
+
 export const ShapesSwitcher = ({
   elementType,
   setAppState,
+  isLibraryOpen,
 }: {
   elementType: ExcalidrawElement["type"];
-  setAppState: any;
+  setAppState: (appState: Partial<AppState>) => void;
+  isLibraryOpen: boolean;
 }) => (
   <>
     {SHAPES.map(({ value, icon, key }, index) => {
@@ -119,9 +128,21 @@ export const ShapesSwitcher = ({
             setCursorForShape(value);
             setAppState({});
           }}
-        ></ToolButton>
+        />
       );
     })}
+    <ToolButton
+      type="button"
+      icon={LIBRARY_ICON}
+      name="editor-library"
+      keyBindingLabel="9"
+      aria-keyshortcuts="9"
+      title={`${capitalizeString(t("toolBar.library"))} — 9`}
+      aria-label={capitalizeString(t("toolBar.library"))}
+      onClick={() => {
+        setAppState({ isLibraryOpen: !isLibraryOpen });
+      }}
+    />
   </>
 );
 

+ 25 - 3
src/components/App.tsx

@@ -299,6 +299,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             });
           }}
           onLockToggle={this.toggleLock}
+          onInsertShape={(elements) =>
+            this.addElementsFromPasteOrLibrary(elements)
+          }
           zenModeEnabled={zenModeEnabled}
           toggleZenMode={this.toggleZenMode}
           lng={getLanguage().lng}
@@ -870,7 +873,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       if (data.error) {
         alert(data.error);
       } else if (data.elements) {
-        this.addElementsFromPaste(data.elements);
+        this.addElementsFromPasteOrLibrary(data.elements);
       } else if (data.text) {
         this.addTextFromPaste(data.text);
       }
@@ -879,8 +882,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     },
   );
 
-  private addElementsFromPaste = (
+  private addElementsFromPasteOrLibrary = (
     clipboardElements: readonly ExcalidrawElement[],
+    clientX = cursorX,
+    clientY = cursorY,
   ) => {
     const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements);
 
@@ -888,7 +893,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     const elementsCenterY = distance(minY, maxY) / 2;
 
     const { x, y } = viewportCoordsToSceneCoords(
-      { clientX: cursorX, clientY: cursorY },
+      { clientX, clientY },
       this.state,
       this.canvas,
       window.devicePixelRatio,
@@ -911,6 +916,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     ]);
     history.resumeRecording();
     this.setState({
+      isLibraryOpen: false,
       selectedElementIds: newElements.reduce((map, element) => {
         map[element.id] = true;
         return map;
@@ -1355,6 +1361,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       return;
     }
 
+    if (event.code === "Digit9") {
+      this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
+    }
+
     const shape = findShapeByKey(event.key);
 
     if (isArrowKey(event.key)) {
@@ -3135,6 +3145,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
   };
 
   private handleCanvasOnDrop = (event: React.DragEvent<HTMLCanvasElement>) => {
+    const libraryShapes = event.dataTransfer.getData(
+      "application/vnd.excalidraw.json",
+    );
+    if (libraryShapes !== "") {
+      this.addElementsFromPasteOrLibrary(
+        JSON.parse(libraryShapes),
+        event.clientX,
+        event.clientY,
+      );
+      return;
+    }
+
     const file = event.dataTransfer?.files[0];
     if (
       file?.type === "application/json" ||

+ 17 - 0
src/components/LayerUI.scss

@@ -1,5 +1,22 @@
 @import "open-color/open-color";
 
+.layer-ui__library {
+  margin: auto;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.layer-ui__library-message {
+  padding: 10px 20px;
+  max-width: 200px;
+}
+
+.layer-ui__library-items {
+  max-height: 50vh;
+  overflow: auto;
+}
+
 .layer-ui__wrapper {
   .encrypted-icon {
     position: relative;

+ 212 - 4
src/components/LayerUI.tsx

@@ -1,10 +1,20 @@
-import React from "react";
+import React, {
+  useRef,
+  useState,
+  RefObject,
+  useEffect,
+  useCallback,
+} from "react";
 import { showSelectedShapeActions } from "../element";
-import { calculateScrollCenter } from "../scene";
+import { calculateScrollCenter, getSelectedElements } from "../scene";
 import { exportCanvas } from "../data";
 
-import { AppState } from "../types";
-import { NonDeletedExcalidrawElement } from "../element/types";
+import { AppState, LibraryItems } from "../types";
+import {
+  NonDeletedExcalidrawElement,
+  ExcalidrawElement,
+  NonDeleted,
+} from "../element/types";
 
 import { ActionManager } from "../actions/manager";
 import { Island } from "./Island";
@@ -32,6 +42,8 @@ import { GitHubCorner } from "./GitHubCorner";
 import { Tooltip } from "./Tooltip";
 
 import "./LayerUI.scss";
+import { LibraryUnit } from "./LibraryUnit";
+import { loadLibrary, saveLibrary } from "../data/localStorage";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -43,11 +55,182 @@ interface LayerUIProps {
   onUsernameChange: (username: string) => void;
   onRoomDestroy: () => void;
   onLockToggle: () => void;
+  onInsertShape: (elements: readonly NonDeleted<ExcalidrawElement>[]) => void;
   zenModeEnabled: boolean;
   toggleZenMode: () => void;
   lng: string;
 }
 
+function 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 = ({
+  library,
+  onRemoveFromLibrary,
+  onAddToLibrary,
+  onInsertShape,
+  pendingElements,
+}: {
+  library: LibraryItems;
+  pendingElements: NonDeleted<ExcalidrawElement>[];
+  onClickOutside: (event: MouseEvent) => void;
+  onRemoveFromLibrary: (index: number) => void;
+  onInsertShape: (elements: readonly NonDeleted<ExcalidrawElement>[]) => void;
+  onAddToLibrary: (elements: NonDeleted<ExcalidrawElement>[]) => void;
+}) => {
+  const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
+  const CELLS_PER_ROW = 3;
+  const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
+  const rows = [];
+  let addedPendingElements = false;
+
+  for (let row = 0; row < numRows; row++) {
+    const i = CELLS_PER_ROW * row;
+    const children = [];
+    for (let j = 0; j < 3; j++) {
+      const shouldAddPendingElements: boolean =
+        pendingElements.length > 0 &&
+        !addedPendingElements &&
+        i + j >= library.length;
+      addedPendingElements = addedPendingElements || shouldAddPendingElements;
+
+      children.push(
+        <Stack.Col key={j}>
+          <LibraryUnit
+            elements={library[i + j]}
+            pendingElements={
+              shouldAddPendingElements ? pendingElements : undefined
+            }
+            onRemoveFromLibrary={onRemoveFromLibrary.bind(null, i + j)}
+            onClick={
+              shouldAddPendingElements
+                ? onAddToLibrary.bind(null, pendingElements)
+                : onInsertShape.bind(null, library[i + j])
+            }
+          />
+        </Stack.Col>,
+      );
+    }
+    rows.push(
+      <Stack.Row align="center" gap={1} key={row}>
+        {children}
+      </Stack.Row>,
+    );
+  }
+
+  return (
+    <Stack.Col align="center" gap={1} className="layer-ui__library-items">
+      {rows}
+    </Stack.Col>
+  );
+};
+
+const LibraryMenu = ({
+  onClickOutside,
+  onInsertShape,
+  pendingElements,
+  onAddToLibrary,
+}: {
+  pendingElements: NonDeleted<ExcalidrawElement>[];
+  onClickOutside: (event: MouseEvent) => void;
+  onInsertShape: (elements: readonly NonDeleted<ExcalidrawElement>[]) => void;
+  onAddToLibrary: () => void;
+}) => {
+  const ref = useRef<HTMLDivElement | null>(null);
+  useOnClickOutside(ref, onClickOutside);
+
+  const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
+
+  const [loadingState, setIsLoading] = useState<
+    "preloading" | "loading" | "ready"
+  >("preloading");
+
+  const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
+
+  useEffect(() => {
+    Promise.race([
+      new Promise((resolve) => {
+        loadingTimerRef.current = setTimeout(() => {
+          resolve("loading");
+        }, 100);
+      }),
+      loadLibrary().then((items) => {
+        setLibraryItems(items);
+        setIsLoading("ready");
+      }),
+    ]).then((data) => {
+      if (data === "loading") {
+        setIsLoading("loading");
+      }
+    });
+    return () => {
+      clearTimeout(loadingTimerRef.current!);
+    };
+  }, []);
+
+  const removeFromLibrary = useCallback(async (indexToRemove) => {
+    const items = await loadLibrary();
+    const nextItems = items.filter((_, index) => index !== indexToRemove);
+    saveLibrary(nextItems);
+    setLibraryItems(nextItems);
+  }, []);
+
+  const addToLibrary = useCallback(
+    async (elements: NonDeleted<ExcalidrawElement>[]) => {
+      const items = await loadLibrary();
+      const nextItems = [...items, elements];
+      onAddToLibrary();
+      saveLibrary(nextItems);
+      setLibraryItems(nextItems);
+    },
+    [onAddToLibrary],
+  );
+
+  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
+          library={libraryItems}
+          onClickOutside={onClickOutside}
+          onRemoveFromLibrary={removeFromLibrary}
+          onAddToLibrary={addToLibrary}
+          onInsertShape={onInsertShape}
+          pendingElements={pendingElements}
+        />
+      )}
+    </Island>
+  );
+};
+
 const LayerUI = ({
   actionManager,
   appState,
@@ -58,6 +241,7 @@ const LayerUI = ({
   onUsernameChange,
   onRoomDestroy,
   onLockToggle,
+  onInsertShape,
   zenModeEnabled,
   toggleZenMode,
 }: LayerUIProps) => {
@@ -167,11 +351,33 @@ const LayerUI = ({
     </Section>
   );
 
+  const closeLibrary = useCallback(
+    (event) => {
+      setAppState({ isLibraryOpen: false });
+    },
+    [setAppState],
+  );
+
+  const deselectItems = useCallback(() => {
+    setAppState({
+      selectedElementIds: {},
+      selectedGroupIds: {},
+    });
+  }, [setAppState]);
+
   const renderFixedSideContainer = () => {
     const shouldRenderSelectedShapeActions = showSelectedShapeActions(
       appState,
       elements,
     );
+    const libraryMenu = appState.isLibraryOpen ? (
+      <LibraryMenu
+        pendingElements={getSelectedElements(elements, appState)}
+        onClickOutside={closeLibrary}
+        onInsertShape={onInsertShape}
+        onAddToLibrary={deselectItems}
+      />
+    ) : null;
     return (
       <FixedSideContainer side="top">
         <HintViewer appState={appState} elements={elements} />
@@ -193,6 +399,7 @@ const LayerUI = ({
                       <ShapesSwitcher
                         elementType={appState.elementType}
                         setAppState={setAppState}
+                        isLibraryOpen={appState.isLibraryOpen}
                       />
                     </Stack.Row>
                   </Island>
@@ -203,6 +410,7 @@ const LayerUI = ({
                     title={t("toolBar.lock")}
                   />
                 </Stack.Row>
+                {libraryMenu}
               </Stack.Col>
             )}
           </Section>

+ 75 - 0
src/components/LibraryUnit.scss

@@ -0,0 +1,75 @@
+.library-unit {
+  align-items: center;
+  border: 1px solid #ccc;
+  display: flex;
+  height: 126px; // match width
+  justify-content: center;
+  position: relative;
+  width: 126px; // exactly match the toolbar width when 3 are lined up + padding
+}
+
+.library-unit__dragger {
+  display: flex;
+  height: 100%;
+  width: 100%;
+}
+
+.library-unit__dragger > svg {
+  flex-grow: 1;
+  max-height: 100%;
+  max-width: 100%;
+}
+
+.library-unit__removeFromLibrary,
+.library-unit__removeFromLibrary:hover,
+.library-unit__removeFromLibrary:active {
+  align-items: center;
+  background: none;
+  border: none;
+  display: flex;
+  justify-content: center;
+  margin: 0;
+  padding: 0;
+  position: absolute;
+  right: 5px;
+  top: 5px;
+}
+
+.library-unit__removeFromLibrary > svg {
+  height: 16px;
+  width: 16px;
+}
+
+.library-unit__pulse {
+  transform: scale(1);
+  animation: library-unit__pulse-animation 1s ease-in infinite;
+}
+
+.library-unit__adder {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  width: 20px;
+  height: 20px;
+  margin-left: -10px;
+  margin-top: -10px;
+  pointer-events: none;
+}
+
+.library-unit__active {
+  cursor: pointer;
+}
+
+@keyframes library-unit__pulse-animation {
+  0% {
+    transform: scale(0.95);
+  }
+
+  50% {
+    transform: scale(1);
+  }
+
+  100% {
+    transform: scale(0.95);
+  }
+}

+ 93 - 0
src/components/LibraryUnit.tsx

@@ -0,0 +1,93 @@
+import React, { useRef, useEffect, useState } from "react";
+import { exportToSvg } from "../scene/export";
+import { ExcalidrawElement, NonDeleted } from "../element/types";
+import { close } from "../components/icons";
+
+import "./LibraryUnit.scss";
+import { t } from "../i18n";
+
+// fa-plus
+const PLUS_ICON = (
+  <svg viewBox="0 0 1792 1792">
+    <path d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z" />
+  </svg>
+);
+
+export const LibraryUnit = ({
+  elements,
+  pendingElements,
+  onRemoveFromLibrary,
+  onClick,
+}: {
+  elements?: NonDeleted<ExcalidrawElement>[];
+  pendingElements?: NonDeleted<ExcalidrawElement>[];
+  onRemoveFromLibrary: () => void;
+  onClick: () => void;
+}) => {
+  const ref = useRef<HTMLDivElement | null>(null);
+  useEffect(() => {
+    const elementsToRender = elements || pendingElements;
+    if (!elementsToRender) {
+      return;
+    }
+    const svg = exportToSvg(elementsToRender, {
+      exportBackground: false,
+      viewBackgroundColor: "#fff",
+      shouldAddWatermark: false,
+    });
+    for (const child of ref.current!.children) {
+      if (child.tagName !== "svg") {
+        continue;
+      }
+      ref.current!.removeChild(child);
+    }
+    ref.current!.appendChild(svg);
+
+    const current = ref.current!;
+    return () => {
+      current.removeChild(svg);
+    };
+  }, [elements, pendingElements]);
+
+  const [isHovered, setIsHovered] = useState(false);
+
+  const adder = isHovered && pendingElements && (
+    <div className="library-unit__adder">{PLUS_ICON}</div>
+  );
+
+  return (
+    <div
+      className={`library-unit ${
+        elements || pendingElements ? "library-unit__active" : ""
+      }`}
+      onMouseEnter={() => setIsHovered(true)}
+      onMouseLeave={() => setIsHovered(false)}
+    >
+      <div
+        className={`library-unit__dragger ${
+          !!pendingElements ? "library-unit__pulse" : ""
+        }`}
+        ref={ref}
+        draggable={!!elements}
+        onClick={!!elements || !!pendingElements ? onClick : undefined}
+        onDragStart={(event) => {
+          setIsHovered(false);
+          event.dataTransfer.setData(
+            "application/vnd.excalidraw.json",
+            JSON.stringify(elements),
+          );
+        }}
+      />
+      {adder}
+      {elements && isHovered && (
+        <button
+          className="library-unit__removeFromLibrary"
+          aria-label={t("labels.removeFromLibrary")}
+          onClick={onRemoveFromLibrary}
+        >
+          {close}
+        </button>
+      )}
+    </div>
+  );
+};

+ 1 - 0
src/components/MobileMenu.tsx

@@ -56,6 +56,7 @@ export const MobileMenu = ({
                   <ShapesSwitcher
                     elementType={appState.elementType}
                     setAppState={setAppState}
+                    isLibraryOpen={appState.isLibraryOpen}
                   />
                 </Stack.Row>
               </Island>

+ 5 - 0
src/components/ToolButton.tsx

@@ -63,6 +63,11 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
       >
         <div className="ToolIcon__icon" aria-hidden="true">
           {props.icon || props.label}
+          {props.keyBindingLabel && (
+            <span className="ToolIcon__keybinding">
+              {props.keyBindingLabel}
+            </span>
+          )}
         </div>
         {props.showAriaLabel && (
           <div className="ToolIcon__label">{props["aria-label"]}</div>

+ 6 - 5
src/data/index.ts

@@ -348,11 +348,12 @@ export const exportCanvas = async (
       window.alert(t("alerts.couldNotCopyToClipboard"));
     }
   } else if (type === "backend") {
-    const appState = getDefaultAppState();
-    if (exportBackground) {
-      appState.viewBackgroundColor = viewBackgroundColor;
-    }
-    exportToBackend(elements, appState);
+    exportToBackend(elements, {
+      ...appState,
+      viewBackgroundColor: exportBackground
+        ? appState.viewBackgroundColor
+        : getDefaultAppState().viewBackgroundColor,
+    });
   }
 
   // clean up the DOM

+ 44 - 1
src/data/localStorage.ts

@@ -1,11 +1,54 @@
 import { ExcalidrawElement } from "../element/types";
-import { AppState } from "../types";
+import { AppState, LibraryItems } from "../types";
 import { clearAppStateForLocalStorage } from "../appState";
 import { restore } from "./restore";
 
 const LOCAL_STORAGE_KEY = "excalidraw";
 const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
 const LOCAL_STORAGE_KEY_COLLAB = "excalidraw-collab";
+const LOCAL_STORAGE_KEY_LIBRARY = "excalidraw-library";
+
+let _LATEST_LIBRARY_ITEMS: LibraryItems | null = null;
+export const loadLibrary = (): Promise<LibraryItems> => {
+  return new Promise(async (resolve) => {
+    if (_LATEST_LIBRARY_ITEMS) {
+      return resolve(JSON.parse(JSON.stringify(_LATEST_LIBRARY_ITEMS)));
+    }
+
+    try {
+      const data = localStorage.getItem(LOCAL_STORAGE_KEY_LIBRARY);
+      if (!data) {
+        return resolve([]);
+      }
+
+      const items = (JSON.parse(data) as ExcalidrawElement[][]).map(
+        (elements) => restore(elements, null).elements,
+      ) as Mutable<LibraryItems>;
+
+      // clone to ensure we don't mutate the cached library elements in the app
+      _LATEST_LIBRARY_ITEMS = JSON.parse(JSON.stringify(items));
+
+      resolve(items);
+    } catch (e) {
+      console.error(e);
+      resolve([]);
+    }
+  });
+};
+
+export const saveLibrary = (items: LibraryItems) => {
+  const prevLibraryItems = _LATEST_LIBRARY_ITEMS;
+  try {
+    const serializedItems = JSON.stringify(items);
+    // cache optimistically so that consumers have access to the latest
+    //  immediately
+    _LATEST_LIBRARY_ITEMS = JSON.parse(serializedItems);
+    localStorage.setItem(LOCAL_STORAGE_KEY_LIBRARY, serializedItems);
+  } catch (e) {
+    _LATEST_LIBRARY_ITEMS = prevLibraryItems;
+    console.error(e);
+  }
+};
 
 export const saveUsernameToLocalStorage = (username: string) => {
   try {

+ 5 - 1
src/locales/en.json

@@ -65,7 +65,10 @@
     "group": "Group selection",
     "ungroup": "Ungroup selection",
     "collaborators": "Collaborators",
-    "toggleGridMode": "Toggle grid mode"
+    "toggleGridMode": "Toggle grid mode",
+    "addToLibrary": "Add to library",
+    "removeFromLibrary": "Remove from library",
+    "libraryLoadingMessage": "Loading library..."
   },
   "buttons": {
     "clearReset": "Reset the canvas",
@@ -115,6 +118,7 @@
     "arrow": "Arrow",
     "line": "Line",
     "text": "Text",
+    "library": "Library",
     "lock": "Keep selected tool active after drawing"
   },
   "headings": {

+ 54 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -27,6 +27,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -427,6 +428,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -636,6 +638,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -762,6 +765,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1024,6 +1028,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1188,6 +1193,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1390,6 +1396,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1598,6 +1605,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1907,6 +1915,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2302,6 +2311,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4090,6 +4100,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4216,6 +4227,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4342,6 +4354,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4468,6 +4481,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4616,6 +4630,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4764,6 +4779,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4912,6 +4928,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5060,6 +5077,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5186,6 +5204,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5312,6 +5331,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5460,6 +5480,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5586,6 +5607,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5734,6 +5756,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -6374,6 +6397,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -6583,6 +6607,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -6650,6 +6675,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -6715,6 +6741,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7537,6 +7564,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7936,6 +7964,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8252,6 +8281,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8489,6 +8519,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8651,6 +8682,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9422,6 +9454,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -10094,6 +10127,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -10671,6 +10705,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11157,6 +11192,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11599,6 +11635,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11956,6 +11993,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -12232,6 +12270,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -12431,6 +12470,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -13253,6 +13293,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -13974,6 +14015,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -14598,6 +14640,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -15129,6 +15172,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -15402,6 +15446,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -15613,6 +15658,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -15892,6 +15938,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -15957,6 +16004,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -16083,6 +16131,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -16148,6 +16197,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -16802,6 +16852,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -16869,6 +16920,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -17297,6 +17349,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -17373,6 +17426,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isCollaborating": false,
+  "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,

+ 6 - 3
src/tests/regressionTests.test.tsx

@@ -884,6 +884,7 @@ describe("regression tests", () => {
       "Copy styles",
       "Paste styles",
       "Delete",
+      "Add to library",
       "Send backward",
       "Bring forward",
       "Send to back",
@@ -892,7 +893,7 @@ describe("regression tests", () => {
     ];
 
     expect(contextMenu).not.toBeNull();
-    expect(contextMenu?.children.length).toBe(8);
+    expect(contextMenu?.children.length).toBe(9);
     options?.forEach((opt, i) => {
       expect(opt.textContent).toBe(expectedOptions[i]);
     });
@@ -926,6 +927,7 @@ describe("regression tests", () => {
       "Paste styles",
       "Delete",
       "Group selection",
+      "Add to library",
       "Send backward",
       "Bring forward",
       "Send to back",
@@ -934,7 +936,7 @@ describe("regression tests", () => {
     ];
 
     expect(contextMenu).not.toBeNull();
-    expect(contextMenu?.children.length).toBe(9);
+    expect(contextMenu?.children.length).toBe(10);
     options?.forEach((opt, i) => {
       expect(opt.textContent).toBe(expectedOptions[i]);
     });
@@ -973,6 +975,7 @@ describe("regression tests", () => {
       "Delete",
       "Group selection",
       "Ungroup selection",
+      "Add to library",
       "Send backward",
       "Bring forward",
       "Send to back",
@@ -981,7 +984,7 @@ describe("regression tests", () => {
     ];
 
     expect(contextMenu).not.toBeNull();
-    expect(contextMenu?.children.length).toBe(10);
+    expect(contextMenu?.children.length).toBe(11);
     options?.forEach((opt, i) => {
       expect(opt.textContent).toBe(expectedOptions[i]);
     });

+ 4 - 0
src/types.ts

@@ -81,6 +81,8 @@ export type AppState = {
   editingGroupId: GroupId | null;
   width: number;
   height: number;
+
+  isLibraryOpen: boolean;
 };
 
 export type PointerCoords = Readonly<{
@@ -103,3 +105,5 @@ export declare class GestureEvent extends UIEvent {
 export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
   _brand: "socketUpdateData";
 };
+
+export type LibraryItems = readonly NonDeleted<ExcalidrawElement>[][];