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 { Library } from "../data/library"; import { showSelectedShapeActions } from "../element"; import { NonDeletedExcalidrawElement } from "../element/types"; import { Language, t } from "../i18n"; import useIsMobile from "../is-mobile"; import { calculateScrollCenter, getSelectedElements } from "../scene"; import { ExportType } from "../scene/types"; import { AppState, 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, ExportDialog } from "./ExportDialog"; import { FixedSideContainer } from "./FixedSideContainer"; import { GitHubCorner } from "./GitHubCorner"; import { HintViewer } from "./HintViewer"; import { exportFile, load, shield } 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"; 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; toggleZenMode: () => void; langCode: Language["code"]; isCollaborating: boolean; onExportToBackend?: ( exportedElements: readonly NonDeletedExcalidrawElement[], appState: AppState, canvas: HTMLCanvasElement | null, ) => void; renderCustomFooter?: (isMobile: boolean) => JSX.Element; } 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 = ({ library, onRemoveFromLibrary, onAddToLibrary, onInsertShape, pendingElements, setAppState, }: { library: LibraryItems; pendingElements: LibraryItem; onRemoveFromLibrary: (index: number) => void; onInsertShape: (elements: LibraryItem) => void; onAddToLibrary: (elements: LibraryItem) => void; setAppState: React.Component["setState"]; }) => { const isMobile = useIsMobile(); const numCells = library.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; rows.push(
{ importLibraryFromJSON() .then(() => { // Maybe we should close and open the menu so that the items get updated. // But for now we just close the menu. setAppState({ isLibraryOpen: false }); }) .catch(muteFSAbortError) .catch((error) => { setAppState({ errorMessage: error.message }); }); }} /> { saveLibraryAsJSON() .catch(muteFSAbortError) .catch((error) => { setAppState({ errorMessage: error.message }); }); }} /> {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 >= library.length; addedPendingElements = addedPendingElements || shouldAddPendingElements; children.push( , ); } rows.push( {children} , ); } return ( {rows} ); }; const LibraryMenu = ({ onClickOutside, onInsertShape, pendingElements, onAddToLibrary, setAppState, }: { pendingElements: LibraryItem; onClickOutside: (event: MouseEvent) => void; onInsertShape: (elements: LibraryItem) => void; onAddToLibrary: () => void; setAppState: React.Component["setState"]; }) => { 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!); }; }, []); const removeFromLibrary = useCallback(async (indexToRemove) => { const items = await Library.loadLibrary(); const nextItems = items.filter((_, index) => index !== indexToRemove); Library.saveLibrary(nextItems); setLibraryItems(nextItems); }, []); const addToLibrary = useCallback( async (elements: LibraryItem) => { const items = await Library.loadLibrary(); const nextItems = [...items, elements]; onAddToLibrary(); Library.saveLibrary(nextItems); setLibraryItems(nextItems); }, [onAddToLibrary], ); return loadingState === "preloading" ? null : ( {loadingState === "loading" ? (
{t("labels.libraryLoadingMessage")}
) : ( )}
); }; const LayerUI = ({ actionManager, appState, setAppState, canvas, elements, onCollabButtonClick, onLockToggle, onInsertElements, zenModeEnabled, toggleZenMode, isCollaborating, onExportToBackend, renderCustomFooter, }: LayerUIProps) => { const isMobile = useIsMobile(); const renderEncryptedIcon = () => ( {shield} ); const renderExportDialog = () => { const createExporter = (type: ExportType): ExportCB => async ( exportedElements, scale, ) => { if (canvas) { await exportCanvas(type, exportedElements, appState, canvas, { exportBackground: appState.exportBackground, name: appState.name, viewBackgroundColor: appState.viewBackgroundColor, scale, shouldAddWatermark: appState.shouldAddWatermark, }) .catch(muteFSAbortError) .catch((error) => { console.error(error); setAppState({ errorMessage: error.message }); }); } }; return ( { onExportToBackend && onExportToBackend(elements, appState, canvas); } : undefined } /> ); }; const renderCanvasActions = () => (
{/* the zIndex ensures this menu has higher stacking order, see https://github.com/excalidraw/excalidraw/pull/1445 */} {actionManager.renderAction("loadScene")} {actionManager.renderAction("saveScene")} {actionManager.renderAction("saveAsScene")} {renderExportDialog()} {actionManager.renderAction("clearCanvas")} {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 (
{renderCanvasActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
{(heading) => ( {heading} {libraryMenu} )}
{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)} ))}
); }; const renderBottomAppMenu = () => { return (
{renderEncryptedIcon()}
); }; const renderFooter = () => (
{renderCustomFooter?.(false)} {actionManager.renderAction("toggleShortcuts")}
{appState.scrolledOutside && ( )}
); 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()} { } {renderFooter()}
); }; 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.langCode === next.langCode && prev.elements === next.elements && keys.every((key) => prevAppState[key] === nextAppState[key]) ); }; export default React.memo(LayerUI, areEqual);