import clsx from "clsx"; import React from "react"; import { CLASSES, DEFAULT_SIDEBAR, TOOL_TYPE, arrayToMap, capitalizeString, isShallowEqual, } from "@excalidraw/common"; import { getColorUpdate, mutateElement } from "@excalidraw/element"; import { showSelectedShapeActions } from "@excalidraw/element"; import { ShapeCache } from "@excalidraw/element"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import { actionToggleStats } from "../actions"; import { trackEvent } from "../analytics"; import { TunnelsContext, useInitializeTunnels } from "../context/tunnels"; import { UIAppStateContext } from "../context/ui-appState"; import { useAtom, useAtomValue } from "../editor-jotai"; import { t } from "../i18n"; import { getScrollToContentState, getSelectedElements } from "../scene"; import { getColorTargetAppStateUpdates, resolveColorTarget, type ColorDefaultKey, } from "../actions/colorTargets"; import { SelectedShapeActions, CompactShapeActions } from "./Actions"; import { LoadingMessage } from "./LoadingMessage"; import { MobileMenu } from "./MobileMenu"; import { PasteChartDialog } from "./PasteChartDialog"; import { Section } from "./Section"; import Stack from "./Stack"; import { UserList } from "./UserList"; import { PenModeButton } from "./PenModeButton"; import Footer from "./footer/Footer"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import MainMenu from "./main-menu/MainMenu"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { useAppProps, useEditorInterface, useStylesPanelMode } from "./App"; import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm"; import { sidebarRightIcon } from "./icons"; import { DefaultSidebar } from "./DefaultSidebar"; import { TTDDialog } from "./TTDDialog/TTDDialog"; import { Stats } from "./Stats"; import ElementLinkDialog from "./ElementLinkDialog"; import { ErrorDialog } from "./ErrorDialog"; import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; import { FixedSideContainer } from "./FixedSideContainer"; import { HelpDialog } from "./HelpDialog"; import { ImageExportDialog } from "./ImageExportDialog"; import { Island } from "./Island"; import { JSONExportDialog } from "./JSONExportDialog"; import { LaserPointerButton } from "./LaserPointerButton"; import { Toast } from "./Toast"; import { Toolbar } from "./Toolbar"; import { ViewportStatusBadge, ViewportStatusBorder, } from "./ViewportStatusFrame/ViewportStatusFrame"; import "./LayerUI.scss"; import "./Toolbar.scss"; import type { ActionManager } from "../actions/manager"; import type { Language } from "../i18n"; import type { AppProps, AppState, ExcalidrawProps, BinaryFiles, UIAppState, AppClassProperties, } from "../types"; interface LayerUIProps { actionManager: ActionManager; appState: UIAppState; files: BinaryFiles; canvas: HTMLCanvasElement; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; onLockToggle: () => void; onPenModeToggle: AppClassProperties["togglePenMode"]; showExitZenModeBtn: boolean; langCode: Language["code"]; renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; UIOptions: AppProps["UIOptions"]; onExportImage: AppClassProperties["onExportImage"]; renderWelcomeScreen: boolean; children?: React.ReactNode; app: AppClassProperties; defaultUIEnabled: boolean; zoomUIEnabled: boolean; scrollBackToContentUIEnabled: boolean; isCollaborating: boolean; generateLinkForSelection?: AppProps["generateLinkForSelection"]; currentUserControls?: ExcalidrawProps["currentUserControls"]; } 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, onPenModeToggle, showExitZenModeBtn, renderTopLeftUI, renderTopRightUI, renderCustomStats, UIOptions, onExportImage, renderWelcomeScreen, children, app, defaultUIEnabled, zoomUIEnabled, scrollBackToContentUIEnabled, isCollaborating, generateLinkForSelection, currentUserControls, }: LayerUIProps) => { const editorInterface = useEditorInterface(); const appProps = useAppProps(); const stylesPanelMode = useStylesPanelMode(); const isCompactStylesPanel = stylesPanelMode === "compact"; const tunnels = useInitializeTunnels(); const spacing = isCompactStylesPanel ? { menuTopGap: 4, toolbarColGap: 4, toolbarRowGap: 1, toolbarInnerRowGap: 0.5, islandPadding: 1, collabMarginLeft: 8, } : { menuTopGap: 6, toolbarColGap: 4, toolbarRowGap: 1, toolbarInnerRowGap: 1, islandPadding: 1, collabMarginLeft: 8, }; const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider; const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); 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 = () => (
{renderTopLeftUI?.(false, appState)}
{renderWelcomeScreen && }
); const renderSelectedShapeActions = () => { return (
{isCompactStylesPanel ? ( ) : ( )}
); }; const renderFixedSideContainer = () => { const shouldRenderSelectedShapeActions = defaultUIEnabled && showSelectedShapeActions(appState, elements); const shouldShowStats = defaultUIEnabled && appState.stats.open && !appState.zenModeEnabled && !appState.viewModeEnabled && appState.openDialog?.name !== "elementLinkSelector"; return (
{renderCanvasActions()} {defaultUIEnabled && (
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
)} {/* in compact UI the pen mode button lives outside the toolbar, as a separate floating button below the compact actions menu (same as we render it on mobile); shown alongside the compact actions island, i.e. when a drawing tool or elements are selected */} {defaultUIEnabled && isCompactStylesPanel && !appState.viewModeEnabled && shouldRenderSelectedShapeActions && ( onPenModeToggle(null)} title={t("toolBar.penMode")} isMobile penDetected={appState.penDetected} /> )}
{defaultUIEnabled && !appState.viewModeEnabled && appState.openDialog?.name !== "elementLinkSelector" && (
{(heading: React.ReactNode) => (
{renderWelcomeScreen && ( )} {isCollaborating && ( app.setActiveTool({ type: TOOL_TYPE.laser }) } isMobile /> )}
)}
)}
{defaultUIEnabled && appState.collaborators.size > 0 && ( )} {renderTopRightUI?.( editorInterface.formFactor === "phone", appState, )} {!appState.viewModeEnabled && appState.openDialog?.name !== "elementLinkSelector" && // hide button when sidebar docked (!isSidebarDocked || appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && ( )} {shouldShowStats && ( { actionManager.executeAction(actionToggleStats); }} renderCustomStats={renderCustomStats} /> )}
); }; const renderSidebars = () => { if (!defaultUIEnabled) { return null; } return ( { trackEvent( "sidebar", `toggleDock (${docked ? "dock" : "undock"})`, `(${ editorInterface.formFactor === "phone" ? "mobile" : "desktop" })`, ); }} /> ); }; const isSidebarDocked = useAtomValue(isSidebarDockedAtom); const isSidebarDockedAndFits = !!( appState.openSidebar && isSidebarDocked && editorInterface.canFitSidebar ); 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} {/* Fallback entry points are the default UI. Host components above keep rendering into the outlets below even when defaults are disabled. */} {defaultUIEnabled && ( <> { if (open) { trackEvent( "sidebar", `${DEFAULT_SIDEBAR.name} (open)`, `button (${ editorInterface.formFactor === "phone" ? "mobile" : "desktop" })`, ); } }} tab={DEFAULT_SIDEBAR.defaultTab} /> )} {/* Keep supporting surfaces available to host-supplied UI, including MainMenu.DefaultItems. */} {appState.openDialog?.name === "ttd" && } {/* ------------------------------------------------------------------ */} {defaultUIEnabled && appState.isLoading && } {defaultUIEnabled && appState.errorMessage && ( setAppState({ errorMessage: null })}> {appState.errorMessage} )} {defaultUIEnabled && eyeDropperState && editorInterface.formFactor !== "phone" && ( { setEyeDropperState(null); }} onChange={( colorPickerType, color, selectedElements, { altKey }, ) => { if ( colorPickerType !== "elementBackground" && colorPickerType !== "elementStroke" ) { return; } const property = altKey && eyeDropperState.swapPreviewOnAlt ? colorPickerType === "elementBackground" ? "strokeColor" : "backgroundColor" : colorPickerType === "elementBackground" ? "backgroundColor" : "strokeColor"; if (selectedElements.length) { const elementsMap = arrayToMap(elements); // a note's visible text is its label, so stroke picks // include bound labels const targets = getSelectedElements(elements, appState, { includeBoundTextElement: property === "strokeColor", }); for (const element of targets) { mutateElement( element, elementsMap, getColorUpdate(element, property, color, elementsMap), ); ShapeCache.delete(element); } app.scene.triggerUpdate(); } else { setAppState( getColorTargetAppStateUpdates( resolveColorTarget(appState, elements, property), color, ) as Pick, ); } }} onSelect={(color, event) => { setEyeDropperState((state) => { return state?.keepOpenOnAlt && event.altKey ? state : null; }); eyeDropperState?.onSelect?.(color, event); }} /> )} {appState.openDialog?.name === "help" && ( { setAppState({ openDialog: null }); }} /> )} {defaultUIEnabled && appState.openDialog?.name === "elementLinkSelector" && ( { setAppState({ openDialog: null, }); }} scene={app.scene} appState={appState} generateLinkForSelection={generateLinkForSelection} /> )} {renderImageExportDialog()} {renderJSONExportDialog()} {defaultUIEnabled && appState.openDialog?.name === "charts" && ( setAppState({ openDialog: null, }) } /> )} {editorInterface.formFactor === "phone" && ( )} {editorInterface.formFactor !== "phone" && ( <> {appProps.viewportStatusFrame?.border && ( )}
{renderWelcomeScreen && } {renderFixedSideContainer()}
{(appState.toast || (scrollBackToContentUIEnabled && appState.scrolledOutside) || appProps.viewportStatusFrame?.label) && (
{appState.toast && ( setAppState({ toast: null })} duration={appState.toast.duration} closable={appState.toast.closable} /> )} {!appState.toast && scrollBackToContentUIEnabled && appState.scrolledOutside && ( )} {appProps.viewportStatusFrame?.label && ( )}
)}
{renderSidebars()} )} ); return ( {layerUIJSX} ); }; const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { const { cursorButton, scrollX, scrollY, zoom, shouldCacheIgnoreZoom, snapLines, originSnapOffset, suggestedBinding, frameToHighlight, elementsToHighlight, ...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);