Parcourir la source

feat: sidebar tabs support (#6213)

* feat: Sidebar tabs support [wip]

* tab trigger styling tweaks

* add `:hover` & `:active` states

* replace `@dwelle/tunnel-rat` with `tunnel-rat`

* make stuff more explicit

- remove `Sidebar.Header` fallback (host apps need to render manually), and stop tunneling it (render in place)
- make `docked` state explicit
- stop tunneling `Sidebar.TabTriggers` (render in place)

* redesign sidebar / library as per latest spec

* support no label on `Sidebar.Trigger`

* add Sidebar `props.onStateChange`

* style fixes

* make `appState.isSidebarDocked` into a soft user preference

* px -> rem & refactor

* remove `props.renderSidebar`

* update tests

* remove

* refactor

* rename constants

* tab triggers styling fixes

* factor out library-related logic from generic sidebar trigger

* change `props.onClose` to `onToggle`

* rename `props.value` -> `props.tab`

* add displayNames

* allow HTMLAttributes on applicable compos

* fix example App

* more styling tweaks and fixes

* fix not setting `dockable`

* more style fixes

* fix and align sidebar header button styling

* make DefaultSidebar dockable on if host apps supplies `onDock`

* stop `Sidebar.Trigger` hiding label on mobile

this should be only the default sidebar trigger behavior, and for that we don't need to use `device` hook as we handle in CSS

* fix `dockable` prop of defaultSidebar

* remove extra `typescript` dep

* remove `defaultTab` prop

in favor of explicit `tab` value in `<Sidebar.Trigger/>` and `toggleSidebar()`, to reduce API surface area and solve inconsistency of `appState.openSidebar.tab` not reflecting actual UI value if `defaultTab` was supported (without additional syncing logic which feels like the wrong solution).

* remove `onToggle` in favor of `onStateChange`

reducing API surface area

* fix restore

* comment no longer applies

* reuse `Button` component in sidebar buttons

* fix tests

* split Sidebar sub-components into files

* remove `props.dockable` in favor of `props.onDock` only

* split tests

* fix sidebar showing dock button if no `props.docked` supplied & add more tests

* reorder and group sidebar tests

* clarify

* rename classes & dedupe css

* refactor tests

* update changelog

* update changelog

---------

Co-authored-by: barnabasmolnar <[email protected]>
David Luzar il y a 2 ans
Parent
commit
e9cae918a7
61 fichiers modifiés avec 1936 ajouts et 1395 suppressions
  1. 2 2
      package.json
  2. 6 2
      src/appState.ts
  3. 114 115
      src/components/App.tsx
  4. 8 4
      src/components/Button.tsx
  5. 5 2
      src/components/ConfirmDialog.tsx
  6. 144 0
      src/components/DefaultSidebar.test.tsx
  7. 118 0
      src/components/DefaultSidebar.tsx
  8. 1 1
      src/components/Dialog.tsx
  9. 1 1
      src/components/HintViewer.tsx
  10. 70 67
      src/components/LayerUI.tsx
  11. 0 32
      src/components/LibraryButton.scss
  12. 0 57
      src/components/LibraryButton.tsx
  13. 22 48
      src/components/LibraryMenu.scss
  14. 35 177
      src/components/LibraryMenu.tsx
  15. 33 0
      src/components/LibraryMenuControlButtons.tsx
  16. 55 10
      src/components/LibraryMenuHeaderContent.tsx
  17. 2 2
      src/components/LibraryMenuItems.scss
  18. 8 14
      src/components/LibraryMenuItems.tsx
  19. 11 12
      src/components/MobileMenu.tsx
  20. 4 4
      src/components/PasteChartDialog.tsx
  21. 122 81
      src/components/Sidebar/Sidebar.scss
  22. 253 277
      src/components/Sidebar/Sidebar.test.tsx
  23. 218 120
      src/components/Sidebar/Sidebar.tsx
  24. 33 65
      src/components/Sidebar/SidebarHeader.tsx
  25. 18 0
      src/components/Sidebar/SidebarTab.tsx
  26. 26 0
      src/components/Sidebar/SidebarTabTrigger.tsx
  27. 16 0
      src/components/Sidebar/SidebarTabTriggers.tsx
  28. 36 0
      src/components/Sidebar/SidebarTabs.tsx
  29. 34 0
      src/components/Sidebar/SidebarTrigger.scss
  30. 45 0
      src/components/Sidebar/SidebarTrigger.tsx
  31. 26 8
      src/components/Sidebar/common.ts
  32. 0 32
      src/components/context/tunnels.ts
  33. 2 2
      src/components/dropdownMenu/DropdownMenuContent.tsx
  34. 4 4
      src/components/footer/Footer.tsx
  35. 4 4
      src/components/footer/FooterCenter.tsx
  36. 20 6
      src/components/hoc/withInternalFallback.tsx
  37. 0 63
      src/components/hoc/withUpstreamOverride.tsx
  38. 4 4
      src/components/main-menu/MainMenu.tsx
  39. 4 4
      src/components/welcome-screen/WelcomeScreen.Center.tsx
  40. 10 10
      src/components/welcome-screen/WelcomeScreen.Hints.tsx
  41. 7 0
      src/constants.ts
  42. 36 0
      src/context/tunnels.ts
  43. 5 0
      src/context/ui-appState.ts
  44. 1 1
      src/css/styles.scss
  45. 6 0
      src/css/theme.scss
  46. 14 4
      src/css/variables.module.scss
  47. 18 4
      src/data/library.ts
  48. 12 20
      src/data/restore.ts
  49. 2 4
      src/data/types.ts
  50. 1 1
      src/hooks/useOutsideClick.ts
  51. 16 0
      src/packages/excalidraw/CHANGELOG.md
  52. 24 27
      src/packages/excalidraw/example/App.tsx
  53. 4 2
      src/packages/excalidraw/index.tsx
  54. 17 17
      src/tests/__snapshots__/contextmenu.test.tsx.snap
  55. 53 53
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  56. 24 1
      src/tests/data/restore.test.ts
  57. 7 2
      src/tests/library.test.tsx
  58. 1 1
      src/tests/packages/__snapshots__/utils.test.ts.snap
  59. 15 8
      src/types.ts
  60. 17 3
      src/utils.ts
  61. 142 17
      yarn.lock

+ 2 - 2
package.json

@@ -19,7 +19,7 @@
     ]
     ]
   },
   },
   "dependencies": {
   "dependencies": {
-    "@dwelle/tunnel-rat": "0.1.1",
+    "@radix-ui/react-tabs": "1.0.2",
     "@sentry/browser": "6.2.5",
     "@sentry/browser": "6.2.5",
     "@sentry/integrations": "6.2.5",
     "@sentry/integrations": "6.2.5",
     "@testing-library/jest-dom": "5.16.2",
     "@testing-library/jest-dom": "5.16.2",
@@ -51,7 +51,7 @@
     "roughjs": "4.5.2",
     "roughjs": "4.5.2",
     "sass": "1.51.0",
     "sass": "1.51.0",
     "socket.io-client": "2.3.1",
     "socket.io-client": "2.3.1",
-    "tunnel-rat": "0.1.0",
+    "tunnel-rat": "0.1.2",
     "workbox-background-sync": "^6.5.4",
     "workbox-background-sync": "^6.5.4",
     "workbox-broadcast-update": "^6.5.4",
     "workbox-broadcast-update": "^6.5.4",
     "workbox-cacheable-response": "^6.5.4",
     "workbox-cacheable-response": "^6.5.4",

+ 6 - 2
src/appState.ts

@@ -58,7 +58,7 @@ export const getDefaultAppState = (): Omit<
     fileHandle: null,
     fileHandle: null,
     gridSize: null,
     gridSize: null,
     isBindingEnabled: true,
     isBindingEnabled: true,
-    isSidebarDocked: false,
+    defaultSidebarDockedPreference: false,
     isLoading: false,
     isLoading: false,
     isResizing: false,
     isResizing: false,
     isRotating: false,
     isRotating: false,
@@ -150,7 +150,11 @@ 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 },
-  isSidebarDocked: { browser: true, export: false, server: false },
+  defaultSidebarDockedPreference: {
+    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 },

+ 114 - 115
src/components/App.tsx

@@ -210,6 +210,8 @@ import {
   PointerDownState,
   PointerDownState,
   SceneData,
   SceneData,
   Device,
   Device,
+  SidebarName,
+  SidebarTabName,
 } from "../types";
 } from "../types";
 import {
 import {
   debounce,
   debounce,
@@ -299,6 +301,9 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
 import { actionWrapTextInContainer } from "../actions/actionBoundText";
 import { actionWrapTextInContainer } from "../actions/actionBoundText";
 import BraveMeasureTextError from "./BraveMeasureTextError";
 import BraveMeasureTextError from "./BraveMeasureTextError";
 
 
+const AppContext = React.createContext<AppClassProperties>(null!);
+const AppPropsContext = React.createContext<AppProps>(null!);
+
 const deviceContextInitialValue = {
 const deviceContextInitialValue = {
   isSmScreen: false,
   isSmScreen: false,
   isMobile: false,
   isMobile: false,
@@ -340,6 +345,8 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
 );
 );
 ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
 ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
 
 
+export const useApp = () => useContext(AppContext);
+export const useAppProps = () => useContext(AppPropsContext);
 export const useDevice = () => useContext<Device>(DeviceContext);
 export const useDevice = () => useContext<Device>(DeviceContext);
 export const useExcalidrawContainer = () =>
 export const useExcalidrawContainer = () =>
   useContext(ExcalidrawContainerContext);
   useContext(ExcalidrawContainerContext);
@@ -400,7 +407,7 @@ class App extends React.Component<AppProps, AppState> {
   private nearestScrollableContainer: HTMLElement | Document | undefined;
   private nearestScrollableContainer: HTMLElement | Document | undefined;
   public library: AppClassProperties["library"];
   public library: AppClassProperties["library"];
   public libraryItemsFromStorage: LibraryItems | undefined;
   public libraryItemsFromStorage: LibraryItems | undefined;
-  private id: string;
+  public id: string;
   private history: History;
   private history: History;
   private excalidrawContainerValue: {
   private excalidrawContainerValue: {
     container: HTMLDivElement | null;
     container: HTMLDivElement | null;
@@ -438,7 +445,7 @@ class App extends React.Component<AppProps, AppState> {
       width: window.innerWidth,
       width: window.innerWidth,
       height: window.innerHeight,
       height: window.innerHeight,
       showHyperlinkPopup: false,
       showHyperlinkPopup: false,
-      isSidebarDocked: false,
+      defaultSidebarDockedPreference: false,
     };
     };
 
 
     this.id = nanoid();
     this.id = nanoid();
@@ -469,7 +476,7 @@ class App extends React.Component<AppProps, AppState> {
         setActiveTool: this.setActiveTool,
         setActiveTool: this.setActiveTool,
         setCursor: this.setCursor,
         setCursor: this.setCursor,
         resetCursor: this.resetCursor,
         resetCursor: this.resetCursor,
-        toggleMenu: this.toggleMenu,
+        toggleSidebar: this.toggleSidebar,
       } as const;
       } as const;
       if (typeof excalidrawRef === "function") {
       if (typeof excalidrawRef === "function") {
         excalidrawRef(api);
         excalidrawRef(api);
@@ -577,101 +584,91 @@ class App extends React.Component<AppProps, AppState> {
           this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
           this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
         }
         }
       >
       >
-        <ExcalidrawContainerContext.Provider
-          value={this.excalidrawContainerValue}
-        >
-          <DeviceContext.Provider value={this.device}>
-            <ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
-              <ExcalidrawAppStateContext.Provider value={this.state}>
-                <ExcalidrawElementsContext.Provider
-                  value={this.scene.getNonDeletedElements()}
-                >
-                  <ExcalidrawActionManagerContext.Provider
-                    value={this.actionManager}
-                  >
-                    <LayerUI
-                      canvas={this.canvas}
-                      appState={this.state}
-                      files={this.files}
-                      setAppState={this.setAppState}
-                      actionManager={this.actionManager}
-                      elements={this.scene.getNonDeletedElements()}
-                      onLockToggle={this.toggleLock}
-                      onPenModeToggle={this.togglePenMode}
-                      onHandToolToggle={this.onHandToolToggle}
-                      onInsertElements={(elements) =>
-                        this.addElementsFromPasteOrLibrary({
-                          elements,
-                          position: "center",
-                          files: null,
-                        })
-                      }
-                      langCode={getLanguage().code}
-                      renderTopRightUI={renderTopRightUI}
-                      renderCustomStats={renderCustomStats}
-                      renderCustomSidebar={this.props.renderSidebar}
-                      showExitZenModeBtn={
-                        typeof this.props?.zenModeEnabled === "undefined" &&
-                        this.state.zenModeEnabled
-                      }
-                      libraryReturnUrl={this.props.libraryReturnUrl}
-                      UIOptions={this.props.UIOptions}
-                      focusContainer={this.focusContainer}
-                      library={this.library}
-                      id={this.id}
-                      onImageAction={this.onImageAction}
-                      renderWelcomeScreen={
-                        !this.state.isLoading &&
-                        this.state.showWelcomeScreen &&
-                        this.state.activeTool.type === "selection" &&
-                        !this.scene.getElementsIncludingDeleted().length
-                      }
+        <AppContext.Provider value={this}>
+          <AppPropsContext.Provider value={this.props}>
+            <ExcalidrawContainerContext.Provider
+              value={this.excalidrawContainerValue}
+            >
+              <DeviceContext.Provider value={this.device}>
+                <ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
+                  <ExcalidrawAppStateContext.Provider value={this.state}>
+                    <ExcalidrawElementsContext.Provider
+                      value={this.scene.getNonDeletedElements()}
                     >
                     >
-                      {this.props.children}
-                    </LayerUI>
-                    <div className="excalidraw-textEditorContainer" />
-                    <div className="excalidraw-contextMenuContainer" />
-                    {selectedElement.length === 1 &&
-                      !this.state.contextMenu &&
-                      this.state.showHyperlinkPopup && (
-                        <Hyperlink
-                          key={selectedElement[0].id}
-                          element={selectedElement[0]}
+                      <ExcalidrawActionManagerContext.Provider
+                        value={this.actionManager}
+                      >
+                        <LayerUI
+                          canvas={this.canvas}
+                          appState={this.state}
+                          files={this.files}
                           setAppState={this.setAppState}
                           setAppState={this.setAppState}
-                          onLinkOpen={this.props.onLinkOpen}
-                        />
-                      )}
-                    {this.state.toast !== null && (
-                      <Toast
-                        message={this.state.toast.message}
-                        onClose={() => this.setToast(null)}
-                        duration={this.state.toast.duration}
-                        closable={this.state.toast.closable}
-                      />
-                    )}
-                    {this.state.contextMenu && (
-                      <ContextMenu
-                        items={this.state.contextMenu.items}
-                        top={this.state.contextMenu.top}
-                        left={this.state.contextMenu.left}
-                        actionManager={this.actionManager}
-                      />
-                    )}
-                    <main>{this.renderCanvas()}</main>
-                  </ExcalidrawActionManagerContext.Provider>
-                </ExcalidrawElementsContext.Provider>{" "}
-              </ExcalidrawAppStateContext.Provider>
-            </ExcalidrawSetAppStateContext.Provider>
-          </DeviceContext.Provider>
-        </ExcalidrawContainerContext.Provider>
+                          actionManager={this.actionManager}
+                          elements={this.scene.getNonDeletedElements()}
+                          onLockToggle={this.toggleLock}
+                          onPenModeToggle={this.togglePenMode}
+                          onHandToolToggle={this.onHandToolToggle}
+                          langCode={getLanguage().code}
+                          renderTopRightUI={renderTopRightUI}
+                          renderCustomStats={renderCustomStats}
+                          showExitZenModeBtn={
+                            typeof this.props?.zenModeEnabled === "undefined" &&
+                            this.state.zenModeEnabled
+                          }
+                          UIOptions={this.props.UIOptions}
+                          onImageAction={this.onImageAction}
+                          renderWelcomeScreen={
+                            !this.state.isLoading &&
+                            this.state.showWelcomeScreen &&
+                            this.state.activeTool.type === "selection" &&
+                            !this.scene.getElementsIncludingDeleted().length
+                          }
+                        >
+                          {this.props.children}
+                        </LayerUI>
+                        <div className="excalidraw-textEditorContainer" />
+                        <div className="excalidraw-contextMenuContainer" />
+                        {selectedElement.length === 1 &&
+                          !this.state.contextMenu &&
+                          this.state.showHyperlinkPopup && (
+                            <Hyperlink
+                              key={selectedElement[0].id}
+                              element={selectedElement[0]}
+                              setAppState={this.setAppState}
+                              onLinkOpen={this.props.onLinkOpen}
+                            />
+                          )}
+                        {this.state.toast !== null && (
+                          <Toast
+                            message={this.state.toast.message}
+                            onClose={() => this.setToast(null)}
+                            duration={this.state.toast.duration}
+                            closable={this.state.toast.closable}
+                          />
+                        )}
+                        {this.state.contextMenu && (
+                          <ContextMenu
+                            items={this.state.contextMenu.items}
+                            top={this.state.contextMenu.top}
+                            left={this.state.contextMenu.left}
+                            actionManager={this.actionManager}
+                          />
+                        )}
+                        <main>{this.renderCanvas()}</main>
+                      </ExcalidrawActionManagerContext.Provider>
+                    </ExcalidrawElementsContext.Provider>{" "}
+                  </ExcalidrawAppStateContext.Provider>
+                </ExcalidrawSetAppStateContext.Provider>
+              </DeviceContext.Provider>
+            </ExcalidrawContainerContext.Provider>
+          </AppPropsContext.Provider>
+        </AppContext.Provider>
       </div>
       </div>
     );
     );
   }
   }
 
 
   public focusContainer: AppClassProperties["focusContainer"] = () => {
   public focusContainer: AppClassProperties["focusContainer"] = () => {
-    if (this.props.autoFocus) {
-      this.excalidrawContainerRef.current?.focus();
-    }
+    this.excalidrawContainerRef.current?.focus();
   };
   };
 
 
   public getSceneElementsIncludingDeleted = () => {
   public getSceneElementsIncludingDeleted = () => {
@@ -682,6 +679,14 @@ class App extends React.Component<AppProps, AppState> {
     return this.scene.getNonDeletedElements();
     return this.scene.getNonDeletedElements();
   };
   };
 
 
+  public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
+    this.addElementsFromPasteOrLibrary({
+      elements,
+      position: "center",
+      files: null,
+    });
+  };
+
   private syncActionResult = withBatchedUpdates(
   private syncActionResult = withBatchedUpdates(
     (actionResult: ActionResult) => {
     (actionResult: ActionResult) => {
       if (this.unmounted || actionResult === false) {
       if (this.unmounted || actionResult === false) {
@@ -951,7 +956,7 @@ class App extends React.Component<AppProps, AppState> {
     this.scene.addCallback(this.onSceneUpdated);
     this.scene.addCallback(this.onSceneUpdated);
     this.addEventListeners();
     this.addEventListeners();
 
 
-    if (this.excalidrawContainerRef.current) {
+    if (this.props.autoFocus && this.excalidrawContainerRef.current) {
       this.focusContainer();
       this.focusContainer();
     }
     }
 
 
@@ -1679,7 +1684,7 @@ class App extends React.Component<AppProps, AppState> {
           openSidebar:
           openSidebar:
             this.state.openSidebar &&
             this.state.openSidebar &&
             this.device.canDeviceFitSidebar &&
             this.device.canDeviceFitSidebar &&
-            this.state.isSidebarDocked
+            this.state.defaultSidebarDockedPreference
               ? this.state.openSidebar
               ? this.state.openSidebar
               : null,
               : null,
           selectedElementIds: newElements.reduce(
           selectedElementIds: newElements.reduce(
@@ -2017,30 +2022,24 @@ class App extends React.Component<AppProps, AppState> {
   /**
   /**
    * @returns whether the menu was toggled on or off
    * @returns whether the menu was toggled on or off
    */
    */
-  public toggleMenu = (
-    type: "library" | "customSidebar",
-    force?: boolean,
-  ): boolean => {
-    if (type === "customSidebar" && !this.props.renderSidebar) {
-      console.warn(
-        `attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`,
-      );
-      return false;
-    }
-
-    if (type === "library" || type === "customSidebar") {
-      let nextValue;
-      if (force === undefined) {
-        nextValue = this.state.openSidebar === type ? null : type;
-      } else {
-        nextValue = force ? type : null;
-      }
-      this.setState({ openSidebar: nextValue });
-
-      return !!nextValue;
+  public toggleSidebar = ({
+    name,
+    tab,
+    force,
+  }: {
+    name: SidebarName;
+    tab?: SidebarTabName;
+    force?: boolean;
+  }): boolean => {
+    let nextName;
+    if (force === undefined) {
+      nextName = this.state.openSidebar?.name === name ? null : name;
+    } else {
+      nextName = force ? name : null;
     }
     }
+    this.setState({ openSidebar: nextName ? { name: nextName, tab } : null });
 
 
-    return false;
+    return !!nextName;
   };
   };
 
 
   private updateCurrentCursorPosition = withBatchedUpdates(
   private updateCurrentCursorPosition = withBatchedUpdates(

+ 8 - 4
src/components/Button.tsx

@@ -1,8 +1,12 @@
+import clsx from "clsx";
+import { composeEventHandlers } from "../utils";
 import "./Button.scss";
 import "./Button.scss";
 
 
 interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
 interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
   type?: "button" | "submit" | "reset";
   type?: "button" | "submit" | "reset";
   onSelect: () => any;
   onSelect: () => any;
+  /** whether button is in active state */
+  selected?: boolean;
   children: React.ReactNode;
   children: React.ReactNode;
   className?: string;
   className?: string;
 }
 }
@@ -15,18 +19,18 @@ interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
 export const Button = ({
 export const Button = ({
   type = "button",
   type = "button",
   onSelect,
   onSelect,
+  selected,
   children,
   children,
   className = "",
   className = "",
   ...rest
   ...rest
 }: ButtonProps) => {
 }: ButtonProps) => {
   return (
   return (
     <button
     <button
-      onClick={(event) => {
+      onClick={composeEventHandlers(rest.onClick, (event) => {
         onSelect();
         onSelect();
-        rest.onClick?.(event);
-      }}
+      })}
       type={type}
       type={type}
-      className={`excalidraw-button ${className}`}
+      className={clsx("excalidraw-button", className, { selected })}
       {...rest}
       {...rest}
     >
     >
       {children}
       {children}

+ 5 - 2
src/components/ConfirmDialog.tsx

@@ -4,8 +4,8 @@ import { Dialog, DialogProps } from "./Dialog";
 import "./ConfirmDialog.scss";
 import "./ConfirmDialog.scss";
 import DialogActionButton from "./DialogActionButton";
 import DialogActionButton from "./DialogActionButton";
 import { useSetAtom } from "jotai";
 import { useSetAtom } from "jotai";
-import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
-import { useExcalidrawSetAppState } from "./App";
+import { isLibraryMenuOpenAtom } from "./LibraryMenu";
+import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
 import { jotaiScope } from "../jotai";
 import { jotaiScope } from "../jotai";
 
 
 interface Props extends Omit<DialogProps, "onCloseRequest"> {
 interface Props extends Omit<DialogProps, "onCloseRequest"> {
@@ -26,6 +26,7 @@ const ConfirmDialog = (props: Props) => {
   } = props;
   } = props;
   const setAppState = useExcalidrawSetAppState();
   const setAppState = useExcalidrawSetAppState();
   const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
   const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
+  const { container } = useExcalidrawContainer();
 
 
   return (
   return (
     <Dialog
     <Dialog
@@ -42,6 +43,7 @@ const ConfirmDialog = (props: Props) => {
             setAppState({ openMenu: null });
             setAppState({ openMenu: null });
             setIsLibraryMenuOpen(false);
             setIsLibraryMenuOpen(false);
             onCancel();
             onCancel();
+            container?.focus();
           }}
           }}
         />
         />
         <DialogActionButton
         <DialogActionButton
@@ -50,6 +52,7 @@ const ConfirmDialog = (props: Props) => {
             setAppState({ openMenu: null });
             setAppState({ openMenu: null });
             setIsLibraryMenuOpen(false);
             setIsLibraryMenuOpen(false);
             onConfirm();
             onConfirm();
+            container?.focus();
           }}
           }}
           actionType="danger"
           actionType="danger"
         />
         />

+ 144 - 0
src/components/DefaultSidebar.test.tsx

@@ -0,0 +1,144 @@
+import React from "react";
+import { DEFAULT_SIDEBAR } from "../constants";
+import { DefaultSidebar } from "../packages/excalidraw/index";
+import {
+  fireEvent,
+  waitFor,
+  withExcalidrawDimensions,
+} from "../tests/test-utils";
+import {
+  assertExcalidrawWithSidebar,
+  assertSidebarDockButton,
+} from "./Sidebar/Sidebar.test";
+
+const { h } = window;
+
+describe("DefaultSidebar", () => {
+  it("when `docked={undefined}` & `onDock={undefined}`, should allow docking", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { dockButton } = await assertSidebarDockButton(true);
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(true);
+          expect(dockButton).toHaveClass("selected");
+        });
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(false);
+          expect(dockButton).not.toHaveClass("selected");
+        });
+      },
+    );
+  });
+
+  it("when `docked={undefined}` & `onDock`, should allow docking", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar onDock={() => {}} />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { dockButton } = await assertSidebarDockButton(true);
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(true);
+          expect(dockButton).toHaveClass("selected");
+        });
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(false);
+          expect(dockButton).not.toHaveClass("selected");
+        });
+      },
+    );
+  });
+
+  it("when `docked={true}` & `onDock`, should allow docking", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar onDock={() => {}} />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { dockButton } = await assertSidebarDockButton(true);
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(true);
+          expect(dockButton).toHaveClass("selected");
+        });
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(false);
+          expect(dockButton).not.toHaveClass("selected");
+        });
+      },
+    );
+  });
+
+  it("when `onDock={false}`, should disable docking", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar onDock={false} />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        await withExcalidrawDimensions(
+          { width: 1920, height: 1080 },
+          async () => {
+            expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+            await assertSidebarDockButton(false);
+          },
+        );
+      },
+    );
+  });
+
+  it("when `docked={true}` & `onDock={false}`, should force-dock sidebar", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar docked onDock={false} />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { sidebar } = await assertSidebarDockButton(false);
+        expect(sidebar).toHaveClass("sidebar--docked");
+      },
+    );
+  });
+
+  it("when `docked={true}` & `onDock={undefined}`, should force-dock sidebar", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar docked />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { sidebar } = await assertSidebarDockButton(false);
+        expect(sidebar).toHaveClass("sidebar--docked");
+      },
+    );
+  });
+
+  it("when `docked={false}` & `onDock={undefined}`, should force-undock sidebar", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar docked={false} />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { sidebar } = await assertSidebarDockButton(false);
+        expect(sidebar).not.toHaveClass("sidebar--docked");
+      },
+    );
+  });
+});

