import clsx from "clsx"; import React, { useCallback } from "react"; import { ActionManager } from "../actions/manager"; import { CLASSES } from "../constants"; import { exportCanvas } from "../data"; import { isTextElement, showSelectedShapeActions } from "../element"; import { NonDeletedExcalidrawElement } from "../element/types"; import { Language, t } from "../i18n"; import { useIsMobile } from "../components/App"; import { calculateScrollCenter, getSelectedElements } from "../scene"; import { ExportType } from "../scene/types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import { muteFSAbortError } from "../utils"; import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import CollabButton from "./CollabButton"; 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 { Tooltip } from "./Tooltip"; 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"; interface LayerUIProps { actionManager: ActionManager; appState: AppState; files: BinaryFiles; canvas: HTMLCanvasElement | null; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; onCollabButtonClick?: () => void; onLockToggle: () => void; onPenModeToggle: () => void; onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; zenModeEnabled: boolean; showExitZenModeBtn: boolean; showThemeBtn: boolean; toggleZenMode: () => void; langCode: Language["code"]; isCollaborating: boolean; renderTopRightUI?: ( isMobile: boolean, appState: AppState, ) => JSX.Element | null; renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element; viewModeEnabled: boolean; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; UIOptions: AppProps["UIOptions"]; focusContainer: () => void; library: Library; id: string; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; } const LayerUI = ({ actionManager, appState, files, setAppState, canvas, elements, onCollabButtonClick, onLockToggle, onPenModeToggle, onInsertElements, zenModeEnabled, showExitZenModeBtn, showThemeBtn, toggleZenMode, isCollaborating, renderTopRightUI, renderCustomFooter, viewModeEnabled, libraryReturnUrl, UIOptions, focusContainer, library, id, onImageAction, }: LayerUIProps) => { const isMobile = useIsMobile(); const renderJSONExportDialog = () => { if (!UIOptions.canvasActions.export) { return null; } return ( ); }; const renderImageExportDialog = () => { if (!UIOptions.canvasActions.saveAsImage) { return null; } const createExporter = (type: ExportType): ExportCB => async (exportedElements) => { 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 Separator = () => { return
; }; const renderViewModeCanvasActions = () => { return (
{/* the zIndex ensures this menu has higher stacking order, see https://github.com/excalidraw/excalidraw/pull/1445 */} {renderJSONExportDialog()} {renderImageExportDialog()}
); }; const renderCanvasActions = () => (
{/* the zIndex ensures this menu has higher stacking order, see https://github.com/excalidraw/excalidraw/pull/1445 */} {actionManager.renderAction("clearCanvas")} {actionManager.renderAction("loadScene")} {renderJSONExportDialog()} {renderImageExportDialog()} {onCollabButtonClick && ( )} {appState.fileHandle && ( <>{actionManager.renderAction("saveToActiveFile")} )}
); const renderSelectedShapeActions = () => (
); const closeLibrary = useCallback(() => { const isDialogOpen = !!document.querySelector(".Dialog"); // Prevent closing if any dialog is open if (isDialogOpen) { return; } setAppState({ isLibraryOpen: false }); }, [setAppState]); const deselectItems = useCallback(() => { setAppState({ selectedElementIds: {}, selectedGroupIds: {}, }); }, [setAppState]); const libraryMenu = appState.isLibraryOpen ? ( ) : null; const renderFixedSideContainer = () => { const shouldRenderSelectedShapeActions = showSelectedShapeActions( appState, elements, ); return (
{viewModeEnabled ? renderViewModeCanvasActions() : renderCanvasActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {!viewModeEnabled && (
{(heading) => ( {heading} { onImageAction({ insertOnCanvasDirectly: pointerType !== "mouse", }); }} /> {libraryMenu} )}
)}
{appState.collaborators.size > 0 && Array.from(appState.collaborators) // Collaborator is either not initialized or is actually the current user. .filter(([_, client]) => Object.keys(client).length !== 0) .map(([clientId, client]) => ( {actionManager.renderAction("goToCollaborator", { id: clientId, })} ))} {renderTopRightUI?.(isMobile, appState)}
); }; const renderBottomAppMenu = () => { return (
{!viewModeEnabled && (
{actionManager.renderAction("undo", { size: "small" })} {actionManager.renderAction("redo", { size: "small" })}
)}
{renderCustomFooter?.(false, appState)}
{actionManager.renderAction("toggleShortcuts")}
); }; const dialogs = ( <> {appState.isLoading && } {appState.errorMessage && ( setAppState({ errorMessage: null })} /> )} {appState.showHelpDialog && ( { setAppState({ showHelpDialog: false }); }} /> )} {appState.pasteDialog.shown && ( setAppState({ pasteDialog: { shown: false, data: null }, }) } /> )} ); return isMobile ? ( <> {dialogs} ) : (
{dialogs} {renderFixedSideContainer()} {renderBottomAppMenu()} {appState.scrolledOutside && ( )}
); }; const areEqual = (prev: LayerUIProps, next: LayerUIProps) => { const getNecessaryObj = (appState: AppState): Partial => { const { suggestedBindings, startBoundElement: boundElement, ...ret } = appState; return ret; }; const prevAppState = getNecessaryObj(prev.appState); const nextAppState = getNecessaryObj(next.appState); const keys = Object.keys(prevAppState) as (keyof Partial)[]; return ( prev.renderCustomFooter === next.renderCustomFooter && prev.langCode === next.langCode && prev.elements === next.elements && prev.files === next.files && keys.every((key) => prevAppState[key] === nextAppState[key]) ); }; export default React.memo(LayerUI, areEqual);