浏览代码

feat: render library into `Sidebar` on mobile (#5774)

David Luzar 2 年之前
父节点
当前提交
941b2d7042

+ 21 - 62
src/components/LayerUI.tsx

@@ -1,12 +1,12 @@
 import clsx from "clsx";
-import React, { useCallback } from "react";
+import React from "react";
 import { ActionManager } from "../actions/manager";
 import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
 import { exportCanvas } from "../data";
 import { isTextElement, showSelectedShapeActions } from "../element";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { Language, t } from "../i18n";
-import { calculateScrollCenter, getSelectedElements } from "../scene";
+import { calculateScrollCenter } from "../scene";
 import { ExportType } from "../scene/types";
 import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
 import { muteFSAbortError } from "../utils";
@@ -26,7 +26,7 @@ import { Section } from "./Section";
 import { HelpDialog } from "./HelpDialog";
 import Stack from "./Stack";
 import { UserList } from "./UserList";
-import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
+import Library from "../data/library";
 import { JSONExportDialog } from "./JSONExportDialog";
 import { LibraryButton } from "./LibraryButton";
 import { isImageFileHandle } from "../data/blob";
@@ -40,7 +40,7 @@ import { useDevice } from "../components/App";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions/actionToggleStats";
 import Footer from "./Footer";
-import { hostSidebarCountersAtom, Sidebar } from "./Sidebar/Sidebar";
+import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
 import { jotaiScope } from "../jotai";
 import { useAtom } from "jotai";
 
@@ -247,42 +247,6 @@ const LayerUI = ({
     </Section>
   );
 
-  const closeLibrary = useCallback(() => {
-    const isDialogOpen = !!document.querySelector(".Dialog");
-
-    // Prevent closing if any dialog is open
-    if (isDialogOpen) {
-      return;
-    }
-    setAppState({ openSidebar: null });
-  }, [setAppState]);
-
-  const deselectItems = useCallback(() => {
-    setAppState({
-      selectedElementIds: {},
-      selectedGroupIds: {},
-    });
-  }, [setAppState]);
-
-  const libraryMenu =
-    appState.openSidebar === "library" ? (
-      <LibraryMenu
-        pendingElements={getSelectedElements(elements, appState, true)}
-        onClose={closeLibrary}
-        onInsertLibraryItems={(libraryItems) => {
-          onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
-        }}
-        onAddToLibrary={deselectItems}
-        setAppState={setAppState}
-        libraryReturnUrl={libraryReturnUrl}
-        focusContainer={focusContainer}
-        library={library}
-        files={files}
-        id={id}
-        appState={appState}
-      />
-    ) : null;
-
   const renderFixedSideContainer = () => {
     const shouldRenderSelectedShapeActions = showSelectedShapeActions(
       appState,
@@ -381,6 +345,21 @@ const LayerUI = ({
     );
   };
 
+  const renderSidebars = () => {
+    return appState.openSidebar === "customSidebar" ? (
+      renderCustomSidebar?.() || null
+    ) : appState.openSidebar === "library" ? (
+      <LibraryMenu
+        appState={appState}
+        onInsertElements={onInsertElements}
+        libraryReturnUrl={libraryReturnUrl}
+        focusContainer={focusContainer}
+        library={library}
+        id={id}
+      />
+    ) : null;
+  };
+
   const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
 
   return (
@@ -416,7 +395,6 @@ const LayerUI = ({
           appState={appState}
           elements={elements}
           actionManager={actionManager}
-          libraryMenu={libraryMenu}
           renderJSONExportDialog={renderJSONExportDialog}
           renderImageExportDialog={renderImageExportDialog}
           setAppState={setAppState}
@@ -429,7 +407,7 @@ const LayerUI = ({
           onImageAction={onImageAction}
           renderTopRightUI={renderTopRightUI}
           renderCustomStats={renderCustomStats}
-          renderCustomSidebar={renderCustomSidebar}
+          renderSidebars={renderSidebars}
           device={device}
         />
       )}
@@ -484,26 +462,7 @@ const LayerUI = ({
               </button>
             )}
           </div>
-          {appState.openSidebar === "customSidebar" ? (
-            renderCustomSidebar?.()
-          ) : appState.openSidebar === "library" ? (
-            <Sidebar
-              __isInternal
-              // necessary to remount when switching between internal
-              // and custom (host app) sidebar, so that the `props.onClose`
-              // is colled correctly
-              key="library"
-              onDock={(docked) => {
-                trackEvent(
-                  "library",
-                  `toggleLibraryDock (${docked ? "dock" : "undock"})`,
-                  `sidebar (${device.isMobile ? "mobile" : "desktop"})`,
-                );
-              }}
-            >
-              {libraryMenu}
-            </Sidebar>
-          ) : null}
+          {renderSidebars()}
         </>
       )}
     </>

+ 96 - 6
src/components/LibraryMenu.scss

@@ -1,10 +1,16 @@
 @import "open-color/open-color";
 
 .excalidraw {
+  .layer-ui__library-sidebar {
+    display: flex;
+    flex-direction: column;
+  }
+
   .layer-ui__library {
     display: flex;
-    align-items: center;
-    justify-content: center;
+    flex-direction: column;
+
+    flex: 1 1 auto;
 
     .layer-ui__library-header {
       display: flex;
@@ -23,16 +29,100 @@
   }
 
   .layer-ui__sidebar {
-    .layer-ui__library {
-      padding: 0;
-      height: 100%;
-    }
     .library-menu-items-container {
       height: 100%;
       width: 100%;
     }
   }
 
+  .library-actions {
+    width: 100%;
+    display: flex;
+    margin-right: auto;
+    align-items: center;
+
+    button .library-actions-counter {
+      position: absolute;
+      right: 2px;
+      bottom: 2px;
+      border-radius: 50%;
+      width: 1em;
+      height: 1em;
+      padding: 1px;
+      font-size: 0.7rem;
+      background: #fff;
+    }
+
+    &--remove {
+      background-color: $oc-red-7;
+      &:hover {
+        background-color: $oc-red-8;
+      }
+      &:active {
+        background-color: $oc-red-9;
+      }
+      svg {
+        color: $oc-white;
+      }
+      .library-actions-counter {
+        color: $oc-red-7;
+      }
+    }
+
+    &--export {
+      background-color: $oc-lime-5;
+
+      &:hover {
+        background-color: $oc-lime-7;
+      }
+
+      &:active {
+        background-color: $oc-lime-8;
+      }
+      svg {
+        color: $oc-white;
+      }
+      .library-actions-counter {
+        color: $oc-lime-5;
+      }
+    }
+
+    &--publish {
+      background-color: $oc-cyan-6;
+      &:hover {
+        background-color: $oc-cyan-7;
+      }
+      &:active {
+        background-color: $oc-cyan-9;
+      }
+      svg {
+        color: $oc-white;
+      }
+      label {
+        margin-left: -0.2em;
+        margin-right: 1.1em;
+        color: $oc-white;
+        font-size: 0.86em;
+      }
+      .library-actions-counter {
+        color: $oc-cyan-6;
+      }
+    }
+
+    &--load {
+      background-color: $oc-blue-6;
+      &:hover {
+        background-color: $oc-blue-7;
+      }
+      &:active {
+        background-color: $oc-blue-9;
+      }
+      svg {
+        color: $oc-white;
+      }
+    }
+  }
+
   .layer-ui__library-message {
     padding: 2em 4em;
     min-width: 200px;

+ 169 - 163
src/components/LibraryMenu.tsx

@@ -6,29 +6,31 @@ import {
   RefObject,
   forwardRef,
 } from "react";
-import Library, { libraryItemsAtom } from "../data/library";
+import Library, {
+  distributeLibraryItemsOnSquareGrid,
+  libraryItemsAtom,
+} from "../data/library";
 import { t } from "../i18n";
 import { randomId } from "../random";
-import {
-  LibraryItems,
-  LibraryItem,
-  AppState,
-  BinaryFiles,
-  ExcalidrawProps,
-} from "../types";
-import { Dialog } from "./Dialog";
-import PublishLibrary from "./PublishLibrary";
-import { ToolButton } from "./ToolButton";
+import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
 
 import "./LibraryMenu.scss";
 import LibraryMenuItems from "./LibraryMenuItems";
-import { EVENT } from "../constants";
+import { EVENT, VERSIONS } from "../constants";
 import { KEYS } from "../keys";
 import { trackEvent } from "../analytics";
 import { useAtom } from "jotai";
 import { jotaiScope } from "../jotai";
 import Spinner from "./Spinner";
-import { useDevice } from "./App";
+import {
+  useDevice,
+  useExcalidrawElements,
+  useExcalidrawSetAppState,
+} from "./App";
+import { Sidebar } from "./Sidebar/Sidebar";
+import { getSelectedElements } from "../scene";
+import { NonDeletedExcalidrawElement } from "../element/types";
+import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
 
 const useOnClickOutside = (
   ref: RefObject<HTMLElement>,
@@ -58,11 +60,6 @@ const useOnClickOutside = (
   }, [ref, cb]);
 };
 
-const getSelectedItems = (
-  libraryItems: LibraryItems,
-  selectedItems: LibraryItem["id"][],
-) => libraryItems.filter((item) => selectedItems.includes(item.id));
-
 const LibraryMenuWrapper = forwardRef<
   HTMLDivElement,
   { children: React.ReactNode }
@@ -74,34 +71,135 @@ const LibraryMenuWrapper = forwardRef<
   );
 });
 
-export const LibraryMenu = ({
-  onClose,
+export const LibraryMenuContent = ({
   onInsertLibraryItems,
   pendingElements,
   onAddToLibrary,
   setAppState,
-  files,
   libraryReturnUrl,
-  focusContainer,
   library,
   id,
   appState,
+  selectedItems,
+  onSelectItems,
 }: {
   pendingElements: LibraryItem["elements"];
-  onClose: () => void;
   onInsertLibraryItems: (libraryItems: LibraryItems) => void;
   onAddToLibrary: () => void;
-  files: BinaryFiles;
   setAppState: React.Component<any, AppState>["setState"];
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
-  focusContainer: () => void;
   library: Library;
   id: string;
   appState: AppState;
+  selectedItems: LibraryItem["id"][];
+  onSelectItems: (id: LibraryItem["id"][]) => void;
 }) => {
-  const ref = useRef<HTMLDivElement | null>(null);
+  const referrer =
+    libraryReturnUrl || window.location.origin + window.location.pathname;
+
+  const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
 
+  const addToLibrary = useCallback(
+    async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
+      trackEvent("element", "addToLibrary", "ui");
+      if (elements.some((element) => element.type === "image")) {
+        return setAppState({
+          errorMessage: "Support for adding images to the library coming soon!",
+        });
+      }
+      const nextItems: LibraryItems = [
+        {
+          status: "unpublished",
+          elements,
+          id: randomId(),
+          created: Date.now(),
+        },
+        ...libraryItems,
+      ];
+      onAddToLibrary();
+      library.setLibrary(nextItems).catch(() => {
+        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
+      });
+    },
+    [onAddToLibrary, library, setAppState],
+  );
+
+  if (
+    libraryItemsData.status === "loading" &&
+    !libraryItemsData.isInitialized
+  ) {
+    return (
+      <LibraryMenuWrapper>
+        <div className="layer-ui__library-message">
+          <Spinner size="2em" />
+          <span>{t("labels.libraryLoadingMessage")}</span>
+        </div>
+      </LibraryMenuWrapper>
+    );
+  }
+
+  return (
+    <LibraryMenuWrapper>
+      <LibraryMenuItems
+        isLoading={libraryItemsData.status === "loading"}
+        libraryItems={libraryItemsData.libraryItems}
+        onAddToLibrary={(elements) =>
+          addToLibrary(elements, libraryItemsData.libraryItems)
+        }
+        onInsertLibraryItems={onInsertLibraryItems}
+        pendingElements={pendingElements}
+        selectedItems={selectedItems}
+        onSelectItems={onSelectItems}
+      />
+      <a
+        className="library-menu-browse-button"
+        href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
+          window.name || "_blank"
+        }&referrer=${referrer}&useHash=true&token=${id}&theme=${
+          appState.theme
+        }&version=${VERSIONS.excalidrawLibrary}`}
+        target="_excalidraw_libraries"
+      >
+        {t("labels.libraries")}
+      </a>
+    </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,
+}) => {
+  const setAppState = useExcalidrawSetAppState();
+  const elements = useExcalidrawElements();
   const device = useDevice();
+
+  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(
@@ -112,10 +210,10 @@ export const LibraryMenu = ({
           return;
         }
         if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
-          onClose();
+          closeLibrary();
         }
       },
-      [onClose, appState.isSidebarDocked, device.canDeviceFitSidebar],
+      [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
     ),
   );
 
@@ -125,24 +223,21 @@ export const LibraryMenu = ({
         event.key === KEYS.ESCAPE &&
         (!appState.isSidebarDocked || !device.canDeviceFitSidebar)
       ) {
-        onClose();
+        closeLibrary();
       }
     };
     document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
     return () => {
       document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
     };
-  }, [onClose, appState.isSidebarDocked, device.canDeviceFitSidebar]);
+  }, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
 
-  const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
-  const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
-    useState(false);
-  const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
-    url: string;
-    authorName: string;
-  }>(null);
-
-  const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
+  const deselectItems = useCallback(() => {
+    setAppState({
+      selectedElementIds: {},
+      selectedGroupIds: {},
+    });
+  }, [setAppState]);
 
   const removeFromLibrary = useCallback(
     async (libraryItems: LibraryItems) => {
@@ -162,139 +257,50 @@ export const LibraryMenu = ({
     focusContainer();
   }, [library, focusContainer]);
 
-  const addToLibrary = useCallback(
-    async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
-      trackEvent("element", "addToLibrary", "ui");
-      if (elements.some((element) => element.type === "image")) {
-        return setAppState({
-          errorMessage: "Support for adding images to the library coming soon!",
-        });
-      }
-      const nextItems: LibraryItems = [
-        {
-          status: "unpublished",
-          elements,
-          id: randomId(),
-          created: Date.now(),
-        },
-        ...libraryItems,
-      ];
-      onAddToLibrary();
-      library.setLibrary(nextItems).catch(() => {
-        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
-      });
-    },
-    [onAddToLibrary, library, setAppState],
-  );
-
-  const renderPublishSuccess = useCallback(() => {
-    return (
-      <Dialog
-        onCloseRequest={() => setPublishLibSuccess(null)}
-        title={t("publishSuccessDialog.title")}
-        className="publish-library-success"
-        small={true}
-      >
-        <p>
-          {t("publishSuccessDialog.content", {
-            authorName: publishLibSuccess!.authorName,
-          })}{" "}
-          <a
-            href={publishLibSuccess?.url}
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            {t("publishSuccessDialog.link")}
-          </a>
-        </p>
-        <ToolButton
-          type="button"
-          title={t("buttons.close")}
-          aria-label={t("buttons.close")}
-          label={t("buttons.close")}
-          onClick={() => setPublishLibSuccess(null)}
-          data-testid="publish-library-success-close"
-          className="publish-library-success-close"
-        />
-      </Dialog>
-    );
-  }, [setPublishLibSuccess, publishLibSuccess]);
-
-  const onPublishLibSuccess = useCallback(
-    (data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
-      setShowPublishLibraryDialog(false);
-      setPublishLibSuccess({ url: data.url, authorName: data.authorName });
-      const nextLibItems = libraryItems.slice();
-      nextLibItems.forEach((libItem) => {
-        if (selectedItems.includes(libItem.id)) {
-          libItem.status = "published";
-        }
-      });
-      library.setLibrary(nextLibItems);
-    },
-    [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
-  );
-
-  if (
-    libraryItemsData.status === "loading" &&
-    !libraryItemsData.isInitialized
-  ) {
-    return (
-      <LibraryMenuWrapper ref={ref}>
-        <div className="layer-ui__library-message">
-          <Spinner size="2em" />
-          <span>{t("labels.libraryLoadingMessage")}</span>
-        </div>
-      </LibraryMenuWrapper>
-    );
-  }
-
   return (
-    <LibraryMenuWrapper ref={ref}>
-      {showPublishLibraryDialog && (
-        <PublishLibrary
-          onClose={() => setShowPublishLibraryDialog(false)}
-          libraryItems={getSelectedItems(
-            libraryItemsData.libraryItems,
-            selectedItems,
-          )}
+    <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"
+      onDock={(docked) => {
+        trackEvent(
+          "library",
+          `toggleLibraryDock (${docked ? "dock" : "undock"})`,
+          `sidebar (${device.isMobile ? "mobile" : "desktop"})`,
+        );
+      }}
+      ref={ref}
+    >
+      <Sidebar.Header className="layer-ui__library-header">
+        <LibraryMenuHeader
           appState={appState}
-          onSuccess={(data) =>
-            onPublishLibSuccess(data, libraryItemsData.libraryItems)
-          }
-          onError={(error) => window.alert(error)}
-          updateItemsInStorage={() =>
-            library.setLibrary(libraryItemsData.libraryItems)
-          }
-          onRemove={(id: string) =>
-            setSelectedItems(selectedItems.filter((_id) => _id !== id))
+          setAppState={setAppState}
+          selectedItems={selectedItems}
+          onSelectItems={setSelectedItems}
+          library={library}
+          onRemoveFromLibrary={() =>
+            removeFromLibrary(libraryItemsData.libraryItems)
           }
+          resetLibrary={resetLibrary}
         />
-      )}
-      {publishLibSuccess && renderPublishSuccess()}
-      <LibraryMenuItems
-        isLoading={libraryItemsData.status === "loading"}
-        libraryItems={libraryItemsData.libraryItems}
-        onRemoveFromLibrary={() =>
-          removeFromLibrary(libraryItemsData.libraryItems)
-        }
-        onAddToLibrary={(elements) =>
-          addToLibrary(elements, libraryItemsData.libraryItems)
-        }
-        onInsertLibraryItems={onInsertLibraryItems}
-        pendingElements={pendingElements}
+      </Sidebar.Header>
+      <LibraryMenuContent
+        pendingElements={getSelectedElements(elements, appState, true)}
+        onInsertLibraryItems={(libraryItems) => {
+          onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
+        }}
+        onAddToLibrary={deselectItems}
         setAppState={setAppState}
-        appState={appState}
         libraryReturnUrl={libraryReturnUrl}
         library={library}
-        theme={appState.theme}
-        files={files}
         id={id}
+        appState={appState}
         selectedItems={selectedItems}
-        onSelectItems={(ids) => setSelectedItems(ids)}
-        onPublish={() => setShowPublishLibraryDialog(true)}
-        resetLibrary={resetLibrary}
+        onSelectItems={setSelectedItems}
       />
-    </LibraryMenuWrapper>
+    </Sidebar>
   );
 };

+ 258 - 0
src/components/LibraryMenuHeaderContent.tsx

@@ -0,0 +1,258 @@
+import React, { useCallback, useState } from "react";
+import { saveLibraryAsJSON } from "../data/json";
+import Library, { libraryItemsAtom } from "../data/library";
+import { t } from "../i18n";
+import { AppState, LibraryItem, LibraryItems } from "../types";
+import { exportToFileIcon, load, publishIcon, trash } from "./icons";
+import { ToolButton } from "./ToolButton";
+import { Tooltip } from "./Tooltip";
+import { fileOpen } from "../data/filesystem";
+import { muteFSAbortError } from "../utils";
+import { useAtom } from "jotai";
+import { jotaiScope } from "../jotai";
+import ConfirmDialog from "./ConfirmDialog";
+import PublishLibrary from "./PublishLibrary";
+import { Dialog } from "./Dialog";
+
+const getSelectedItems = (
+  libraryItems: LibraryItems,
+  selectedItems: LibraryItem["id"][],
+) => libraryItems.filter((item) => selectedItems.includes(item.id));
+
+export const LibraryMenuHeader: React.FC<{
+  setAppState: React.Component<any, AppState>["setState"];
+  selectedItems: LibraryItem["id"][];
+  library: Library;
+  onRemoveFromLibrary: () => void;
+  resetLibrary: () => void;
+  onSelectItems: (items: LibraryItem["id"][]) => void;
+  appState: AppState;
+}> = ({
+  setAppState,
+  selectedItems,
+  library,
+  onRemoveFromLibrary,
+  resetLibrary,
+  onSelectItems,
+  appState,
+}) => {
+  const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
+
+  const renderRemoveLibAlert = useCallback(() => {
+    const content = selectedItems.length
+      ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
+      : t("alerts.resetLibrary");
+    const title = selectedItems.length
+      ? t("confirmDialog.removeItemsFromLib")
+      : t("confirmDialog.resetLibrary");
+    return (
+      <ConfirmDialog
+        onConfirm={() => {
+          if (selectedItems.length) {
+            onRemoveFromLibrary();
+          } else {
+            resetLibrary();
+          }
+          setShowRemoveLibAlert(false);
+        }}
+        onCancel={() => {
+          setShowRemoveLibAlert(false);
+        }}
+        title={title}
+      >
+        <p>{content}</p>
+      </ConfirmDialog>
+    );
+  }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
+
+  const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
+
+  const itemsSelected = !!selectedItems.length;
+  const items = itemsSelected
+    ? libraryItemsData.libraryItems.filter((item) =>
+        selectedItems.includes(item.id),
+      )
+    : libraryItemsData.libraryItems;
+  const resetLabel = itemsSelected
+    ? t("buttons.remove")
+    : t("buttons.resetLibrary");
+
+  const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
+    useState(false);
+  const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
+    url: string;
+    authorName: string;
+  }>(null);
+  const renderPublishSuccess = useCallback(() => {
+    return (
+      <Dialog
+        onCloseRequest={() => setPublishLibSuccess(null)}
+        title={t("publishSuccessDialog.title")}
+        className="publish-library-success"
+        small={true}
+      >
+        <p>
+          {t("publishSuccessDialog.content", {
+            authorName: publishLibSuccess!.authorName,
+          })}{" "}
+          <a
+            href={publishLibSuccess?.url}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            {t("publishSuccessDialog.link")}
+          </a>
+        </p>
+        <ToolButton
+          type="button"
+          title={t("buttons.close")}
+          aria-label={t("buttons.close")}
+          label={t("buttons.close")}
+          onClick={() => setPublishLibSuccess(null)}
+          data-testid="publish-library-success-close"
+          className="publish-library-success-close"
+        />
+      </Dialog>
+    );
+  }, [setPublishLibSuccess, publishLibSuccess]);
+
+  const onPublishLibSuccess = useCallback(
+    (data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
+      setShowPublishLibraryDialog(false);
+      setPublishLibSuccess({ url: data.url, authorName: data.authorName });
+      const nextLibItems = libraryItems.slice();
+      nextLibItems.forEach((libItem) => {
+        if (selectedItems.includes(libItem.id)) {
+          libItem.status = "published";
+        }
+      });
+      library.setLibrary(nextLibItems);
+    },
+    [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
+  );
+
+  const onLibraryImport = async () => {
+    try {
+      await library.updateLibrary({
+        libraryItems: fileOpen({
+          description: "Excalidraw library files",
+          // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
+          // gets resolved. Else, iOS users cannot open `.excalidraw` files.
+          /*
+            extensions: [".json", ".excalidrawlib"],
+            */
+        }),
+        merge: true,
+        openLibraryMenu: true,
+      });
+    } catch (error: any) {
+      if (error?.name === "AbortError") {
+        console.warn(error);
+        return;
+      }
+      setAppState({ errorMessage: t("errors.importLibraryError") });
+    }
+  };
+
+  const onLibraryExport = async () => {
+    const libraryItems = itemsSelected
+      ? items
+      : await library.getLatestLibrary();
+    saveLibraryAsJSON(libraryItems)
+      .catch(muteFSAbortError)
+      .catch((error) => {
+        setAppState({ errorMessage: error.message });
+      });
+  };
+
+  return (
+    <div className="library-actions">
+      {showRemoveLibAlert && renderRemoveLibAlert()}
+      {showPublishLibraryDialog && (
+        <PublishLibrary
+          onClose={() => setShowPublishLibraryDialog(false)}
+          libraryItems={getSelectedItems(
+            libraryItemsData.libraryItems,
+            selectedItems,
+          )}
+          appState={appState}
+          onSuccess={(data) =>
+            onPublishLibSuccess(data, libraryItemsData.libraryItems)
+          }
+          onError={(error) => window.alert(error)}
+          updateItemsInStorage={() =>
+            library.setLibrary(libraryItemsData.libraryItems)
+          }
+          onRemove={(id: string) =>
+            onSelectItems(selectedItems.filter((_id) => _id !== id))
+          }
+        />
+      )}
+      {publishLibSuccess && renderPublishSuccess()}
+      {!itemsSelected && (
+        <ToolButton
+          key="import"
+          type="button"
+          title={t("buttons.load")}
+          aria-label={t("buttons.load")}
+          icon={load}
+          onClick={onLibraryImport}
+          className="library-actions--load"
+        />
+      )}
+      {!!items.length && (
+        <>
+          <ToolButton
+            key="export"
+            type="button"
+            title={t("buttons.export")}
+            aria-label={t("buttons.export")}
+            icon={exportToFileIcon}
+            onClick={onLibraryExport}
+            className="library-actions--export"
+          >
+            {selectedItems.length > 0 && (
+              <span className="library-actions-counter">
+                {selectedItems.length}
+              </span>
+            )}
+          </ToolButton>
+          <ToolButton
+            key="reset"
+            type="button"
+            title={resetLabel}
+            aria-label={resetLabel}
+            icon={trash}
+            onClick={() => setShowRemoveLibAlert(true)}
+            className="library-actions--remove"
+          >
+            {selectedItems.length > 0 && (
+              <span className="library-actions-counter">
+                {selectedItems.length}
+              </span>
+            )}
+          </ToolButton>
+        </>
+      )}
+      {itemsSelected && (
+        <Tooltip label={t("hints.publishLibrary")}>
+          <ToolButton
+            type="button"
+            aria-label={t("buttons.publishLibrary")}
+            label={t("buttons.publishLibrary")}
+            icon={publishIcon}
+            className="library-actions--publish"
+            onClick={() => setShowPublishLibraryDialog(true)}
+          >
+            <label>{t("buttons.publishLibrary")}</label>
+            {selectedItems.length > 0 && (
+              <span className="library-actions-counter">
+                {selectedItems.length}
+              </span>
+            )}
+          </ToolButton>
+        </Tooltip>
+      )}
+    </div>
+  );
+};

+ 0 - 87
src/components/LibraryMenuItems.scss

@@ -6,93 +6,6 @@
     flex-direction: column;
     height: 100%;
 
-    .library-actions {
-      width: 100%;
-      display: flex;
-      margin-right: auto;
-      align-items: center;
-
-      button .library-actions-counter {
-        position: absolute;
-        right: 2px;
-        bottom: 2px;
-        border-radius: 50%;
-        width: 1em;
-        height: 1em;
-        padding: 1px;
-        font-size: 0.7rem;
-        background: #fff;
-      }
-
-      &--remove {
-        background-color: $oc-red-7;
-        &:hover {
-          background-color: $oc-red-8;
-        }
-        &:active {
-          background-color: $oc-red-9;
-        }
-        svg {
-          color: $oc-white;
-        }
-        .library-actions-counter {
-          color: $oc-red-7;
-        }
-      }
-
-      &--export {
-        background-color: $oc-lime-5;
-
-        &:hover {
-          background-color: $oc-lime-7;
-        }
-
-        &:active {
-          background-color: $oc-lime-8;
-        }
-        svg {
-          color: $oc-white;
-        }
-        .library-actions-counter {
-          color: $oc-lime-5;
-        }
-      }
-
-      &--publish {
-        background-color: $oc-cyan-6;
-        &:hover {
-          background-color: $oc-cyan-7;
-        }
-        &:active {
-          background-color: $oc-cyan-9;
-        }
-        svg {
-          color: $oc-white;
-        }
-        label {
-          margin-left: -0.2em;
-          margin-right: 1.1em;
-          color: $oc-white;
-          font-size: 0.86em;
-        }
-        .library-actions-counter {
-          color: $oc-cyan-6;
-        }
-      }
-
-      &--load {
-        background-color: $oc-blue-6;
-        &:hover {
-          background-color: $oc-blue-7;
-        }
-        &:active {
-          background-color: $oc-blue-9;
-        }
-        svg {
-          color: $oc-white;
-        }
-      }
-    }
     &__items {
       flex: 1;
       overflow-y: auto;

+ 24 - 239
src/components/LibraryMenuItems.tsx

@@ -1,223 +1,35 @@
-import React, { useCallback, useState } from "react";
-import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
-import Library from "../data/library";
+import React, { useState } from "react";
+import { serializeLibraryAsJSON } from "../data/json";
 import { ExcalidrawElement, NonDeleted } from "../element/types";
 import { t } from "../i18n";
-import {
-  AppState,
-  BinaryFiles,
-  ExcalidrawProps,
-  LibraryItem,
-  LibraryItems,
-} from "../types";
-import { arrayToMap, chunk, muteFSAbortError } from "../utils";
-import { useDevice } from "./App";
-import ConfirmDialog from "./ConfirmDialog";
-import { exportToFileIcon, load, publishIcon, trash } from "./icons";
+import { LibraryItem, LibraryItems } from "../types";
+import { arrayToMap, chunk } from "../utils";
 import { LibraryUnit } from "./LibraryUnit";
 import Stack from "./Stack";
-import { ToolButton } from "./ToolButton";
-import { Tooltip } from "./Tooltip";
 
 import "./LibraryMenuItems.scss";
-import { MIME_TYPES, VERSIONS } from "../constants";
+import { MIME_TYPES } from "../constants";
 import Spinner from "./Spinner";
-import { fileOpen } from "../data/filesystem";
-import { Sidebar } from "./Sidebar/Sidebar";
+
+const CELLS_PER_ROW = 4;
 
 const LibraryMenuItems = ({
   isLoading,
   libraryItems,
-  onRemoveFromLibrary,
   onAddToLibrary,
   onInsertLibraryItems,
   pendingElements,
-  theme,
-  setAppState,
-  appState,
-  libraryReturnUrl,
-  library,
-  files,
-  id,
   selectedItems,
   onSelectItems,
-  onPublish,
-  resetLibrary,
 }: {
   isLoading: boolean;
   libraryItems: LibraryItems;
   pendingElements: LibraryItem["elements"];
-  onRemoveFromLibrary: () => void;
   onInsertLibraryItems: (libraryItems: LibraryItems) => void;
   onAddToLibrary: (elements: LibraryItem["elements"]) => void;
-  theme: AppState["theme"];
-  files: BinaryFiles;
-  setAppState: React.Component<any, AppState>["setState"];
-  appState: AppState;
-  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
-  library: Library;
-  id: string;
   selectedItems: LibraryItem["id"][];
   onSelectItems: (id: LibraryItem["id"][]) => void;
-  onPublish: () => void;
-  resetLibrary: () => void;
 }) => {
-  const renderRemoveLibAlert = useCallback(() => {
-    const content = selectedItems.length
-      ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
-      : t("alerts.resetLibrary");
-    const title = selectedItems.length
-      ? t("confirmDialog.removeItemsFromLib")
-      : t("confirmDialog.resetLibrary");
-    return (
-      <ConfirmDialog
-        onConfirm={() => {
-          if (selectedItems.length) {
-            onRemoveFromLibrary();
-          } else {
-            resetLibrary();
-          }
-          setShowRemoveLibAlert(false);
-        }}
-        onCancel={() => {
-          setShowRemoveLibAlert(false);
-        }}
-        title={title}
-      >
-        <p>{content}</p>
-      </ConfirmDialog>
-    );
-  }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
-
-  const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
-  const device = useDevice();
-  const renderLibraryActions = () => {
-    const itemsSelected = !!selectedItems.length;
-    const items = itemsSelected
-      ? libraryItems.filter((item) => selectedItems.includes(item.id))
-      : libraryItems;
-    const resetLabel = itemsSelected
-      ? t("buttons.remove")
-      : t("buttons.resetLibrary");
-    return (
-      <div className="library-actions">
-        {!itemsSelected && (
-          <ToolButton
-            key="import"
-            type="button"
-            title={t("buttons.load")}
-            aria-label={t("buttons.load")}
-            icon={load}
-            onClick={async () => {
-              try {
-                await library.updateLibrary({
-                  libraryItems: fileOpen({
-                    description: "Excalidraw library files",
-                    // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
-                    // gets resolved. Else, iOS users cannot open `.excalidraw` files.
-                    /*
-                    extensions: [".json", ".excalidrawlib"],
-                    */
-                  }),
-                  merge: true,
-                  openLibraryMenu: true,
-                });
-              } catch (error: any) {
-                if (error?.name === "AbortError") {
-                  console.warn(error);
-                  return;
-                }
-                setAppState({ errorMessage: t("errors.importLibraryError") });
-              }
-            }}
-            className="library-actions--load"
-          />
-        )}
-        {!!items.length && (
-          <>
-            <ToolButton
-              key="export"
-              type="button"
-              title={t("buttons.export")}
-              aria-label={t("buttons.export")}
-              icon={exportToFileIcon}
-              onClick={async () => {
-                const libraryItems = itemsSelected
-                  ? items
-                  : await library.getLatestLibrary();
-                saveLibraryAsJSON(libraryItems)
-                  .catch(muteFSAbortError)
-                  .catch((error) => {
-                    setAppState({ errorMessage: error.message });
-                  });
-              }}
-              className="library-actions--export"
-            >
-              {selectedItems.length > 0 && (
-                <span className="library-actions-counter">
-                  {selectedItems.length}
-                </span>
-              )}
-            </ToolButton>
-            <ToolButton
-              key="reset"
-              type="button"
-              title={resetLabel}
-              aria-label={resetLabel}
-              icon={trash}
-              onClick={() => setShowRemoveLibAlert(true)}
-              className="library-actions--remove"
-            >
-              {selectedItems.length > 0 && (
-                <span className="library-actions-counter">
-                  {selectedItems.length}
-                </span>
-              )}
-            </ToolButton>
-          </>
-        )}
-        {itemsSelected && (
-          <Tooltip label={t("hints.publishLibrary")}>
-            <ToolButton
-              type="button"
-              aria-label={t("buttons.publishLibrary")}
-              label={t("buttons.publishLibrary")}
-              icon={publishIcon}
-              className="library-actions--publish"
-              onClick={onPublish}
-            >
-              {!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
-              {selectedItems.length > 0 && (
-                <span className="library-actions-counter">
-                  {selectedItems.length}
-                </span>
-              )}
-            </ToolButton>
-          </Tooltip>
-        )}
-        {device.isMobile && (
-          <div className="library-menu-browse-button--mobile">
-            <a
-              href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
-                window.name || "_blank"
-              }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
-                VERSIONS.excalidrawLibrary
-              }`}
-              target="_excalidraw_libraries"
-            >
-              {t("labels.libraries")}
-            </a>
-          </div>
-        )}
-      </div>
-    );
-  };
-
-  const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
-
-  const referrer =
-    libraryReturnUrl || window.location.origin + window.location.pathname;
-
   const [lastSelectedItem, setLastSelectedItem] = useState<
     LibraryItem["id"] | null
   >(null);
@@ -294,7 +106,6 @@ const LibraryMenuItems = ({
       <Stack.Col key={params.key}>
         <LibraryUnit
           elements={params.item?.elements}
-          files={files}
           isPending={!params.item?.id && !!params.item?.elements}
           onClick={params.onClick || (() => {})}
           id={params.item?.id || null}
@@ -370,8 +181,21 @@ const LibraryMenuItems = ({
     (item) => item.status === "published",
   );
 
-  const renderLibraryMenuItems = () => {
-    return (
+  return (
+    <div
+      className="library-menu-items-container"
+      style={
+        publishedItems.length || unpublishedItems.length
+          ? {
+              flex: "1 1 0",
+              overflowY: "auto",
+            }
+          : {
+              marginBottom: "2rem",
+              flex: 0,
+            }
+      }
+    >
       <Stack.Col
         className="library-menu-items-container__items"
         align="start"
@@ -443,8 +267,8 @@ const LibraryMenuItems = ({
 
         <>
           {(publishedItems.length > 0 ||
-            (!device.isMobile &&
-              (pendingElements.length > 0 || unpublishedItems.length > 0))) && (
+            pendingElements.length > 0 ||
+            unpublishedItems.length > 0) && (
             <div className="separator">{t("labels.excalidrawLib")}</div>
           )}
           {publishedItems.length > 0 ? (
@@ -466,45 +290,6 @@ const LibraryMenuItems = ({
           ) : null}
         </>
       </Stack.Col>
-    );
-  };
-
-  const renderLibraryFooter = () => {
-    return (
-      <a
-        className="library-menu-browse-button"
-        href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
-          window.name || "_blank"
-        }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
-          VERSIONS.excalidrawLibrary
-        }`}
-        target="_excalidraw_libraries"
-      >
-        {t("labels.libraries")}
-      </a>
-    );
-  };
-
-  return (
-    <div
-      className="library-menu-items-container"
-      style={
-        device.isMobile
-          ? {
-              minHeight: "200px",
-              maxHeight: "70vh",
-            }
-          : undefined
-      }
-    >
-      {showRemoveLibAlert && renderRemoveLibAlert()}
-      {/* NOTE using SidebarHeader here isn't semantic since this may render
-          outside of a sidebar, but for now it doesn't matter */}
-      <Sidebar.Header className="layer-ui__library-header">
-        {renderLibraryActions()}
-      </Sidebar.Header>
-      {renderLibraryMenuItems()}
-      {!device.isMobile && renderLibraryFooter()}
     </div>
   );
 };

+ 3 - 5
src/components/LibraryUnit.tsx

@@ -3,7 +3,7 @@ import oc from "open-color";
 import { useEffect, useRef, useState } from "react";
 import { useDevice } from "../components/App";
 import { exportToSvg } from "../scene/export";
-import { BinaryFiles, LibraryItem } from "../types";
+import { LibraryItem } from "../types";
 import "./LibraryUnit.scss";
 import { CheckboxItem } from "./CheckboxItem";
 
@@ -23,7 +23,6 @@ const PLUS_ICON = (
 export const LibraryUnit = ({
   id,
   elements,
-  files,
   isPending,
   onClick,
   selected,
@@ -32,7 +31,6 @@ export const LibraryUnit = ({
 }: {
   id: LibraryItem["id"] | /** for pending item */ null;
   elements?: LibraryItem["elements"];
-  files: BinaryFiles;
   isPending?: boolean;
   onClick: () => void;
   selected: boolean;
@@ -56,7 +54,7 @@ export const LibraryUnit = ({
           exportBackground: false,
           viewBackgroundColor: oc.white,
         },
-        files,
+        null,
       );
       node.innerHTML = svg.outerHTML;
     })();
@@ -64,7 +62,7 @@ export const LibraryUnit = ({
     return () => {
       node.innerHTML = "";
     };
-  }, [elements, files]);
+  }, [elements]);
 
   const [isHovered, setIsHovered] = useState(false);
   const isMobile = useDevice().isMobile;

+ 3 - 6
src/components/MobileMenu.tsx

@@ -28,7 +28,6 @@ type MobileMenuProps = {
   renderImageExportDialog: () => React.ReactNode;
   setAppState: React.Component<any, AppState>["setState"];
   elements: readonly NonDeletedExcalidrawElement[];
-  libraryMenu: JSX.Element | null;
   onCollabButtonClick?: () => void;
   onLockToggle: () => void;
   onPenModeToggle: () => void;
@@ -44,14 +43,13 @@ type MobileMenuProps = {
     appState: AppState,
   ) => JSX.Element | null;
   renderCustomStats?: ExcalidrawProps["renderCustomStats"];
-  renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
+  renderSidebars: () => JSX.Element | null;
   device: Device;
 };
 
 export const MobileMenu = ({
   appState,
   elements,
-  libraryMenu,
   actionManager,
   renderJSONExportDialog,
   renderImageExportDialog,
@@ -65,7 +63,7 @@ export const MobileMenu = ({
   onImageAction,
   renderTopRightUI,
   renderCustomStats,
-  renderCustomSidebar,
+  renderSidebars,
   device,
 }: MobileMenuProps) => {
   const renderToolbar = () => {
@@ -111,7 +109,6 @@ export const MobileMenu = ({
                   penDetected={appState.penDetected}
                 />
               </Stack.Row>
-              {libraryMenu && <Island padding={2}>{libraryMenu}</Island>}
             </Stack.Col>
           )}
         </Section>
@@ -184,7 +181,7 @@ export const MobileMenu = ({
   };
   return (
     <>
-      {appState.openSidebar === "customSidebar" && renderCustomSidebar?.()}
+      {renderSidebars()}
       {!appState.viewModeEnabled && renderToolbar()}
       {!appState.openMenu && appState.showStats && (
         <Stats

+ 110 - 92
src/components/Sidebar/Sidebar.tsx

@@ -1,4 +1,10 @@
-import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import {
+  useEffect,
+  useLayoutEffect,
+  useRef,
+  useState,
+  forwardRef,
+} from "react";
 import { Island } from ".././Island";
 import { atom, useAtom } from "jotai";
 import { jotaiScope } from "../../jotai";
@@ -19,103 +25,115 @@ import { updateObject } from "../../utils";
  * the host app may render (mount/unmount) multiple different sidebar */
 export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
 
-export const Sidebar = ({
-  children,
-  onClose,
-  onDock,
-  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;
-}>) => {
-  const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
-    hostSidebarCountersAtom,
-    jotaiScope,
-  );
+export const Sidebar = Object.assign(
+  forwardRef(
+    (
+      {
+        children,
+        onClose,
+        onDock,
+        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,
+      );
 
-  const setAppState = useExcalidrawSetAppState();
+      const setAppState = useExcalidrawSetAppState();
 
-  const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false);
+      const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false);
 
-  useLayoutEffect(() => {
-    if (docked === undefined) {
-      // ugly hack to get initial state out of AppState without susbcribing
-      // 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]);
+      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]);
 
-  const onCloseRef = useRef(onClose);
-  onCloseRef.current = onClose;
+      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]);
 
-  useEffect(() => {
-    return () => {
-      onCloseRef.current?.();
-    };
-  }, []);
+      const onCloseRef = useRef(onClose);
+      onCloseRef.current = onClose;
 
-  const headerPropsRef = useRef<SidebarPropsContextValue>({});
-  headerPropsRef.current.onClose = () => {
-    setAppState({ openSidebar: null });
-  };
-  headerPropsRef.current.onDock = (isDocked) => {
-    if (docked === undefined) {
-      setAppState({ isSidebarDocked: isDocked });
-      setIsDockedFallback(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 upsream.
-  headerPropsRef.current = updateObject(headerPropsRef.current, {
-    docked: docked ?? isDockedFallback,
-    dockable,
-  });
+      useEffect(() => {
+        return () => {
+          onCloseRef.current?.();
+        };
+      }, []);
 
-  if (hostSidebarCounters.rendered > 0 && __isInternal) {
-    return null;
-  }
+      const headerPropsRef = useRef<SidebarPropsContextValue>({});
+      headerPropsRef.current.onClose = () => {
+        setAppState({ openSidebar: null });
+      };
+      headerPropsRef.current.onDock = (isDocked) => {
+        if (docked === undefined) {
+          setAppState({ isSidebarDocked: isDocked });
+          setIsDockedFallback(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 upsream.
+      headerPropsRef.current = updateObject(headerPropsRef.current, {
+        docked: docked ?? isDockedFallback,
+        dockable,
+      });
 
-  return (
-    <Island padding={2} className={clsx("layer-ui__sidebar", className)}>
-      <SidebarPropsContext.Provider value={headerPropsRef.current}>
-        <SidebarHeaderComponents.Context>
-          <SidebarHeaderComponents.Component __isFallback />
-          {children}
-        </SidebarHeaderComponents.Context>
-      </SidebarPropsContext.Provider>
-    </Island>
-  );
-};
+      if (hostSidebarCounters.rendered > 0 && __isInternal) {
+        return null;
+      }
 
-Sidebar.Header = SidebarHeaderComponents.Component;
+      return (
+        <Island
+          padding={2}
+          className={clsx("layer-ui__sidebar", className)}
+          ref={ref}
+        >
+          <SidebarPropsContext.Provider value={headerPropsRef.current}>
+            <SidebarHeaderComponents.Context>
+              <SidebarHeaderComponents.Component __isFallback />
+              {children}
+            </SidebarHeaderComponents.Context>
+          </SidebarPropsContext.Provider>
+        </Island>
+      );
+    },
+  ),
+  {
+    Header: SidebarHeaderComponents.Component,
+  },
+);