aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/Sidebar/Sidebar.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/components/Sidebar/Sidebar.tsx')
-rw-r--r--packages/excalidraw/components/Sidebar/Sidebar.tsx213
1 files changed, 213 insertions, 0 deletions
diff --git a/packages/excalidraw/components/Sidebar/Sidebar.tsx b/packages/excalidraw/components/Sidebar/Sidebar.tsx
new file mode 100644
index 0000000..7c747d2
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/Sidebar.tsx
@@ -0,0 +1,213 @@
+import React, {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ forwardRef,
+ useImperativeHandle,
+ useCallback,
+} from "react";
+import { Island } from "../Island";
+import { atom, useSetAtom } from "../../editor-jotai";
+import type { SidebarProps, SidebarPropsContextValue } from "./common";
+import { SidebarPropsContext } from "./common";
+import { SidebarHeader } from "./SidebarHeader";
+import clsx from "clsx";
+import { useDevice, useExcalidrawSetAppState } from "../App";
+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";
+import { useUIAppState } from "../../context/ui-appState";
+import { useOutsideClick } from "../../hooks/useOutsideClick";
+
+import "./Sidebar.scss";
+
+/**
+ * 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 (import.meta.env.DEV && 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`",
+ );
+ }
+
+ const setAppState = useExcalidrawSetAppState();
+
+ const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
+
+ useLayoutEffect(() => {
+ setIsSidebarDockedAtom(!!docked);
+ return () => {
+ setIsSidebarDockedAtom(false);
+ };
+ }, [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]);
+
+ useOutsideClick(
+ 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.editor.canFitSidebar) {
+ closeLibrary();
+ }
+ },
+ [closeLibrary, docked, device.editor.canFitSidebar],
+ ),
+ );
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === KEYS.ESCAPE &&
+ (!docked || !device.editor.canFitSidebar)
+ ) {
+ closeLibrary();
+ }
+ };
+ document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
+ return () => {
+ document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
+ };
+ }, [closeLibrary, docked, device.editor.canFitSidebar]);
+
+ 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 = useUIAppState();
+
+ 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]);
+
+ 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: SidebarHeader,
+ TabTriggers: SidebarTabTriggers,
+ TabTrigger: SidebarTabTrigger,
+ Tabs: SidebarTabs,
+ Tab: SidebarTab,
+ Trigger: SidebarTrigger,
+ },
+);
+Sidebar.displayName = "Sidebar";