import clsx from "clsx"; import React from "react"; import { ActionManager } from "../actions/manager"; import { CLASSES, DEFAULT_SIDEBAR, 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 } from "../scene"; import { ExportType } from "../scene/types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles, UIAppState, } from "../types"; import { capitalizeString, isShallowEqual, muteFSAbortError } from "../utils"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { ErrorDialog } from "./ErrorDialog"; import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { FixedSideContainer } from "./FixedSideContainer"; import { HintViewer } from "./HintViewer"; import { Island } from "./Island"; import { LoadingMessage } from "./LoadingMessage"; import { LockButton } from "./LockButton"; import { MobileMenu } from "./MobileMenu"; import { PasteChartDialog } from "./PasteChartDialog"; import { Section } from "./Section"; import { HelpDialog } from "./HelpDialog"; import Stack from "./Stack"; import { UserList } from "./UserList"; import { JSONExportDialog } from "./JSONExportDialog"; import { isImageFileHandle } from "../data/blob"; import { PenModeButton } from "./PenModeButton"; import { trackEvent } from "../analytics"; import { useDevice } from "../components/App"; import { Stats } from "./Stats"; import { actionToggleStats } from "../actions/actionToggleStats"; import Footer from "./footer/Footer"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { jotaiScope } from "../jotai"; import { Provider, useAtomValue } from "jotai"; import MainMenu from "./main-menu/MainMenu"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { HandButton } from "./HandButton"; import { isHandToolActive } from "../appState"; 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 { actionManager: ActionManager; appState: UIAppState; files: BinaryFiles; canvas: HTMLCanvasElement | null; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; onLockToggle: () => void; onHandToolToggle: () => void; onPenModeToggle: () => void; showExitZenModeBtn: boolean; langCode: Language["code"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; UIOptions: AppProps["UIOptions"]; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; renderWelcomeScreen: boolean; children?: React.ReactNode; } const DefaultMainMenu: React.FC<{ UIOptions: AppProps["UIOptions"]; }> = ({ UIOptions }) => { return ( {/* FIXME we should to test for this inside the item itself */} {UIOptions.canvasActions.export && } {/* FIXME we should to test for this inside the item itself */} {UIOptions.canvasActions.saveAsImage && ( )} ); }; const LayerUI = ({ actionManager, appState, files, setAppState, elements, canvas, onLockToggle, onHandToolToggle, onPenModeToggle, showExitZenModeBtn, renderTopRightUI, renderCustomStats, UIOptions, onImageAction, renderWelcomeScreen, children, }: LayerUIProps) => { const device = useDevice(); const tunnels = useInitializeTunnels(); const renderJSONExportDialog = () => { if (!UIOptions.canvasActions.export) { return null; } return ( ); }; const renderImageExportDialog = () => { if (!UIOptions.canvasActions.saveAsImage) { return null; } const createExporter = (type: ExportType): ExportCB => async (exportedElements) => { trackEvent("export", type, "ui"); const fileHandle = await exportCanvas( type, exportedElements, // FIXME once we split UI canvas from element canvas appState as AppState, files, { exportBackground: appState.exportBackground, name: appState.name, viewBackgroundColor: appState.viewBackgroundColor, }, ) .catch(muteFSAbortError) .catch((error) => { console.error(error); setAppState({ errorMessage: error.message }); }); if ( appState.exportEmbedScene && fileHandle && isImageFileHandle(fileHandle) ) { setAppState({ fileHandle }); } }; return ( ); }; const renderCanvasActions = () => (
{/* wrapping to Fragment stops React from occasionally complaining about identical Keys */} {renderWelcomeScreen && }
); const renderSelectedShapeActions = () => (
); const renderFixedSideContainer = () => { const shouldRenderSelectedShapeActions = showSelectedShapeActions( appState, elements, ); return (
{renderCanvasActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {!appState.viewModeEnabled && (
{(heading: React.ReactNode) => (
{renderWelcomeScreen && ( )} {heading}
onHandToolToggle()} title={t("toolBar.hand")} isMobile /> { onImageAction({ insertOnCanvasDirectly: pointerType !== "mouse", }); }} />
)}
)}
{renderTopRightUI?.(device.isMobile, appState)} {!appState.viewModeEnabled && // hide button when sidebar docked (!isSidebarDocked || appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && ( )}
); }; const renderSidebars = () => { return ( { trackEvent( "sidebar", `toggleDock (${docked ? "dock" : "undock"})`, `(${device.isMobile ? "mobile" : "desktop"})`, ); }} /> ); }; const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope); const layerUIJSX = ( <> {/* ------------------------- tunneled UI ---------------------------- */} {/* make sure we render host app components first so that we can detect them first on initial render to optimize layout shift */} {children} {/* render component fallbacks. Can be rendered anywhere as they'll be tunneled away. We only render tunneled components that actually have defaults when host do not render anything. */} { if (open) { trackEvent( "sidebar", `${DEFAULT_SIDEBAR.name} (open)`, `button (${device.isMobile ? "mobile" : "desktop"})`, ); } }} tab={DEFAULT_SIDEBAR.defaultTab} > {t("toolBar.library")} {/* ------------------------------------------------------------------ */} {appState.isLoading && } {appState.errorMessage && ( setAppState({ errorMessage: null })}> {appState.errorMessage} )} {appState.openDialog === "help" && ( { setAppState({ openDialog: null }); }} /> )} {renderImageExportDialog()} {renderJSONExportDialog()} {appState.pasteDialog.shown && ( setAppState({ pasteDialog: { shown: false, data: null }, }) } /> )} {device.isMobile && ( )} {!device.isMobile && ( <>
{renderWelcomeScreen && } {renderFixedSideContainer()}
{appState.showStats && ( { actionManager.executeAction(actionToggleStats); }} renderCustomStats={renderCustomStats} /> )} {appState.scrolledOutside && ( )}
{renderSidebars()} )} ); return ( {layerUIJSX} ); }; const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { const { suggestedBindings, startBoundElement, cursorButton, scrollX, scrollY, ...ret } = appState; return ret; }; const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => { // short-circuit early if (prevProps.children !== nextProps.children) { return false; } const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps; const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps; return ( isShallowEqual( // asserting AppState because we're being passed the whole AppState // but resolve to only the UI-relevant props stripIrrelevantAppStateProps(prevAppState as AppState), stripIrrelevantAppStateProps(nextAppState as AppState), { selectedElementIds: isShallowEqual, selectedGroupIds: isShallowEqual, }, ) && isShallowEqual(prev, next) ); }; export default React.memo(LayerUI, areEqual);