import clsx from "clsx"; import React, { RefObject, useCallback, useEffect, useRef, useState, } from "react"; import { ActionManager } from "../actions/manager"; import { CLASSES } from "../constants"; import { exportCanvas } from "../data"; import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json"; 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, LibraryItem, LibraryItems, } 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 { exportFile, load, trash } from "./icons"; import { Island } from "./Island"; import "./LayerUI.scss"; import { LibraryUnit } from "./LibraryUnit"; import { LoadingMessage } from "./LoadingMessage"; import { LockIcon } from "./LockIcon"; import { MobileMenu } from "./MobileMenu"; import { PasteChartDialog } from "./PasteChartDialog"; import { Section } from "./Section"; import { HelpDialog } from "./HelpDialog"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; import { Tooltip } from "./Tooltip"; import { UserList } from "./UserList"; import Library from "../data/library"; import { JSONExportDialog } from "./JSONExportDialog"; interface LayerUIProps { actionManager: ActionManager; appState: AppState; canvas: HTMLCanvasElement | null; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; onCollabButtonClick?: () => void; onLockToggle: () => 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; renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element; viewModeEnabled: boolean; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; UIOptions: AppProps["UIOptions"]; focusContainer: () => void; library: Library; id: string; } const useOnClickOutside = ( ref: RefObject, cb: (event: MouseEvent) => void, ) => { useEffect(() => { const listener = (event: MouseEvent) => { if (!ref.current) { return; } if ( event.target instanceof Element && (ref.current.contains(event.target) || !document.body.contains(event.target)) ) { return; } cb(event); }; document.addEventListener("pointerdown", listener, false); return () => { document.removeEventListener("pointerdown", listener); }; }, [ref, cb]); }; const LibraryMenuItems = ({ libraryItems, onRemoveFromLibrary, onAddToLibrary, onInsertShape, pendingElements, setAppState, setLibraryItems, libraryReturnUrl, focusContainer, library, id, }: { libraryItems: LibraryItems; pendingElements: LibraryItem; onRemoveFromLibrary: (index: number) => void; onInsertShape: (elements: LibraryItem) => void; onAddToLibrary: (elements: LibraryItem) => void; setAppState: React.Component["setState"]; setLibraryItems: (library: LibraryItems) => void; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; focusContainer: () => void; library: Library; id: string; }) => { const isMobile = useIsMobile(); const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0); const CELLS_PER_ROW = isMobile ? 4 : 6; const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW)); const rows = []; let addedPendingElements = false; const referrer = libraryReturnUrl || window.location.origin + window.location.pathname; rows.push(
{ importLibraryFromJSON(library) .then(() => { // Close and then open to get the libraries updated setAppState({ isLibraryOpen: false }); setAppState({ isLibraryOpen: true }); }) .catch(muteFSAbortError) .catch((error) => { setAppState({ errorMessage: error.message }); }); }} /> {!!libraryItems.length && ( <> { saveLibraryAsJSON(library) .catch(muteFSAbortError) .catch((error) => { setAppState({ errorMessage: error.message }); }); }} /> { if (window.confirm(t("alerts.resetLibrary"))) { library.resetLibrary(); setLibraryItems([]); focusContainer(); } }} /> )} {t("labels.libraries")}
, ); for (let row = 0; row < numRows; row++) { const y = CELLS_PER_ROW * row; const children = []; for (let x = 0; x < CELLS_PER_ROW; x++) { const shouldAddPendingElements: boolean = pendingElements.length > 0 && !addedPendingElements && y + x >= libraryItems.length; addedPendingElements = addedPendingElements || shouldAddPendingElements; children.push( , ); } rows.push( {children} , ); } return ( {rows} ); }; const LibraryMenu = ({ onClickOutside, onInsertShape, pendingElements, onAddToLibrary, setAppState, libraryReturnUrl, focusContainer, library, id, }: { pendingElements: LibraryItem; onClickOutside: (event: MouseEvent) => void; onInsertShape: (elements: LibraryItem) => void; onAddToLibrary: () => void; setAppState: React.Component["setState"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; focusContainer: () => void; library: Library; id: string; }) => { const ref = useRef(null); useOnClickOutside(ref, (event) => { // If click on the library icon, do nothing. if ((event.target as Element).closest(".ToolIcon_type_button__library")) { return; } onClickOutside(event); }); const [libraryItems, setLibraryItems] = useState([]); const [loadingState, setIsLoading] = useState< "preloading" | "loading" | "ready" >("preloading"); const loadingTimerRef = useRef(null); useEffect(() => { Promise.race([ new Promise((resolve) => { loadingTimerRef.current = setTimeout(() => { resolve("loading"); }, 100); }), library.loadLibrary().then((items) => { setLibraryItems(items); setIsLoading("ready"); }), ]).then((data) => { if (data === "loading") { setIsLoading("loading"); } }); return () => { clearTimeout(loadingTimerRef.current!); }; }, [library]); const removeFromLibrary = useCallback( async (indexToRemove) => { const items = await library.loadLibrary(); const nextItems = items.filter((_, index) => index !== indexToRemove); library.saveLibrary(nextItems).catch((error) => { setLibraryItems(items); setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); }); setLibraryItems(nextItems); }, [library, setAppState], ); const addToLibrary = useCallback( async (elements: LibraryItem) => { const items = await library.loadLibrary(); const nextItems = [...items, elements]; onAddToLibrary(); library.saveLibrary(nextItems).catch((error) => { setLibraryItems(items); setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); }); setLibraryItems(nextItems); }, [onAddToLibrary, library, setAppState], ); return loadingState === "preloading" ? null : ( {loadingState === "loading" ? (
{t("labels.libraryLoadingMessage")}
) : ( )}
); }; const LayerUI = ({ actionManager, appState, setAppState, canvas, elements, onCollabButtonClick, onLockToggle, onInsertElements, zenModeEnabled, showExitZenModeBtn, showThemeBtn, toggleZenMode, isCollaborating, renderTopRightUI, renderCustomFooter, viewModeEnabled, libraryReturnUrl, UIOptions, focusContainer, library, id, }: LayerUIProps) => { const isMobile = useIsMobile(); const renderJSONExportDialog = () => { if (!UIOptions.canvasActions.export) { return null; } return ( { UIOptions.canvasActions.export.onExportToBackend && UIOptions.canvasActions.export.onExportToBackend( elements, appState, canvas, ); }} exportOpts={UIOptions.canvasActions.export} /> ); }; const renderImageExportDialog = () => { if (!UIOptions.canvasActions.export) { return null; } const createExporter = (type: ExportType): ExportCB => async ( exportedElements, scale, ) => { await exportCanvas(type, exportedElements, appState, { exportBackground: appState.exportBackground, name: appState.name, viewBackgroundColor: appState.viewBackgroundColor, scale, }) .catch(muteFSAbortError) .catch((error) => { console.error(error); setAppState({ errorMessage: error.message }); }); }; 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 && ( )}
); const renderSelectedShapeActions = () => (
); const closeLibrary = useCallback( (event) => { 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} {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", clientId)} ))} {renderTopRightUI?.(isMobile, appState)}
); }; const renderBottomAppMenu = () => { return (
{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 && keys.every((key) => prevAppState[key] === nextAppState[key]) ); }; export default React.memo(LayerUI, areEqual);