import clsx from "clsx"; 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 } from "../scene"; import { ExportType } from "../scene/types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import { 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 Library from "../data/library"; import { JSONExportDialog } from "./JSONExportDialog"; import { LibraryButton } from "./LibraryButton"; import { isImageFileHandle } from "../data/blob"; import { LibraryMenu } from "./LibraryMenu"; import "./LayerUI.scss"; import "./Toolbar.scss"; 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 { hostSidebarCountersAtom } from "./Sidebar/Sidebar"; import { jotaiScope } from "../jotai"; import { Provider, useAtom } 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"; interface LayerUIProps { actionManager: ActionManager; appState: AppState; files: BinaryFiles; canvas: HTMLCanvasElement | null; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; onLockToggle: () => void; onHandToolToggle: () => void; onPenModeToggle: () => void; onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; showExitZenModeBtn: boolean; langCode: Language["code"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomSidebar?: ExcalidrawProps["renderSidebar"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; UIOptions: AppProps["UIOptions"]; focusContainer: () => void; library: Library; id: string; 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, onInsertElements, showExitZenModeBtn, renderTopRightUI, renderCustomStats, renderCustomSidebar, libraryReturnUrl, UIOptions, focusContainer, library, id, 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, 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 && ( )}
); }; const renderSidebars = () => { return appState.openSidebar === "customSidebar" ? ( renderCustomSidebar?.() || null ) : appState.openSidebar === "library" ? ( ) : null; }; const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, 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. */} {/* ------------------------------------------------------------------ */} {appState.isLoading && } {appState.errorMessage && ( setAppState({ errorMessage: null })} /> )} {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, ): Partial => { const { suggestedBindings, startBoundElement, cursorButton, ...ret } = appState; return ret; }; const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => { // short-circuit early if (prevProps.children !== nextProps.children) { 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; return ( isShallowEqual( stripIrrelevantAppStateProps(prevAppState), stripIrrelevantAppStateProps(nextAppState), ) && isShallowEqual(prev, next) ); }; export default React.memo(LayerUI, areEqual);