Browse Source

feat: add sidebar for libraries panel (#5274)

Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
Ishtiaq Bhatti 2 years ago
parent
commit
cdf352d4c3
39 changed files with 782 additions and 241 deletions
  1. 3 3
      src/actions/actionExport.tsx
  2. 1 1
      src/actions/manager.tsx
  3. 3 1
      src/appState.ts
  4. 4 4
      src/components/Actions.tsx
  5. 108 60
      src/components/App.tsx
  6. 2 2
      src/components/ClearCanvas.tsx
  7. 2 2
      src/components/CollabButton.tsx
  8. 2 2
      src/components/Dialog.tsx
  9. 2 2
      src/components/ImageExportDialog.tsx
  10. 2 2
      src/components/JSONExportDialog.tsx
  11. 55 1
      src/components/LayerUI.scss
  12. 68 40
      src/components/LayerUI.tsx
  13. 16 1
      src/components/LibraryButton.tsx
  14. 45 9
      src/components/LibraryMenu.scss
  15. 24 9
      src/components/LibraryMenu.tsx
  16. 16 3
      src/components/LibraryMenuItems.scss
  17. 177 39
      src/components/LibraryMenuItems.tsx
  18. 1 5
      src/components/LibraryUnit.scss
  19. 2 2
      src/components/LibraryUnit.tsx
  20. 3 0
      src/components/MobileMenu.tsx
  21. 6 6
      src/components/Modal.tsx
  22. 22 0
      src/components/SidebarLockButton.scss
  23. 46 0
      src/components/SidebarLockButton.tsx
  24. 2 0
      src/components/Stack.tsx
  25. 1 0
      src/components/Stats.scss
  26. 3 6
      src/components/Stats.tsx
  27. 1 22
      src/components/Toolbar.scss
  28. 10 0
      src/constants.ts
  29. 18 1
      src/css/styles.scss
  30. 24 0
      src/css/variables.module.scss
  31. 5 0
      src/data/restore.ts
  32. 6 1
      src/locales/en.json
  33. 2 0
      src/packages/excalidraw/CHANGELOG.md
  34. 7 1
      src/packages/excalidraw/README_NEXT.md
  35. 1 0
      src/packages/excalidraw/index.tsx
  36. 17 0
      src/tests/__snapshots__/contextmenu.test.tsx.snap
  37. 52 0
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  38. 1 0
      src/tests/packages/__snapshots__/utils.test.ts.snap
  39. 22 16
      src/types.ts

+ 3 - 3
src/actions/actionExport.tsx

@@ -7,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
 import { loadFromJSON, saveAsJSON } from "../data";
 import { loadFromJSON, saveAsJSON } from "../data";
 import { resaveAsImageWithScene } from "../data/resave";
 import { resaveAsImageWithScene } from "../data/resave";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useDeviceType } from "../components/App";
+import { useDevice } from "../components/App";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
 import { register } from "./register";
 import { register } from "./register";
 import { CheckboxItem } from "../components/CheckboxItem";
 import { CheckboxItem } from "../components/CheckboxItem";
