import clsx from "clsx"; import React from "react"; import type { ActionManager } from "../actions/manager"; import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH, TOOL_TYPE, } from "../constants"; import { showSelectedShapeActions } from "../element"; import type { NonDeletedExcalidrawElement } from "../element/types"; import type { Language } from "../i18n"; import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import type { AppProps, AppState, ExcalidrawProps, BinaryFiles, UIAppState, AppClassProperties, } from "../types"; import { capitalizeString, isShallowEqual } from "../utils"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { ErrorDialog } from "./ErrorDialog"; import { 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 { PenModeButton } from "./PenModeButton"; import { trackEvent } from "../analytics"; import { useDevice } from "./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, useAtom, useAtomValue } from "jotai"; import MainMenu from "./main-menu/MainMenu"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm"; 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 { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; import "./LayerUI.scss"; import "./Toolbar.scss"; import { mutateElement } from "../element/mutateElement"; import { ShapeCache } from "../scene/ShapeCache"; import Scene from "../scene/Scene"; import { LaserPointerButton } from "./LaserPointerButton"; import { MagicSettings } from "./MagicSettings"; import { TTDDialog } from "./TTDDialog/TTDDialog"; interface LayerUIProps { actionManager: ActionManager; appState: UIAppState; files: BinaryFiles; canvas: HTMLCanvasElement; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; onLockToggle: () => void; onHandToolToggle: () => void; onPenModeToggle: AppClassProperties["togglePenMode"]; showExitZenModeBtn: boolean; langCode: Language["code"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; UIOptions: AppProps["UIOptions"]; onExportImage: AppClassProperties["onExportImage"]; renderWelcomeScreen: boolean; children?: React.ReactNode; app: AppClassProperties; isCollaborating: boolean; openAIKey: string | null; isOpenAIKeyPersisted: boolean; onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void; onMagicSettingsConfirm: ( apiKey: string, shouldPersist: boolean, source: "tool" | "generation" | "settings", ) => void; } 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 DefaultOverwriteConfirmDialog = () => { return ( ); }; const LayerUI = ({ actionManager, appState, files, setAppState, elements, canvas, onLockToggle, onHandToolToggle, onPenModeToggle, showExitZenModeBtn, renderTopRightUI, renderCustomStats, UIOptions, onExportImage, renderWelcomeScreen, children, app, isCollaborating, openAIKey, isOpenAIKeyPersisted, onOpenAIAPIKeyChange, onMagicSettingsConfirm, }: LayerUIProps) => { const device = useDevice(); const tunnels = useInitializeTunnels(); const [eyeDropperState, setEyeDropperState] = useAtom( activeEyeDropperAtom, jotaiScope, ); const renderJSONExportDialog = () => { if (!UIOptions.canvasActions.export) { return null; } return ( ); }; const renderImageExportDialog = () => { if ( !UIOptions.canvasActions.saveAsImage || appState.openDialog?.name !== "imageExport" ) { return null; } return ( setAppState({ openDialog: null })} name={app.getName()} /> ); }; 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} onPenModeToggle(null)} title={t("toolBar.penMode")} penDetected={appState.penDetected} />
onHandToolToggle()} title={t("toolBar.hand")} isMobile /> {isCollaborating && ( app.setActiveTool({ type: TOOL_TYPE.laser }) } isMobile /> )}
)}
)}
{appState.collaborators.size > 0 && ( )} {renderTopRightUI?.(device.editor.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.editor.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.editor.isMobile ? "mobile" : "desktop"})`, ); } }} tab={DEFAULT_SIDEBAR.defaultTab} > {t("toolBar.library")} {appState.openDialog?.name === "ttd" && } {/* ------------------------------------------------------------------ */} {appState.isLoading && } {appState.errorMessage && ( setAppState({ errorMessage: null })}> {appState.errorMessage} )} {eyeDropperState && !device.editor.isMobile && ( { setEyeDropperState(null); }} onChange={(colorPickerType, color, selectedElements, { altKey }) => { if ( colorPickerType !== "elementBackground" && colorPickerType !== "elementStroke" ) { return; } if (selectedElements.length) { for (const element of selectedElements) { mutateElement( element, { [altKey && eyeDropperState.swapPreviewOnAlt ? colorPickerType === "elementBackground" ? "strokeColor" : "backgroundColor" : colorPickerType === "elementBackground" ? "backgroundColor" : "strokeColor"]: color, }, false, ); ShapeCache.delete(element); } Scene.getScene(selectedElements[0])?.triggerUpdate(); } else if (colorPickerType === "elementBackground") { setAppState({ currentItemBackgroundColor: color, }); } else { setAppState({ currentItemStrokeColor: color }); } }} onSelect={(color, event) => { setEyeDropperState((state) => { return state?.keepOpenOnAlt && event.altKey ? state : null; }); eyeDropperState?.onSelect?.(color, event); }} /> )} {appState.openDialog?.name === "help" && ( { setAppState({ openDialog: null }); }} /> )} {appState.openDialog?.name === "settings" && ( { const source = appState.openDialog?.name === "settings" ? appState.openDialog?.source : "settings"; setAppState({ openDialog: null }, () => { onMagicSettingsConfirm(apiKey, shouldPersist, source); }); }} onClose={() => { setAppState({ openDialog: null }); }} /> )} {renderImageExportDialog()} {renderJSONExportDialog()} {appState.pasteDialog.shown && ( setAppState({ pasteDialog: { shown: false, data: null }, }) } /> )} {device.editor.isMobile && ( )} {!device.editor.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: _pC, appState: prevAppState, ...prev } = prevProps; const { canvas: _nC, 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);