+ 118 - 0
src/components/DefaultSidebar.tsx

@@ -0,0 +1,118 @@
+import clsx from "clsx";
+import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
+import { useTunnels } from "../context/tunnels";
+import { useUIAppState } from "../context/ui-appState";
+import { t } from "../i18n";
+import { MarkOptional, Merge } from "../utility-types";
+import { composeEventHandlers } from "../utils";
+import { useExcalidrawSetAppState } from "./App";
+import { withInternalFallback } from "./hoc/withInternalFallback";
+import { LibraryMenu } from "./LibraryMenu";
+import { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
+import { Sidebar } from "./Sidebar/Sidebar";
+
+const DefaultSidebarTrigger = withInternalFallback(
+  "DefaultSidebarTrigger",
+  (
+    props: Omit<SidebarTriggerProps, "name"> &
+      React.HTMLAttributes<HTMLDivElement>,
+  ) => {
+    const { DefaultSidebarTriggerTunnel } = useTunnels();
+    return (
+      <DefaultSidebarTriggerTunnel.In>
+        <Sidebar.Trigger
+          {...props}
+          className="default-sidebar-trigger"
+          name={DEFAULT_SIDEBAR.name}
+        />
+      </DefaultSidebarTriggerTunnel.In>
+    );
+  },
+);
+DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
+
+const DefaultTabTriggers = ({
+  children,
+  ...rest
+}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
+  const { DefaultSidebarTabTriggersTunnel } = useTunnels();
+  return (
+    <DefaultSidebarTabTriggersTunnel.In>
+      <Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
+    </DefaultSidebarTabTriggersTunnel.In>
+  );
+};
+DefaultTabTriggers.displayName = "DefaultTabTriggers";
+
+export const DefaultSidebar = Object.assign(
+  withInternalFallback(
+    "DefaultSidebar",
+    ({
+      children,
+      className,
+      onDock,
+      docked,
+      ...rest
+    }: Merge<
+      MarkOptional<Omit<SidebarProps, "name">, "children">,
+      {
+        /** pass `false` to disable docking */
+        onDock?: SidebarProps["onDock"] | false;
+      }
+    >) => {
+      const appState = useUIAppState();
+      const setAppState = useExcalidrawSetAppState();
+
+      const { DefaultSidebarTabTriggersTunnel } = useTunnels();
+
+      return (
+        <Sidebar
+          {...rest}
+          name="default"
+          key="default"
+          className={clsx("default-sidebar", className)}
+          docked={docked ?? appState.defaultSidebarDockedPreference}
+          onDock={
+            // `onDock=false` disables docking.
+            // if `docked` passed, but no onDock passed, disable manual docking.
+            onDock === false || (!onDock && docked != null)
+              ? undefined
+              : // compose to allow the host app to listen on default behavior
+                composeEventHandlers(onDock, (docked) => {
+                  setAppState({ defaultSidebarDockedPreference: docked });
+                })
+          }
+        >
+          <Sidebar.Tabs>
+            <Sidebar.Header>
+              {rest.__fallback && (
+                <div
+                  style={{
+                    color: "var(--color-primary)",
+                    fontSize: "1.2em",
+                    fontWeight: "bold",
+                    textOverflow: "ellipsis",
+                    overflow: "hidden",
+                    whiteSpace: "nowrap",
+                    paddingRight: "1em",
+                  }}
+                >
+                  {t("toolBar.library")}
+                </div>
+              )}
+              <DefaultSidebarTabTriggersTunnel.Out />
+            </Sidebar.Header>
+            <Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
+              <LibraryMenu />
+            </Sidebar.Tab>
+            {children}
+          </Sidebar.Tabs>
+        </Sidebar>
+      );
+    },
+  ),
+  {
+    Trigger: DefaultSidebarTrigger,
+    TabTriggers: DefaultTabTriggers,
+  },
+);

+ 1 - 1
src/components/Dialog.tsx

@@ -15,7 +15,7 @@ import { Modal } from "./Modal";
 import { AppState } from "../types";
 import { AppState } from "../types";
 import { queryFocusableElements } from "../utils";
 import { queryFocusableElements } from "../utils";
 import { useSetAtom } from "jotai";
 import { useSetAtom } from "jotai";
-import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
+import { isLibraryMenuOpenAtom } from "./LibraryMenu";
 import { jotaiScope } from "../jotai";
 import { jotaiScope } from "../jotai";
 
 
 export interface DialogProps {
 export interface DialogProps {

+ 1 - 1
src/components/HintViewer.tsx

@@ -29,7 +29,7 @@ const getHints = ({
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
   const multiMode = appState.multiElement !== null;
 
 
-  if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) {
+  if (appState.openSidebar && !device.canDeviceFitSidebar) {
     return null;
     return null;
   }
   }
 
 

+ 70 - 67
src/components/LayerUI.tsx

@@ -1,7 +1,7 @@
 import clsx from "clsx";
 import clsx from "clsx";
 import React from "react";
 import React from "react";
 import { ActionManager } from "../actions/manager";
 import { ActionManager } from "../actions/manager";
-import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
+import { CLASSES, DEFAULT_SIDEBAR, 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";
@@ -9,7 +9,7 @@ import { Language, t } from "../i18n";
 import { calculateScrollCenter } from "../scene";
 import { calculateScrollCenter } from "../scene";
 import { ExportType } from "../scene/types";
 import { ExportType } from "../scene/types";
 import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
 import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
-import { isShallowEqual, muteFSAbortError } from "../utils";
+import { capitalizeString, isShallowEqual, muteFSAbortError } from "../utils";
 import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
 import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
 import { ErrorDialog } from "./ErrorDialog";
 import { ErrorDialog } from "./ErrorDialog";
 import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
 import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
@@ -24,28 +24,28 @@ import { Section } from "./Section";
 import { HelpDialog } from "./HelpDialog";
 import { HelpDialog } from "./HelpDialog";
 import Stack from "./Stack";
 import Stack from "./Stack";
 import { UserList } from "./UserList";
 import { UserList } from "./UserList";
-import Library from "../data/library";
 import { JSONExportDialog } from "./JSONExportDialog";
 import { JSONExportDialog } from "./JSONExportDialog";
-import { LibraryButton } from "./LibraryButton";
 import { isImageFileHandle } from "../data/blob";
 import { isImageFileHandle } from "../data/blob";
-import { LibraryMenu } from "./LibraryMenu";
-
-import "./LayerUI.scss";
-import "./Toolbar.scss";
 import { PenModeButton } from "./PenModeButton";
 import { PenModeButton } from "./PenModeButton";
 import { trackEvent } from "../analytics";
 import { trackEvent } from "../analytics";
 import { useDevice } from "../components/App";
 import { useDevice } from "../components/App";
 import { Stats } from "./Stats";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions/actionToggleStats";
 import { actionToggleStats } from "../actions/actionToggleStats";
 import Footer from "./footer/Footer";
 import Footer from "./footer/Footer";
-import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
+import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
 import { jotaiScope } from "../jotai";
 import { jotaiScope } from "../jotai";
-import { Provider, useAtom } from "jotai";
+import { Provider, useAtomValue } from "jotai";
 import MainMenu from "./main-menu/MainMenu";
 import MainMenu from "./main-menu/MainMenu";
 import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
 import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
 import { HandButton } from "./HandButton";
 import { HandButton } from "./HandButton";
 import { isHandToolActive } from "../appState";
 import { isHandToolActive } from "../appState";
-import { TunnelsContext, useInitializeTunnels } from "./context/tunnels";
+import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
+import { LibraryIcon } from "./icons";
+import { UIAppStateContext } from "../context/ui-appState";
+import { DefaultSidebar } from "./DefaultSidebar";
+
+import "./LayerUI.scss";
+import "./Toolbar.scss";
 
 
 interface LayerUIProps {
 interface LayerUIProps {
   actionManager: ActionManager;
   actionManager: ActionManager;
@@ -57,17 +57,11 @@ interface LayerUIProps {
   onLockToggle: () => void;
   onLockToggle: () => void;
   onHandToolToggle: () => void;
   onHandToolToggle: () => void;
   onPenModeToggle: () => void;
   onPenModeToggle: () => void;
-  onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
   showExitZenModeBtn: boolean;
   showExitZenModeBtn: boolean;
   langCode: Language["code"];
   langCode: Language["code"];
   renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
   renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
   renderCustomStats?: ExcalidrawProps["renderCustomStats"];
   renderCustomStats?: ExcalidrawProps["renderCustomStats"];
-  renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
-  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   UIOptions: AppProps["UIOptions"];
   UIOptions: AppProps["UIOptions"];
-  focusContainer: () => void;
-  library: Library;
-  id: string;
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
   renderWelcomeScreen: boolean;
   renderWelcomeScreen: boolean;
   children?: React.ReactNode;
   children?: React.ReactNode;
@@ -109,16 +103,10 @@ const LayerUI = ({
   onLockToggle,
   onLockToggle,
   onHandToolToggle,
   onHandToolToggle,
   onPenModeToggle,
   onPenModeToggle,
-  onInsertElements,
   showExitZenModeBtn,
   showExitZenModeBtn,
   renderTopRightUI,
   renderTopRightUI,
   renderCustomStats,
   renderCustomStats,
-  renderCustomSidebar,
-  libraryReturnUrl,
   UIOptions,
   UIOptions,
-  focusContainer,
-  library,
-  id,
   onImageAction,
   onImageAction,
   renderWelcomeScreen,
   renderWelcomeScreen,
   children,
   children,
@@ -197,8 +185,8 @@ const LayerUI = ({
     <div style={{ position: "relative" }}>
     <div style={{ position: "relative" }}>
       {/* wrapping to Fragment stops React from occasionally complaining
       {/* wrapping to Fragment stops React from occasionally complaining
                 about identical Keys */}
                 about identical Keys */}
-      <tunnels.mainMenuTunnel.Out />
-      {renderWelcomeScreen && <tunnels.welcomeScreenMenuHintTunnel.Out />}
+      <tunnels.MainMenuTunnel.Out />
+      {renderWelcomeScreen && <tunnels.WelcomeScreenMenuHintTunnel.Out />}
     </div>
     </div>
   );
   );
 
 
@@ -250,7 +238,7 @@ const LayerUI = ({
               {(heading: React.ReactNode) => (
               {(heading: React.ReactNode) => (
                 <div style={{ position: "relative" }}>
                 <div style={{ position: "relative" }}>
                   {renderWelcomeScreen && (
                   {renderWelcomeScreen && (
-                    <tunnels.welcomeScreenToolbarHintTunnel.Out />
+                    <tunnels.WelcomeScreenToolbarHintTunnel.Out />
                   )}
                   )}
                   <Stack.Col gap={4} align="start">
                   <Stack.Col gap={4} align="start">
                     <Stack.Row
                     <Stack.Row
@@ -324,9 +312,12 @@ const LayerUI = ({
           >
           >
             <UserList collaborators={appState.collaborators} />
             <UserList collaborators={appState.collaborators} />
             {renderTopRightUI?.(device.isMobile, appState)}
             {renderTopRightUI?.(device.isMobile, appState)}
-            {!appState.viewModeEnabled && (
-              <LibraryButton appState={appState} setAppState={setAppState} />
-            )}
+            {!appState.viewModeEnabled &&
+              // hide button when sidebar docked
+              (!isSidebarDocked ||
+                appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
+                <tunnels.DefaultSidebarTriggerTunnel.Out />
+              )}
           </div>
           </div>
         </div>
         </div>
       </FixedSideContainer>
       </FixedSideContainer>
@@ -334,21 +325,21 @@ const LayerUI = ({
   };
   };
 
 
   const renderSidebars = () => {
   const renderSidebars = () => {
-    return appState.openSidebar === "customSidebar" ? (
-      renderCustomSidebar?.() || null
-    ) : appState.openSidebar === "library" ? (
-      <LibraryMenu
-        appState={appState}
-        onInsertElements={onInsertElements}
-        libraryReturnUrl={libraryReturnUrl}
-        focusContainer={focusContainer}
-        library={library}
-        id={id}
+    return (
+      <DefaultSidebar
+        __fallback
+        onDock={(docked) => {
+          trackEvent(
+            "sidebar",
+            `toggleDock (${docked ? "dock" : "undock"})`,
+            `(${device.isMobile ? "mobile" : "desktop"})`,
+          );
+        }}
       />
       />
-    ) : null;
+    );
   };
   };
 
 
-  const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
+  const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
 
 
   const layerUIJSX = (
   const layerUIJSX = (
     <>
     <>
@@ -358,8 +349,25 @@ const LayerUI = ({
       {children}
       {children}
       {/* render component fallbacks. Can be rendered anywhere as they'll be
       {/* render component fallbacks. Can be rendered anywhere as they'll be
           tunneled away. We only render tunneled components that actually
           tunneled away. We only render tunneled components that actually
-          have defaults when host do not render anything. */}
+        have defaults when host do not render anything. */}
       <DefaultMainMenu UIOptions={UIOptions} />
       <DefaultMainMenu UIOptions={UIOptions} />
+      <DefaultSidebar.Trigger
+        __fallback
+        icon={LibraryIcon}
+        title={capitalizeString(t("toolBar.library"))}
+        onToggle={(open) => {
+          if (open) {
+            trackEvent(
+              "sidebar",
+              `${DEFAULT_SIDEBAR.name} (open)`,
+              `button (${device.isMobile ? "mobile" : "desktop"})`,
+            );
+          }
+        }}
+        tab={DEFAULT_SIDEBAR.defaultTab}
+      >
+        {t("toolBar.library")}
+      </DefaultSidebar.Trigger>
       {/* ------------------------------------------------------------------ */}
       {/* ------------------------------------------------------------------ */}
 
 
       {appState.isLoading && <LoadingMessage delay={250} />}
       {appState.isLoading && <LoadingMessage delay={250} />}
@@ -382,7 +390,6 @@ const LayerUI = ({
         <PasteChartDialog
         <PasteChartDialog
           setAppState={setAppState}
           setAppState={setAppState}
           appState={appState}
           appState={appState}
-          onInsertChart={onInsertElements}
           onClose={() =>
           onClose={() =>
             setAppState({
             setAppState({
               pasteDialog: { shown: false, data: null },
               pasteDialog: { shown: false, data: null },
@@ -410,7 +417,6 @@ const LayerUI = ({
           renderWelcomeScreen={renderWelcomeScreen}
           renderWelcomeScreen={renderWelcomeScreen}
         />
         />
       )}
       )}
-
       {!device.isMobile && (
       {!device.isMobile && (
         <>
         <>
           <div
           <div
@@ -422,15 +428,14 @@ const LayerUI = ({
                   !isTextElement(appState.editingElement)),
                   !isTextElement(appState.editingElement)),
             })}
             })}
             style={
             style={
-              ((appState.openSidebar === "library" &&
-                appState.isSidebarDocked) ||
-                hostSidebarCounters.docked) &&
+              appState.openSidebar &&
+              isSidebarDocked &&
               device.canDeviceFitSidebar
               device.canDeviceFitSidebar
                 ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
                 ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
                 : {}
                 : {}
             }
             }
           >
           >
-            {renderWelcomeScreen && <tunnels.welcomeScreenCenterTunnel.Out />}
+            {renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
             {renderFixedSideContainer()}
             {renderFixedSideContainer()}
             <Footer
             <Footer
               appState={appState}
               appState={appState}
@@ -469,17 +474,22 @@ const LayerUI = ({
   );
   );
 
 
   return (
   return (
-    <Provider scope={tunnels.jotaiScope}>
-      <TunnelsContext.Provider value={tunnels}>
-        {layerUIJSX}
-      </TunnelsContext.Provider>
-    </Provider>
+    <UIAppStateContext.Provider value={appState}>
+      <Provider scope={tunnels.jotaiScope}>
+        <TunnelsContext.Provider value={tunnels}>
+          {layerUIJSX}
+        </TunnelsContext.Provider>
+      </Provider>
+    </UIAppStateContext.Provider>
   );
   );
 };
 };
 
 
 const stripIrrelevantAppStateProps = (
 const stripIrrelevantAppStateProps = (
   appState: AppState,
   appState: AppState,
-): Partial<AppState> => {
+): Omit<
+  AppState,
+  "suggestedBindings" | "startBoundElement" | "cursorButton"
+> => {
   const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
   const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
     appState;
     appState;
   return ret;
   return ret;
@@ -491,24 +501,17 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
     return false;
     return false;
   }
   }
 
 
-  const {
-    canvas: _prevCanvas,
-    // not stable, but shouldn't matter in our case
-    onInsertElements: _prevOnInsertElements,
-    appState: prevAppState,
-    ...prev
-  } = prevProps;
-  const {
-    canvas: _nextCanvas,
-    onInsertElements: _nextOnInsertElements,
-    appState: nextAppState,
-    ...next
-  } = nextProps;
+  const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
+  const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
 
 
   return (
   return (
     isShallowEqual(
     isShallowEqual(
       stripIrrelevantAppStateProps(prevAppState),
       stripIrrelevantAppStateProps(prevAppState),
       stripIrrelevantAppStateProps(nextAppState),
       stripIrrelevantAppStateProps(nextAppState),
+      {
+        selectedElementIds: isShallowEqual,
+        selectedGroupIds: isShallowEqual,
+      },
     ) && isShallowEqual(prev, next)
     ) && isShallowEqual(prev, next)
   );
   );
 };
 };

+ 0 - 32
src/components/LibraryButton.scss

@@ -1,32 +0,0 @@
-@import "../css/variables.module";
-
-.library-button {
-  @include outlineButtonStyles;
-
-  background-color: var(--island-bg-color);
-
-  width: auto;
-  height: var(--lg-button-size);
-
-  display: flex;
-  align-items: center;
-  gap: 0.5rem;
-
-  line-height: 0;
-
-  font-size: 0.75rem;
-  letter-spacing: 0.4px;
-
-  svg {
-    width: var(--lg-icon-size);
-    height: var(--lg-icon-size);
-  }
-
-  &__label {
-    display: none;
-
-    @media screen and (min-width: 1024px) {
-      display: block;
-    }
-  }
-}

+ 0 - 57
src/components/LibraryButton.tsx

@@ -1,57 +0,0 @@
-import React from "react";
-import { t } from "../i18n";
-import { AppState } from "../types";
-import { capitalizeString } from "../utils";
-import { trackEvent } from "../analytics";
-import { useDevice } from "./App";
-import "./LibraryButton.scss";
-import { LibraryIcon } from "./icons";
-
-export const LibraryButton: React.FC<{
-  appState: AppState;
-  setAppState: React.Component<any, AppState>["setState"];
-  isMobile?: boolean;
-}> = ({ appState, setAppState, isMobile }) => {
-  const device = useDevice();
-  const showLabel = !isMobile;
-
-  // TODO barnabasmolnar/redesign
-  // not great, toolbar jumps in a jarring manner
-  if (appState.isSidebarDocked && appState.openSidebar === "library") {
-    return null;
-  }
-
-  return (
-    <label title={`${capitalizeString(t("toolBar.library"))}`}>
-      <input
-        className="ToolIcon_type_checkbox"
-        type="checkbox"
-        name="editor-library"
-        onChange={(event) => {
-          document
-            .querySelector(".layer-ui__wrapper")
-            ?.classList.remove("animate");
-          const isOpen = event.target.checked;
-          setAppState({ openSidebar: isOpen ? "library" : null });
-          // track only openings
-          if (isOpen) {
-            trackEvent(
-              "library",
-              "toggleLibrary (open)",
-              `toolbar (${device.isMobile ? "mobile" : "desktop"})`,
-            );
-          }
-        }}
-        checked={appState.openSidebar === "library"}
-        aria-label={capitalizeString(t("toolBar.library"))}
-        aria-keyshortcuts="0"
-      />
-      <div className="library-button">
-        <div>{LibraryIcon}</div>
-        {showLabel && (
-          <div className="library-button__label">{t("toolBar.library")}</div>
-        )}
-      </div>
-    </label>
-  );
-};

+ 22 - 48
src/components/LibraryMenu.scss

@@ -1,9 +1,9 @@
 @import "open-color/open-color";
 @import "open-color/open-color";
 
 
 .excalidraw {
 .excalidraw {
-  .layer-ui__library-sidebar {
-    display: flex;
-    flex-direction: column;
+  .library-menu-items-container {
+    height: 100%;
+    width: 100%;
   }
   }
 
 
   .layer-ui__library {
   .layer-ui__library {
@@ -11,28 +11,6 @@
     flex-direction: column;
     flex-direction: column;
 
 
     flex: 1 1 auto;
     flex: 1 1 auto;
-
-    .layer-ui__library-header {
-      display: flex;
-      align-items: center;
-      width: 100%;
-      margin: 2px 0 15px 0;
-      .Spinner {
-        margin-right: 1rem;
-      }
-
-      button {
-        // 2px from the left to account for focus border of left-most button
-        margin: 0 2px;
-      }
-    }
-  }
-
-  .layer-ui__sidebar {
-    .library-menu-items-container {
-      height: 100%;
-      width: 100%;
-    }
   }
   }
 
 
   .library-actions-counter {
   .library-actions-counter {
@@ -87,10 +65,17 @@
     }
     }
   }
   }
 
 
+  .library-menu-control-buttons {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 0.625rem;
+  }
+
   .library-menu-browse-button {
   .library-menu-browse-button {
-    margin: 1rem auto;
+    flex: 1;
 
 
-    padding: 0.875rem 1rem;
+    height: var(--lg-button-size);
 
 
     display: flex;
     display: flex;
     align-items: center;
     align-items: center;
@@ -122,30 +107,19 @@
     }
     }
   }
   }
 
 
-  .library-menu-browse-button--mobile {
-    min-height: 22px;
-    margin-left: auto;
-    a {
-      padding-right: 0;
-    }
+  &.excalidraw--mobile .library-menu-browse-button {
+    height: var(--default-button-size);
   }
   }
 
 
-  .layer-ui__sidebar__header .dropdown-menu {
-    &.dropdown-menu--mobile {
-      top: 100%;
-    }
-    .dropdown-menu-container {
-      --gap: 0;
-      z-index: 1;
-      position: absolute;
-      top: 100%;
-      left: 0;
-
-      :root[dir="rtl"] & {
-        right: 0;
-        left: auto;
-      }
+  .layer-ui__library .dropdown-menu {
+    width: auto;
+    top: initial;
+    right: 0;
+    left: initial;
+    bottom: 100%;
+    margin-bottom: 0.625rem;
 
 
+    .dropdown-menu-container {
       width: 196px;
       width: 196px;
       box-shadow: var(--library-dropdown-shadow);
       box-shadow: var(--library-dropdown-shadow);
       border-radius: var(--border-radius-lg);
       border-radius: var(--border-radius-lg);

+ 35 - 177
src/components/LibraryMenu.tsx

@@ -1,11 +1,4 @@
-import {
-  useRef,
-  useState,
-  useEffect,
-  useCallback,
-  RefObject,
-  forwardRef,
-} from "react";
+import React, { useState, useCallback } from "react";
 import Library, {
 import Library, {
   distributeLibraryItemsOnSquareGrid,
   distributeLibraryItemsOnSquareGrid,
   libraryItemsAtom,
   libraryItemsAtom,
@@ -13,65 +6,29 @@ import Library, {
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { randomId } from "../random";
 import { randomId } from "../random";
 import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
 import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
-
-import "./LibraryMenu.scss";
 import LibraryMenuItems from "./LibraryMenuItems";
 import LibraryMenuItems from "./LibraryMenuItems";
-import { EVENT } from "../constants";
-import { KEYS } from "../keys";
 import { trackEvent } from "../analytics";
 import { trackEvent } from "../analytics";
-import { useAtom } from "jotai";
+import { atom, useAtom } from "jotai";
 import { jotaiScope } from "../jotai";
 import { jotaiScope } from "../jotai";
 import Spinner from "./Spinner";
 import Spinner from "./Spinner";
 import {
 import {
-  useDevice,
+  useApp,
+  useAppProps,
   useExcalidrawElements,
   useExcalidrawElements,
   useExcalidrawSetAppState,
   useExcalidrawSetAppState,
 } from "./App";
 } from "./App";
-import { Sidebar } from "./Sidebar/Sidebar";
 import { getSelectedElements } from "../scene";
 import { getSelectedElements } from "../scene";
-import { NonDeletedExcalidrawElement } from "../element/types";
-import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
-import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
+import { useUIAppState } from "../context/ui-appState";
 
 
-const useOnClickOutside = (
-  ref: RefObject<HTMLElement>,
-  cb: (event: MouseEvent) => void,
-) => {
-  useEffect(() => {
-    const listener = (event: MouseEvent) => {
-      if (!ref.current) {
-        return;
-      }
-
-      if (
-        event.target instanceof Element &&
-        (ref.current.contains(event.target) ||
-          !document.body.contains(event.target))
-      ) {
-        return;
-      }
+import "./LibraryMenu.scss";
+import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 
 
-      cb(event);
-    };
-    document.addEventListener("pointerdown", listener, false);
+export const isLibraryMenuOpenAtom = atom(false);
 
 
-    return () => {
-      document.removeEventListener("pointerdown", listener);
-    };
-  }, [ref, cb]);
+const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
+  return <div className="layer-ui__library">{children}</div>;
 };
 };
 
 
-const LibraryMenuWrapper = forwardRef<
-  HTMLDivElement,
-  { children: React.ReactNode }
->(({ children }, ref) => {
-  return (
-    <div ref={ref} className="layer-ui__library">
-      {children}
-    </div>
-  );
-});
-
 export const LibraryMenuContent = ({
 export const LibraryMenuContent = ({
   onInsertLibraryItems,
   onInsertLibraryItems,
   pendingElements,
   pendingElements,
@@ -158,81 +115,31 @@ export const LibraryMenuContent = ({
         theme={appState.theme}
         theme={appState.theme}
       />
       />
       {showBtn && (
       {showBtn && (
-        <LibraryMenuBrowseButton
+        <LibraryMenuControlButtons
+          style={{ padding: "16px 12px 0 12px" }}
           id={id}
           id={id}
           libraryReturnUrl={libraryReturnUrl}
           libraryReturnUrl={libraryReturnUrl}
           theme={appState.theme}
           theme={appState.theme}
+          selectedItems={selectedItems}
+          onSelectItems={onSelectItems}
         />
         />
       )}
       )}
     </LibraryMenuWrapper>
     </LibraryMenuWrapper>
   );
   );
 };
 };
 
 
-export const LibraryMenu: React.FC<{
-  appState: AppState;
-  onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
-  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
-  focusContainer: () => void;
-  library: Library;
-  id: string;
-}> = ({
-  appState,
-  onInsertElements,
-  libraryReturnUrl,
-  focusContainer,
-  library,
-  id,
-}) => {
+/**
+ * This component is meant to be rendered inside <Sidebar.Tab/> inside our
+ * <DefaultSidebar/> or host apps Sidebar components.
+ */
+export const LibraryMenu = () => {
+  const { library, id, onInsertElements } = useApp();
+  const appProps = useAppProps();
+  const appState = useUIAppState();
   const setAppState = useExcalidrawSetAppState();
   const setAppState = useExcalidrawSetAppState();
   const elements = useExcalidrawElements();
   const elements = useExcalidrawElements();
-  const device = useDevice();
 
 
   const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
   const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
-  const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
-
-  const ref = useRef<HTMLDivElement | null>(null);
-
-  const closeLibrary = useCallback(() => {
-    const isDialogOpen = !!document.querySelector(".Dialog");
-
-    // Prevent closing if any dialog is open
-    if (isDialogOpen) {
-      return;
-    }
-    setAppState({ openSidebar: null });
-  }, [setAppState]);
-
-  useOnClickOutside(
-    ref,
-    useCallback(
-      (event) => {
-        // If click on the library icon, do nothing so that LibraryButton
-        // can toggle library menu
-        if ((event.target as Element).closest(".ToolIcon__library")) {
-          return;
-        }
-        if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
-          closeLibrary();
-        }
-      },
-      [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
-    ),
-  );
-
-  useEffect(() => {
-    const handleKeyDown = (event: KeyboardEvent) => {
-      if (
-        event.key === KEYS.ESCAPE &&
-        (!appState.isSidebarDocked || !device.canDeviceFitSidebar)
-      ) {
-        closeLibrary();
-      }
-    };
-    document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
-    return () => {
-      document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
-    };
-  }, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
 
 
   const deselectItems = useCallback(() => {
   const deselectItems = useCallback(() => {
     setAppState({
     setAppState({
@@ -241,69 +148,20 @@ export const LibraryMenu: React.FC<{
     });
     });
   }, [setAppState]);
   }, [setAppState]);
 
 
-  const removeFromLibrary = useCallback(
-    async (libraryItems: LibraryItems) => {
-      const nextItems = libraryItems.filter(
-        (item) => !selectedItems.includes(item.id),
-      );
-      library.setLibrary(nextItems).catch(() => {
-        setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
-      });
-      setSelectedItems([]);
-    },
-    [library, setAppState, selectedItems, setSelectedItems],
-  );
-
-  const resetLibrary = useCallback(() => {
-    library.resetLibrary();
-    focusContainer();
-  }, [library, focusContainer]);
-
   return (
   return (
-    <Sidebar
-      __isInternal
-      // necessary to remount when switching between internal
-      // and custom (host app) sidebar, so that the `props.onClose`
-      // is colled correctly
-      key="library"
-      className="layer-ui__library-sidebar"
-      initialDockedState={appState.isSidebarDocked}
-      onDock={(docked) => {
-        trackEvent(
-          "library",
-          `toggleLibraryDock (${docked ? "dock" : "undock"})`,
-          `sidebar (${device.isMobile ? "mobile" : "desktop"})`,
-        );
+    <LibraryMenuContent
+      pendingElements={getSelectedElements(elements, appState, true)}
+      onInsertLibraryItems={(libraryItems) => {
+        onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
       }}
       }}
-      ref={ref}
-    >
-      <Sidebar.Header className="layer-ui__library-header">
-        <LibraryMenuHeader
-          appState={appState}
-          setAppState={setAppState}
-          selectedItems={selectedItems}
-          onSelectItems={setSelectedItems}
-          library={library}
-          onRemoveFromLibrary={() =>
-            removeFromLibrary(libraryItemsData.libraryItems)
-          }
-          resetLibrary={resetLibrary}
-        />
-      </Sidebar.Header>
-      <LibraryMenuContent
-        pendingElements={getSelectedElements(elements, appState, true)}
-        onInsertLibraryItems={(libraryItems) => {
-          onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
-        }}
-        onAddToLibrary={deselectItems}
-        setAppState={setAppState}
-        libraryReturnUrl={libraryReturnUrl}
-        library={library}
-        id={id}
-        appState={appState}
-        selectedItems={selectedItems}
-        onSelectItems={setSelectedItems}
-      />
-    </Sidebar>
+      onAddToLibrary={deselectItems}
+      setAppState={setAppState}
+      libraryReturnUrl={appProps.libraryReturnUrl}
+      library={library}
+      id={id}
+      appState={appState}
+      selectedItems={selectedItems}
+      onSelectItems={setSelectedItems}
+    />
   );
   );
 };
 };

+ 33 - 0
src/components/LibraryMenuControlButtons.tsx

@@ -0,0 +1,33 @@
+import { LibraryItem, ExcalidrawProps, AppState } from "../types";
+import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
+import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
+
+export const LibraryMenuControlButtons = ({
+  selectedItems,
+  onSelectItems,
+  libraryReturnUrl,
+  theme,
+  id,
+  style,
+}: {
+  selectedItems: LibraryItem["id"][];
+  onSelectItems: (id: LibraryItem["id"][]) => void;
+  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
+  theme: AppState["theme"];
+  id: string;
+  style: React.CSSProperties;
+}) => {
+  return (
+    <div className="library-menu-control-buttons" style={style}>
+      <LibraryMenuBrowseButton
+        id={id}
+        libraryReturnUrl={libraryReturnUrl}
+        theme={theme}
+      />
+      <LibraryDropdownMenu
+        selectedItems={selectedItems}
+        onSelectItems={onSelectItems}
+      />
+    </div>
+  );
+};

+ 55 - 10
src/components/LibraryMenuHeaderContent.tsx

@@ -1,8 +1,10 @@
-import React, { useCallback, useState } from "react";
-import { saveLibraryAsJSON } from "../data/json";
-import Library, { libraryItemsAtom } from "../data/library";
+import { useCallback, useState } from "react";
 import { t } from "../i18n";
 import { t } from "../i18n";
+import { jotaiScope } from "../jotai";
 import { AppState, LibraryItem, LibraryItems } from "../types";
 import { AppState, LibraryItem, LibraryItems } from "../types";
+import { useApp, useExcalidrawAppState, useExcalidrawSetAppState } from "./App";
+import { saveLibraryAsJSON } from "../data/json";
+import Library, { libraryItemsAtom } from "../data/library";
 import {
 import {
   DotsIcon,
   DotsIcon,
   ExportIcon,
   ExportIcon,
@@ -13,22 +15,19 @@ import {
 import { ToolButton } from "./ToolButton";
 import { ToolButton } from "./ToolButton";
 import { fileOpen } from "../data/filesystem";
 import { fileOpen } from "../data/filesystem";
 import { muteFSAbortError } from "../utils";
 import { muteFSAbortError } from "../utils";
-import { atom, useAtom } from "jotai";
-import { jotaiScope } from "../jotai";
+import { useAtom } from "jotai";
 import ConfirmDialog from "./ConfirmDialog";
 import ConfirmDialog from "./ConfirmDialog";
 import PublishLibrary from "./PublishLibrary";
 import PublishLibrary from "./PublishLibrary";
 import { Dialog } from "./Dialog";
 import { Dialog } from "./Dialog";
-
 import DropdownMenu from "./dropdownMenu/DropdownMenu";
 import DropdownMenu from "./dropdownMenu/DropdownMenu";
-
-export const isLibraryMenuOpenAtom = atom(false);
+import { isLibraryMenuOpenAtom } from "./LibraryMenu";
 
 
 const getSelectedItems = (
 const getSelectedItems = (
   libraryItems: LibraryItems,
   libraryItems: LibraryItems,
   selectedItems: LibraryItem["id"][],
   selectedItems: LibraryItem["id"][],
 ) => libraryItems.filter((item) => selectedItems.includes(item.id));
 ) => libraryItems.filter((item) => selectedItems.includes(item.id));
 
 
-export const LibraryMenuHeader: React.FC<{
+export const LibraryDropdownMenuButton: React.FC<{
   setAppState: React.Component<any, AppState>["setState"];
   setAppState: React.Component<any, AppState>["setState"];
   selectedItems: LibraryItem["id"][];
   selectedItems: LibraryItem["id"][];
   library: Library;
   library: Library;
@@ -50,6 +49,7 @@ export const LibraryMenuHeader: React.FC<{
     isLibraryMenuOpenAtom,
     isLibraryMenuOpenAtom,
     jotaiScope,
     jotaiScope,
   );
   );
+
   const renderRemoveLibAlert = useCallback(() => {
   const renderRemoveLibAlert = useCallback(() => {
     const content = selectedItems.length
     const content = selectedItems.length
       ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
       ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
@@ -181,7 +181,6 @@ export const LibraryMenuHeader: React.FC<{
     return (
     return (
       <DropdownMenu open={isLibraryMenuOpen}>
       <DropdownMenu open={isLibraryMenuOpen}>
         <DropdownMenu.Trigger
         <DropdownMenu.Trigger
-          className="Sidebar__dropdown-btn"
           onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
           onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
         >
         >
           {DotsIcon}
           {DotsIcon}
@@ -230,6 +229,7 @@ export const LibraryMenuHeader: React.FC<{
       </DropdownMenu>
       </DropdownMenu>
     );
     );
   };
   };
+
   return (
   return (
     <div style={{ position: "relative" }}>
     <div style={{ position: "relative" }}>
       {renderLibraryMenu()}
       {renderLibraryMenu()}
@@ -261,3 +261,48 @@ export const LibraryMenuHeader: React.FC<{
     </div>
     </div>
   );
   );
 };
 };
+
+export const LibraryDropdownMenu = ({
+  selectedItems,
+  onSelectItems,
+}: {
+  selectedItems: LibraryItem["id"][];
+  onSelectItems: (id: LibraryItem["id"][]) => void;
+}) => {
+  const { library } = useApp();
+  const appState = useExcalidrawAppState();
+  const setAppState = useExcalidrawSetAppState();
+
+  const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
+
+  const removeFromLibrary = useCallback(
+    async (libraryItems: LibraryItems) => {
+      const nextItems = libraryItems.filter(
+        (item) => !selectedItems.includes(item.id),
+      );
+      library.setLibrary(nextItems).catch(() => {
+        setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
+      });
+      onSelectItems([]);
+    },
+    [library, setAppState, selectedItems, onSelectItems],
+  );
+
+  const resetLibrary = useCallback(() => {
+    library.resetLibrary();
+  }, [library]);
+
+  return (
+    <LibraryDropdownMenuButton
+      appState={appState}
+      setAppState={setAppState}
+      selectedItems={selectedItems}
+      onSelectItems={onSelectItems}
+      library={library}
+      onRemoveFromLibrary={() =>
+        removeFromLibrary(libraryItemsData.libraryItems)
+      }
+      resetLibrary={resetLibrary}
+    />
+  );
+};

+ 2 - 2
src/components/LibraryMenuItems.scss

@@ -47,7 +47,7 @@
 
 
     &__items {
     &__items {
       row-gap: 0.5rem;
       row-gap: 0.5rem;
-      padding: var(--container-padding-y) var(--container-padding-x);
+      padding: var(--container-padding-y) 0;
       flex: 1;
       flex: 1;
       overflow-y: auto;
       overflow-y: auto;
       overflow-x: hidden;
       overflow-x: hidden;
@@ -61,7 +61,7 @@
       margin-bottom: 0.75rem;
       margin-bottom: 0.75rem;
 
 
       &--excal {
       &--excal {
-        margin-top: 2.5rem;
+        margin-top: 2rem;
       }
       }
     }
     }
 
 

+ 8 - 14
src/components/LibraryMenuItems.tsx

@@ -10,9 +10,8 @@ import Stack from "./Stack";
 import "./LibraryMenuItems.scss";
 import "./LibraryMenuItems.scss";
 import { MIME_TYPES } from "../constants";
 import { MIME_TYPES } from "../constants";
 import Spinner from "./Spinner";
 import Spinner from "./Spinner";
-import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
-import clsx from "clsx";
 import { duplicateElements } from "../element/newElement";
 import { duplicateElements } from "../element/newElement";
+import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 
 
 const CELLS_PER_ROW = 4;
 const CELLS_PER_ROW = 4;
 
 
@@ -201,11 +200,7 @@ const LibraryMenuItems = ({
     (item) => item.status === "published",
     (item) => item.status === "published",
   );
   );
 
 
-  const showBtn =
-    !libraryItems.length &&
-    !unpublishedItems.length &&
-    !publishedItems.length &&
-    !pendingElements.length;
+  const showBtn = !libraryItems.length && !pendingElements.length;
 
 
   return (
   return (
     <div
     <div
@@ -215,7 +210,7 @@ const LibraryMenuItems = ({
         unpublishedItems.length ||
         unpublishedItems.length ||
         publishedItems.length
         publishedItems.length
           ? { justifyContent: "flex-start" }
           ? { justifyContent: "flex-start" }
-          : {}
+          : { borderBottom: 0 }
       }
       }
     >
     >
       <Stack.Col
       <Stack.Col
@@ -251,11 +246,7 @@ const LibraryMenuItems = ({
           </div>
           </div>
           {!pendingElements.length && !unpublishedItems.length ? (
           {!pendingElements.length && !unpublishedItems.length ? (
             <div className="library-menu-items__no-items">
             <div className="library-menu-items__no-items">
-              <div
-                className={clsx({
-                  "library-menu-items__no-items__label": showBtn,
-                })}
-              >
+              <div className="library-menu-items__no-items__label">
                 {t("library.noItems")}
                 {t("library.noItems")}
               </div>
               </div>
               <div className="library-menu-items__no-items__hint">
               <div className="library-menu-items__no-items__hint">
@@ -303,10 +294,13 @@ const LibraryMenuItems = ({
         </>
         </>
 
 
         {showBtn && (
         {showBtn && (
-          <LibraryMenuBrowseButton
+          <LibraryMenuControlButtons
+            style={{ padding: "16px 0", width: "100%" }}
             id={id}
             id={id}
             libraryReturnUrl={libraryReturnUrl}
             libraryReturnUrl={libraryReturnUrl}
             theme={theme}
             theme={theme}
+            selectedItems={selectedItems}
+            onSelectItems={onSelectItems}
           />
           />
         )}
         )}
       </Stack.Col>
       </Stack.Col>

+ 11 - 12
src/components/MobileMenu.tsx

@@ -13,13 +13,12 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
 import { Section } from "./Section";
 import { Section } from "./Section";
 import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
 import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
 import { LockButton } from "./LockButton";
 import { LockButton } from "./LockButton";
-import { LibraryButton } from "./LibraryButton";
 import { PenModeButton } from "./PenModeButton";
 import { PenModeButton } from "./PenModeButton";
 import { Stats } from "./Stats";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions";
 import { actionToggleStats } from "../actions";
 import { HandButton } from "./HandButton";
 import { HandButton } from "./HandButton";
 import { isHandToolActive } from "../appState";
 import { isHandToolActive } from "../appState";
-import { useTunnels } from "./context/tunnels";
+import { useTunnels } from "../context/tunnels";
 
 
 type MobileMenuProps = {
 type MobileMenuProps = {
   appState: AppState;
   appState: AppState;
@@ -60,11 +59,15 @@ export const MobileMenu = ({
   device,
   device,
   renderWelcomeScreen,
   renderWelcomeScreen,
 }: MobileMenuProps) => {
 }: MobileMenuProps) => {
-  const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels();
+  const {
+    WelcomeScreenCenterTunnel,
+    MainMenuTunnel,
+    DefaultSidebarTriggerTunnel,
+  } = useTunnels();
   const renderToolbar = () => {
   const renderToolbar = () => {
     return (
     return (
       <FixedSideContainer side="top" className="App-top-bar">
       <FixedSideContainer side="top" className="App-top-bar">
-        {renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />}
+        {renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
         <Section heading="shapes">
         <Section heading="shapes">
           {(heading: React.ReactNode) => (
           {(heading: React.ReactNode) => (
             <Stack.Col gap={4} align="center">
             <Stack.Col gap={4} align="center">
@@ -88,11 +91,7 @@ export const MobileMenu = ({
                 {renderTopRightUI && renderTopRightUI(true, appState)}
                 {renderTopRightUI && renderTopRightUI(true, appState)}
                 <div className="mobile-misc-tools-container">
                 <div className="mobile-misc-tools-container">
                   {!appState.viewModeEnabled && (
                   {!appState.viewModeEnabled && (
-                    <LibraryButton
-                      appState={appState}
-                      setAppState={setAppState}
-                      isMobile
-                    />
+                    <DefaultSidebarTriggerTunnel.Out />
                   )}
                   )}
                   <PenModeButton
                   <PenModeButton
                     checked={appState.penMode}
                     checked={appState.penMode}
@@ -132,14 +131,14 @@ export const MobileMenu = ({
     if (appState.viewModeEnabled) {
     if (appState.viewModeEnabled) {
       return (
       return (
         <div className="App-toolbar-content">
         <div className="App-toolbar-content">
-          <mainMenuTunnel.Out />
+          <MainMenuTunnel.Out />
         </div>
         </div>
       );
       );
     }
     }
 
 
     return (
     return (
       <div className="App-toolbar-content">
       <div className="App-toolbar-content">
-        <mainMenuTunnel.Out />
+        <MainMenuTunnel.Out />
         {actionManager.renderAction("toggleEditMenu")}
         {actionManager.renderAction("toggleEditMenu")}
         {actionManager.renderAction("undo")}
         {actionManager.renderAction("undo")}
         {actionManager.renderAction("redo")}
         {actionManager.renderAction("redo")}
@@ -190,7 +189,7 @@ export const MobileMenu = ({
             {renderAppToolbar()}
             {renderAppToolbar()}
             {appState.scrolledOutside &&
             {appState.scrolledOutside &&
               !appState.openMenu &&
               !appState.openMenu &&
-              appState.openSidebar !== "library" && (
+              !appState.openSidebar && (
                 <button
                 <button
                   className="scroll-back-to-content"
                   className="scroll-back-to-content"
                   onClick={() => {
                   onClick={() => {

+ 4 - 4
src/components/PasteChartDialog.tsx

@@ -5,7 +5,8 @@ import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
 import { ChartType } from "../element/types";
 import { ChartType } from "../element/types";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { exportToSvg } from "../scene/export";
 import { exportToSvg } from "../scene/export";
-import { AppState, LibraryItem } from "../types";
+import { AppState } from "../types";
+import { useApp } from "./App";
 import { Dialog } from "./Dialog";
 import { Dialog } from "./Dialog";
 import "./PasteChartDialog.scss";
 import "./PasteChartDialog.scss";
 
 
@@ -78,13 +79,12 @@ export const PasteChartDialog = ({
   setAppState,
   setAppState,
   appState,
   appState,
   onClose,
   onClose,
-  onInsertChart,
 }: {
 }: {
   appState: AppState;
   appState: AppState;
   onClose: () => void;
   onClose: () => void;
   setAppState: React.Component<any, AppState>["setState"];
   setAppState: React.Component<any, AppState>["setState"];
-  onInsertChart: (elements: LibraryItem["elements"]) => void;
 }) => {
 }) => {
+  const { onInsertElements } = useApp();
   const handleClose = React.useCallback(() => {
   const handleClose = React.useCallback(() => {
     if (onClose) {
     if (onClose) {
       onClose();
       onClose();
@@ -92,7 +92,7 @@ export const PasteChartDialog = ({
   }, [onClose]);
   }, [onClose]);
 
 
   const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
   const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
-    onInsertChart(elements);
+    onInsertElements(elements);
     trackEvent("magic", "chart", chartType);
     trackEvent("magic", "chart", chartType);
     setAppState({
     setAppState({
       currentChartType: chartType,
       currentChartType: chartType,

+ 122 - 81
src/components/Sidebar/Sidebar.scss

@@ -2,67 +2,26 @@
 @import "../../css/variables.module";
 @import "../../css/variables.module";
 
 
 .excalidraw {
 .excalidraw {
-  .Sidebar {
-    &__close-btn,
-    &__pin-btn,
-    &__dropdown-btn {
-      @include outlineButtonStyles;
-      width: var(--lg-button-size);
-      height: var(--lg-button-size);
-      padding: 0;
-
-      svg {
-        width: var(--lg-icon-size);
-        height: var(--lg-icon-size);
-      }
-    }
-
-    &__pin-btn {
-      &--pinned {
-        background-color: var(--color-primary);
-        border-color: var(--color-primary);
-
-        svg {
-          color: #fff;
-        }
-
-        &:hover,
-        &:active {
-          background-color: var(--color-primary-darker);
-        }
-      }
-    }
-  }
-
-  &.theme--dark {
-    .Sidebar {
-      &__pin-btn {
-        &--pinned {
-          svg {
-            color: var(--color-gray-90);
-          }
-        }
-      }
-    }
-  }
-
-  .layer-ui__sidebar {
+  .sidebar {
+    display: flex;
+    flex-direction: column;
     position: absolute;
     position: absolute;
     top: 0;
     top: 0;
     bottom: 0;
     bottom: 0;
     right: 0;
     right: 0;
     z-index: 5;
     z-index: 5;
     margin: 0;
     margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+
+    background-color: var(--sidebar-bg-color);
+    box-shadow: var(--sidebar-shadow);
 
 
     :root[dir="rtl"] & {
     :root[dir="rtl"] & {
       left: 0;
       left: 0;
       right: auto;
       right: auto;
     }
     }
 
 
-    background-color: var(--sidebar-bg-color);
-
-    box-shadow: var(--sidebar-shadow);
-
     &--docked {
     &--docked {
       box-shadow: none;
       box-shadow: none;
     }
     }
@@ -77,52 +36,134 @@
       border-right: 1px solid var(--sidebar-border-color);
       border-right: 1px solid var(--sidebar-border-color);
       border-left: 0;
       border-left: 0;
     }
     }
+  }
 
 
-    padding: 0;
+  // ---------------------------- sidebar header ------------------------------
+
+  .sidebar__header {
     box-sizing: border-box;
     box-sizing: border-box;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    width: 100%;
+    padding-top: 1rem;
+    padding-bottom: 1rem;
+  }
 
 
-    .Island {
-      box-shadow: none;
-    }
+  .sidebar__header__buttons {
+    gap: 0;
+    display: flex;
+    align-items: center;
+    margin-left: auto;
 
 
-    .ToolIcon__icon {
-      border-radius: var(--border-radius-md);
-    }
+    button {
+      @include outlineButtonStyles;
+      --button-bg: transparent;
+      border: 0 !important;
+
+      width: var(--lg-button-size);
+      height: var(--lg-button-size);
+      padding: 0;
 
 
-    .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);
+      svg {
+        width: var(--lg-icon-size);
+        height: var(--lg-icon-size);
+      }
+
+      &:hover {
+        background: var(--button-hover-bg, var(--island-bg-color));
       }
       }
     }
     }
 
 
-    .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;
+    .sidebar__dock.selected {
+      svg {
+        stroke: var(--color-primary);
+        fill: var(--color-primary);
+      }
     }
     }
   }
   }
 
 
-  .layer-ui__sidebar__header {
-    box-sizing: border-box;
+  // ---------------------------- sidebar tabs ------------------------------
+
+  .sidebar-tabs-root {
     display: flex;
     display: flex;
-    justify-content: space-between;
-    align-items: center;
-    width: 100%;
-    padding: 1rem;
-    border-bottom: 1px solid var(--sidebar-border-color);
+    flex-direction: column;
+    flex: 1 1 auto;
+    padding: 1rem 0.75rem;
+
+    [role="tabpanel"] {
+      flex: 1;
+      outline: none;
+
+      flex: 1 1 auto;
+      display: flex;
+      flex-direction: column;
+      outline: none;
+    }
+
+    [role="tabpanel"][data-state="inactive"] {
+      display: none !important;
+    }
+
+    [role="tablist"] {
+      display: grid;
+      gap: 1rem;
+      grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
+    }
+  }
+
+  .sidebar-tabs-root > .sidebar__header {
+    padding-top: 0;
+    padding-bottom: 1rem;
   }
   }
 
 
-  .layer-ui__sidebar__header__buttons {
+  .sidebar-tab-trigger {
+    --button-width: auto;
+    --button-bg: transparent;
+    --button-hover-bg: transparent;
+    --button-active-bg: var(--color-primary);
+    --button-hover-color: var(--color-primary);
+    --button-hover-border: var(--color-primary);
+
+    &[data-state="active"] {
+      --button-bg: var(--color-primary);
+      --button-hover-bg: var(--color-primary-darker);
+      --button-hover-color: var(--color-icon-white);
+      --button-border: var(--color-primary);
+      color: var(--color-icon-white);
+    }
+  }
+
+  // ---------------------------- default sidebar ------------------------------
+
+  .default-sidebar {
     display: flex;
     display: flex;
-    align-items: center;
-    gap: 0.625rem;
+    flex-direction: column;
+
+    .sidebar-triggers {
+      $padding: 2px;
+      $border: 1px;
+      display: flex;
+      gap: 0;
+      padding: $padding;
+      // offset by padding + border to vertically center the list with sibling
+      // buttons (both from top and bototm, due to flex layout)
+      margin-top: -#{$padding + $border};
+      margin-bottom: -#{$padding + $border};
+      border: $border solid var(--sidebar-border-color);
+      background: var(--default-bg-color);
+      border-radius: 0.625rem;
+
+      .sidebar-tab-trigger {
+        height: var(--lg-button-size);
+        width: var(--lg-button-size);
+
+        border: none;
+      }
+    }
+
+    .sidebar__header {
+      border-bottom: 1px solid var(--sidebar-border-color);
+    }
   }
   }
 }
 }

+ 253 - 277
src/components/Sidebar/Sidebar.test.tsx

@@ -1,8 +1,9 @@
 import React from "react";
 import React from "react";
+import { DEFAULT_SIDEBAR } from "../../constants";
 import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
 import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
 import {
 import {
-  act,
   fireEvent,
   fireEvent,
+  GlobalTestState,
   queryAllByTestId,
   queryAllByTestId,
   queryByTestId,
   queryByTestId,
   render,
   render,
@@ -10,346 +11,321 @@ import {
   withExcalidrawDimensions,
   withExcalidrawDimensions,
 } from "../../tests/test-utils";
 } from "../../tests/test-utils";
 
 
-describe("Sidebar", () => {
-  it("should render custom sidebar", async () => {
-    const { container } = await render(
-      <Excalidraw
-        initialData={{ appState: { openSidebar: "customSidebar" } }}
-        renderSidebar={() => (
-          <Sidebar>
-            <div id="test-sidebar-content">42</div>
-          </Sidebar>
-        )}
-      />,
+export const assertSidebarDockButton = async <T extends boolean>(
+  hasDockButton: T,
+): Promise<
+  T extends false
+    ? { dockButton: null; sidebar: HTMLElement }
+    : { dockButton: HTMLElement; sidebar: HTMLElement }
+> => {
+  const sidebar =
+    GlobalTestState.renderResult.container.querySelector<HTMLElement>(
+      ".sidebar",
     );
     );
+  expect(sidebar).not.toBe(null);
+  const dockButton = queryByTestId(sidebar!, "sidebar-dock");
+  if (hasDockButton) {
+    expect(dockButton).not.toBe(null);
+    return { dockButton: dockButton!, sidebar: sidebar! } as any;
+  }
+  expect(dockButton).toBe(null);
+  return { dockButton: null, sidebar: sidebar! } as any;
+};
+
+export const assertExcalidrawWithSidebar = async (
+  sidebar: React.ReactNode,
+  name: string,
+  test: () => void,
+) => {
+  await render(
+    <Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
+      {sidebar}
+    </Excalidraw>,
+  );
+  await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
+};
 
 
-    const node = container.querySelector("#test-sidebar-content");
-    expect(node).not.toBe(null);
-  });
-
-  it("should render custom sidebar header", async () => {
-    const { container } = await render(
-      <Excalidraw
-        initialData={{ appState: { openSidebar: "customSidebar" } }}
-        renderSidebar={() => (
-          <Sidebar>
-            <Sidebar.Header>
-              <div id="test-sidebar-header-content">42</div>
-            </Sidebar.Header>
-          </Sidebar>
-        )}
-      />,
-    );
-
-    const node = container.querySelector("#test-sidebar-header-content");
-    expect(node).not.toBe(null);
-    // make sure we don't render the default fallback header,
-    // just the custom one
-    expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
-  });
-
-  it("should render only one sidebar and prefer the custom one", async () => {
-    const { container } = await render(
-      <Excalidraw
-        initialData={{ appState: { openSidebar: "customSidebar" } }}
-        renderSidebar={() => (
-          <Sidebar>
+describe("Sidebar", () => {
+  describe("General behavior", () => {
+    it("should render custom sidebar", async () => {
+      const { container } = await render(
+        <Excalidraw
+          initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
+        >
+          <Sidebar name="customSidebar">
             <div id="test-sidebar-content">42</div>
             <div id="test-sidebar-content">42</div>
           </Sidebar>
           </Sidebar>
-        )}
-      />,
-    );
+        </Excalidraw>,
+      );
 
 
-    await waitFor(() => {
-      // make sure the custom sidebar is rendered
       const node = container.querySelector("#test-sidebar-content");
       const node = container.querySelector("#test-sidebar-content");
       expect(node).not.toBe(null);
       expect(node).not.toBe(null);
-
-      // make sure only one sidebar is rendered
-      const sidebars = container.querySelectorAll(".layer-ui__sidebar");
-      expect(sidebars.length).toBe(1);
     });
     });
-  });
 
 
-  it("should always render custom sidebar with close button & close on click", async () => {
-    const onClose = jest.fn();
-    const CustomExcalidraw = () => {
-      return (
+    it("should render only one sidebar and prefer the custom one", async () => {
+      const { container } = await render(
         <Excalidraw
         <Excalidraw
-          initialData={{ appState: { openSidebar: "customSidebar" } }}
-          renderSidebar={() => (
-            <Sidebar className="test-sidebar" onClose={onClose}>
-              hello
-            </Sidebar>
-          )}
-        />
-      );
-    };
-
-    const { container } = await render(<CustomExcalidraw />);
-
-    const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-    expect(sidebar).not.toBe(null);
-    const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
-    expect(closeButton).not.toBe(null);
-
-    fireEvent.click(closeButton);
-    await waitFor(() => {
-      expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
-      expect(onClose).toHaveBeenCalled();
-    });
-  });
-
-  it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
-    const CustomExcalidraw = () => {
-      return (
-        <Excalidraw
-          initialData={{ appState: { openSidebar: "customSidebar" } }}
-          renderSidebar={() => (
-            <Sidebar className="test-sidebar">hello</Sidebar>
-          )}
-        />
+          initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
+        >
+          <Sidebar name="customSidebar">
+            <div id="test-sidebar-content">42</div>
+          </Sidebar>
+        </Excalidraw>,
       );
       );
-    };
-
-    const { container } = await render(<CustomExcalidraw />);
-
-    // should show dock button when the sidebar fits to be docked
-    // -------------------------------------------------------------------------
-
-    await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
-      const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-      expect(sidebar).not.toBe(null);
-      const closeButton = queryByTestId(sidebar!, "sidebar-dock");
-      expect(closeButton).not.toBe(null);
-    });
 
 
-    // should not show dock button when the sidebar does not fit to be docked
-    // -------------------------------------------------------------------------
+      await waitFor(() => {
+        // make sure the custom sidebar is rendered
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).not.toBe(null);
 
 
-    await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
-      const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-      expect(sidebar).not.toBe(null);
-      const closeButton = queryByTestId(sidebar!, "sidebar-dock");
-      expect(closeButton).toBe(null);
+        // make sure only one sidebar is rendered
+        const sidebars = container.querySelectorAll(".sidebar");
+        expect(sidebars.length).toBe(1);
+      });
     });
     });
-  });
-
-  it("should support controlled docking", async () => {
-    let _setDockable: (dockable: boolean) => void = null!;
 
 
-    const CustomExcalidraw = () => {
-      const [dockable, setDockable] = React.useState(false);
-      _setDockable = setDockable;
-      return (
-        <Excalidraw
-          initialData={{ appState: { openSidebar: "customSidebar" } }}
-          renderSidebar={() => (
-            <Sidebar
-              className="test-sidebar"
-              docked={false}
-              dockable={dockable}
-            >
-              hello
-            </Sidebar>
-          )}
-        />
+    it("should toggle sidebar using props.toggleMenu()", async () => {
+      const { container } = await render(
+        <Excalidraw>
+          <Sidebar name="customSidebar">
+            <div id="test-sidebar-content">42</div>
+          </Sidebar>
+        </Excalidraw>,
       );
       );
-    };
-
-    const { container } = await render(<CustomExcalidraw />);
 
 
-    await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
-      // should not show dock button when `dockable` is `false`
+      // sidebar isn't rendered initially
       // -------------------------------------------------------------------------
       // -------------------------------------------------------------------------
-
-      act(() => {
-        _setDockable(false);
-      });
-
       await waitFor(() => {
       await waitFor(() => {
-        const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-        expect(sidebar).not.toBe(null);
-        const closeButton = queryByTestId(sidebar!, "sidebar-dock");
-        expect(closeButton).toBe(null);
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).toBe(null);
       });
       });
 
 
-      // should show dock button when `dockable` is `true`, even if `docked`
-      // prop is set
+      // toggle sidebar on
       // -------------------------------------------------------------------------
       // -------------------------------------------------------------------------
-
-      act(() => {
-        _setDockable(true);
-      });
+      expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-        expect(sidebar).not.toBe(null);
-        const closeButton = queryByTestId(sidebar!, "sidebar-dock");
-        expect(closeButton).not.toBe(null);
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).not.toBe(null);
       });
       });
-    });
-  });
-
-  it("should support controlled docking", async () => {
-    let _setDocked: (docked?: boolean) => void = null!;
-
-    const CustomExcalidraw = () => {
-      const [docked, setDocked] = React.useState<boolean | undefined>();
-      _setDocked = setDocked;
-      return (
-        <Excalidraw
-          initialData={{ appState: { openSidebar: "customSidebar" } }}
-          renderSidebar={() => (
-            <Sidebar className="test-sidebar" docked={docked}>
-              hello
-            </Sidebar>
-          )}
-        />
-      );
-    };
-
-    const { container } = await render(<CustomExcalidraw />);
 
 
-    const { h } = window;
-
-    await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
-      const dockButton = await waitFor(() => {
-        const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-        expect(sidebar).not.toBe(null);
-        const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
-        expect(dockBotton).not.toBe(null);
-        return dockBotton!;
-      });
-
-      const dockButtonInput = dockButton.querySelector("input")!;
-
-      // should not show dock button when `dockable` is `false`
+      // toggle sidebar off
       // -------------------------------------------------------------------------
       // -------------------------------------------------------------------------
+      expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
 
 
-      expect(h.state.isSidebarDocked).toBe(false);
-
-      fireEvent.click(dockButtonInput);
       await waitFor(() => {
       await waitFor(() => {
-        expect(h.state.isSidebarDocked).toBe(true);
-        expect(dockButtonInput).toBeChecked();
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).toBe(null);
       });
       });
 
 
-      fireEvent.click(dockButtonInput);
-      await waitFor(() => {
-        expect(h.state.isSidebarDocked).toBe(false);
-        expect(dockButtonInput).not.toBeChecked();
-      });
-
-      // shouldn't update `appState.isSidebarDocked` when the sidebar
-      // is controlled (`docked` prop is set), as host apps should handle
-      // the state themselves
+      // force-toggle sidebar off (=> still hidden)
       // -------------------------------------------------------------------------
       // -------------------------------------------------------------------------
-
-      act(() => {
-        _setDocked(true);
-      });
+      expect(
+        window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
+      ).toBe(false);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(dockButtonInput).toBeChecked();
-        expect(h.state.isSidebarDocked).toBe(false);
-        expect(dockButtonInput).toBeChecked();
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).toBe(null);
       });
       });
 
 
-      fireEvent.click(dockButtonInput);
+      // force-toggle sidebar on
+      // -------------------------------------------------------------------------
+      expect(
+        window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
+      ).toBe(true);
+      expect(
+        window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
+      ).toBe(true);
+
       await waitFor(() => {
       await waitFor(() => {
-        expect(h.state.isSidebarDocked).toBe(false);
-        expect(dockButtonInput).toBeChecked();
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).not.toBe(null);
       });
       });
 
 
-      // the `appState.isSidebarDocked` should remain untouched when
-      // `props.docked` is set to `false`, and user toggles
+      // toggle library (= hide custom sidebar)
       // -------------------------------------------------------------------------
       // -------------------------------------------------------------------------
-
-      act(() => {
-        _setDocked(false);
-        h.setState({ isSidebarDocked: true });
-      });
+      expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
+        true,
+      );
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(h.state.isSidebarDocked).toBe(true);
-        expect(dockButtonInput).not.toBeChecked();
-      });
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).toBe(null);
 
 
-      fireEvent.click(dockButtonInput);
-      await waitFor(() => {
-        expect(dockButtonInput).not.toBeChecked();
-        expect(h.state.isSidebarDocked).toBe(true);
+        // make sure only one sidebar is rendered
+        const sidebars = container.querySelectorAll(".sidebar");
+        expect(sidebars.length).toBe(1);
       });
       });
     });
     });
   });
   });
 
 
-  it("should toggle sidebar using props.toggleMenu()", async () => {
-    const { container } = await render(
-      <Excalidraw
-        renderSidebar={() => (
-          <Sidebar>
-            <div id="test-sidebar-content">42</div>
+  describe("<Sidebar.Header/>", () => {
+    it("should render custom sidebar header", async () => {
+      const { container } = await render(
+        <Excalidraw
+          initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
+        >
+          <Sidebar name="customSidebar">
+            <Sidebar.Header>
+              <div id="test-sidebar-header-content">42</div>
+            </Sidebar.Header>
           </Sidebar>
           </Sidebar>
-        )}
-      />,
-    );
+        </Excalidraw>,
+      );
 
 
-    // sidebar isn't rendered initially
-    // -------------------------------------------------------------------------
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).toBe(null);
+      const node = container.querySelector("#test-sidebar-header-content");
+      expect(node).not.toBe(null);
+      // make sure we don't render the default fallback header,
+      // just the custom one
+      expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
     });
     });
 
 
-    // toggle sidebar on
-    // -------------------------------------------------------------------------
-    expect(window.h.app.toggleMenu("customSidebar")).toBe(true);
+    it("should not render <Sidebar.Header> for custom sidebars by default", async () => {
+      const CustomExcalidraw = () => {
+        return (
+          <Excalidraw
+            initialData={{
+              appState: { openSidebar: { name: "customSidebar" } },
+            }}
+          >
+            <Sidebar name="customSidebar" className="test-sidebar">
+              hello
+            </Sidebar>
+          </Excalidraw>
+        );
+      };
 
 
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).not.toBe(null);
+      const { container } = await render(<CustomExcalidraw />);
+
+      const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
+      expect(sidebar).not.toBe(null);
+      const closeButton = queryByTestId(sidebar!, "sidebar-close");
+      expect(closeButton).toBe(null);
     });
     });
 
 
-    // toggle sidebar off
-    // -------------------------------------------------------------------------
-    expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
+    it("<Sidebar.Header> should render close button", async () => {
+      const onStateChange = jest.fn();
+      const CustomExcalidraw = () => {
+        return (
+          <Excalidraw
+            initialData={{
+              appState: { openSidebar: { name: "customSidebar" } },
+            }}
+          >
+            <Sidebar
+              name="customSidebar"
+              className="test-sidebar"
+              onStateChange={onStateChange}
+            >
+              <Sidebar.Header />
+            </Sidebar>
+          </Excalidraw>
+        );
+      };
 
 
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).toBe(null);
-    });
+      const { container } = await render(<CustomExcalidraw />);
 
 
-    // force-toggle sidebar off (=> still hidden)
-    // -------------------------------------------------------------------------
-    expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
+      // initial open
+      expect(onStateChange).toHaveBeenCalledWith({ name: "customSidebar" });
 
 
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).toBe(null);
+      const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
+      expect(sidebar).not.toBe(null);
+      const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
+      expect(closeButton).not.toBe(null);
+
+      fireEvent.click(closeButton);
+      await waitFor(() => {
+        expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(
+          null,
+        );
+        expect(onStateChange).toHaveBeenCalledWith(null);
+      });
     });
     });
+  });
 
 
-    // force-toggle sidebar on
-    // -------------------------------------------------------------------------
-    expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
-    expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
+  describe("Docking behavior", () => {
+    it("shouldn't be user-dockable if `onDock` not supplied", async () => {
+      await assertExcalidrawWithSidebar(
+        <Sidebar name="customSidebar">
+          <Sidebar.Header />
+        </Sidebar>,
+        "customSidebar",
+        async () => {
+          await assertSidebarDockButton(false);
+        },
+      );
+    });
 
 
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).not.toBe(null);
+    it("shouldn't be user-dockable if `onDock` not supplied & `docked={true}`", async () => {
+      await assertExcalidrawWithSidebar(
+        <Sidebar name="customSidebar" docked={true}>
+          <Sidebar.Header />
+        </Sidebar>,
+        "customSidebar",
+        async () => {
+          await assertSidebarDockButton(false);
+        },
+      );
     });
     });
 
 
-    // toggle library (= hide custom sidebar)
-    // -------------------------------------------------------------------------
-    expect(window.h.app.toggleMenu("library")).toBe(true);
+    it("shouldn't be user-dockable if `onDock` not supplied & docked={false}`", async () => {
+      await assertExcalidrawWithSidebar(
+        <Sidebar name="customSidebar" docked={false}>
+          <Sidebar.Header />
+        </Sidebar>,
+        "customSidebar",
+        async () => {
+          await assertSidebarDockButton(false);
+        },
+      );
+    });
 
 
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).toBe(null);
+    it("should be user-dockable when both `onDock` and `docked` supplied", async () => {
+      await render(
+        <Excalidraw
+          initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
+        >
+          <Sidebar
+            name="customSidebar"
+            className="test-sidebar"
+            onDock={() => {}}
+            docked
+          >
+            <Sidebar.Header />
+          </Sidebar>
+        </Excalidraw>,
+      );
 
 
-      // make sure only one sidebar is rendered
-      const sidebars = container.querySelectorAll(".layer-ui__sidebar");
-      expect(sidebars.length).toBe(1);
+      await withExcalidrawDimensions(
+        { width: 1920, height: 1080 },
+        async () => {
+          await assertSidebarDockButton(true);
+        },
+      );
+    });
+
+    it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
+      await render(
+        <Excalidraw
+          initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
+        >
+          <Sidebar
+            name="customSidebar"
+            className="test-sidebar"
+            onDock={() => {}}
+          >
+            <Sidebar.Header />
+          </Sidebar>
+        </Excalidraw>,
+      );
+
+      await withExcalidrawDimensions(
+        { width: 1920, height: 1080 },
+        async () => {
+          await assertSidebarDockButton(false);
+        },
+      );
     });
     });
   });
   });
 });
 });

+ 218 - 120
src/components/Sidebar/Sidebar.tsx

@@ -1,151 +1,249 @@
-import {
+import React, {
   useEffect,
   useEffect,
   useLayoutEffect,
   useLayoutEffect,
   useRef,
   useRef,
   useState,
   useState,
   forwardRef,
   forwardRef,
+  useImperativeHandle,
+  useCallback,
+  RefObject,
 } from "react";
 } from "react";
 import { Island } from ".././Island";
 import { Island } from ".././Island";
-import { atom, useAtom } from "jotai";
+import { atom, useSetAtom } from "jotai";
 import { jotaiScope } from "../../jotai";
 import { jotaiScope } from "../../jotai";
 import {
 import {
   SidebarPropsContext,
   SidebarPropsContext,
   SidebarProps,
   SidebarProps,
   SidebarPropsContextValue,
   SidebarPropsContextValue,
 } from "./common";
 } from "./common";
-
-import { SidebarHeaderComponents } from "./SidebarHeader";
-
-import "./Sidebar.scss";
+import { SidebarHeader } from "./SidebarHeader";
 import clsx from "clsx";
 import clsx from "clsx";
-import { useExcalidrawSetAppState } from "../App";
+import {
+  useDevice,
+  useExcalidrawAppState,
+  useExcalidrawSetAppState,
+} from "../App";
 import { updateObject } from "../../utils";
 import { updateObject } from "../../utils";
+import { KEYS } from "../../keys";
+import { EVENT } from "../../constants";
+import { SidebarTrigger } from "./SidebarTrigger";
+import { SidebarTabTriggers } from "./SidebarTabTriggers";
+import { SidebarTabTrigger } from "./SidebarTabTrigger";
+import { SidebarTabs } from "./SidebarTabs";
+import { SidebarTab } from "./SidebarTab";
 
 
-/** using a counter instead of boolean to handle race conditions where
- * the host app may render (mount/unmount) multiple different sidebar */
-export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
+import "./Sidebar.scss";
 
 
-export const Sidebar = Object.assign(
-  forwardRef(
-    (
-      {
-        children,
-        onClose,
-        onDock,
-        docked,
-        /** Undocumented, may be removed later. Generally should either be
-         * `props.docked` or `appState.isSidebarDocked`. Currently serves to
-         *  prevent unwanted animation of the shadow if initially docked. */
-        //
-        // NOTE we'll want to remove this after we sort out how to subscribe to
-        // individual appState properties
-        initialDockedState = docked,
-        dockable = true,
-        className,
-        __isInternal,
-      }: SidebarProps<{
-        // NOTE sidebars we use internally inside the editor must have this flag set.
-        // It indicates that this sidebar should have lower precedence over host
-        // sidebars, if both are open.
-        /** @private internal */
-        __isInternal?: boolean;
-      }>,
-      ref: React.ForwardedRef<HTMLDivElement>,
-    ) => {
-      const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
-        hostSidebarCountersAtom,
-        jotaiScope,
-      );
+// FIXME replace this with the implem from ColorPicker once it's merged
+const useOnClickOutside = (
+  ref: RefObject<HTMLElement>,
+  cb: (event: MouseEvent) => void,
+) => {
+  useEffect(() => {
+    const listener = (event: MouseEvent) => {
+      if (!ref.current) {
+        return;
+      }
 
 
-      const setAppState = useExcalidrawSetAppState();
+      if (
+        event.target instanceof Element &&
+        (ref.current.contains(event.target) ||
+          !document.body.contains(event.target))
+      ) {
+        return;
+      }
 
 
-      const [isDockedFallback, setIsDockedFallback] = useState(
-        docked ?? initialDockedState ?? false,
-      );
+      cb(event);
+    };
+    document.addEventListener("pointerdown", listener, false);
 
 
-      useLayoutEffect(() => {
-        if (docked === undefined) {
-          // ugly hack to get initial state out of AppState without subscribing
-          // to it as a whole (once we have granular subscriptions, we'll move
-          // to that)
-          //
-          // NOTE this means that is updated `state.isSidebarDocked` changes outside
-          // of this compoent, it won't be reflected here. Currently doesn't happen.
-          setAppState((state) => {
-            setIsDockedFallback(state.isSidebarDocked);
-            // bail from update
-            return null;
-          });
-        }
-      }, [setAppState, docked]);
-
-      useLayoutEffect(() => {
-        if (!__isInternal) {
-          setHostSidebarCounters((s) => ({
-            rendered: s.rendered + 1,
-            docked: isDockedFallback ? s.docked + 1 : s.docked,
-          }));
-          return () => {
-            setHostSidebarCounters((s) => ({
-              rendered: s.rendered - 1,
-              docked: isDockedFallback ? s.docked - 1 : s.docked,
-            }));
-          };
-        }
-      }, [__isInternal, setHostSidebarCounters, isDockedFallback]);
+    return () => {
+      document.removeEventListener("pointerdown", listener);
+    };
+  }, [ref, cb]);
+};
 
 
-      const onCloseRef = useRef(onClose);
-      onCloseRef.current = onClose;
+/**
+ * Flags whether the currently rendered Sidebar is docked or not, for use
+ * in upstream components that need to act on this (e.g. LayerUI to shift the
+ * UI). We use an atom because of potential host app sidebars (for the default
+ * sidebar we could just read from appState.defaultSidebarDockedPreference).
+ *
+ * Since we can only render one Sidebar at a time, we can use a simple flag.
+ */
+export const isSidebarDockedAtom = atom(false);
+
+export const SidebarInner = forwardRef(
+  (
+    {
+      name,
+      children,
+      onDock,
+      docked,
+      className,
+      ...rest
+    }: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
+    ref: React.ForwardedRef<HTMLDivElement>,
+  ) => {
+    if (process.env.NODE_ENV === "development" && onDock && docked == null) {
+      console.warn(
+        "Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
+      );
+    }
 
 
-      useEffect(() => {
-        return () => {
-          onCloseRef.current?.();
-        };
-      }, []);
+    const setAppState = useExcalidrawSetAppState();
 
 
-      const headerPropsRef = useRef<SidebarPropsContextValue>({});
-      headerPropsRef.current.onClose = () => {
-        setAppState({ openSidebar: null });
+    const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
+
+    useLayoutEffect(() => {
+      setIsSidebarDockedAtom(!!docked);
+      return () => {
+        setIsSidebarDockedAtom(false);
       };
       };
-      headerPropsRef.current.onDock = (isDocked) => {
-        if (docked === undefined) {
-          setAppState({ isSidebarDocked: isDocked });
-          setIsDockedFallback(isDocked);
+    }, [setIsSidebarDockedAtom, docked]);
+
+    const headerPropsRef = useRef<SidebarPropsContextValue>(
+      {} as SidebarPropsContextValue,
+    );
+    headerPropsRef.current.onCloseRequest = () => {
+      setAppState({ openSidebar: null });
+    };
+    headerPropsRef.current.onDock = (isDocked) => onDock?.(isDocked);
+    // renew the ref object if the following props change since we want to
+    // rerender. We can't pass down as component props manually because
+    // the <Sidebar.Header/> can be rendered upstream.
+    headerPropsRef.current = updateObject(headerPropsRef.current, {
+      docked,
+      // explicit prop to rerender on update
+      shouldRenderDockButton: !!onDock && docked != null,
+    });
+
+    const islandRef = useRef<HTMLDivElement>(null);
+
+    useImperativeHandle(ref, () => {
+      return islandRef.current!;
+    });
+
+    const device = useDevice();
+
+    const closeLibrary = useCallback(() => {
+      const isDialogOpen = !!document.querySelector(".Dialog");
+
+      // Prevent closing if any dialog is open
+      if (isDialogOpen) {
+        return;
+      }
+      setAppState({ openSidebar: null });
+    }, [setAppState]);
+
+    useOnClickOutside(
+      islandRef,
+      useCallback(
+        (event) => {
+          // If click on the library icon, do nothing so that LibraryButton
+          // can toggle library menu
+          if ((event.target as Element).closest(".sidebar-trigger")) {
+            return;
+          }
+          if (!docked || !device.canDeviceFitSidebar) {
+            closeLibrary();
+          }
+        },
+        [closeLibrary, docked, device.canDeviceFitSidebar],
+      ),
+    );
+
+    useEffect(() => {
+      const handleKeyDown = (event: KeyboardEvent) => {
+        if (
+          event.key === KEYS.ESCAPE &&
+          (!docked || !device.canDeviceFitSidebar)
+        ) {
+          closeLibrary();
         }
         }
-        onDock?.(isDocked);
       };
       };
-      // renew the ref object if the following props change since we want to
-      // rerender. We can't pass down as component props manually because
-      // the <Sidebar.Header/> can be rendered upsream.
-      headerPropsRef.current = updateObject(headerPropsRef.current, {
-        docked: docked ?? isDockedFallback,
-        dockable,
-      });
-
-      if (hostSidebarCounters.rendered > 0 && __isInternal) {
-        return null;
+      document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
+      return () => {
+        document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
+      };
+    }, [closeLibrary, docked, device.canDeviceFitSidebar]);
+
+    return (
+      <Island
+        {...rest}
+        className={clsx("sidebar", { "sidebar--docked": docked }, className)}
+        ref={islandRef}
+      >
+        <SidebarPropsContext.Provider value={headerPropsRef.current}>
+          {children}
+        </SidebarPropsContext.Provider>
+      </Island>
+    );
+  },
+);
+SidebarInner.displayName = "SidebarInner";
+
+export const Sidebar = Object.assign(
+  forwardRef((props: SidebarProps, ref: React.ForwardedRef<HTMLDivElement>) => {
+    const appState = useExcalidrawAppState();
+
+    const { onStateChange } = props;
+
+    const refPrevOpenSidebar = useRef(appState.openSidebar);
+    useEffect(() => {
+      if (
+        // closing sidebar
+        ((!appState.openSidebar &&
+          refPrevOpenSidebar?.current?.name === props.name) ||
+          // opening current sidebar
+          (appState.openSidebar?.name === props.name &&
+            refPrevOpenSidebar?.current?.name !== props.name) ||
+          // switching tabs or switching to a different sidebar
+          refPrevOpenSidebar.current?.name === props.name) &&
+        appState.openSidebar !== refPrevOpenSidebar.current
+      ) {
+        onStateChange?.(
+          appState.openSidebar?.name !== props.name
+            ? null
+            : appState.openSidebar,
+        );
       }
       }
+      refPrevOpenSidebar.current = appState.openSidebar;
+    }, [appState.openSidebar, onStateChange, props.name]);
 
 
-      return (
-        <Island
-          className={clsx(
-            "layer-ui__sidebar",
-            { "layer-ui__sidebar--docked": isDockedFallback },
-            className,
-          )}
-          ref={ref}
-        >
-          <SidebarPropsContext.Provider value={headerPropsRef.current}>
-            <SidebarHeaderComponents.Context>
-              <SidebarHeaderComponents.Component __isFallback />
-              {children}
-            </SidebarHeaderComponents.Context>
-          </SidebarPropsContext.Provider>
-        </Island>
-      );
-    },
-  ),
+    const [mounted, setMounted] = useState(false);
+    useLayoutEffect(() => {
+      setMounted(true);
+      return () => setMounted(false);
+    }, []);
+
+    // We want to render in the next tick (hence `mounted` flag) so that it's
+    // guaranteed to happen after unmount of the previous sidebar (in case the
+    // previous sidebar is mounted after the next one). This is necessary to
+    // prevent flicker of subcomponents that support fallbacks
+    // (e.g. SidebarHeader). This is because we're using flags to determine
+    // whether prefer the fallback component or not (otherwise both will render
+    // initially), and the flag won't be reset in time if the unmount order
+    // it not correct.
+    //
+    // Alternative, and more general solution would be to namespace the fallback
+    // HoC so that state is not shared between subcomponents when the wrapping
+    // component is of the same type (e.g. Sidebar -> SidebarHeader).
+    const shouldRender = mounted && appState.openSidebar?.name === props.name;
+
+    if (!shouldRender) {
+      return null;
+    }
+
+    return <SidebarInner {...props} ref={ref} key={props.name} />;
+  }),
   {
   {
-    Header: SidebarHeaderComponents.Component,
+    Header: SidebarHeader,
+    TabTriggers: SidebarTabTriggers,
+    TabTrigger: SidebarTabTrigger,
+    Tabs: SidebarTabs,
+    Tab: SidebarTab,
+    Trigger: SidebarTrigger,
   },
   },
 );
 );
+Sidebar.displayName = "Sidebar";

+ 33 - 65
src/components/Sidebar/SidebarHeader.tsx

@@ -4,86 +4,54 @@ import { t } from "../../i18n";
 import { useDevice } from "../App";
 import { useDevice } from "../App";
 import { SidebarPropsContext } from "./common";
 import { SidebarPropsContext } from "./common";
 import { CloseIcon, PinIcon } from "../icons";
 import { CloseIcon, PinIcon } from "../icons";
-import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
 import { Tooltip } from "../Tooltip";
 import { Tooltip } from "../Tooltip";
+import { Button } from "../Button";
 
 
-export const SidebarDockButton = (props: {
-  checked: boolean;
-  onChange?(): void;
-}) => {
-  return (
-    <div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
-      <Tooltip label={t("labels.sidebarLock")}>
-        <label
-          className={clsx(
-            "ToolIcon ToolIcon__lock ToolIcon_type_floating",
-            `ToolIcon_size_medium`,
-          )}
-        >
-          <input
-            className="ToolIcon_type_checkbox"
-            type="checkbox"
-            onChange={props.onChange}
-            checked={props.checked}
-            aria-label={t("labels.sidebarLock")}
-          />{" "}
-          <div
-            className={clsx("Sidebar__pin-btn", {
-              "Sidebar__pin-btn--pinned": props.checked,
-            })}
-            tabIndex={0}
-          >
-            {PinIcon}
-          </div>{" "}
-        </label>{" "}
-      </Tooltip>
-    </div>
-  );
-};
-
-const _SidebarHeader: React.FC<{
+export const SidebarHeader = ({
+  children,
+  className,
+}: {
   children?: React.ReactNode;
   children?: React.ReactNode;
   className?: string;
   className?: string;
-}> = ({ children, className }) => {
+}) => {
   const device = useDevice();
   const device = useDevice();
   const props = useContext(SidebarPropsContext);
   const props = useContext(SidebarPropsContext);
 
 
-  const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable);
-  const renderCloseButton = !!props.onClose;
+  const renderDockButton = !!(
+    device.canDeviceFitSidebar && props.shouldRenderDockButton
+  );
 
 
   return (
   return (
     <div
     <div
-      className={clsx("layer-ui__sidebar__header", className)}
+      className={clsx("sidebar__header", className)}
       data-testid="sidebar-header"
       data-testid="sidebar-header"
     >
     >
       {children}
       {children}
-      {(renderDockButton || renderCloseButton) && (
-        <div className="layer-ui__sidebar__header__buttons">
-          {renderDockButton && (
-            <SidebarDockButton
-              checked={!!props.docked}
-              onChange={() => {
-                props.onDock?.(!props.docked);
-              }}
-            />
-          )}
-          {renderCloseButton && (
-            <button
-              data-testid="sidebar-close"
-              className="Sidebar__close-btn"
-              onClick={props.onClose}
-              aria-label={t("buttons.close")}
+      <div className="sidebar__header__buttons">
+        {renderDockButton && (
+          <Tooltip label={t("labels.sidebarLock")}>
+            <Button
+              onSelect={() => props.onDock?.(!props.docked)}
+              selected={!!props.docked}
+              className="sidebar__dock"
+              data-testid="sidebar-dock"
+              aria-label={t("labels.sidebarLock")}
             >
             >
-              {CloseIcon}
-            </button>
-          )}
-        </div>
-      )}
+              {PinIcon}
+            </Button>
+          </Tooltip>
+        )}
+        <Button
+          data-testid="sidebar-close"
+          className="sidebar__close"
+          onSelect={props.onCloseRequest}
+          aria-label={t("buttons.close")}
+        >
+          {CloseIcon}
+        </Button>
+      </div>
     </div>
     </div>
   );
   );
 };
 };
 
 
-const [Context, Component] = withUpstreamOverride(_SidebarHeader);
-
-/** @private */
-export const SidebarHeaderComponents = { Context, Component };
+SidebarHeader.displayName = "SidebarHeader";

+ 18 - 0
src/components/Sidebar/SidebarTab.tsx

@@ -0,0 +1,18 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { SidebarTabName } from "../../types";
+
+export const SidebarTab = ({
+  tab,
+  children,
+  ...rest
+}: {
+  tab: SidebarTabName;
+  children: React.ReactNode;
+} & React.HTMLAttributes<HTMLDivElement>) => {
+  return (
+    <RadixTabs.Content {...rest} value={tab}>
+      {children}
+    </RadixTabs.Content>
+  );
+};
+SidebarTab.displayName = "SidebarTab";

+ 26 - 0
src/components/Sidebar/SidebarTabTrigger.tsx

@@ -0,0 +1,26 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { SidebarTabName } from "../../types";
+
+export const SidebarTabTrigger = ({
+  children,
+  tab,
+  onSelect,
+  ...rest
+}: {
+  children: React.ReactNode;
+  tab: SidebarTabName;
+  onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
+} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
+  return (
+    <RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
+      <button
+        type={"button"}
+        className={`excalidraw-button sidebar-tab-trigger`}
+        {...rest}
+      >
+        {children}
+      </button>
+    </RadixTabs.Trigger>
+  );
+};
+SidebarTabTrigger.displayName = "SidebarTabTrigger";

+ 16 - 0
src/components/Sidebar/SidebarTabTriggers.tsx

@@ -0,0 +1,16 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+
+export const SidebarTabTriggers = ({
+  children,
+  ...rest
+}: { children: React.ReactNode } & Omit<
+  React.RefAttributes<HTMLDivElement>,
+  "onSelect"
+>) => {
+  return (
+    <RadixTabs.List className="sidebar-triggers" {...rest}>
+      {children}
+    </RadixTabs.List>
+  );
+};
+SidebarTabTriggers.displayName = "SidebarTabTriggers";

+ 36 - 0
src/components/Sidebar/SidebarTabs.tsx

@@ -0,0 +1,36 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { useUIAppState } from "../../context/ui-appState";
+import { useExcalidrawSetAppState } from "../App";
+
+export const SidebarTabs = ({
+  children,
+  ...rest
+}: {
+  children: React.ReactNode;
+} & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">) => {
+  const appState = useUIAppState();
+  const setAppState = useExcalidrawSetAppState();
+
+  if (!appState.openSidebar) {
+    return null;
+  }
+
+  const { name } = appState.openSidebar;
+
+  return (
+    <RadixTabs.Root
+      className="sidebar-tabs-root"
+      value={appState.openSidebar.tab}
+      onValueChange={(tab) =>
+        setAppState((state) => ({
+          ...state,
+          openSidebar: { ...state.openSidebar, name, tab },
+        }))
+      }
+      {...rest}
+    >
+      {children}
+    </RadixTabs.Root>
+  );
+};
+SidebarTabs.displayName = "SidebarTabs";

+ 34 - 0
src/components/Sidebar/SidebarTrigger.scss

@@ -0,0 +1,34 @@
+@import "../../css/variables.module";
+
+.excalidraw {
+  .sidebar-trigger {
+    @include outlineButtonStyles;
+
+    background-color: var(--island-bg-color);
+
+    width: auto;
+    height: var(--lg-button-size);
+
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+
+    line-height: 0;
+
+    font-size: 0.75rem;
+    letter-spacing: 0.4px;
+
+    svg {
+      width: var(--lg-icon-size);
+      height: var(--lg-icon-size);
+    }
+  }
+
+  .default-sidebar-trigger .sidebar-trigger__label {
+    display: none;
+
+    @media screen and (min-width: 1024px) {
+      display: block;
+    }
+  }
+}

+ 45 - 0
src/components/Sidebar/SidebarTrigger.tsx

@@ -0,0 +1,45 @@
+import { useExcalidrawSetAppState, useExcalidrawAppState } from "../App";
+import { SidebarTriggerProps } from "./common";
+
+import "./SidebarTrigger.scss";
+import clsx from "clsx";
+
+export const SidebarTrigger = ({
+  name,
+  tab,
+  icon,
+  title,
+  children,
+  onToggle,
+  className,
+  style,
+}: SidebarTriggerProps) => {
+  const setAppState = useExcalidrawSetAppState();
+  // TODO replace with sidebar context
+  const appState = useExcalidrawAppState();
+
+  return (
+    <label title={title}>
+      <input
+        className="ToolIcon_type_checkbox"
+        type="checkbox"
+        onChange={(event) => {
+          document
+            .querySelector(".layer-ui__wrapper")
+            ?.classList.remove("animate");
+          const isOpen = event.target.checked;
+          setAppState({ openSidebar: isOpen ? { name, tab } : null });
+          onToggle?.(isOpen);
+        }}
+        checked={appState.openSidebar?.name === name}
+        aria-label={title}
+        aria-keyshortcuts="0"
+      />
+      <div className={clsx("sidebar-trigger", className)} style={style}>
+        {icon && <div>{icon}</div>}
+        {children && <div className="sidebar-trigger__label">{children}</div>}
+      </div>
+    </label>
+  );
+};
+SidebarTrigger.displayName = "SidebarTrigger";

+ 26 - 8
src/components/Sidebar/common.ts

@@ -1,23 +1,41 @@
 import React from "react";
 import React from "react";
+import { AppState, SidebarName, SidebarTabName } from "../../types";
+
+export type SidebarTriggerProps = {
+  name: SidebarName;
+  tab?: SidebarTabName;
+  icon?: JSX.Element;
+  children?: React.ReactNode;
+  title?: string;
+  className?: string;
+  onToggle?: (open: boolean) => void;
+  style?: React.CSSProperties;
+};
 
 
 export type SidebarProps<P = {}> = {
 export type SidebarProps<P = {}> = {
+  name: SidebarName;
   children: React.ReactNode;
   children: React.ReactNode;
   /**
   /**
-   * Called on sidebar close (either by user action or by the editor).
+   * Called on sidebar open/close or tab change.
+   */
+  onStateChange?: (state: AppState["openSidebar"]) => void;
+  /**
+   * supply alongside `docked` prop in order to make the Sidebar user-dockable
    */
    */
-  onClose?: () => void | boolean;
-  /** if not supplied, sidebar won't be dockable */
   onDock?: (docked: boolean) => void;
   onDock?: (docked: boolean) => void;
   docked?: boolean;
   docked?: boolean;
-  initialDockedState?: boolean;
-  dockable?: boolean;
   className?: string;
   className?: string;
+  // NOTE sidebars we use internally inside the editor must have this flag set.
+  // It indicates that this sidebar should have lower precedence over host
+  // sidebars, if both are open.
+  /** @private internal */
+  __fallback?: boolean;
 } & P;
 } & P;
 
 
 export type SidebarPropsContextValue = Pick<
 export type SidebarPropsContextValue = Pick<
   SidebarProps,
   SidebarProps,
-  "onClose" | "onDock" | "docked" | "dockable"
->;
+  "onDock" | "docked"
+> & { onCloseRequest: () => void; shouldRenderDockButton: boolean };
 
 
 export const SidebarPropsContext =
 export const SidebarPropsContext =
-  React.createContext<SidebarPropsContextValue>({});
+  React.createContext<SidebarPropsContextValue>({} as SidebarPropsContextValue);

+ 0 - 32
src/components/context/tunnels.ts

@@ -1,32 +0,0 @@
-import React from "react";
-import tunnel from "@dwelle/tunnel-rat";
-
-type Tunnel = ReturnType<typeof tunnel>;
-
-type TunnelsContextValue = {
-  mainMenuTunnel: Tunnel;
-  welcomeScreenMenuHintTunnel: Tunnel;
-  welcomeScreenToolbarHintTunnel: Tunnel;
-  welcomeScreenHelpHintTunnel: Tunnel;
-  welcomeScreenCenterTunnel: Tunnel;
-  footerCenterTunnel: Tunnel;
-  jotaiScope: symbol;
-};
-
-export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
-
-export const useTunnels = () => React.useContext(TunnelsContext);
-
-export const useInitializeTunnels = () => {
-  return React.useMemo((): TunnelsContextValue => {
-    return {
-      mainMenuTunnel: tunnel(),
-      welcomeScreenMenuHintTunnel: tunnel(),
-      welcomeScreenToolbarHintTunnel: tunnel(),
-      welcomeScreenHelpHintTunnel: tunnel(),
-      welcomeScreenCenterTunnel: tunnel(),
-      footerCenterTunnel: tunnel(),
-      jotaiScope: Symbol(),
-    };
-  }, []);
-};

+ 2 - 2
src/components/dropdownMenu/DropdownMenuContent.tsx

@@ -1,4 +1,4 @@
-import { useOutsideClickHook } from "../../hooks/useOutsideClick";
+import { useOutsideClick } from "../../hooks/useOutsideClick";
 import { Island } from "../Island";
 import { Island } from "../Island";
 
 
 import { useDevice } from "../App";
 import { useDevice } from "../App";
@@ -24,7 +24,7 @@ const MenuContent = ({
   style?: React.CSSProperties;
   style?: React.CSSProperties;
 }) => {
 }) => {
   const device = useDevice();
   const device = useDevice();
-  const menuRef = useOutsideClickHook(() => {
+  const menuRef = useOutsideClick(() => {
     onClickOutside?.();
     onClickOutside?.();
   });
   });
 
 

+ 4 - 4
src/components/footer/Footer.tsx

@@ -9,7 +9,7 @@ import {
   ZoomActions,
   ZoomActions,
 } from "../Actions";
 } from "../Actions";
 import { useDevice } from "../App";
 import { useDevice } from "../App";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 import { HelpButton } from "../HelpButton";
 import { HelpButton } from "../HelpButton";
 import { Section } from "../Section";
 import { Section } from "../Section";
 import Stack from "../Stack";
 import Stack from "../Stack";
@@ -25,7 +25,7 @@ const Footer = ({
   showExitZenModeBtn: boolean;
   showExitZenModeBtn: boolean;
   renderWelcomeScreen: boolean;
   renderWelcomeScreen: boolean;
 }) => {
 }) => {
-  const { footerCenterTunnel, welcomeScreenHelpHintTunnel } = useTunnels();
+  const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
 
 
   const device = useDevice();
   const device = useDevice();
   const showFinalize =
   const showFinalize =
@@ -70,14 +70,14 @@ const Footer = ({
           </Section>
           </Section>
         </Stack.Col>
         </Stack.Col>
       </div>
       </div>
-      <footerCenterTunnel.Out />
+      <FooterCenterTunnel.Out />
       <div
       <div
         className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
         className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
           "transition-right disable-pointerEvents": appState.zenModeEnabled,
           "transition-right disable-pointerEvents": appState.zenModeEnabled,
         })}
         })}
       >
       >
         <div style={{ position: "relative" }}>
         <div style={{ position: "relative" }}>
-          {renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />}
+          {renderWelcomeScreen && <WelcomeScreenHelpHintTunnel.Out />}
           <HelpButton
           <HelpButton
             onClick={() => actionManager.executeAction(actionShortcuts)}
             onClick={() => actionManager.executeAction(actionShortcuts)}
           />
           />

+ 4 - 4
src/components/footer/FooterCenter.tsx

@@ -1,13 +1,13 @@
 import clsx from "clsx";
 import clsx from "clsx";
 import { useExcalidrawAppState } from "../App";
 import { useExcalidrawAppState } from "../App";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 import "./FooterCenter.scss";
 import "./FooterCenter.scss";
 
 
 const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
 const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
-  const { footerCenterTunnel } = useTunnels();
+  const { FooterCenterTunnel } = useTunnels();
   const appState = useExcalidrawAppState();
   const appState = useExcalidrawAppState();
   return (
   return (
-    <footerCenterTunnel.In>
+    <FooterCenterTunnel.In>
       <div
       <div
         className={clsx("footer-center zen-mode-transition", {
         className={clsx("footer-center zen-mode-transition", {
           "layer-ui__wrapper__footer-left--transition-bottom":
           "layer-ui__wrapper__footer-left--transition-bottom":
@@ -16,7 +16,7 @@ const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
       >
       >
         {children}
         {children}
       </div>
       </div>
-    </footerCenterTunnel.In>
+    </FooterCenterTunnel.In>
   );
   );
 };
 };
 
 

+ 20 - 6
src/components/hoc/withInternalFallback.tsx

@@ -1,32 +1,46 @@
 import { atom, useAtom } from "jotai";
 import { atom, useAtom } from "jotai";
 import React, { useLayoutEffect } from "react";
 import React, { useLayoutEffect } from "react";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 
 
 export const withInternalFallback = <P,>(
 export const withInternalFallback = <P,>(
   componentName: string,
   componentName: string,
   Component: React.FC<P>,
   Component: React.FC<P>,
 ) => {
 ) => {
-  const counterAtom = atom(0);
+  const renderAtom = atom(0);
   // flag set on initial render to tell the fallback component to skip the
   // flag set on initial render to tell the fallback component to skip the
   // render until mount counter are initialized. This is because the counter
   // render until mount counter are initialized. This is because the counter
   // is initialized in an effect, and thus we could end rendering both
   // is initialized in an effect, and thus we could end rendering both
   // components at the same time until counter is initialized.
   // components at the same time until counter is initialized.
   let preferHost = false;
   let preferHost = false;
 
 
+  let counter = 0;
+
   const WrapperComponent: React.FC<
   const WrapperComponent: React.FC<
     P & {
     P & {
       __fallback?: boolean;
       __fallback?: boolean;
     }
     }
   > = (props) => {
   > = (props) => {
     const { jotaiScope } = useTunnels();
     const { jotaiScope } = useTunnels();
-    const [counter, setCounter] = useAtom(counterAtom, jotaiScope);
+    const [, setRender] = useAtom(renderAtom, jotaiScope);
 
 
     useLayoutEffect(() => {
     useLayoutEffect(() => {
-      setCounter((counter) => counter + 1);
+      setRender((c) => {
+        const next = c + 1;
+        counter = next;
+
+        return next;
+      });
       return () => {
       return () => {
-        setCounter((counter) => counter - 1);
+        setRender((c) => {
+          const next = c - 1;
+          counter = next;
+          if (!next) {
+            preferHost = false;
+          }
+          return next;
+        });
       };
       };
-    }, [setCounter]);
+    }, [setRender]);
 
 
     if (!props.__fallback) {
     if (!props.__fallback) {
       preferHost = true;
       preferHost = true;

+ 0 - 63
src/components/hoc/withUpstreamOverride.tsx

@@ -1,63 +0,0 @@
-import React, {
-  useMemo,
-  useContext,
-  useLayoutEffect,
-  useState,
-  createContext,
-} from "react";
-
-export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
-  type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
-
-  const DefaultComponentContext = createContext<ContextValue>([
-    false,
-    () => {},
-  ]);
-
-  const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
-    children,
-  }) => {
-    const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
-    const contextValue: ContextValue = useMemo(
-      () => [isRenderedUpstream, setIsRenderedUpstream],
-      [isRenderedUpstream],
-    );
-
-    return (
-      <DefaultComponentContext.Provider value={contextValue}>
-        {children}
-      </DefaultComponentContext.Provider>
-    );
-  };
-
-  const DefaultComponent = (
-    props: P & {
-      // indicates whether component should render when not rendered upstream
-      /** @private internal */
-      __isFallback?: boolean;
-    },
-  ) => {
-    const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
-      DefaultComponentContext,
-    );
-
-    useLayoutEffect(() => {
-      if (!props.__isFallback) {
-        setIsRenderedUpstream(true);
-        return () => setIsRenderedUpstream(false);
-      }
-    }, [props.__isFallback, setIsRenderedUpstream]);
-
-    if (props.__isFallback && isRenderedUpstream) {
-      return null;
-    }
-
-    return <Component {...props} />;
-  };
-  if (Component.name) {
-    DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
-    ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
-  }
-
-  return [ComponentContext, DefaultComponent] as const;
-};

+ 4 - 4
src/components/main-menu/MainMenu.tsx

@@ -13,7 +13,7 @@ import { t } from "../../i18n";
 import { HamburgerMenuIcon } from "../icons";
 import { HamburgerMenuIcon } from "../icons";
 import { withInternalFallback } from "../hoc/withInternalFallback";
 import { withInternalFallback } from "../hoc/withInternalFallback";
 import { composeEventHandlers } from "../../utils";
 import { composeEventHandlers } from "../../utils";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 
 
 const MainMenu = Object.assign(
 const MainMenu = Object.assign(
   withInternalFallback(
   withInternalFallback(
@@ -28,7 +28,7 @@ const MainMenu = Object.assign(
        */
        */
       onSelect?: (event: Event) => void;
       onSelect?: (event: Event) => void;
     }) => {
     }) => {
-      const { mainMenuTunnel } = useTunnels();
+      const { MainMenuTunnel } = useTunnels();
       const device = useDevice();
       const device = useDevice();
       const appState = useExcalidrawAppState();
       const appState = useExcalidrawAppState();
       const setAppState = useExcalidrawSetAppState();
       const setAppState = useExcalidrawSetAppState();
@@ -37,7 +37,7 @@ const MainMenu = Object.assign(
         : () => setAppState({ openMenu: null });
         : () => setAppState({ openMenu: null });
 
 
       return (
       return (
-        <mainMenuTunnel.In>
+        <MainMenuTunnel.In>
           <DropdownMenu open={appState.openMenu === "canvas"}>
           <DropdownMenu open={appState.openMenu === "canvas"}>
             <DropdownMenu.Trigger
             <DropdownMenu.Trigger
               onToggle={() => {
               onToggle={() => {
@@ -66,7 +66,7 @@ const MainMenu = Object.assign(
               )}
               )}
             </DropdownMenu.Content>
             </DropdownMenu.Content>
           </DropdownMenu>
           </DropdownMenu>
-        </mainMenuTunnel.In>
+        </MainMenuTunnel.In>
       );
       );
     },
     },
   ),
   ),

+ 4 - 4
src/components/welcome-screen/WelcomeScreen.Center.tsx

@@ -6,7 +6,7 @@ import {
   useExcalidrawActionManager,
   useExcalidrawActionManager,
   useExcalidrawAppState,
   useExcalidrawAppState,
 } from "../App";
 } from "../App";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
 import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
 
 
 const WelcomeScreenMenuItemContent = ({
 const WelcomeScreenMenuItemContent = ({
@@ -89,9 +89,9 @@ const WelcomeScreenMenuItemLink = ({
 WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
 WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
 
 
 const Center = ({ children }: { children?: React.ReactNode }) => {
 const Center = ({ children }: { children?: React.ReactNode }) => {
-  const { welcomeScreenCenterTunnel } = useTunnels();
+  const { WelcomeScreenCenterTunnel } = useTunnels();
   return (
   return (
-    <welcomeScreenCenterTunnel.In>
+    <WelcomeScreenCenterTunnel.In>
       <div className="welcome-screen-center">
       <div className="welcome-screen-center">
         {children || (
         {children || (
           <>
           <>
@@ -104,7 +104,7 @@ const Center = ({ children }: { children?: React.ReactNode }) => {
           </>
           </>
         )}
         )}
       </div>
       </div>
-    </welcomeScreenCenterTunnel.In>
+    </WelcomeScreenCenterTunnel.In>
   );
   );
 };
 };
 Center.displayName = "Center";
 Center.displayName = "Center";

+ 10 - 10
src/components/welcome-screen/WelcomeScreen.Hints.tsx

@@ -1,5 +1,5 @@
 import { t } from "../../i18n";
 import { t } from "../../i18n";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 import {
 import {
   WelcomeScreenHelpArrow,
   WelcomeScreenHelpArrow,
   WelcomeScreenMenuArrow,
   WelcomeScreenMenuArrow,
@@ -7,44 +7,44 @@ import {
 } from "../icons";
 } from "../icons";
 
 
 const MenuHint = ({ children }: { children?: React.ReactNode }) => {
 const MenuHint = ({ children }: { children?: React.ReactNode }) => {
-  const { welcomeScreenMenuHintTunnel } = useTunnels();
+  const { WelcomeScreenMenuHintTunnel } = useTunnels();
   return (
   return (
-    <welcomeScreenMenuHintTunnel.In>
+    <WelcomeScreenMenuHintTunnel.In>
       <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
       <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
         {WelcomeScreenMenuArrow}
         {WelcomeScreenMenuArrow}
         <div className="welcome-screen-decor-hint__label">
         <div className="welcome-screen-decor-hint__label">
           {children || t("welcomeScreen.defaults.menuHint")}
           {children || t("welcomeScreen.defaults.menuHint")}
         </div>
         </div>
       </div>
       </div>
-    </welcomeScreenMenuHintTunnel.In>
+    </WelcomeScreenMenuHintTunnel.In>
   );
   );
 };
 };
 MenuHint.displayName = "MenuHint";
 MenuHint.displayName = "MenuHint";
 
 
 const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
 const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
-  const { welcomeScreenToolbarHintTunnel } = useTunnels();
+  const { WelcomeScreenToolbarHintTunnel } = useTunnels();
   return (
   return (
-    <welcomeScreenToolbarHintTunnel.In>
+    <WelcomeScreenToolbarHintTunnel.In>
       <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
       <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
         <div className="welcome-screen-decor-hint__label">
         <div className="welcome-screen-decor-hint__label">
           {children || t("welcomeScreen.defaults.toolbarHint")}
           {children || t("welcomeScreen.defaults.toolbarHint")}
         </div>
         </div>
         {WelcomeScreenTopToolbarArrow}
         {WelcomeScreenTopToolbarArrow}
       </div>
       </div>
-    </welcomeScreenToolbarHintTunnel.In>
+    </WelcomeScreenToolbarHintTunnel.In>
   );
   );
 };
 };
 ToolbarHint.displayName = "ToolbarHint";
 ToolbarHint.displayName = "ToolbarHint";
 
 
 const HelpHint = ({ children }: { children?: React.ReactNode }) => {
 const HelpHint = ({ children }: { children?: React.ReactNode }) => {
-  const { welcomeScreenHelpHintTunnel } = useTunnels();
+  const { WelcomeScreenHelpHintTunnel } = useTunnels();
   return (
   return (
-    <welcomeScreenHelpHintTunnel.In>
+    <WelcomeScreenHelpHintTunnel.In>
       <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
       <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
         <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
         <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
         {WelcomeScreenHelpArrow}
         {WelcomeScreenHelpArrow}
       </div>
       </div>
-    </welcomeScreenHelpHintTunnel.In>
+    </WelcomeScreenHelpHintTunnel.In>
   );
   );
 };
 };
 HelpHint.displayName = "HelpHint";
 HelpHint.displayName = "HelpHint";

+ 7 - 0
src/constants.ts

@@ -275,3 +275,10 @@ export const DEFAULT_ELEMENT_PROPS: {
   opacity: 100,
   opacity: 100,
   locked: false,
   locked: false,
 };
 };
+
+export const LIBRARY_SIDEBAR_TAB = "library";
+
+export const DEFAULT_SIDEBAR = {
+  name: "default",
+  defaultTab: LIBRARY_SIDEBAR_TAB,
+} as const;

+ 36 - 0
src/context/tunnels.ts

@@ -0,0 +1,36 @@
+import React from "react";
+import tunnel from "tunnel-rat";
+
+export type Tunnel = ReturnType<typeof tunnel>;
+
+type TunnelsContextValue = {
+  MainMenuTunnel: Tunnel;
+  WelcomeScreenMenuHintTunnel: Tunnel;
+  WelcomeScreenToolbarHintTunnel: Tunnel;
+  WelcomeScreenHelpHintTunnel: Tunnel;
+  WelcomeScreenCenterTunnel: Tunnel;
+  FooterCenterTunnel: Tunnel;
+  DefaultSidebarTriggerTunnel: Tunnel;
+  DefaultSidebarTabTriggersTunnel: Tunnel;
+  jotaiScope: symbol;
+};
+
+export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
+
+export const useTunnels = () => React.useContext(TunnelsContext);
+
+export const useInitializeTunnels = () => {
+  return React.useMemo((): TunnelsContextValue => {
+    return {
+      MainMenuTunnel: tunnel(),
+      WelcomeScreenMenuHintTunnel: tunnel(),
+      WelcomeScreenToolbarHintTunnel: tunnel(),
+      WelcomeScreenHelpHintTunnel: tunnel(),
+      WelcomeScreenCenterTunnel: tunnel(),
+      FooterCenterTunnel: tunnel(),
+      DefaultSidebarTriggerTunnel: tunnel(),
+      DefaultSidebarTabTriggersTunnel: tunnel(),
+      jotaiScope: Symbol(),
+    };
+  }, []);
+};

+ 5 - 0
src/context/ui-appState.ts

@@ -0,0 +1,5 @@
+import React from "react";
+import { AppState } from "../types";
+
+export const UIAppStateContext = React.createContext<AppState>(null!);
+export const useUIAppState = () => React.useContext(UIAppStateContext);

+ 1 - 1
src/css/styles.scss

@@ -567,7 +567,7 @@
       border-radius: 0;
       border-radius: 0;
     }
     }
 
 
-    .library-button {
+    .default-sidebar-trigger {
       border: 0;
       border: 0;
     }
     }
   }
   }

+ 6 - 0
src/css/theme.scss

@@ -78,10 +78,13 @@
 
 
   --color-selection: #6965db;
   --color-selection: #6965db;
 
 
+  --color-icon-white: #{$oc-white};
+
   --color-primary: #6965db;
   --color-primary: #6965db;
   --color-primary-darker: #5b57d1;
   --color-primary-darker: #5b57d1;
   --color-primary-darkest: #4a47b1;
   --color-primary-darkest: #4a47b1;
   --color-primary-light: #e3e2fe;
   --color-primary-light: #e3e2fe;
+  --color-primary-light-darker: #d7d5ff;
 
 
   --color-gray-10: #f5f5f5;
   --color-gray-10: #f5f5f5;
   --color-gray-20: #ebebeb;
   --color-gray-20: #ebebeb;
@@ -161,10 +164,13 @@
     // will be inverted to a lighter color.
     // will be inverted to a lighter color.
     --color-selection: #3530c4;
     --color-selection: #3530c4;
 
 
+    --color-icon-white: var(--color-gray-90);
+
     --color-primary: #a8a5ff;
     --color-primary: #a8a5ff;
     --color-primary-darker: #b2aeff;
     --color-primary-darker: #b2aeff;
     --color-primary-darkest: #beb9ff;
     --color-primary-darkest: #beb9ff;
     --color-primary-light: #4f4d6f;
     --color-primary-light: #4f4d6f;
+    --color-primary-light-darker: #43415e;
 
 
     --color-text-warning: var(--color-gray-80);
     --color-text-warning: var(--color-gray-80);
 
 

+ 14 - 4
src/css/variables.module.scss

@@ -72,7 +72,14 @@
 
 
   &:hover {
   &:hover {
     background-color: var(--button-hover-bg, var(--island-bg-color));
     background-color: var(--button-hover-bg, var(--island-bg-color));
-    border-color: var(--button-hover-border, var(--default-border-color));
+    border-color: var(
+      --button-hover-border,
+      var(--button-border, var(--default-border-color))
+    );
+    color: var(
+      --button-hover-color,
+      var(--button-color, var(--text-primary-color, inherit))
+    );
   }
   }
 
 
   &:active {
   &:active {
@@ -81,11 +88,14 @@
   }
   }
 
 
   &.active {
   &.active {
-    background-color: var(--color-primary-light);
-    border-color: var(--color-primary-light);
+    background-color: var(--button-selected-bg, var(--color-primary-light));
+    border-color: var(--button-selected-border, var(--color-primary-light));
 
 
     &:hover {
     &:hover {
-      background-color: var(--color-primary-light);
+      background-color: var(
+        --button-selected-hover-bg,
+        var(--color-primary-light)
+      );
     }
     }
 
 
     svg {
     svg {

+ 18 - 4
src/data/library.ts

@@ -14,7 +14,14 @@ import { getCommonBoundingBox } from "../element/bounds";
 import { AbortError } from "../errors";
 import { AbortError } from "../errors";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import { useEffect, useRef } from "react";
 import { useEffect, useRef } from "react";
-import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants";
+import {
+  URL_HASH_KEYS,
+  URL_QUERY_KEYS,
+  APP_NAME,
+  EVENT,
+  DEFAULT_SIDEBAR,
+  LIBRARY_SIDEBAR_TAB,
+} from "../constants";
 
 
 export const libraryItemsAtom = atom<{
 export const libraryItemsAtom = atom<{
   status: "loading" | "loaded";
   status: "loading" | "loaded";
@@ -148,7 +155,9 @@ class Library {
     defaultStatus?: "unpublished" | "published";
     defaultStatus?: "unpublished" | "published";
   }): Promise<LibraryItems> => {
   }): Promise<LibraryItems> => {
     if (openLibraryMenu) {
     if (openLibraryMenu) {
-      this.app.setState({ openSidebar: "library" });
+      this.app.setState({
+        openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
+      });
     }
     }
 
 
     return this.setLibrary(() => {
     return this.setLibrary(() => {
@@ -174,6 +183,13 @@ class Library {
               }),
               }),
             )
             )
           ) {
           ) {
+            if (prompt) {
+              // focus container if we've prompted. We focus conditionally
+              // lest `props.autoFocus` is disabled (in which case we should
+              // focus only on user action such as prompt confirm)
+              this.app.focusContainer();
+            }
+
             if (merge) {
             if (merge) {
               resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
               resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
             } else {
             } else {
@@ -186,8 +202,6 @@ class Library {
           reject(error);
           reject(error);
         }
         }
       });
       });
-    }).finally(() => {
-      this.app.focusContainer();
     });
     });
   };
   };
 
 

+ 12 - 20
src/data/restore.ts

@@ -27,6 +27,7 @@ import {
   PRECEDING_ELEMENT_KEY,
   PRECEDING_ELEMENT_KEY,
   FONT_FAMILY,
   FONT_FAMILY,
   ROUNDNESS,
   ROUNDNESS,
+  DEFAULT_SIDEBAR,
 } from "../constants";
 } from "../constants";
 import { getDefaultAppState } from "../appState";
 import { getDefaultAppState } from "../appState";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { LinearElementEditor } from "../element/linearElementEditor";
@@ -431,21 +432,15 @@ const LegacyAppStateMigrations: {
     defaultAppState: ReturnType<typeof getDefaultAppState>,
     defaultAppState: ReturnType<typeof getDefaultAppState>,
   ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
   ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
 } = {
 } = {
-  isLibraryOpen: (appState, defaultAppState) => {
+  isSidebarDocked: (appState, defaultAppState) => {
     return [
     return [
-      "openSidebar",
-      "isLibraryOpen" in appState
-        ? appState.isLibraryOpen
-          ? "library"
-          : null
-        : coalesceAppStateValue("openSidebar", appState, defaultAppState),
-    ];
-  },
-  isLibraryMenuDocked: (appState, defaultAppState) => {
-    return [
-      "isSidebarDocked",
-      appState.isLibraryMenuDocked ??
-        coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
+      "defaultSidebarDockedPreference",
+      appState.isSidebarDocked ??
+        coalesceAppStateValue(
+          "defaultSidebarDockedPreference",
+          appState,
+          defaultAppState,
+        ),
     ];
     ];
   },
   },
 };
 };
@@ -517,13 +512,10 @@ export const restoreAppState = (
         : appState.zoom?.value
         : appState.zoom?.value
         ? appState.zoom
         ? appState.zoom
         : defaultAppState.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.
     openSidebar:
     openSidebar:
-      nextAppState.openSidebar === "library"
-        ? nextAppState.isSidebarDocked
-          ? "library"
-          : null
+      // string (legacy)
+      typeof (appState.openSidebar as any as string) === "string"
+        ? { name: DEFAULT_SIDEBAR.name }
         : nextAppState.openSidebar,
         : nextAppState.openSidebar,
   };
   };
 };
 };

+ 2 - 4
src/data/types.ts

@@ -25,10 +25,8 @@ export interface ExportedDataState {
  * Don't consume on its own.
  * Don't consume on its own.
  */
  */
 export type LegacyAppState = {
 export type LegacyAppState = {
-  /** @deprecated #5663 TODO remove 22-12-15 */
-  isLibraryOpen: [boolean, "openSidebar"];
-  /** @deprecated #5663 TODO remove 22-12-15 */
-  isLibraryMenuDocked: [boolean, "isSidebarDocked"];
+  /** @deprecated #6213 TODO remove 23-06-01 */
+  isSidebarDocked: [boolean, "defaultSidebarDockedPreference"];
 };
 };
 
 
 export interface ImportedDataState {
 export interface ImportedDataState {

+ 1 - 1
src/hooks/useOutsideClick.ts

@@ -1,6 +1,6 @@
 import { useEffect, useRef } from "react";
 import { useEffect, useRef } from "react";
 
 
-export const useOutsideClickHook = (handler: (event: Event) => void) => {
+export const useOutsideClick = (handler: (event: Event) => void) => {
   const ref = useRef(null);
   const ref = useRef(null);
 
 
   useEffect(
   useEffect(

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

@@ -11,6 +11,22 @@ The change should be grouped under one of the below section and must contain PR
 Please add the latest change on the top under the correct section.
 Please add the latest change on the top under the correct section.
 -->
 -->
 
 
+## Unreleased
+
+### Features
+
+- Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
+- Exposed `DefaultSidebar` component to allow modifying the default sidebar, such as adding custom tabs to it. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
+
+  #### BREAKING CHANGES
+
+  - `props.renderSidebar` is removed in favor of rendering as `children`.
+  - `appState.isSidebarDocked` replaced with `appState.defaultSidebarDockedPreference` with slightly different semantics, and relating only to the default sidebar. You need to handle `docked` state for your custom sidebars yourself.
+  - Sidebar `props.dockable` is removed. To indicate dockability, supply `props.onDock()` alongside setting `props.docked`.
+  - `Sidebar.Header` is no longer rendered by default. You need to render it yourself.
+  - `props.onClose` replaced with `props.onStateChange`.
+  - `restore()`/`restoreAppState()` now retains `appState.openSidebar` regardless of docked state.
+
 ## 0.15.2 (2023-04-20)
 ## 0.15.2 (2023-04-20)
 
 
 ### Docs
 ### Docs

+ 24 - 27
src/packages/excalidraw/example/App.tsx

@@ -494,15 +494,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
     );
     );
   };
   };
 
 
-  const renderSidebar = () => {
-    return (
-      <Sidebar>
-        <Sidebar.Header>Custom header!</Sidebar.Header>
-        Custom sidebar!
-      </Sidebar>
-    );
-  };
-
   const renderMenu = () => {
   const renderMenu = () => {
     return (
     return (
       <MainMenu>
       <MainMenu>
@@ -668,23 +659,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
           </div>
           </div>
         </div>
         </div>
         <div className="excalidraw-wrapper">
         <div className="excalidraw-wrapper">
-          <div
-            style={{
-              position: "absolute",
-              left: "50%",
-              bottom: "20px",
-              display: "flex",
-              zIndex: 9999999999999999,
-              padding: "5px 10px",
-              transform: "translateX(-50%)",
-              background: "rgba(255, 255, 255, 0.8)",
-              gap: "1rem",
-            }}
-          >
-            <button onClick={() => excalidrawAPI?.toggleMenu("customSidebar")}>
-              Toggle Custom Sidebar
-            </button>
-          </div>
           <Excalidraw
           <Excalidraw
             ref={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
             ref={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
             initialData={initialStatePromiseRef.current.promise}
             initialData={initialStatePromiseRef.current.promise}
@@ -706,7 +680,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
             onLinkOpen={onLinkOpen}
             onLinkOpen={onLinkOpen}
             onPointerDown={onPointerDown}
             onPointerDown={onPointerDown}
             onScrollChange={rerenderCommentIcons}
             onScrollChange={rerenderCommentIcons}
-            renderSidebar={renderSidebar}
           >
           >
             {excalidrawAPI && (
             {excalidrawAPI && (
               <Footer>
               <Footer>
@@ -714,6 +687,30 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
               </Footer>
               </Footer>
             )}
             )}
             <WelcomeScreen />
             <WelcomeScreen />
+            <Sidebar name="custom">
+              <Sidebar.Tabs>
+                <Sidebar.Header />
+                <Sidebar.Tab tab="one">Tab one!</Sidebar.Tab>
+                <Sidebar.Tab tab="two">Tab two!</Sidebar.Tab>
+                <Sidebar.TabTriggers>
+                  <Sidebar.TabTrigger tab="one">One</Sidebar.TabTrigger>
+                  <Sidebar.TabTrigger tab="two">Two</Sidebar.TabTrigger>
+                </Sidebar.TabTriggers>
+              </Sidebar.Tabs>
+            </Sidebar>
+            <Sidebar.Trigger
+              name="custom"
+              tab="one"
+              style={{
+                position: "absolute",
+                left: "50%",
+                transform: "translateX(-50%)",
+                bottom: "20px",
+                zIndex: 9999999999999999,
+              }}
+            >
+              Toggle Custom Sidebar
+            </Sidebar.Trigger>
             {renderMenu()}
             {renderMenu()}
           </Excalidraw>
           </Excalidraw>
           {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
           {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}

+ 4 - 2
src/packages/excalidraw/index.tsx

@@ -24,7 +24,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
     isCollaborating = false,
     isCollaborating = false,
     onPointerUpdate,
     onPointerUpdate,
     renderTopRightUI,
     renderTopRightUI,
-    renderSidebar,
     langCode = defaultLang.code,
     langCode = defaultLang.code,
     viewModeEnabled,
     viewModeEnabled,
     zenModeEnabled,
     zenModeEnabled,
@@ -47,6 +46,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
 
 
   const canvasActions = props.UIOptions?.canvasActions;
   const canvasActions = props.UIOptions?.canvasActions;
 
 
+  // FIXME normalize/set defaults in parent component so that the memo resolver
+  // compares the same values
   const UIOptions: AppProps["UIOptions"] = {
   const UIOptions: AppProps["UIOptions"] = {
     ...props.UIOptions,
     ...props.UIOptions,
     canvasActions: {
     canvasActions: {
@@ -114,7 +115,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
           onLinkOpen={onLinkOpen}
           onLinkOpen={onLinkOpen}
           onPointerDown={onPointerDown}
           onPointerDown={onPointerDown}
           onScrollChange={onScrollChange}
           onScrollChange={onScrollChange}
-          renderSidebar={renderSidebar}
         >
         >
           {children}
           {children}
         </App>
         </App>
@@ -245,3 +245,5 @@ export { MainMenu };
 export { useDevice } from "../../components/App";
 export { useDevice } from "../../components/App";
 export { WelcomeScreen };
 export { WelcomeScreen };
 export { LiveCollaborationTrigger };
 export { LiveCollaborationTrigger };
+
+export { DefaultSidebar } from "../../components/DefaultSidebar";

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

@@ -284,6 +284,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -300,7 +301,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -464,6 +464,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -480,7 +481,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -650,6 +650,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -666,7 +667,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -1005,6 +1005,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -1021,7 +1022,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -1360,6 +1360,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -1376,7 +1377,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -1546,6 +1546,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -1562,7 +1563,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -1768,6 +1768,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -1784,7 +1785,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -2053,6 +2053,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -2069,7 +2070,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -2426,6 +2426,7 @@ Object {
   "currentItemStrokeWidth": 2,
   "currentItemStrokeWidth": 2,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -2442,7 +2443,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -3273,6 +3273,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -3289,7 +3290,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -3628,6 +3628,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -3644,7 +3645,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -3983,6 +3983,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -3999,7 +4000,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -4681,6 +4681,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -4697,7 +4698,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -5231,6 +5231,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -5247,7 +5248,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -5712,6 +5712,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -5728,7 +5729,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -6080,6 +6080,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -6096,7 +6097,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -6426,6 +6426,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -6442,7 +6443,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",

+ 53 - 53
src/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -25,6 +25,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -41,7 +42,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -561,6 +561,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -577,7 +578,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -1103,6 +1103,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": "id10",
   "editingGroupId": "id10",
@@ -1119,7 +1120,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -2010,6 +2010,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -2026,7 +2027,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -2240,6 +2240,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -2256,7 +2257,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -2773,6 +2773,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -2789,7 +2790,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -3062,6 +3062,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -3078,7 +3079,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -3246,6 +3246,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -3262,7 +3263,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -3762,6 +3762,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -3778,7 +3779,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -4030,6 +4030,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -4046,7 +4047,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -4260,6 +4260,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -4276,7 +4277,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -4536,6 +4536,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -4552,7 +4553,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -4824,6 +4824,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -4840,7 +4841,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -5242,6 +5242,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "down",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": Object {
   "draggingElement": Object {
     "angle": 0,
     "angle": 0,
     "backgroundColor": "transparent",
     "backgroundColor": "transparent",
@@ -5285,7 +5286,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -5583,6 +5583,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": Object {
   "draggingElement": Object {
     "angle": 0,
     "angle": 0,
     "backgroundColor": "transparent",
     "backgroundColor": "transparent",
@@ -5626,7 +5627,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -5897,6 +5897,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "down",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": Object {
   "draggingElement": Object {
     "angle": 0,
     "angle": 0,
     "backgroundColor": "transparent",
     "backgroundColor": "transparent",
@@ -5940,7 +5941,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -6135,6 +6135,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -6151,7 +6152,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -6321,6 +6321,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": "id3",
   "editingGroupId": "id3",
@@ -6337,7 +6338,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -6849,6 +6849,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -6865,7 +6866,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -7214,6 +7214,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -7230,7 +7231,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -9566,6 +9566,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -9582,7 +9583,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -9985,6 +9985,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -10001,7 +10002,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -10274,6 +10274,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -10290,7 +10291,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -10522,6 +10522,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -10538,7 +10539,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -10843,6 +10843,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -10859,7 +10860,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -11027,6 +11027,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -11043,7 +11044,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -11211,6 +11211,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -11227,7 +11228,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -11395,6 +11395,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -11411,7 +11412,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -11632,6 +11632,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -11648,7 +11649,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -11869,6 +11869,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -11885,7 +11886,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -12097,6 +12097,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -12113,7 +12114,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -12334,6 +12334,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -12350,7 +12351,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -12518,6 +12518,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -12534,7 +12535,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -12755,6 +12755,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -12771,7 +12772,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -12939,6 +12939,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -12955,7 +12956,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -13167,6 +13167,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -13183,7 +13184,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -13351,6 +13351,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -13367,7 +13368,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -14190,6 +14190,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -14206,7 +14207,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -14479,6 +14479,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "down",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -14495,7 +14496,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "touch",
   "lastPointerDownWith": "touch",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -14590,6 +14590,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -14606,7 +14607,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -14699,6 +14699,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -14715,7 +14716,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -14886,6 +14886,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -14902,7 +14903,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -15254,6 +15254,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -15270,7 +15271,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -15885,6 +15885,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -15901,7 +15902,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -16111,6 +16111,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -16127,7 +16128,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -17074,6 +17074,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -17090,7 +17091,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -17183,6 +17183,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": "id3",
   "editingGroupId": "id3",
@@ -17199,7 +17200,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -18042,6 +18042,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "down",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": Object {
   "draggingElement": Object {
     "angle": 0,
     "angle": 0,
     "backgroundColor": "transparent",
     "backgroundColor": "transparent",
@@ -18085,7 +18086,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -18514,6 +18514,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "down",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": Object {
   "draggingElement": Object {
     "angle": 0,
     "angle": 0,
     "backgroundColor": "transparent",
     "backgroundColor": "transparent",
@@ -18557,7 +18558,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -18855,6 +18855,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "down",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -18871,7 +18872,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "touch",
   "lastPointerDownWith": "touch",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -18966,6 +18966,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -18982,7 +18983,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -19537,6 +19537,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -19553,7 +19554,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",
@@ -19646,6 +19646,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -19662,7 +19663,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "Untitled-201933152653",
   "name": "Untitled-201933152653",

+ 24 - 1
src/tests/data/restore.test.ts

@@ -10,7 +10,7 @@ import { API } from "../helpers/api";
 import { getDefaultAppState } from "../../appState";
 import { getDefaultAppState } from "../../appState";
 import { ImportedDataState } from "../../data/types";
 import { ImportedDataState } from "../../data/types";
 import { NormalizedZoomValue } from "../../types";
 import { NormalizedZoomValue } from "../../types";
-import { FONT_FAMILY, ROUNDNESS } from "../../constants";
+import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "../../constants";
 import { newElementWith } from "../../element/mutateElement";
 import { newElementWith } from "../../element/mutateElement";
 
 
 describe("restoreElements", () => {
 describe("restoreElements", () => {
@@ -453,6 +453,29 @@ describe("restoreAppState", () => {
       expect(restoredAppState.zoom).toMatchObject(getDefaultAppState().zoom);
       expect(restoredAppState.zoom).toMatchObject(getDefaultAppState().zoom);
     });
     });
   });
   });
+
+  it("should handle appState.openSidebar legacy values", () => {
+    expect(restore.restoreAppState({}, null).openSidebar).toBe(null);
+    expect(
+      restore.restoreAppState({ openSidebar: "library" } as any, null)
+        .openSidebar,
+    ).toEqual({ name: DEFAULT_SIDEBAR.name });
+    expect(
+      restore.restoreAppState({ openSidebar: "xxx" } as any, null).openSidebar,
+    ).toEqual({ name: DEFAULT_SIDEBAR.name });
+    // while "library" was our legacy sidebar name, we can't assume it's legacy
+    // value as it may be some host app's custom sidebar name ¯\_(ツ)_/¯
+    expect(
+      restore.restoreAppState({ openSidebar: { name: "library" } } as any, null)
+        .openSidebar,
+    ).toEqual({ name: "library" });
+    expect(
+      restore.restoreAppState(
+        { openSidebar: { name: DEFAULT_SIDEBAR.name, tab: "ola" } } as any,
+        null,
+      ).openSidebar,
+    ).toEqual({ name: DEFAULT_SIDEBAR.name, tab: "ola" });
+  });
 });
 });
 
 
 describe("restore", () => {
 describe("restore", () => {

+ 7 - 2
src/tests/library.test.tsx

@@ -189,10 +189,15 @@ describe("library menu", () => {
     const latestLibrary = await h.app.library.getLatestLibrary();
     const latestLibrary = await h.app.library.getLatestLibrary();
     expect(latestLibrary.length).toBe(0);
     expect(latestLibrary.length).toBe(0);
 
 
-    const libraryButton = container.querySelector(".library-button");
+    const libraryButton = container.querySelector(".sidebar-trigger");
 
 
     fireEvent.click(libraryButton!);
     fireEvent.click(libraryButton!);
-    fireEvent.click(container.querySelector(".Sidebar__dropdown-btn")!);
+    fireEvent.click(
+      queryByTestId(
+        container.querySelector(".layer-ui__library")!,
+        "dropdown-menu-button",
+      )!,
+    );
     queryByTestId(container, "lib-dropdown--load")!.click();
     queryByTestId(container, "lib-dropdown--load")!.click();
 
 
     const libraryItems = parseLibraryJSON(await libraryJSONPromise);
     const libraryItems = parseLibraryJSON(await libraryJSONPromise);

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

@@ -25,6 +25,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "currentItemTextAlign": "left",
   "cursorButton": "up",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "draggingElement": null,
   "editingElement": null,
   "editingElement": null,
   "editingGroupId": null,
   "editingGroupId": null,
@@ -41,7 +42,6 @@ Object {
   "isLoading": false,
   "isLoading": false,
   "isResizing": false,
   "isResizing": false,
   "isRotating": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "multiElement": null,
   "name": "name",
   "name": "name",

+ 15 - 8
src/types.ts

@@ -94,6 +94,9 @@ export type LastActiveTool =
     }
     }
   | null;
   | null;
 
 
+export type SidebarName = string;
+export type SidebarTabName = string;
+
 export type AppState = {
 export type AppState = {
   contextMenu: {
   contextMenu: {
     items: ContextMenuItems;
     items: ContextMenuItems;
@@ -159,16 +162,22 @@ export type AppState = {
   isResizing: boolean;
   isResizing: boolean;
   isRotating: boolean;
   isRotating: boolean;
   zoom: Zoom;
   zoom: Zoom;
-  // mobile-only
   openMenu: "canvas" | "shape" | null;
   openMenu: "canvas" | "shape" | null;
   openPopup:
   openPopup:
     | "canvasColorPicker"
     | "canvasColorPicker"
     | "backgroundColorPicker"
     | "backgroundColorPicker"
     | "strokeColorPicker"
     | "strokeColorPicker"
     | null;
     | null;
-  openSidebar: "library" | "customSidebar" | null;
+  openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
   openDialog: "imageExport" | "help" | "jsonExport" | null;
   openDialog: "imageExport" | "help" | "jsonExport" | null;
-  isSidebarDocked: boolean;
+  /**
+   * Reflects user preference for whether the default sidebar should be docked.
+   *
+   * NOTE this is only a user preference and does not reflect the actual docked
+   * state of the sidebar, because the host apps can override this through
+   * a DefaultSidebar prop, which is not reflected back to the appState.
+   */
+  defaultSidebarDockedPreference: boolean;
 
 
   lastPointerDownWith: PointerType;
   lastPointerDownWith: PointerType;
   selectedElementIds: { [id: string]: boolean };
   selectedElementIds: { [id: string]: boolean };
@@ -335,10 +344,6 @@ export interface ExcalidrawProps {
     pointerDownState: PointerDownState,
     pointerDownState: PointerDownState,
   ) => void;
   ) => void;
   onScrollChange?: (scrollX: number, scrollY: number) => void;
   onScrollChange?: (scrollX: number, scrollY: number) => void;
-  /**
-   * Render function that renders custom <Sidebar /> component.
-   */
-  renderSidebar?: () => JSX.Element | null;
   children?: React.ReactNode;
   children?: React.ReactNode;
 }
 }
 
 
@@ -426,6 +431,8 @@ export type AppClassProperties = {
   device: App["device"];
   device: App["device"];
   scene: App["scene"];
   scene: App["scene"];
   pasteFromClipboard: App["pasteFromClipboard"];
   pasteFromClipboard: App["pasteFromClipboard"];
+  id: App["id"];
+  onInsertElements: App["onInsertElements"];
 };
 };
 
 
 export type PointerDownState = Readonly<{
 export type PointerDownState = Readonly<{
@@ -517,7 +524,7 @@ export type ExcalidrawImperativeAPI = {
   setActiveTool: InstanceType<typeof App>["setActiveTool"];
   setActiveTool: InstanceType<typeof App>["setActiveTool"];
   setCursor: InstanceType<typeof App>["setCursor"];
   setCursor: InstanceType<typeof App>["setCursor"];
   resetCursor: InstanceType<typeof App>["resetCursor"];
   resetCursor: InstanceType<typeof App>["resetCursor"];
-  toggleMenu: InstanceType<typeof App>["toggleMenu"];
+  toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
 };
 };
 
 
 export type Device = Readonly<{
 export type Device = Readonly<{

+ 17 - 3
src/utils.ts

@@ -767,16 +767,30 @@ export const queryFocusableElements = (container: HTMLElement | null) => {
     : [];
     : [];
 };
 };
 
 
-export const isShallowEqual = <T extends Record<string, any>>(
+export const isShallowEqual = <
+  T extends Record<string, any>,
+  I extends keyof T,
+>(
   objA: T,
   objA: T,
   objB: T,
   objB: T,
+  comparators?: Record<I, (a: T[I], b: T[I]) => boolean>,
+  debug = false,
 ) => {
 ) => {
   const aKeys = Object.keys(objA);
   const aKeys = Object.keys(objA);
-  const bKeys = Object.keys(objA);
+  const bKeys = Object.keys(objB);
   if (aKeys.length !== bKeys.length) {
   if (aKeys.length !== bKeys.length) {
     return false;
     return false;
   }
   }
-  return aKeys.every((key) => objA[key] === objB[key]);
+  return aKeys.every((key) => {
+    const comparator = comparators?.[key as I];
+    const ret = comparator
+      ? comparator(objA[key], objB[key])
+      : objA[key] === objB[key];
+    if (!ret && debug) {
+      console.warn(`isShallowEqual: ${key} not equal ->`, objA[key], objB[key]);
+    }
+    return ret;
+  });
 };
 };
 
 
 // taken from Radix UI
 // taken from Radix UI

+ 142 - 17
yarn.lock

@@ -1262,6 +1262,13 @@
   dependencies:
   dependencies:
     regenerator-runtime "^0.13.10"
     regenerator-runtime "^0.13.10"
 
 
+"@babel/runtime@^7.13.10":
+  version "7.20.13"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b"
+  integrity sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==
+  dependencies:
+    regenerator-runtime "^0.13.11"
+
 "@babel/template@^7.18.10", "@babel/template@^7.3.3":
 "@babel/template@^7.18.10", "@babel/template@^7.3.3":
   version "7.18.10"
   version "7.18.10"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71"
@@ -1437,13 +1444,6 @@
   resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz#1bfafe4b7ed0f3e4105837e056e0a89b108ebe36"
   resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz#1bfafe4b7ed0f3e4105837e056e0a89b108ebe36"
   integrity sha512-IkpVW/ehM1hWKln4fCA3NzJU8KwD+kIOvPZA4cqxoJHtE21CCzjyp+Kxbu0i5I4tBNOlXPL9mjwnWlL0VEG4Fg==
   integrity sha512-IkpVW/ehM1hWKln4fCA3NzJU8KwD+kIOvPZA4cqxoJHtE21CCzjyp+Kxbu0i5I4tBNOlXPL9mjwnWlL0VEG4Fg==
 
 
-"@dwelle/[email protected]":
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/@dwelle/tunnel-rat/-/tunnel-rat-0.1.1.tgz#0a0b235f8fc22ff1cf47ed102f4cc612eb51bc71"
-  integrity sha512-jb5/ZsT/af1J7tnbBXp7KO1xEyw61lWSDqJ+Bqdc6JlL3vbAvsifNhe+/mRFs6aSBCRaDqp5f2pJDHtA3MUZLw==
-  dependencies:
-    zustand "^4.3.2"
-
 "@eslint/eslintrc@^0.4.3":
 "@eslint/eslintrc@^0.4.3":
   version "0.4.3"
   version "0.4.3"
   resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c"
   resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c"
@@ -2164,6 +2164,131 @@
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
   integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
 
 
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.0.tgz#e1d8ef30b10ea10e69c76e896f608d9276352253"
+  integrity sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.1.tgz#259506f97c6703b36291826768d3c1337edd1de5"
+  integrity sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-compose-refs" "1.0.0"
+    "@radix-ui/react-context" "1.0.0"
+    "@radix-ui/react-primitive" "1.0.1"
+    "@radix-ui/react-slot" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz#37595b1f16ec7f228d698590e78eeed18ff218ae"
+  integrity sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.0.tgz#f38e30c5859a9fb5e9aa9a9da452ee3ed9e0aee0"
+  integrity sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.0.tgz#a2e0b552352459ecf96342c79949dd833c1e6e45"
+  integrity sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.0.tgz#8d43224910741870a45a8c9d092f25887bb6d11e"
+  integrity sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-use-layout-effect" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.0.tgz#814fe46df11f9a468808a6010e3f3ca7e0b2e84a"
+  integrity sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-compose-refs" "1.0.0"
+    "@radix-ui/react-use-layout-effect" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz#c1ebcce283dd2f02e4fbefdaa49d1cb13dbc990a"
+  integrity sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-slot" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz#d8ac2e3b8006697bdfc2b0eb06bef7e15b6245de"
+  integrity sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/primitive" "1.0.0"
+    "@radix-ui/react-collection" "1.0.1"
+    "@radix-ui/react-compose-refs" "1.0.0"
+    "@radix-ui/react-context" "1.0.0"
+    "@radix-ui/react-direction" "1.0.0"
+    "@radix-ui/react-id" "1.0.0"
+    "@radix-ui/react-primitive" "1.0.1"
+    "@radix-ui/react-use-callback-ref" "1.0.0"
+    "@radix-ui/react-use-controllable-state" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.1.tgz#e7868c669c974d649070e9ecbec0b367ee0b4d81"
+  integrity sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-compose-refs" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz#8f5ec73ca41b151a413bdd6e00553408ff34ce07"
+  integrity sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/primitive" "1.0.0"
+    "@radix-ui/react-context" "1.0.0"
+    "@radix-ui/react-direction" "1.0.0"
+    "@radix-ui/react-id" "1.0.0"
+    "@radix-ui/react-presence" "1.0.0"
+    "@radix-ui/react-primitive" "1.0.1"
+    "@radix-ui/react-roving-focus" "1.0.2"
+    "@radix-ui/react-use-controllable-state" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz#9e7b8b6b4946fe3cbe8f748c82a2cce54e7b6a90"
+  integrity sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz#a64deaafbbc52d5d407afaa22d493d687c538b7f"
+  integrity sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-use-callback-ref" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz#2fc19e97223a81de64cd3ba1dc42ceffd82374dc"
+  integrity sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
 "@rollup/plugin-babel@^5.2.0":
 "@rollup/plugin-babel@^5.2.0":
   version "5.3.1"
   version "5.3.1"
   resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"
   resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"
@@ -9160,7 +9285,7 @@ regenerator-runtime@^0.13.10:
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee"
   integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==
   integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==
 
 
-regenerator-runtime@^0.13.9:
+regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.9:
   version "0.13.11"
   version "0.13.11"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
   integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
   integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
@@ -10245,12 +10370,12 @@ tsutils@^3.21.0:
   dependencies:
   dependencies:
     tslib "^1.8.1"
     tslib "^1.8.1"
 
 
[email protected].0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/tunnel-rat/-/tunnel-rat-0.1.0.tgz#62cfbaf1b24cabac9318fe45ef26d70dc40e86fe"
-  integrity sha512-/FKZLBXCoKhA7Wz+dsqitrItaLXYmT2bkZXod+1UuR4JqHtdb54yHvHhmMgLg+eyH1Od/CCnhA2VQQ2A/54Tcw==
[email protected].2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/tunnel-rat/-/tunnel-rat-0.1.2.tgz#1717efbc474ea2d8aa05a91622457a6e201c0aeb"
+  integrity sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==
   dependencies:
   dependencies:
-    zustand "^4.1.0"
+    zustand "^4.3.2"
 
 
 type-check@^0.4.0, type-check@~0.4.0:
 type-check@^0.4.0, type-check@~0.4.0:
   version "0.4.0"
   version "0.4.0"
@@ -11035,9 +11160,9 @@ yocto-queue@^0.1.0:
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
   integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
   integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
 
 
-zustand@^4.1.0, zustand@^4.3.2:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.2.tgz#bb121fcad84c5a569e94bd1a2695e1a93ba85d39"
-  integrity sha512-rd4haDmlwMTVWVqwvgy00ny8rtti/klRoZjFbL/MAcDnmD5qSw/RZc+Vddstdv90M5Lv6RPgWvm1Hivyn0QgJw==
+zustand@^4.3.2:
+  version "4.3.7"
+  resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.7.tgz#501b1f0393a7f1d103332e45ab574be5747fedce"
+  integrity sha512-dY8ERwB9Nd21ellgkBZFhudER8KVlelZm8388B5nDAXhO/+FZDhYMuRnqDgu5SYyRgz/iaf8RKnbUs/cHfOGlQ==
   dependencies:
   dependencies:
     use-sync-external-store "1.2.0"
     use-sync-external-store "1.2.0"