@@ -204,7 +204,7 @@ export const actionSaveFileToDisk = register({
       icon={saveAs}
       icon={saveAs}
       title={t("buttons.saveAs")}
       title={t("buttons.saveAs")}
       aria-label={t("buttons.saveAs")}
       aria-label={t("buttons.saveAs")}
-      showAriaLabel={useDeviceType().isMobile}
+      showAriaLabel={useDevice().isMobile}
       hidden={!nativeFileSystemSupported}
       hidden={!nativeFileSystemSupported}
       onClick={() => updateData(null)}
       onClick={() => updateData(null)}
       data-testid="save-as-button"
       data-testid="save-as-button"
@@ -248,7 +248,7 @@ export const actionLoadScene = register({
       icon={load}
       icon={load}
       title={t("buttons.load")}
       title={t("buttons.load")}
       aria-label={t("buttons.load")}
       aria-label={t("buttons.load")}
-      showAriaLabel={useDeviceType().isMobile}
+      showAriaLabel={useDevice().isMobile}
       onClick={updateData}
       onClick={updateData}
       data-testid="load-button"
       data-testid="load-button"
     />
     />

+ 1 - 1
src/actions/manager.tsx

@@ -30,7 +30,7 @@ const trackAction = (
           trackEvent(
           trackEvent(
             action.trackEvent.category,
             action.trackEvent.category,
             action.trackEvent.action || action.name,
             action.trackEvent.action || action.name,
-            `${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
+            `${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
           );
           );
         }
         }
       }
       }

+ 3 - 1
src/appState.ts

@@ -58,6 +58,7 @@ export const getDefaultAppState = (): Omit<
     gridSize: null,
     gridSize: null,
     isBindingEnabled: true,
     isBindingEnabled: true,
     isLibraryOpen: false,
     isLibraryOpen: false,
+    isLibraryMenuDocked: false,
     isLoading: false,
     isLoading: false,
     isResizing: false,
     isResizing: false,
     isRotating: false,
     isRotating: false,
@@ -146,7 +147,8 @@ const APP_STATE_STORAGE_CONF = (<
   gridSize: { browser: true, export: true, server: true },
   gridSize: { browser: true, export: true, server: true },
   height: { browser: false, export: false, server: false },
   height: { browser: false, export: false, server: false },
   isBindingEnabled: { browser: false, export: false, server: false },
   isBindingEnabled: { browser: false, export: false, server: false },
-  isLibraryOpen: { browser: false, export: false, server: false },
+  isLibraryOpen: { browser: true, export: false, server: false },
+  isLibraryMenuDocked: { browser: true, export: false, server: false },
   isLoading: { browser: false, export: false, server: false },
   isLoading: { browser: false, export: false, server: false },
   isResizing: { browser: false, export: false, server: false },
   isResizing: { browser: false, export: false, server: false },
   isRotating: { browser: false, export: false, server: false },
   isRotating: { browser: false, export: false, server: false },

+ 4 - 4
src/components/Actions.tsx

@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
 import { getNonDeletedElements } from "../element";
 import { getNonDeletedElements } from "../element";
 import { ExcalidrawElement, PointerType } from "../element/types";
 import { ExcalidrawElement, PointerType } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useDeviceType } from "../components/App";
+import { useDevice } from "../components/App";
 import {
 import {
   canChangeSharpness,
   canChangeSharpness,
   canHaveArrowheads,
   canHaveArrowheads,
@@ -52,7 +52,7 @@ export const SelectedShapeActions = ({
     isSingleElementBoundContainer = true;
     isSingleElementBoundContainer = true;
   }
   }
   const isEditing = Boolean(appState.editingElement);
   const isEditing = Boolean(appState.editingElement);
-  const deviceType = useDeviceType();
+  const device = useDevice();
   const isRTL = document.documentElement.getAttribute("dir") === "rtl";
   const isRTL = document.documentElement.getAttribute("dir") === "rtl";
 
 
   const showFillIcons =
   const showFillIcons =
@@ -177,8 +177,8 @@ export const SelectedShapeActions = ({
         <fieldset>
         <fieldset>
           <legend>{t("labels.actions")}</legend>
           <legend>{t("labels.actions")}</legend>
           <div className="buttonList">
           <div className="buttonList">
-            {!deviceType.isMobile && renderAction("duplicateSelection")}
-            {!deviceType.isMobile && renderAction("deleteSelectedElements")}
+            {!device.isMobile && renderAction("duplicateSelection")}
+            {!device.isMobile && renderAction("deleteSelectedElements")}
             {renderAction("group")}
             {renderAction("group")}
             {renderAction("ungroup")}
             {renderAction("ungroup")}
             {showLinkIcon && renderAction("hyperlink")}
             {showLinkIcon && renderAction("hyperlink")}

+ 108 - 60
src/components/App.tsx

@@ -64,6 +64,8 @@ import {
   MQ_MAX_HEIGHT_LANDSCAPE,
   MQ_MAX_HEIGHT_LANDSCAPE,
   MQ_MAX_WIDTH_LANDSCAPE,
   MQ_MAX_WIDTH_LANDSCAPE,
   MQ_MAX_WIDTH_PORTRAIT,
   MQ_MAX_WIDTH_PORTRAIT,
+  MQ_RIGHT_SIDEBAR_MIN_WIDTH,
+  MQ_SM_MAX_WIDTH,
   POINTER_BUTTON,
   POINTER_BUTTON,
   SCROLL_TIMEOUT,
   SCROLL_TIMEOUT,
   TAP_TWICE_TIMEOUT,
   TAP_TWICE_TIMEOUT,
@@ -194,7 +196,7 @@ import {
   LibraryItems,
   LibraryItems,
   PointerDownState,
   PointerDownState,
   SceneData,
   SceneData,
-  DeviceType,
+  Device,
 } from "../types";
 } from "../types";
 import {
 import {
   debounce,
   debounce,
@@ -220,7 +222,6 @@ import {
 } from "../utils";
 } from "../utils";
 import ContextMenu, { ContextMenuOption } from "./ContextMenu";
 import ContextMenu, { ContextMenuOption } from "./ContextMenu";
 import LayerUI from "./LayerUI";
 import LayerUI from "./LayerUI";
-import { Stats } from "./Stats";
 import { Toast } from "./Toast";
 import { Toast } from "./Toast";
 import { actionToggleViewMode } from "../actions/actionToggleViewMode";
 import { actionToggleViewMode } from "../actions/actionToggleViewMode";
 import {
 import {
@@ -259,12 +260,14 @@ import {
   isLocalLink,
   isLocalLink,
 } from "../element/Hyperlink";
 } from "../element/Hyperlink";
 
 
-const defaultDeviceTypeContext: DeviceType = {
+const deviceContextInitialValue = {
+  isSmScreen: false,
   isMobile: false,
   isMobile: false,
   isTouchScreen: false,
   isTouchScreen: false,
+  canDeviceFitSidebar: false,
 };
 };
-const DeviceTypeContext = React.createContext(defaultDeviceTypeContext);
-export const useDeviceType = () => useContext(DeviceTypeContext);
+const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
+export const useDevice = () => useContext<Device>(DeviceContext);
 const ExcalidrawContainerContext = React.createContext<{
 const ExcalidrawContainerContext = React.createContext<{
   container: HTMLDivElement | null;
   container: HTMLDivElement | null;
   id: string | null;
   id: string | null;
@@ -296,10 +299,7 @@ class App extends React.Component<AppProps, AppState> {
   rc: RoughCanvas | null = null;
   rc: RoughCanvas | null = null;
   unmounted: boolean = false;
   unmounted: boolean = false;
   actionManager: ActionManager;
   actionManager: ActionManager;
-  deviceType: DeviceType = {
-    isMobile: false,
-    isTouchScreen: false,
-  };
+  device: Device = deviceContextInitialValue;
   detachIsMobileMqHandler?: () => void;
   detachIsMobileMqHandler?: () => void;
 
 
   private excalidrawContainerRef = React.createRef<HTMLDivElement>();
   private excalidrawContainerRef = React.createRef<HTMLDivElement>();
@@ -353,12 +353,12 @@ class App extends React.Component<AppProps, AppState> {
       width: window.innerWidth,
       width: window.innerWidth,
       height: window.innerHeight,
       height: window.innerHeight,
       showHyperlinkPopup: false,
       showHyperlinkPopup: false,
+      isLibraryMenuDocked: false,
     };
     };
 
 
     this.id = nanoid();
     this.id = nanoid();
 
 
     this.library = new Library(this);
     this.library = new Library(this);
-
     if (excalidrawRef) {
     if (excalidrawRef) {
       const readyPromise =
       const readyPromise =
         ("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
         ("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
@@ -485,7 +485,7 @@ class App extends React.Component<AppProps, AppState> {
       <div
       <div
         className={clsx("excalidraw excalidraw-container", {
         className={clsx("excalidraw excalidraw-container", {
           "excalidraw--view-mode": viewModeEnabled,
           "excalidraw--view-mode": viewModeEnabled,
-          "excalidraw--mobile": this.deviceType.isMobile,
+          "excalidraw--mobile": this.device.isMobile,
         })}
         })}
         ref={this.excalidrawContainerRef}
         ref={this.excalidrawContainerRef}
         onDrop={this.handleAppOnDrop}
         onDrop={this.handleAppOnDrop}
@@ -497,7 +497,7 @@ class App extends React.Component<AppProps, AppState> {
         <ExcalidrawContainerContext.Provider
         <ExcalidrawContainerContext.Provider
           value={this.excalidrawContainerValue}
           value={this.excalidrawContainerValue}
         >
         >
-          <DeviceTypeContext.Provider value={this.deviceType}>
+          <DeviceContext.Provider value={this.device}>
             <LayerUI
             <LayerUI
               canvas={this.canvas}
               canvas={this.canvas}
               appState={this.state}
               appState={this.state}
@@ -521,6 +521,7 @@ class App extends React.Component<AppProps, AppState> {
               isCollaborating={this.props.isCollaborating}
               isCollaborating={this.props.isCollaborating}
               renderTopRightUI={renderTopRightUI}
               renderTopRightUI={renderTopRightUI}
               renderCustomFooter={renderFooter}
               renderCustomFooter={renderFooter}
+              renderCustomStats={renderCustomStats}
               viewModeEnabled={viewModeEnabled}
               viewModeEnabled={viewModeEnabled}
               showExitZenModeBtn={
               showExitZenModeBtn={
                 typeof this.props?.zenModeEnabled === "undefined" &&
                 typeof this.props?.zenModeEnabled === "undefined" &&
@@ -548,15 +549,6 @@ class App extends React.Component<AppProps, AppState> {
                 onLinkOpen={this.props.onLinkOpen}
                 onLinkOpen={this.props.onLinkOpen}
               />
               />
             )}
             )}
-            {this.state.showStats && (
-              <Stats
-                appState={this.state}
-                setAppState={this.setAppState}
-                elements={this.scene.getNonDeletedElements()}
-                onClose={this.toggleStats}
-                renderCustomStats={renderCustomStats}
-              />
-            )}
             {this.state.toastMessage !== null && (
             {this.state.toastMessage !== null && (
               <Toast
               <Toast
                 message={this.state.toastMessage}
                 message={this.state.toastMessage}
@@ -564,7 +556,7 @@ class App extends React.Component<AppProps, AppState> {
               />
               />
             )}
             )}
             <main>{this.renderCanvas()}</main>
             <main>{this.renderCanvas()}</main>
-          </DeviceTypeContext.Provider>
+          </DeviceContext.Provider>
         </ExcalidrawContainerContext.Provider>
         </ExcalidrawContainerContext.Provider>
       </div>
       </div>
     );
     );
@@ -763,7 +755,12 @@ class App extends React.Component<AppProps, AppState> {
     const scene = restore(initialData, null, null);
     const scene = restore(initialData, null, null);
     scene.appState = {
     scene.appState = {
       ...scene.appState,
       ...scene.appState,
-      isLibraryOpen: this.state.isLibraryOpen,
+      // we're falling back to current (pre-init) state when deciding
+      // whether to open the library, to handle a case where we
+      // update the state outside of initialData (e.g. when loading the app
+      // with a library install link, which should auto-open the library)
+      isLibraryOpen:
+        initialData?.appState?.isLibraryOpen || this.state.isLibraryOpen,
       activeTool:
       activeTool:
         scene.appState.activeTool.type === "image"
         scene.appState.activeTool.type === "image"
           ? { ...scene.appState.activeTool, type: "selection" }
           ? { ...scene.appState.activeTool, type: "selection" }
@@ -794,6 +791,21 @@ class App extends React.Component<AppProps, AppState> {
     });
     });
   };
   };
 
 
+  private refreshDeviceState = (container: HTMLDivElement) => {
+    const { width, height } = container.getBoundingClientRect();
+    const sidebarBreakpoint =
+      this.props.UIOptions.dockedSidebarBreakpoint != null
+        ? this.props.UIOptions.dockedSidebarBreakpoint
+        : MQ_RIGHT_SIDEBAR_MIN_WIDTH;
+    this.device = updateObject(this.device, {
+      isSmScreen: width < MQ_SM_MAX_WIDTH,
+      isMobile:
+        width < MQ_MAX_WIDTH_PORTRAIT ||
+        (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE),
+      canDeviceFitSidebar: width > sidebarBreakpoint,
+    });
+  };
+
   public async componentDidMount() {
   public async componentDidMount() {
     this.unmounted = false;
     this.unmounted = false;
     this.excalidrawContainerValue.container =
     this.excalidrawContainerValue.container =
@@ -835,34 +847,53 @@ class App extends React.Component<AppProps, AppState> {
       this.focusContainer();
       this.focusContainer();
     }
     }
 
 
+    if (
+      this.excalidrawContainerRef.current &&
+      // bounding rects don't work in tests so updating
+      // the state on init would result in making the test enviro run
+      // in mobile breakpoint (0 width/height), making everything fail
+      process.env.NODE_ENV !== "test"
+    ) {
+      this.refreshDeviceState(this.excalidrawContainerRef.current);
+    }
+
     if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
     if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
       this.resizeObserver = new ResizeObserver(() => {
       this.resizeObserver = new ResizeObserver(() => {
-        // compute isMobile state
+        // recompute device dimensions state
         // ---------------------------------------------------------------------
         // ---------------------------------------------------------------------
-        const { width, height } =
-          this.excalidrawContainerRef.current!.getBoundingClientRect();
-        this.deviceType = updateObject(this.deviceType, {
-          isMobile:
-            width < MQ_MAX_WIDTH_PORTRAIT ||
-            (height < MQ_MAX_HEIGHT_LANDSCAPE &&
-              width < MQ_MAX_WIDTH_LANDSCAPE),
-        });
+        this.refreshDeviceState(this.excalidrawContainerRef.current!);
         // refresh offsets
         // refresh offsets
         // ---------------------------------------------------------------------
         // ---------------------------------------------------------------------
         this.updateDOMRect();
         this.updateDOMRect();
       });
       });
       this.resizeObserver?.observe(this.excalidrawContainerRef.current);
       this.resizeObserver?.observe(this.excalidrawContainerRef.current);
     } else if (window.matchMedia) {
     } else if (window.matchMedia) {
-      const mediaQuery = window.matchMedia(
+      const mdScreenQuery = window.matchMedia(
         `(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`,
         `(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`,
       );
       );
+      const smScreenQuery = window.matchMedia(
+        `(max-width: ${MQ_SM_MAX_WIDTH}px)`,
+      );
+      const canDeviceFitSidebarMediaQuery = window.matchMedia(
+        `(min-width: ${
+          // NOTE this won't update if a different breakpoint is supplied
+          // after mount
+          this.props.UIOptions.dockedSidebarBreakpoint != null
+            ? this.props.UIOptions.dockedSidebarBreakpoint
+            : MQ_RIGHT_SIDEBAR_MIN_WIDTH
+        }px)`,
+      );
       const handler = () => {
       const handler = () => {
-        this.deviceType = updateObject(this.deviceType, {
-          isMobile: mediaQuery.matches,
+        this.excalidrawContainerRef.current!.getBoundingClientRect();
+        this.device = updateObject(this.device, {
+          isSmScreen: smScreenQuery.matches,
+          isMobile: mdScreenQuery.matches,
+          canDeviceFitSidebar: canDeviceFitSidebarMediaQuery.matches,
         });
         });
       };
       };
-      mediaQuery.addListener(handler);
-      this.detachIsMobileMqHandler = () => mediaQuery.removeListener(handler);
+      mdScreenQuery.addListener(handler);
+      this.detachIsMobileMqHandler = () =>
+        mdScreenQuery.removeListener(handler);
     }
     }
 
 
     const searchParams = new URLSearchParams(window.location.search.slice(1));
     const searchParams = new URLSearchParams(window.location.search.slice(1));
@@ -1004,6 +1035,14 @@ class App extends React.Component<AppProps, AppState> {
 
 
   componentDidUpdate(prevProps: AppProps, prevState: AppState) {
   componentDidUpdate(prevProps: AppProps, prevState: AppState) {
     if (
     if (
+      this.excalidrawContainerRef.current &&
+      prevProps.UIOptions.dockedSidebarBreakpoint !==
+        this.props.UIOptions.dockedSidebarBreakpoint
+    ) {
+      this.refreshDeviceState(this.excalidrawContainerRef.current);
+    }
+
+    if (
       prevState.scrollX !== this.state.scrollX ||
       prevState.scrollX !== this.state.scrollX ||
       prevState.scrollY !== this.state.scrollY
       prevState.scrollY !== this.state.scrollY
     ) {
     ) {
@@ -1175,7 +1214,7 @@ class App extends React.Component<AppProps, AppState> {
         theme: this.state.theme,
         theme: this.state.theme,
         imageCache: this.imageCache,
         imageCache: this.imageCache,
         isExporting: false,
         isExporting: false,
-        renderScrollbars: !this.deviceType.isMobile,
+        renderScrollbars: !this.device.isMobile,
       },
       },
     );
     );
 
 
@@ -1453,11 +1492,15 @@ class App extends React.Component<AppProps, AppState> {
 
 
     this.scene.replaceAllElements(nextElements);
     this.scene.replaceAllElements(nextElements);
     this.history.resumeRecording();
     this.history.resumeRecording();
+
     this.setState(
     this.setState(
       selectGroupsForSelectedElements(
       selectGroupsForSelectedElements(
         {
         {
           ...this.state,
           ...this.state,
-          isLibraryOpen: false,
+          isLibraryOpen:
+            this.state.isLibraryOpen && this.device.canDeviceFitSidebar
+              ? this.state.isLibraryMenuDocked
+              : false,
           selectedElementIds: newElements.reduce((map, element) => {
           selectedElementIds: newElements.reduce((map, element) => {
             if (!isBoundToContainer(element)) {
             if (!isBoundToContainer(element)) {
               map[element.id] = true;
               map[element.id] = true;
@@ -1529,7 +1572,7 @@ class App extends React.Component<AppProps, AppState> {
       trackEvent(
       trackEvent(
         "toolbar",
         "toolbar",
         "toggleLock",
         "toggleLock",
-        `${source} (${this.deviceType.isMobile ? "mobile" : "desktop"})`,
+        `${source} (${this.device.isMobile ? "mobile" : "desktop"})`,
       );
       );
     }
     }
     this.setState((prevState) => {
     this.setState((prevState) => {
@@ -1560,10 +1603,6 @@ class App extends React.Component<AppProps, AppState> {
     this.actionManager.executeAction(actionToggleZenMode);
     this.actionManager.executeAction(actionToggleZenMode);
   };
   };
 
 
-  toggleStats = () => {
-    this.actionManager.executeAction(actionToggleStats);
-  };
-
   scrollToContent = (
   scrollToContent = (
     target:
     target:
       | ExcalidrawElement
       | ExcalidrawElement
@@ -1721,7 +1760,16 @@ class App extends React.Component<AppProps, AppState> {
       }
       }
 
 
       if (event.code === CODES.ZERO) {
       if (event.code === CODES.ZERO) {
-        this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
+        const nextState = !this.state.isLibraryOpen;
+        this.setState({ isLibraryOpen: nextState });
+        // track only openings
+        if (nextState) {
+          trackEvent(
+            "library",
+            "toggleLibrary (open)",
+            `keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
+          );
+        }
       }
       }
 
 
       if (isArrowKey(event.key)) {
       if (isArrowKey(event.key)) {
@@ -1815,7 +1863,7 @@ class App extends React.Component<AppProps, AppState> {
             trackEvent(
             trackEvent(
               "toolbar",
               "toolbar",
               shape,
               shape,
-              `keyboard (${this.deviceType.isMobile ? "mobile" : "desktop"})`,
+              `keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
             );
             );
           }
           }
           this.setActiveTool({ type: shape });
           this.setActiveTool({ type: shape });
@@ -2440,7 +2488,7 @@ class App extends React.Component<AppProps, AppState> {
           element,
           element,
           this.state,
           this.state,
           [scenePointer.x, scenePointer.y],
           [scenePointer.x, scenePointer.y],
-          this.deviceType.isMobile,
+          this.device.isMobile,
         )
         )
       );
       );
     });
     });
@@ -2472,7 +2520,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement,
       this.hitLinkElement,
       this.state,
       this.state,
       [lastPointerDownCoords.x, lastPointerDownCoords.y],
       [lastPointerDownCoords.x, lastPointerDownCoords.y],
-      this.deviceType.isMobile,
+      this.device.isMobile,
     );
     );
     const lastPointerUpCoords = viewportCoordsToSceneCoords(
     const lastPointerUpCoords = viewportCoordsToSceneCoords(
       this.lastPointerUp!,
       this.lastPointerUp!,
@@ -2482,7 +2530,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement,
       this.hitLinkElement,
       this.state,
       this.state,
       [lastPointerUpCoords.x, lastPointerUpCoords.y],
       [lastPointerUpCoords.x, lastPointerUpCoords.y],
-      this.deviceType.isMobile,
+      this.device.isMobile,
     );
     );
     if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
     if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
       const url = this.hitLinkElement.link;
       const url = this.hitLinkElement.link;
@@ -2921,10 +2969,10 @@ class App extends React.Component<AppProps, AppState> {
     }
     }
 
 
     if (
     if (
-      !this.deviceType.isTouchScreen &&
+      !this.device.isTouchScreen &&
       ["pen", "touch"].includes(event.pointerType)
       ["pen", "touch"].includes(event.pointerType)
     ) {
     ) {
-      this.deviceType = updateObject(this.deviceType, { isTouchScreen: true });
+      this.device = updateObject(this.device, { isTouchScreen: true });
     }
     }
 
 
     if (isPanning) {
     if (isPanning) {
@@ -3066,7 +3114,7 @@ class App extends React.Component<AppProps, AppState> {
     event: React.PointerEvent<HTMLCanvasElement>,
     event: React.PointerEvent<HTMLCanvasElement>,
   ) => {
   ) => {
     this.lastPointerUp = event;
     this.lastPointerUp = event;
-    if (this.deviceType.isTouchScreen) {
+    if (this.device.isTouchScreen) {
       const scenePointer = viewportCoordsToSceneCoords(
       const scenePointer = viewportCoordsToSceneCoords(
         { clientX: event.clientX, clientY: event.clientY },
         { clientX: event.clientX, clientY: event.clientY },
         this.state,
         this.state,
@@ -3084,7 +3132,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement &&
       this.hitLinkElement &&
       !this.state.selectedElementIds[this.hitLinkElement.id]
       !this.state.selectedElementIds[this.hitLinkElement.id]
     ) {
     ) {
-      this.redirectToLink(event, this.deviceType.isTouchScreen);
+      this.redirectToLink(event, this.device.isTouchScreen);
     }
     }
 
 
     this.removePointer(event);
     this.removePointer(event);
@@ -3456,7 +3504,7 @@ class App extends React.Component<AppProps, AppState> {
               pointerDownState.hit.element,
               pointerDownState.hit.element,
               this.state,
               this.state,
               [pointerDownState.origin.x, pointerDownState.origin.y],
               [pointerDownState.origin.x, pointerDownState.origin.y],
-              this.deviceType.isMobile,
+              this.device.isMobile,
             )
             )
           ) {
           ) {
             return false;
             return false;
@@ -5563,7 +5611,7 @@ class App extends React.Component<AppProps, AppState> {
       } else {
       } else {
         ContextMenu.push({
         ContextMenu.push({
           options: [
           options: [
-            this.deviceType.isMobile &&
+            this.device.isMobile &&
               navigator.clipboard && {
               navigator.clipboard && {
                 trackEvent: false,
                 trackEvent: false,
                 name: "paste",
                 name: "paste",
@@ -5575,7 +5623,7 @@ class App extends React.Component<AppProps, AppState> {
                 },
                 },
                 contextItemLabel: "labels.paste",
                 contextItemLabel: "labels.paste",
               },
               },
-            this.deviceType.isMobile && navigator.clipboard && separator,
+            this.device.isMobile && navigator.clipboard && separator,
             probablySupportsClipboardBlob &&
             probablySupportsClipboardBlob &&
               elements.length > 0 &&
               elements.length > 0 &&
               actionCopyAsPng,
               actionCopyAsPng,
@@ -5620,9 +5668,9 @@ class App extends React.Component<AppProps, AppState> {
       } else {
       } else {
         ContextMenu.push({
         ContextMenu.push({
           options: [
           options: [
-            this.deviceType.isMobile && actionCut,
-            this.deviceType.isMobile && navigator.clipboard && actionCopy,
-            this.deviceType.isMobile &&
+            this.device.isMobile && actionCut,
+            this.device.isMobile && navigator.clipboard && actionCopy,
+            this.device.isMobile &&
               navigator.clipboard && {
               navigator.clipboard && {
                 name: "paste",
                 name: "paste",
                 trackEvent: false,
                 trackEvent: false,
@@ -5634,7 +5682,7 @@ class App extends React.Component<AppProps, AppState> {
                 },
                 },
                 contextItemLabel: "labels.paste",
                 contextItemLabel: "labels.paste",
               },
               },
-            this.deviceType.isMobile && separator,
+            this.device.isMobile && separator,
             ...options,
             ...options,
             separator,
             separator,
             actionCopyStyles,
             actionCopyStyles,

+ 2 - 2
src/components/ClearCanvas.tsx

@@ -1,6 +1,6 @@
 import { useState } from "react";
 import { useState } from "react";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useDeviceType } from "./App";
+import { useDevice } from "./App";
 import { trash } from "./icons";
 import { trash } from "./icons";
 import { ToolButton } from "./ToolButton";
 import { ToolButton } from "./ToolButton";
 
 
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
         icon={trash}
         icon={trash}
         title={t("buttons.clearReset")}
         title={t("buttons.clearReset")}
         aria-label={t("buttons.clearReset")}
         aria-label={t("buttons.clearReset")}
-        showAriaLabel={useDeviceType().isMobile}
+        showAriaLabel={useDevice().isMobile}
         onClick={toggleDialog}
         onClick={toggleDialog}
         data-testid="clear-canvas-button"
         data-testid="clear-canvas-button"
       />
       />

+ 2 - 2
src/components/CollabButton.tsx

@@ -1,7 +1,7 @@
 import clsx from "clsx";
 import clsx from "clsx";
 import { ToolButton } from "./ToolButton";
 import { ToolButton } from "./ToolButton";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useDeviceType } from "../components/App";
+import { useDevice } from "../components/App";
 import { users } from "./icons";
 import { users } from "./icons";
 
 
 import "./CollabButton.scss";
 import "./CollabButton.scss";
@@ -26,7 +26,7 @@ const CollabButton = ({
         type="button"
         type="button"
         title={t("labels.liveCollaboration")}
         title={t("labels.liveCollaboration")}
         aria-label={t("labels.liveCollaboration")}
         aria-label={t("labels.liveCollaboration")}
-        showAriaLabel={useDeviceType().isMobile}
+        showAriaLabel={useDevice().isMobile}
       >
       >
         {collaboratorCount > 0 && (
         {collaboratorCount > 0 && (
           <div className="CollabButton-collaborators">{collaboratorCount}</div>
           <div className="CollabButton-collaborators">{collaboratorCount}</div>

+ 2 - 2
src/components/Dialog.tsx

@@ -2,7 +2,7 @@ import clsx from "clsx";
 import React, { useEffect, useState } from "react";
 import React, { useEffect, useState } from "react";
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useExcalidrawContainer, useDeviceType } from "../components/App";
+import { useExcalidrawContainer, useDevice } from "../components/App";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
 import "./Dialog.scss";
 import "./Dialog.scss";
 import { back, close } from "./icons";
 import { back, close } from "./icons";
@@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
             onClick={onClose}
             onClick={onClose}
             aria-label={t("buttons.close")}
             aria-label={t("buttons.close")}
           >
           >
-            {useDeviceType().isMobile ? back : close}
+            {useDevice().isMobile ? back : close}
           </button>
           </button>
         </h2>
         </h2>
         <div className="Dialog__content">{props.children}</div>
         <div className="Dialog__content">{props.children}</div>

+ 2 - 2
src/components/ImageExportDialog.tsx

@@ -5,7 +5,7 @@ import { canvasToBlob } from "../data/blob";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { CanvasError } from "../errors";
 import { CanvasError } from "../errors";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useDeviceType } from "./App";
+import { useDevice } from "./App";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { exportToCanvas } from "../scene/export";
 import { exportToCanvas } from "../scene/export";
 import { AppState, BinaryFiles } from "../types";
 import { AppState, BinaryFiles } from "../types";
@@ -250,7 +250,7 @@ export const ImageExportDialog = ({
         icon={exportImage}
         icon={exportImage}
         type="button"
         type="button"
         aria-label={t("buttons.exportImage")}
         aria-label={t("buttons.exportImage")}
-        showAriaLabel={useDeviceType().isMobile}
+        showAriaLabel={useDevice().isMobile}
         title={t("buttons.exportImage")}
         title={t("buttons.exportImage")}
       />
       />
       {modalIsShown && (
       {modalIsShown && (

+ 2 - 2
src/components/JSONExportDialog.tsx

@@ -1,7 +1,7 @@
 import React, { useState } from "react";
 import React, { useState } from "react";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useDeviceType } from "./App";
+import { useDevice } from "./App";
 import { AppState, ExportOpts, BinaryFiles } from "../types";
 import { AppState, ExportOpts, BinaryFiles } from "../types";
 import { Dialog } from "./Dialog";
 import { Dialog } from "./Dialog";
 import { exportFile, exportToFileIcon, link } from "./icons";
 import { exportFile, exportToFileIcon, link } from "./icons";
@@ -117,7 +117,7 @@ export const JSONExportDialog = ({
         icon={exportFile}
         icon={exportFile}
         type="button"
         type="button"
         aria-label={t("buttons.export")}
         aria-label={t("buttons.export")}
-        showAriaLabel={useDeviceType().isMobile}
+        showAriaLabel={useDevice().isMobile}
         title={t("buttons.export")}
         title={t("buttons.export")}
       />
       />
       {modalIsShown && (
       {modalIsShown && (

+ 55 - 1
src/components/LayerUI.scss

@@ -1,9 +1,63 @@
 @import "open-color/open-color";
 @import "open-color/open-color";
+@import "../css/variables.module";
+
+.layer-ui__sidebar {
+  position: absolute;
+  top: var(--sat);
+  bottom: var(--sab);
+  right: var(--sar);
+  z-index: 5;
+
+  box-shadow: var(--shadow-island);
+  overflow: hidden;
+  border-radius: var(--border-radius-lg);
+  margin: var(--space-factor);
+  width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
+
+  .Island {
+    box-shadow: none;
+  }
+
+  .ToolIcon__icon {
+    border-radius: var(--border-radius-md);
+  }
+
+  .ToolIcon__icon__close {
+    .Modal__close {
+      width: calc(var(--space-factor) * 7);
+      height: calc(var(--space-factor) * 7);
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      color: var(--color-text);
+    }
+  }
+
+  .Island {
+    --padding: 0;
+    background-color: var(--island-bg-color);
+    border-radius: var(--border-radius-lg);
+    padding: calc(var(--padding) * var(--space-factor));
+    position: relative;
+    transition: box-shadow 0.5s ease-in-out;
+  }
+}
 
 
 .excalidraw {
 .excalidraw {
+  .layer-ui__wrapper.animate {
+    transition: width 0.1s ease-in-out;
+  }
   .layer-ui__wrapper {
   .layer-ui__wrapper {
+    // when the rightside sidebar is docked, we need to resize the UI by its
+    // width, making the nested UI content shift to the left. To do this,
+    // we need the UI container to actually have dimensions set, but
+    // then we also need to disable pointer events else the canvas below
+    // wouldn't be interactive.
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    pointer-events: none;
     z-index: var(--zIndex-layerUI);
     z-index: var(--zIndex-layerUI);
-
     &__top-right {
     &__top-right {
       display: flex;
       display: flex;
     }
     }

+ 68 - 40
src/components/LayerUI.tsx

@@ -1,7 +1,7 @@
 import clsx from "clsx";
 import clsx from "clsx";
 import React, { useCallback } from "react";
 import React, { useCallback } from "react";
 import { ActionManager } from "../actions/manager";
 import { ActionManager } from "../actions/manager";
-import { CLASSES } from "../constants";
+import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
 import { exportCanvas } from "../data";
 import { exportCanvas } from "../data";
 import { isTextElement, showSelectedShapeActions } from "../element";
 import { isTextElement, showSelectedShapeActions } from "../element";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
@@ -36,7 +36,9 @@ import "./LayerUI.scss";
 import "./Toolbar.scss";
 import "./Toolbar.scss";
 import { PenModeButton } from "./PenModeButton";
 import { PenModeButton } from "./PenModeButton";
 import { trackEvent } from "../analytics";
 import { trackEvent } from "../analytics";
-import { useDeviceType } from "../components/App";
+import { useDevice } from "../components/App";
+import { Stats } from "./Stats";
+import { actionToggleStats } from "../actions/actionToggleStats";
 
 
 interface LayerUIProps {
 interface LayerUIProps {
   actionManager: ActionManager;
   actionManager: ActionManager;
@@ -55,14 +57,9 @@ interface LayerUIProps {
   toggleZenMode: () => void;
   toggleZenMode: () => void;
   langCode: Language["code"];
   langCode: Language["code"];
   isCollaborating: boolean;
   isCollaborating: boolean;
-  renderTopRightUI?: (
-    isMobile: boolean,
-    appState: AppState,
-  ) => JSX.Element | null;
-  renderCustomFooter?: (
-    isMobile: boolean,
-    appState: AppState,
-  ) => JSX.Element | null;
+  renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
+  renderCustomFooter?: ExcalidrawProps["renderFooter"];
+  renderCustomStats?: ExcalidrawProps["renderCustomStats"];
   viewModeEnabled: boolean;
   viewModeEnabled: boolean;
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   UIOptions: AppProps["UIOptions"];
   UIOptions: AppProps["UIOptions"];
@@ -71,7 +68,6 @@ interface LayerUIProps {
   id: string;
   id: string;
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
 }
 }
-
 const LayerUI = ({
 const LayerUI = ({
   actionManager,
   actionManager,
   appState,
   appState,
@@ -90,6 +86,7 @@ const LayerUI = ({
   isCollaborating,
   isCollaborating,
   renderTopRightUI,
   renderTopRightUI,
   renderCustomFooter,
   renderCustomFooter,
+  renderCustomStats,
   viewModeEnabled,
   viewModeEnabled,
   libraryReturnUrl,
   libraryReturnUrl,
   UIOptions,
   UIOptions,
@@ -98,7 +95,7 @@ const LayerUI = ({
   id,
   id,
   onImageAction,
   onImageAction,
 }: LayerUIProps) => {
 }: LayerUIProps) => {
-  const deviceType = useDeviceType();
+  const device = useDevice();
 
 
   const renderJSONExportDialog = () => {
   const renderJSONExportDialog = () => {
     if (!UIOptions.canvasActions.export) {
     if (!UIOptions.canvasActions.export) {
@@ -344,7 +341,7 @@ const LayerUI = ({
                       <HintViewer
                       <HintViewer
                         appState={appState}
                         appState={appState}
                         elements={elements}
                         elements={elements}
-                        isMobile={deviceType.isMobile}
+                        isMobile={device.isMobile}
                       />
                       />
                       {heading}
                       {heading}
                       <Stack.Row gap={1}>
                       <Stack.Row gap={1}>
@@ -366,7 +363,6 @@ const LayerUI = ({
                       setAppState={setAppState}
                       setAppState={setAppState}
                     />
                     />
                   </Stack.Row>
                   </Stack.Row>
-                  {libraryMenu}
                 </Stack.Col>
                 </Stack.Col>
               )}
               )}
             </Section>
             </Section>
@@ -383,7 +379,7 @@ const LayerUI = ({
               collaborators={appState.collaborators}
               collaborators={appState.collaborators}
               actionManager={actionManager}
               actionManager={actionManager}
             />
             />
-            {renderTopRightUI?.(deviceType.isMobile, appState)}
+            {renderTopRightUI?.(device.isMobile, appState)}
           </div>
           </div>
         </div>
         </div>
       </FixedSideContainer>
       </FixedSideContainer>
@@ -436,7 +432,7 @@ const LayerUI = ({
               )}
               )}
               {!viewModeEnabled &&
               {!viewModeEnabled &&
                 appState.multiElement &&
                 appState.multiElement &&
-                deviceType.isTouchScreen && (
+                device.isTouchScreen && (
                   <div
                   <div
                     className={clsx("finalize-button zen-mode-transition", {
                     className={clsx("finalize-button zen-mode-transition", {
                       "layer-ui__wrapper__footer-left--transition-left":
                       "layer-ui__wrapper__footer-left--transition-left":
@@ -513,7 +509,24 @@ const LayerUI = ({
     </>
     </>
   );
   );
 
 
-  return deviceType.isMobile ? (
+  const renderStats = () => {
+    if (!appState.showStats) {
+      return null;
+    }
+    return (
+      <Stats
+        appState={appState}
+        setAppState={setAppState}
+        elements={elements}
+        onClose={() => {
+          actionManager.executeAction(actionToggleStats);
+        }}
+        renderCustomStats={renderCustomStats}
+      />
+    );
+  };
+
+  return device.isMobile ? (
     <>
     <>
       {dialogs}
       {dialogs}
       <MobileMenu
       <MobileMenu
@@ -534,33 +547,48 @@ const LayerUI = ({
         showThemeBtn={showThemeBtn}
         showThemeBtn={showThemeBtn}
         onImageAction={onImageAction}
         onImageAction={onImageAction}
         renderTopRightUI={renderTopRightUI}
         renderTopRightUI={renderTopRightUI}
+        renderStats={renderStats}
       />
       />
     </>
     </>
   ) : (
   ) : (
-    <div
-      className={clsx("layer-ui__wrapper", {
-        "disable-pointerEvents":
-          appState.draggingElement ||
-          appState.resizingElement ||
-          (appState.editingElement && !isTextElement(appState.editingElement)),
-      })}
-    >
-      {dialogs}
-      {renderFixedSideContainer()}
-      {renderBottomAppMenu()}
-      {appState.scrolledOutside && (
-        <button
-          className="scroll-back-to-content"
-          onClick={() => {
-            setAppState({
-              ...calculateScrollCenter(elements, appState, canvas),
-            });
-          }}
-        >
-          {t("buttons.scrollBackToContent")}
-        </button>
+    <>
+      <div
+        className={clsx("layer-ui__wrapper", {
+          "disable-pointerEvents":
+            appState.draggingElement ||
+            appState.resizingElement ||
+            (appState.editingElement &&
+              !isTextElement(appState.editingElement)),
+        })}
+        style={
+          appState.isLibraryOpen &&
+          appState.isLibraryMenuDocked &&
+          device.canDeviceFitSidebar
+            ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
+            : {}
+        }
+      >
+        {dialogs}
+        {renderFixedSideContainer()}
+        {renderBottomAppMenu()}
+        {renderStats()}
+        {appState.scrolledOutside && (
+          <button
+            className="scroll-back-to-content"
+            onClick={() => {
+              setAppState({
+                ...calculateScrollCenter(elements, appState, canvas),
+              });
+            }}
+          >
+            {t("buttons.scrollBackToContent")}
+          </button>
+        )}
+      </div>
+      {appState.isLibraryOpen && (
+        <div className="layer-ui__sidebar">{libraryMenu}</div>
       )}
       )}
-    </div>
+    </>
   );
   );
 };
 };
 
 

+ 16 - 1
src/components/LibraryButton.tsx

@@ -3,6 +3,8 @@ import clsx from "clsx";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { capitalizeString } from "../utils";
 import { capitalizeString } from "../utils";
+import { trackEvent } from "../analytics";
+import { useDevice } from "./App";
 
 
 const LIBRARY_ICON = (
 const LIBRARY_ICON = (
   <svg viewBox="0 0 576 512">
   <svg viewBox="0 0 576 512">
@@ -18,6 +20,7 @@ export const LibraryButton: React.FC<{
   setAppState: React.Component<any, AppState>["setState"];
   setAppState: React.Component<any, AppState>["setState"];
   isMobile?: boolean;
   isMobile?: boolean;
 }> = ({ appState, setAppState, isMobile }) => {
 }> = ({ appState, setAppState, isMobile }) => {
+  const device = useDevice();
   return (
   return (
     <label
     <label
       className={clsx(
       className={clsx(
@@ -34,7 +37,19 @@ export const LibraryButton: React.FC<{
         type="checkbox"
         type="checkbox"
         name="editor-library"
         name="editor-library"
         onChange={(event) => {
         onChange={(event) => {
-          setAppState({ isLibraryOpen: event.target.checked });
+          document
+            .querySelector(".layer-ui__wrapper")
+            ?.classList.remove("animate");
+          const nextState = event.target.checked;
+          setAppState({ isLibraryOpen: nextState });
+          // track only openings
+          if (nextState) {
+            trackEvent(
+              "library",
+              "toggleLibrary (open)",
+              `toolbar (${device.isMobile ? "mobile" : "desktop"})`,
+            );
+          }
         }}
         }}
         checked={appState.isLibraryOpen}
         checked={appState.isLibraryOpen}
         aria-label={capitalizeString(t("toolBar.library"))}
         aria-label={capitalizeString(t("toolBar.library"))}

+ 45 - 9
src/components/LibraryMenu.scss

@@ -2,7 +2,6 @@
 
 
 .excalidraw {
 .excalidraw {
   .layer-ui__library {
   .layer-ui__library {
-    margin: auto;
     display: flex;
     display: flex;
     align-items: center;
     align-items: center;
     justify-content: center;
     justify-content: center;
@@ -11,8 +10,7 @@
       display: flex;
       display: flex;
       align-items: center;
       align-items: center;
       width: 100%;
       width: 100%;
-      margin: 2px 0;
-
+      margin: 2px 0 15px 0;
       .Spinner {
       .Spinner {
         margin-right: 1rem;
         margin-right: 1rem;
       }
       }
@@ -21,13 +19,17 @@
         // 2px from the left to account for focus border of left-most button
         // 2px from the left to account for focus border of left-most button
         margin: 0 2px;
         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__sidebar {
+    .layer-ui__library {
+      padding: 0;
+      height: 100%;
+    }
+    .library-menu-items-container {
+      height: 100%;
+      width: 100%;
     }
     }
   }
   }
 
 
@@ -65,4 +67,38 @@
       }
       }
     }
     }
   }
   }
+
+  .library-menu-browse-button {
+    width: 80%;
+    min-height: 22px;
+    margin: 0 auto;
+    margin-top: 1rem;
+    padding: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+    position: relative;
+
+    border-radius: var(--border-radius-lg);
+    background-color: var(--color-primary);
+    color: $oc-white;
+    text-align: center;
+    white-space: nowrap;
+    text-decoration: none !important;
+    &:hover {
+      background-color: var(--color-primary-darker);
+    }
+    &:active {
+      background-color: var(--color-primary-darkest);
+    }
+  }
+
+  .library-menu-browse-button--mobile {
+    min-height: 22px;
+    margin-left: auto;
+    a {
+      padding-right: 0;
+    }
+  }
 }
 }

+ 24 - 9
src/components/LibraryMenu.tsx

@@ -29,6 +29,7 @@ import { trackEvent } from "../analytics";
 import { useAtom } from "jotai";
 import { useAtom } from "jotai";
 import { jotaiScope } from "../jotai";
 import { jotaiScope } from "../jotai";
 import Spinner from "./Spinner";
 import Spinner from "./Spinner";
+import { useDevice } from "./App";
 
 
 const useOnClickOutside = (
 const useOnClickOutside = (
   ref: RefObject<HTMLElement>,
   ref: RefObject<HTMLElement>,
@@ -103,17 +104,30 @@ export const LibraryMenu = ({
 }) => {
 }) => {
   const ref = useRef<HTMLDivElement | null>(null);
   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();
-  });
+  const device = useDevice();
+
+  useOnClickOutside(
+    ref,
+    useCallback(
+      (event) => {
+        // If click on the library icon, do nothing.
+        if ((event.target as Element).closest(".ToolIcon__library")) {
+          return;
+        }
+        if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
+          onClose();
+        }
+      },
+      [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
+    ),
+  );
 
 
   useEffect(() => {
   useEffect(() => {
     const handleKeyDown = (event: KeyboardEvent) => {
     const handleKeyDown = (event: KeyboardEvent) => {
-      if (event.key === KEYS.ESCAPE) {
+      if (
+        event.key === KEYS.ESCAPE &&
+        (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
+      ) {
         onClose();
         onClose();
       }
       }
     };
     };
@@ -121,7 +135,7 @@ export const LibraryMenu = ({
     return () => {
     return () => {
       document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
       document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
     };
     };
-  }, [onClose]);
+  }, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
 
 
   const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
   const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
   const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
   const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
@@ -273,6 +287,7 @@ export const LibraryMenu = ({
         onInsertLibraryItems={onInsertLibraryItems}
         onInsertLibraryItems={onInsertLibraryItems}
         pendingElements={pendingElements}
         pendingElements={pendingElements}
         setAppState={setAppState}
         setAppState={setAppState}
+        appState={appState}
         libraryReturnUrl={libraryReturnUrl}
         libraryReturnUrl={libraryReturnUrl}
         library={library}
         library={library}
         theme={theme}
         theme={theme}

+ 16 - 3
src/components/LibraryMenuItems.scss

@@ -2,8 +2,17 @@
 
 
 .excalidraw {
 .excalidraw {
   .library-menu-items-container {
   .library-menu-items-container {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    padding: 0.5rem;
+    box-sizing: border-box;
+
     .library-actions {
     .library-actions {
+      width: 100%;
       display: flex;
       display: flex;
+      margin-right: auto;
+      align-items: center;
 
 
       button .library-actions-counter {
       button .library-actions-counter {
         position: absolute;
         position: absolute;
@@ -87,12 +96,16 @@
       }
       }
     }
     }
     &__items {
     &__items {
-      max-height: 50vh;
-      overflow: auto;
-      margin-top: 0.5rem;
+      flex: 1;
+      overflow-y: auto;
+      overflow-x: hidden;
+      margin-bottom: 1rem;
     }
     }
 
 
     .separator {
     .separator {
+      width: 100%;
+      display: flex;
+      align-items: center;
       font-weight: 500;
       font-weight: 500;
       font-size: 0.9rem;
       font-size: 0.9rem;
       margin: 0.6em 0.2em;
       margin: 0.6em 0.2em;

+ 177 - 39
src/components/LibraryMenuItems.tsx

@@ -12,9 +12,9 @@ import {
   LibraryItems,
   LibraryItems,
 } from "../types";
 } from "../types";
 import { arrayToMap, muteFSAbortError } from "../utils";
 import { arrayToMap, muteFSAbortError } from "../utils";
-import { useDeviceType } from "./App";
+import { useDevice } from "./App";
 import ConfirmDialog from "./ConfirmDialog";
 import ConfirmDialog from "./ConfirmDialog";
-import { exportToFileIcon, load, publishIcon, trash } from "./icons";
+import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
 import { LibraryUnit } from "./LibraryUnit";
 import { LibraryUnit } from "./LibraryUnit";
 import Stack from "./Stack";
 import Stack from "./Stack";
 import { ToolButton } from "./ToolButton";
 import { ToolButton } from "./ToolButton";
@@ -25,6 +25,9 @@ import { MIME_TYPES, VERSIONS } from "../constants";
 import Spinner from "./Spinner";
 import Spinner from "./Spinner";
 import { fileOpen } from "../data/filesystem";
 import { fileOpen } from "../data/filesystem";
 
 
+import { SidebarLockButton } from "./SidebarLockButton";
+import { trackEvent } from "../analytics";
+
 const LibraryMenuItems = ({
 const LibraryMenuItems = ({
   isLoading,
   isLoading,
   libraryItems,
   libraryItems,
@@ -34,6 +37,7 @@ const LibraryMenuItems = ({
   pendingElements,
   pendingElements,
   theme,
   theme,
   setAppState,
   setAppState,
+  appState,
   libraryReturnUrl,
   libraryReturnUrl,
   library,
   library,
   files,
   files,
@@ -52,6 +56,7 @@ const LibraryMenuItems = ({
   theme: AppState["theme"];
   theme: AppState["theme"];
   files: BinaryFiles;
   files: BinaryFiles;
   setAppState: React.Component<any, AppState>["setState"];
   setAppState: React.Component<any, AppState>["setState"];
+  appState: AppState;
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   library: Library;
   library: Library;
   id: string;
   id: string;
@@ -88,9 +93,7 @@ const LibraryMenuItems = ({
   }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
   }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
 
 
   const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
   const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
-
-  const isMobile = useDeviceType().isMobile;
-
+  const device = useDevice();
   const renderLibraryActions = () => {
   const renderLibraryActions = () => {
     const itemsSelected = !!selectedItems.length;
     const itemsSelected = !!selectedItems.length;
     const items = itemsSelected
     const items = itemsSelected
@@ -101,7 +104,7 @@ const LibraryMenuItems = ({
       : t("buttons.resetLibrary");
       : t("buttons.resetLibrary");
     return (
     return (
       <div className="library-actions">
       <div className="library-actions">
-        {(!itemsSelected || !isMobile) && (
+        {!itemsSelected && (
           <ToolButton
           <ToolButton
             key="import"
             key="import"
             type="button"
             type="button"
@@ -186,7 +189,7 @@ const LibraryMenuItems = ({
               className="library-actions--publish"
               className="library-actions--publish"
               onClick={onPublish}
               onClick={onPublish}
             >
             >
-              {!isMobile && <label>{t("buttons.publishLibrary")}</label>}
+              {!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
               {selectedItems.length > 0 && (
               {selectedItems.length > 0 && (
                 <span className="library-actions-counter">
                 <span className="library-actions-counter">
                   {selectedItems.length}
                   {selectedItems.length}
@@ -195,11 +198,25 @@ const LibraryMenuItems = ({
             </ToolButton>
             </ToolButton>
           </Tooltip>
           </Tooltip>
         )}
         )}
+        {device.isMobile && (
+          <div className="library-menu-browse-button--mobile">
+            <a
+              href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
+                window.name || "_blank"
+              }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
+                VERSIONS.excalidrawLibrary
+              }`}
+              target="_excalidraw_libraries"
+            >
+              {t("labels.libraries")}
+            </a>
+          </div>
+        )}
       </div>
       </div>
     );
     );
   };
   };
 
 
-  const CELLS_PER_ROW = isMobile ? 4 : 6;
+  const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
 
 
   const referrer =
   const referrer =
     libraryReturnUrl || window.location.origin + window.location.pathname;
     libraryReturnUrl || window.location.origin + window.location.pathname;
@@ -356,48 +373,169 @@ const LibraryMenuItems = ({
     (item) => item.status === "published",
     (item) => item.status === "published",
   );
   );
 
 
-  return (
-    <div className="library-menu-items-container">
-      {showRemoveLibAlert && renderRemoveLibAlert()}
-      <div className="layer-ui__library-header" key="library-header">
-        {renderLibraryActions()}
-        {isLoading ? (
-          <Spinner />
-        ) : (
-          <a
-            href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
-              window.name || "_blank"
-            }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
-              VERSIONS.excalidrawLibrary
-            }`}
-            target="_excalidraw_libraries"
-          >
-            {t("labels.libraries")}
-          </a>
-        )}
-      </div>
+  const renderLibraryHeader = () => {
+    return (
+      <>
+        <div className="layer-ui__library-header" key="library-header">
+          {renderLibraryActions()}
+          {device.canDeviceFitSidebar && (
+            <>
+              <div className="layer-ui__sidebar-lock-button">
+                <SidebarLockButton
+                  checked={appState.isLibraryMenuDocked}
+                  onChange={() => {
+                    document
+                      .querySelector(".layer-ui__wrapper")
+                      ?.classList.add("animate");
+                    const nextState = !appState.isLibraryMenuDocked;
+                    setAppState({
+                      isLibraryMenuDocked: nextState,
+                    });
+                    trackEvent(
+                      "library",
+                      `toggleLibraryDock (${nextState ? "dock" : "undock"})`,
+                      `sidebar (${device.isMobile ? "mobile" : "desktop"})`,
+                    );
+                  }}
+                />
+              </div>
+            </>
+          )}
+          {!device.isMobile && (
+            <div className="ToolIcon__icon__close">
+              <button
+                className="Modal__close"
+                onClick={() =>
+                  setAppState({
+                    isLibraryOpen: false,
+                  })
+                }
+                aria-label={t("buttons.close")}
+              >
+                {close}
+              </button>
+            </div>
+          )}
+        </div>
+      </>
+    );
+  };
+
+  const renderLibraryMenuItems = () => {
+    return (
       <Stack.Col
       <Stack.Col
         className="library-menu-items-container__items"
         className="library-menu-items-container__items"
         align="start"
         align="start"
         gap={1}
         gap={1}
+        style={{
+          flex: publishedItems.length > 0 ? 1 : "0 0 auto",
+          marginBottom: 0,
+        }}
       >
       >
         <>
         <>
-          <div className="separator">{t("labels.personalLib")}</div>
-          {renderLibrarySection([
-            // append pending library item
-            ...(pendingElements.length
-              ? [{ id: null, elements: pendingElements }]
-              : []),
-            ...unpublishedItems,
-          ])}
+          <div className="separator">
+            {(pendingElements.length > 0 ||
+              unpublishedItems.length > 0 ||
+              publishedItems.length > 0) && (
+              <div>{t("labels.personalLib")}</div>
+            )}
+            {isLoading && (
+              <div
+                style={{
+                  marginLeft: "auto",
+                  marginRight: "1rem",
+                  display: "flex",
+                  alignItems: "center",
+                  fontWeight: "normal",
+                }}
+              >
+                <div style={{ transform: "translateY(2px)" }}>
+                  <Spinner />
+                </div>
+              </div>
+            )}
+          </div>
+          {!pendingElements.length && !unpublishedItems.length ? (
+            <div
+              style={{
+                height: 65,
+                display: "flex",
+                flexDirection: "column",
+                alignItems: "center",
+                justifyContent: "center",
+                width: "100%",
+                fontSize: ".9rem",
+              }}
+            >
+              No items yet!
+              <div
+                style={{
+                  margin: ".6rem 0",
+                  fontSize: ".8em",
+                  width: "70%",
+                  textAlign: "center",
+                }}
+              >
+                {publishedItems.length > 0
+                  ? t("library.hint_emptyPrivateLibrary")
+                  : t("library.hint_emptyLibrary")}
+              </div>
+            </div>
+          ) : (
+            renderLibrarySection([
+              // append pending library item
+              ...(pendingElements.length
+                ? [{ id: null, elements: pendingElements }]
+                : []),
+              ...unpublishedItems,
+            ])
+          )}
         </>
         </>
 
 
         <>
         <>
-          <div className="separator">{t("labels.excalidrawLib")} </div>
-
-          {renderLibrarySection(publishedItems)}
+          {(publishedItems.length > 0 ||
+            (!device.isMobile &&
+              (pendingElements.length > 0 || unpublishedItems.length > 0))) && (
+            <div className="separator">{t("labels.excalidrawLib")}</div>
+          )}
+          {publishedItems.length > 0 && renderLibrarySection(publishedItems)}
         </>
         </>
       </Stack.Col>
       </Stack.Col>
+    );
+  };
+
+  const renderLibraryFooter = () => {
+    return (
+      <a
+        className="library-menu-browse-button"
+        href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
+          window.name || "_blank"
+        }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
+          VERSIONS.excalidrawLibrary
+        }`}
+        target="_excalidraw_libraries"
+      >
+        {t("labels.libraries")}
+      </a>
+    );
+  };
+
+  return (
+    <div
+      className="library-menu-items-container"
+      style={
+        device.isMobile
+          ? {
+              minHeight: "200px",
+              maxHeight: "70vh",
+            }
+          : undefined
+      }
+    >
+      {showRemoveLibAlert && renderRemoveLibAlert()}
+      {renderLibraryHeader()}
+      {renderLibraryMenuItems()}
+      {!device.isMobile && renderLibraryFooter()}
     </div>
     </div>
   );
   );
 };
 };

+ 1 - 5
src/components/LibraryUnit.scss

@@ -3,7 +3,7 @@
 .excalidraw {
 .excalidraw {
   .library-unit {
   .library-unit {
     align-items: center;
     align-items: center;
-    border: 1px solid var(--button-gray-2);
+    border: 1px solid transparent;
     display: flex;
     display: flex;
     justify-content: center;
     justify-content: center;
     position: relative;
     position: relative;
@@ -21,10 +21,6 @@
     }
     }
   }
   }
 
 
-  &.theme--dark .library-unit {
-    border-color: rgb(48, 48, 48);
-  }
-
   .library-unit__dragger {
   .library-unit__dragger {
     display: flex;
     display: flex;
     align-items: center;
     align-items: center;

+ 2 - 2
src/components/LibraryUnit.tsx

@@ -1,7 +1,7 @@
 import clsx from "clsx";
 import clsx from "clsx";
 import oc from "open-color";
 import oc from "open-color";
 import { useEffect, useRef, useState } from "react";
 import { useEffect, useRef, useState } from "react";
-import { useDeviceType } from "../components/App";
+import { useDevice } from "../components/App";
 import { exportToSvg } from "../scene/export";
 import { exportToSvg } from "../scene/export";
 import { BinaryFiles, LibraryItem } from "../types";
 import { BinaryFiles, LibraryItem } from "../types";
 import "./LibraryUnit.scss";
 import "./LibraryUnit.scss";
@@ -67,7 +67,7 @@ export const LibraryUnit = ({
   }, [elements, files]);
   }, [elements, files]);
 
 
   const [isHovered, setIsHovered] = useState(false);
   const [isHovered, setIsHovered] = useState(false);
-  const isMobile = useDeviceType().isMobile;
+  const isMobile = useDevice().isMobile;
   const adder = isPending && (
   const adder = isPending && (
     <div className="library-unit__adder">{PLUS_ICON}</div>
     <div className="library-unit__adder">{PLUS_ICON}</div>
   );
   );

+ 3 - 0
src/components/MobileMenu.tsx

@@ -43,6 +43,7 @@ type MobileMenuProps = {
     isMobile: boolean,
     isMobile: boolean,
     appState: AppState,
     appState: AppState,
   ) => JSX.Element | null;
   ) => JSX.Element | null;
+  renderStats: () => JSX.Element | null;
 };
 };
 
 
 export const MobileMenu = ({
 export const MobileMenu = ({
@@ -63,6 +64,7 @@ export const MobileMenu = ({
   showThemeBtn,
   showThemeBtn,
   onImageAction,
   onImageAction,
   renderTopRightUI,
   renderTopRightUI,
+  renderStats,
 }: MobileMenuProps) => {
 }: MobileMenuProps) => {
   const renderToolbar = () => {
   const renderToolbar = () => {
     return (
     return (
@@ -184,6 +186,7 @@ export const MobileMenu = ({
   return (
   return (
     <>
     <>
       {!viewModeEnabled && renderToolbar()}
       {!viewModeEnabled && renderToolbar()}
+      {renderStats()}
       <div
       <div
         className="App-bottom-bar"
         className="App-bottom-bar"
         style={{
         style={{

+ 6 - 6
src/components/Modal.tsx

@@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
 import { createPortal } from "react-dom";
 import { createPortal } from "react-dom";
 import clsx from "clsx";
 import clsx from "clsx";
 import { KEYS } from "../keys";
 import { KEYS } from "../keys";
-import { useExcalidrawContainer, useDeviceType } from "./App";
+import { useExcalidrawContainer, useDevice } from "./App";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { THEME } from "../constants";
 import { THEME } from "../constants";
 
 
@@ -59,17 +59,17 @@ export const Modal = (props: {
 const useBodyRoot = (theme: AppState["theme"]) => {
 const useBodyRoot = (theme: AppState["theme"]) => {
   const [div, setDiv] = useState<HTMLDivElement | null>(null);
   const [div, setDiv] = useState<HTMLDivElement | null>(null);
 
 
-  const deviceType = useDeviceType();
-  const isMobileRef = useRef(deviceType.isMobile);
-  isMobileRef.current = deviceType.isMobile;
+  const device = useDevice();
+  const isMobileRef = useRef(device.isMobile);
+  isMobileRef.current = device.isMobile;
 
 
   const { container: excalidrawContainer } = useExcalidrawContainer();
   const { container: excalidrawContainer } = useExcalidrawContainer();
 
 
   useLayoutEffect(() => {
   useLayoutEffect(() => {
     if (div) {
     if (div) {
-      div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
+      div.classList.toggle("excalidraw--mobile", device.isMobile);
     }
     }
-  }, [div, deviceType.isMobile]);
+  }, [div, device.isMobile]);
 
 
   useLayoutEffect(() => {
   useLayoutEffect(() => {
     const isDarkTheme =
     const isDarkTheme =

+ 22 - 0
src/components/SidebarLockButton.scss

@@ -0,0 +1,22 @@
+@import "../css/variables.module";
+
+.excalidraw {
+  .layer-ui__sidebar-lock-button {
+    @include toolbarButtonColorStates;
+    margin-right: 0.2rem;
+  }
+  .ToolIcon_type_floating .side_lock_icon {
+    width: calc(var(--space-factor) * 7);
+    height: calc(var(--space-factor) * 7);
+    svg {
+      // mirror
+      transform: scale(-1, 1);
+    }
+  }
+
+  .ToolIcon_type_checkbox {
+    &:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
+      background-color: var(--color-primary);
+    }
+  }
+}

+ 46 - 0
src/components/SidebarLockButton.tsx

@@ -0,0 +1,46 @@
+import "./ToolIcon.scss";
+
+import React from "react";
+import clsx from "clsx";
+import { ToolButtonSize } from "./ToolButton";
+import { t } from "../i18n";
+import { Tooltip } from "./Tooltip";
+
+import "./SidebarLockButton.scss";
+
+type SidebarLockIconProps = {
+  checked: boolean;
+  onChange?(): void;
+};
+
+const DEFAULT_SIZE: ToolButtonSize = "medium";
+
+const SIDE_LIBRARY_TOGGLE_ICON = (
+  <svg viewBox="0 0 24 24" fill="#ffffff">
+    <path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
+  </svg>
+);
+
+export const SidebarLockButton = (props: SidebarLockIconProps) => {
+  return (
+    <Tooltip label={t("labels.sidebarLock")}>
+      <label
+        className={clsx(
+          "ToolIcon ToolIcon__lock ToolIcon_type_floating",
+          `ToolIcon_size_${DEFAULT_SIZE}`,
+        )}
+      >
+        <input
+          className="ToolIcon_type_checkbox"
+          type="checkbox"
+          onChange={props.onChange}
+          checked={props.checked}
+          aria-label={t("labels.sidebarLock")}
+        />{" "}
+        <div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
+          {SIDE_LIBRARY_TOGGLE_ICON}
+        </div>{" "}
+      </label>{" "}
+    </Tooltip>
+  );
+};

+ 2 - 0
src/components/Stack.tsx

@@ -41,6 +41,7 @@ const ColStack = ({
   align,
   align,
   justifyContent,
   justifyContent,
   className,
   className,
+  style,
 }: StackProps) => {
 }: StackProps) => {
   return (
   return (
     <div
     <div
@@ -49,6 +50,7 @@ const ColStack = ({
         "--gap": gap,
         "--gap": gap,
         justifyItems: align,
         justifyItems: align,
         justifyContent,
         justifyContent,
+        ...style,
       }}
       }}
     >
     >
       {children}
       {children}

+ 1 - 0
src/components/Stats.scss

@@ -7,6 +7,7 @@
     right: 12px;
     right: 12px;
     font-size: 12px;
     font-size: 12px;
     z-index: 10;
     z-index: 10;
+    pointer-events: all;
 
 
     h3 {
     h3 {
       margin: 0 24px 8px 0;
       margin: 0 24px 8px 0;

+ 3 - 6
src/components/Stats.tsx

@@ -2,7 +2,7 @@ import React from "react";
 import { getCommonBounds } from "../element/bounds";
 import { getCommonBounds } from "../element/bounds";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
-import { useDeviceType } from "../components/App";
+import { useDevice } from "../components/App";
 import { getTargetElements } from "../scene";
 import { getTargetElements } from "../scene";
 import { AppState, ExcalidrawProps } from "../types";
 import { AppState, ExcalidrawProps } from "../types";
 import { close } from "./icons";
 import { close } from "./icons";
@@ -16,16 +16,13 @@ export const Stats = (props: {
   onClose: () => void;
   onClose: () => void;
   renderCustomStats: ExcalidrawProps["renderCustomStats"];
   renderCustomStats: ExcalidrawProps["renderCustomStats"];
 }) => {
 }) => {
-  const deviceType = useDeviceType();
-
+  const device = useDevice();
   const boundingBox = getCommonBounds(props.elements);
   const boundingBox = getCommonBounds(props.elements);
   const selectedElements = getTargetElements(props.elements, props.appState);
   const selectedElements = getTargetElements(props.elements, props.appState);
   const selectedBoundingBox = getCommonBounds(selectedElements);
   const selectedBoundingBox = getCommonBounds(selectedElements);
-
-  if (deviceType.isMobile && props.appState.openMenu) {
+  if (device.isMobile && props.appState.openMenu) {
     return null;
     return null;
   }
   }
-
   return (
   return (
     <div className="Stats">
     <div className="Stats">
       <Island padding={2}>
       <Island padding={2}>

+ 1 - 22
src/components/Toolbar.scss

@@ -1,26 +1,5 @@
 @import "open-color/open-color.scss";
 @import "open-color/open-color.scss";
-
-@mixin toolbarButtonColorStates {
-  .ToolIcon_type_radio,
-  .ToolIcon_type_checkbox {
-    & + .ToolIcon__icon:active {
-      background: var(--color-primary-light);
-    }
-    &:checked + .ToolIcon__icon {
-      background: var(--color-primary);
-      --icon-fill-color: #{$oc-white};
-      --keybinding-color: #{$oc-white};
-    }
-    &:checked + .ToolIcon__icon:active {
-      background: var(--color-primary-darker);
-    }
-  }
-
-  .ToolIcon__keybinding {
-    bottom: 4px;
-    right: 4px;
-  }
-}
+@import "../css/variables.module";
 
 
 .excalidraw {
 .excalidraw {
   .App-toolbar-container {
   .App-toolbar-container {

+ 10 - 0
src/constants.ts

@@ -155,9 +155,19 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
   },
   },
 };
 };
 
 
+// breakpoints
+// -----------------------------------------------------------------------------
+// sm screen
+export const MQ_SM_MAX_WIDTH = 640;
+// md screen
 export const MQ_MAX_WIDTH_PORTRAIT = 730;
 export const MQ_MAX_WIDTH_PORTRAIT = 730;
 export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
 export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
 export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
 export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
+// sidebar
+export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
+// -----------------------------------------------------------------------------
+
+export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth);
 
 
 export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
 export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
 
 

+ 18 - 1
src/css/styles.scss

@@ -350,7 +350,6 @@
     align-items: flex-start;
     align-items: flex-start;
     cursor: default;
     cursor: default;
     pointer-events: none !important;
     pointer-events: none !important;
-    z-index: 100;
 
 
     :root[dir="ltr"] & {
     :root[dir="ltr"] & {
       left: 0.25rem;
       left: 0.25rem;
@@ -391,6 +390,7 @@
 
 
   .App-menu__left {
   .App-menu__left {
     overflow-y: auto;
     overflow-y: auto;
+    box-shadow: var(--shadow-island);
   }
   }
 
 
   .dropdown-select {
   .dropdown-select {
@@ -449,6 +449,7 @@
     bottom: 30px;
     bottom: 30px;
     transform: translateX(-50%);
     transform: translateX(-50%);
     padding: 10px 20px;
     padding: 10px 20px;
+    pointer-events: all;
   }
   }
 
 
   .help-icon {
   .help-icon {
@@ -567,6 +568,22 @@
       display: none;
       display: none;
     }
     }
   }
   }
+
+  // use custom, minimalistic scrollbar
+  // (doesn't work in Firefox)
+  ::-webkit-scrollbar {
+    width: 5px;
+  }
+  ::-webkit-scrollbar-thumb {
+    background: var(--button-gray-2);
+    border-radius: 10px;
+  }
+  ::-webkit-scrollbar-thumb:hover {
+    background: var(--button-gray-3);
+  }
+  ::-webkit-scrollbar-thumb:active {
+    background: var(--button-gray-2);
+  }
 }
 }
 
 
 .ErrorSplash.excalidraw {
 .ErrorSplash.excalidraw {

+ 24 - 0
src/css/variables.module.scss

@@ -6,8 +6,32 @@
   }
   }
 }
 }
 
 
+@mixin toolbarButtonColorStates {
+  .ToolIcon_type_radio,
+  .ToolIcon_type_checkbox {
+    & + .ToolIcon__icon:active {
+      background: var(--color-primary-light);
+    }
+    &:checked + .ToolIcon__icon {
+      background: var(--color-primary);
+      --icon-fill-color: #{$oc-white};
+      --keybinding-color: #{$oc-white};
+    }
+    &:checked + .ToolIcon__icon:active {
+      background: var(--color-primary-darker);
+    }
+  }
+
+  .ToolIcon__keybinding {
+    bottom: 4px;
+    right: 4px;
+  }
+}
+
 $theme-filter: "invert(93%) hue-rotate(180deg)";
 $theme-filter: "invert(93%) hue-rotate(180deg)";
+$right-sidebar-width: "302px";
 
 
 :export {
 :export {
   themeFilter: unquote($theme-filter);
   themeFilter: unquote($theme-filter);
+  rightSidebarWidth: unquote($right-sidebar-width);
 }
 }

+ 5 - 0
src/data/restore.ts

@@ -283,6 +283,11 @@ export const restoreAppState = (
             value: appState.zoom as NormalizedZoomValue,
             value: appState.zoom as NormalizedZoomValue,
           }
           }
         : appState.zoom || defaultAppState.zoom,
         : appState.zoom || defaultAppState.zoom,
+    // when sidebar docked and user left it open in last session,
+    // keep it open. If not docked, keep it closed irrespective of last state.
+    isLibraryOpen: nextAppState.isLibraryMenuDocked
+      ? nextAppState.isLibraryOpen
+      : false,
   };
   };
 };
 };
 
 

+ 6 - 1
src/locales/en.json

@@ -120,7 +120,12 @@
       "lockAll": "Lock all",
       "lockAll": "Lock all",
       "unlockAll": "Unlock all"
       "unlockAll": "Unlock all"
     },
     },
-    "statusPublished": "Published"
+    "statusPublished": "Published",
+    "sidebarLock": "Keep sidebar open"
+  },
+  "library": {
+    "hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.",
+    "hint_emptyPrivateLibrary": "Select an item on canvas to add it here."
   },
   },
   "buttons": {
   "buttons": {
     "clearReset": "Reset the canvas",
     "clearReset": "Reset the canvas",

+ 2 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -17,6 +17,8 @@ Please add the latest change on the top under the correct section.
 
 
 #### Features
 #### Features
 
 
+- Add `[UIOptions.dockedSidebarBreakpoint]`(https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#dockedSidebarBreakpoint) to customize at which point to break from the docked sidebar [#5274](https://github.com/excalidraw/excalidraw/pull/5274).
+
 - Added support for supplying user `id` in the Collaborator object (see `collaborators` in [`updateScene()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#updateScene)), which will be used to deduplicate users when rendering collaborator avatar list. Cursors will still be rendered for every user. [#5309](https://github.com/excalidraw/excalidraw/pull/5309)
 - Added support for supplying user `id` in the Collaborator object (see `collaborators` in [`updateScene()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#updateScene)), which will be used to deduplicate users when rendering collaborator avatar list. Cursors will still be rendered for every user. [#5309](https://github.com/excalidraw/excalidraw/pull/5309)
 
 
 - Export API to [set](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#setCursor) and [reset](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#resetCursor) mouse cursor on the canvas [#5215](https://github.com/excalidraw/excalidraw/pull/5215).
 - Export API to [set](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#setCursor) and [reset](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#resetCursor) mouse cursor on the canvas [#5215](https://github.com/excalidraw/excalidraw/pull/5215).

+ 7 - 1
src/packages/excalidraw/README_NEXT.md

@@ -639,7 +639,7 @@ This prop sets the name of the drawing which will be used when exporting the dra
 
 
 #### `UIOptions`
 #### `UIOptions`
 
 
-This prop can be used to customise UI of Excalidraw. Currently we support customising only [`canvasActions`](#canvasActions). It accepts the below parameters
+This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasActions) and [`dockedSidebarBreakpoint`](dockedSidebarBreakpoint). It accepts the below parameters
 
 
 <pre>
 <pre>
 { canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }
 { canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }
@@ -657,6 +657,12 @@ This prop can be used to customise UI of Excalidraw. Currently we support custom
 | `theme` | boolean | true | Implies whether to show `Theme toggle` |
 | `theme` | boolean | true | Implies whether to show `Theme toggle` |
 | `saveAsImage` | boolean | true | Implies whether to show `Save as image button` |
 | `saveAsImage` | boolean | true | Implies whether to show `Save as image button` |
 
 
+##### `dockedSidebarBreakpoint`
+
+This prop indicates at what point should we break to a docked, permanent sidebar. If not passed it defaults to [`MQ_RIGHT_SIDEBAR_MAX_WIDTH_PORTRAIT`](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L167). If the `width` of the `excalidraw` container exceeds `dockedSidebarBreakpoint`, the sidebar will be dockable. If user choses to dock the sidebar, it will push the right part of the UI towards the left, making space for the sidebar as shown below.
+
+![image](https://user-images.githubusercontent.com/11256141/174664866-c698c3fa-197b-43ff-956c-d79852c7b326.png)
+
 #### `exportOpts`
 #### `exportOpts`
 
 
 The below attributes can be set in `UIOptions.canvasActions.export` to customize the export dialog. If `UIOptions.canvasActions.export` is `false` the export button will not be rendered.
 The below attributes can be set in `UIOptions.canvasActions.export` to customize the export dialog. If `UIOptions.canvasActions.export` is `false` the export button will not be rendered.

+ 1 - 0
src/packages/excalidraw/index.tsx

@@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
   const canvasActions = props.UIOptions?.canvasActions;
   const canvasActions = props.UIOptions?.canvasActions;
 
 
   const UIOptions: AppProps["UIOptions"] = {
   const UIOptions: AppProps["UIOptions"] = {
+    ...props.UIOptions,
     canvasActions: {
     canvasActions: {
       ...DEFAULT_UI_OPTIONS.canvasActions,
       ...DEFAULT_UI_OPTIONS.canvasActions,
       ...canvasActions,
       ...canvasActions,

+ 17 - 0
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -38,6 +38,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -211,6 +212,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -388,6 +390,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -726,6 +729,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -1064,6 +1068,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -1241,6 +1246,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -1454,6 +1460,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -1726,6 +1733,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -2082,6 +2090,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -2882,6 +2891,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -3220,6 +3230,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -3558,6 +3569,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -3976,6 +3988,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -4254,6 +4267,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -4613,6 +4627,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -4719,6 +4734,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -4803,6 +4819,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 100,
   "height": 100,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,

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

@@ -38,6 +38,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -547,6 +548,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -1062,6 +1064,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": false,
   "isBindingEnabled": false,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -1922,6 +1925,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -2143,6 +2147,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -2649,6 +2654,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -2925,6 +2931,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -3102,6 +3109,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -3591,6 +3599,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -3848,6 +3857,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -4069,6 +4079,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -4334,6 +4345,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -4609,6 +4621,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -5031,6 +5044,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -5355,6 +5369,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -5654,6 +5669,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -5881,6 +5897,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -6058,6 +6075,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -6559,6 +6577,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -6907,6 +6926,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -9146,6 +9166,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -9544,6 +9565,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -9820,6 +9842,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -10057,6 +10080,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -10363,6 +10387,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -10540,6 +10565,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -10717,6 +10743,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -10894,6 +10921,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -11101,6 +11129,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -11308,6 +11337,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -11533,6 +11563,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -11740,6 +11771,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -11917,6 +11949,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -12124,6 +12157,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -12301,6 +12335,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -12478,6 +12513,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -12703,6 +12739,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -13497,6 +13534,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -13773,6 +13811,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -13881,6 +13920,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -13987,6 +14027,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -14167,6 +14208,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -14518,6 +14560,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -14735,6 +14778,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -15647,6 +15691,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -15753,6 +15798,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -16592,6 +16638,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -17039,6 +17086,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -17338,6 +17386,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -17446,6 +17495,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -17990,6 +18040,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
@@ -18096,6 +18147,7 @@ Object {
   "gridSize": null,
   "gridSize": null,
   "height": 768,
   "height": 768,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,

+ 1 - 0
src/tests/packages/__snapshots__/utils.test.ts.snap

@@ -38,6 +38,7 @@ Object {
   "fileHandle": null,
   "fileHandle": null,
   "gridSize": null,
   "gridSize": null,
   "isBindingEnabled": true,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,

+ 22 - 16
src/types.ts

@@ -162,6 +162,7 @@ export type AppState = {
   offsetLeft: number;
   offsetLeft: number;
 
 
   isLibraryOpen: boolean;
   isLibraryOpen: boolean;
+  isLibraryMenuDocked: boolean;
   fileHandle: FileSystemHandle | null;
   fileHandle: FileSystemHandle | null;
   collaborators: Map<string, Collaborator>;
   collaborators: Map<string, Collaborator>;
   showStats: boolean;
   showStats: boolean;
@@ -291,7 +292,10 @@ export interface ExcalidrawProps {
     elements: readonly NonDeletedExcalidrawElement[],
     elements: readonly NonDeletedExcalidrawElement[],
     appState: AppState,
     appState: AppState,
   ) => JSX.Element;
   ) => JSX.Element;
-  UIOptions?: UIOptions;
+  UIOptions?: {
+    dockedSidebarBreakpoint?: number;
+    canvasActions?: CanvasActions;
+  };
   detectScroll?: boolean;
   detectScroll?: boolean;
   handleKeyboardGlobally?: boolean;
   handleKeyboardGlobally?: boolean;
   onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
   onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
@@ -349,18 +353,18 @@ type CanvasActions = {
   saveAsImage?: boolean;
   saveAsImage?: boolean;
 };
 };
 
 
-export type UIOptions = {
-  canvasActions?: CanvasActions;
-};
-
-export type AppProps = ExcalidrawProps & {
-  UIOptions: {
-    canvasActions: Required<CanvasActions> & { export: ExportOpts };
-  };
-  detectScroll: boolean;
-  handleKeyboardGlobally: boolean;
-  isCollaborating: boolean;
-};
+export type AppProps = Merge<
+  ExcalidrawProps,
+  {
+    UIOptions: {
+      canvasActions: Required<CanvasActions> & { export: ExportOpts };
+      dockedSidebarBreakpoint?: number;
+    };
+    detectScroll: boolean;
+    handleKeyboardGlobally: boolean;
+    isCollaborating: boolean;
+  }
+>;
 
 
 /** A subset of App class properties that we need to use elsewhere
 /** A subset of App class properties that we need to use elsewhere
  * in the app, eg Manager. Factored out into a separate type to keep DRY. */
  * in the app, eg Manager. Factored out into a separate type to keep DRY. */
@@ -377,7 +381,7 @@ export type AppClassProperties = {
     }
     }
   >;
   >;
   files: BinaryFiles;
   files: BinaryFiles;
-  deviceType: App["deviceType"];
+  device: App["device"];
   scene: App["scene"];
   scene: App["scene"];
 };
 };
 
 
@@ -473,7 +477,9 @@ export type ExcalidrawImperativeAPI = {
   resetCursor: InstanceType<typeof App>["resetCursor"];
   resetCursor: InstanceType<typeof App>["resetCursor"];
 };
 };
 
 
-export type DeviceType = {
+export type Device = Readonly<{
+  isSmScreen: boolean;
   isMobile: boolean;
   isMobile: boolean;
   isTouchScreen: boolean;
   isTouchScreen: boolean;
-};
+  canDeviceFitSidebar: boolean;
+}>;