import React, { useRef, useState, RefObject, useEffect, useCallback, } from "react"; import { showSelectedShapeActions } from "../element"; import { calculateScrollCenter, getSelectedElements } from "../scene"; import { exportCanvas } from "../data"; import { AppState, LibraryItems, LibraryItem } from "../types"; import { NonDeletedExcalidrawElement } from "../element/types"; import { ActionManager } from "../actions/manager"; import { Island } from "./Island"; import Stack from "./Stack"; import { FixedSideContainer } from "./FixedSideContainer"; import { UserList } from "./UserList"; import { LockIcon } from "./LockIcon"; import { ExportDialog, ExportCB } from "./ExportDialog"; import { LanguageList } from "./LanguageList"; import { t, languages, setLanguage } from "../i18n"; import { HintViewer } from "./HintViewer"; import useIsMobile from "../is-mobile"; import { ExportType } from "../scene/types"; import { MobileMenu } from "./MobileMenu"; import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { Section } from "./Section"; import CollabButton from "./CollabButton"; import { ErrorDialog } from "./ErrorDialog"; import { ShortcutsDialog } from "./ShortcutsDialog"; import { LoadingMessage } from "./LoadingMessage"; import { CLASSES } from "../constants"; import { shield, exportFile, load } from "./icons"; import { GitHubCorner } from "./GitHubCorner"; import { Tooltip } from "./Tooltip"; import "./LayerUI.scss"; import { LibraryUnit } from "./LibraryUnit"; import { ToolButton } from "./ToolButton"; import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json"; import { muteFSAbortError } from "../utils"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import clsx from "clsx"; import { Library } from "../data/library"; import { EVENT_ACTION, EVENT_EXIT, EVENT_LIBRARY, trackEvent, } from "../analytics"; interface LayerUIProps { actionManager: ActionManager; appState: AppState; canvas: HTMLCanvasElement | null; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; onCollabButtonClick?: () => void; onLockToggle: () => void; onInsertShape: (elements: LibraryItem) => void; zenModeEnabled: boolean; toggleZenMode: () => void; lng: string; isCollaborating: boolean; onExportToBackend?: ( exportedElements: readonly NonDeletedExcalidrawElement[], appState: AppState, canvas: HTMLCanvasElement | null, ) => void; } 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( <> { trackEvent(EVENT_EXIT, "libraries"); }} > {t("labels.libraries")} { 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 }); }); }} /> , ); 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); trackEvent(EVENT_LIBRARY, "remove"); setLibraryItems(nextItems); }, []); const addToLibrary = useCallback( async (elements: LibraryItem) => { const items = await Library.loadLibrary(); const nextItems = [...items, elements]; onAddToLibrary(); trackEvent(EVENT_LIBRARY, "add"); Library.saveLibrary(nextItems); setLibraryItems(nextItems); }, [onAddToLibrary], ); return loadingState === "preloading" ? null : ( {loadingState === "loading" ? (
{t("labels.libraryLoadingMessage")}
) : ( )}
); }; const LayerUI = ({ actionManager, appState, setAppState, canvas, elements, onCollabButtonClick, onLockToggle, onInsertShape, zenModeEnabled, toggleZenMode, isCollaborating, onExportToBackend, }: LayerUIProps) => { const isMobile = useIsMobile(); const renderEncryptedIcon = () => ( { trackEvent(EVENT_EXIT, "e2ee shield"); }} > {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 = () => (
{ await setLanguage(lng); setAppState({}); }} languages={languages} floating /> {actionManager.renderAction("toggleShortcuts")}
{appState.scrolledOutside && ( )}
); return isMobile ? ( ) : (
{appState.isLoading && } {appState.errorMessage && ( setAppState({ errorMessage: null })} /> )} {appState.showShortcutsDialog && ( setAppState({ showShortcutsDialog: false })} /> )} {renderFixedSideContainer()} {renderBottomAppMenu()} { } {renderFooter()}
); }; const areEqual = (prev: LayerUIProps, next: LayerUIProps) => { const getNecessaryObj = (appState: AppState): Partial => { const { cursorX, cursorY, 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.lng === next.lng && prev.elements === next.elements && keys.every((key) => prevAppState[key] === nextAppState[key]) ); }; export default React.memo(LayerUI, areEqual);