diff --git a/packages/excalidraw/components/LibraryMenu.tsx b/packages/excalidraw/components/LibraryMenu.tsx index 4b76dfc4a4..0162d93a00 100644 --- a/packages/excalidraw/components/LibraryMenu.tsx +++ b/packages/excalidraw/components/LibraryMenu.tsx @@ -2,8 +2,9 @@ import React, { useState, useCallback, useMemo, - useRef, useEffect, + memo, + useRef, } from "react"; import type Library from "../data/library"; import { @@ -17,6 +18,7 @@ import type { LibraryItem, ExcalidrawProps, UIAppState, + AppClassProperties, } from "../types"; import LibraryMenuItems from "./LibraryMenuItems"; import { trackEvent } from "../analytics"; @@ -33,9 +35,12 @@ import { useUIAppState } from "../context/ui-appState"; import "./LibraryMenu.scss"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; -import { isShallowEqual } from "../utils"; -import type { NonDeletedExcalidrawElement } from "../element/types"; +import type { + ExcalidrawElement, + NonDeletedExcalidrawElement, +} from "../element/types"; import { LIBRARY_DISABLED_TYPES } from "../constants"; +import { isShallowEqual } from "../utils"; export const isLibraryMenuOpenAtom = atom(false); @@ -43,170 +48,215 @@ const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => { return
{children}
; }; -export const LibraryMenuContent = ({ - onInsertLibraryItems, - pendingElements, - onAddToLibrary, - setAppState, - libraryReturnUrl, - library, - id, - theme, - selectedItems, - onSelectItems, -}: { - pendingElements: LibraryItem["elements"]; - onInsertLibraryItems: (libraryItems: LibraryItems) => void; - onAddToLibrary: () => void; - setAppState: React.Component["setState"]; - libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; - library: Library; - id: string; - theme: UIAppState["theme"]; - selectedItems: LibraryItem["id"][]; - onSelectItems: (id: LibraryItem["id"][]) => void; -}) => { - const [libraryItemsData] = useAtom(libraryItemsAtom); - - const _onAddToLibrary = useCallback( - (elements: LibraryItem["elements"]) => { - const addToLibrary = async ( - processedElements: LibraryItem["elements"], - libraryItems: LibraryItems, - ) => { - trackEvent("element", "addToLibrary", "ui"); - for (const type of LIBRARY_DISABLED_TYPES) { - if (processedElements.some((element) => element.type === type)) { - return setAppState({ - errorMessage: t(`errors.libraryElementTypeError.${type}`), - }); +const LibraryMenuContent = memo( + ({ + onInsertLibraryItems, + pendingElements, + onAddToLibrary, + setAppState, + libraryReturnUrl, + library, + id, + theme, + selectedItems, + onSelectItems, + }: { + pendingElements: LibraryItem["elements"]; + onInsertLibraryItems: (libraryItems: LibraryItems) => void; + onAddToLibrary: () => void; + setAppState: React.Component["setState"]; + libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; + library: Library; + id: string; + theme: UIAppState["theme"]; + selectedItems: LibraryItem["id"][]; + onSelectItems: (id: LibraryItem["id"][]) => void; + }) => { + const [libraryItemsData] = useAtom(libraryItemsAtom); + + const _onAddToLibrary = useCallback( + (elements: LibraryItem["elements"]) => { + const addToLibrary = async ( + processedElements: LibraryItem["elements"], + libraryItems: LibraryItems, + ) => { + trackEvent("element", "addToLibrary", "ui"); + for (const type of LIBRARY_DISABLED_TYPES) { + if (processedElements.some((element) => element.type === type)) { + return setAppState({ + errorMessage: t(`errors.libraryElementTypeError.${type}`), + }); + } } - } - const nextItems: LibraryItems = [ - { - status: "unpublished", - elements: processedElements, - id: randomId(), - created: Date.now(), - }, - ...libraryItems, - ]; - onAddToLibrary(); - library.setLibrary(nextItems).catch(() => { - setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); - }); - }; - addToLibrary(elements, libraryItemsData.libraryItems); - }, - [onAddToLibrary, library, setAppState, libraryItemsData.libraryItems], - ); + const nextItems: LibraryItems = [ + { + status: "unpublished", + elements: processedElements, + id: randomId(), + created: Date.now(), + }, + ...libraryItems, + ]; + onAddToLibrary(); + library.setLibrary(nextItems).catch(() => { + setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); + }); + }; + addToLibrary(elements, libraryItemsData.libraryItems); + }, + [onAddToLibrary, library, setAppState, libraryItemsData.libraryItems], + ); - const libraryItems = useMemo( - () => libraryItemsData.libraryItems, - [libraryItemsData], - ); + const libraryItems = useMemo( + () => libraryItemsData.libraryItems, + [libraryItemsData], + ); - if ( - libraryItemsData.status === "loading" && - !libraryItemsData.isInitialized - ) { - return ( - -
-
- - {t("labels.libraryLoadingMessage")} + if ( + libraryItemsData.status === "loading" && + !libraryItemsData.isInitialized + ) { + return ( + +
+
+ + {t("labels.libraryLoadingMessage")} +
-
- - ); - } + + ); + } - const showBtn = - libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0; + const showBtn = + libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0; - return ( - - - {showBtn && ( - + - )} - - ); -}; + {showBtn && ( + + )} + + ); + }, +); + +const getPendingElements = ( + elements: readonly NonDeletedExcalidrawElement[], + selectedElementIds: UIAppState["selectedElementIds"], +) => ({ + elements, + pending: getSelectedElements( + elements, + { selectedElementIds }, + { + includeBoundTextElement: true, + includeElementsInFrames: true, + }, + ), + selectedElementIds, +}); const usePendingElementsMemo = ( appState: UIAppState, - elements: readonly NonDeletedExcalidrawElement[], + app: AppClassProperties, ) => { - const create = useCallback( - (appState: UIAppState, elements: readonly NonDeletedExcalidrawElement[]) => - getSelectedElements(elements, appState, { - includeBoundTextElement: true, - includeElementsInFrames: true, - }), - [], + const elements = useExcalidrawElements(); + const [state, setState] = useState(() => + getPendingElements(elements, appState.selectedElementIds), + ); + + const selectedElementVersions = useRef( + new Map(), ); - const val = useRef(create(appState, elements)); - const prevAppState = useRef(appState); - const prevElements = useRef(elements); + useEffect(() => { + for (const element of state.pending) { + selectedElementVersions.current.set(element.id, element.version); + } + }, [state.pending]); - const update = useCallback(() => { + useEffect(() => { if ( - !isShallowEqual( - appState.selectedElementIds, - prevAppState.current.selectedElementIds, - ) || - !isShallowEqual(elements, prevElements.current) + // Only update once pointer is released. + // Reading directly from app.state to make it clear it's not reactive + // (hence, there's potential for stale state) + app.state.cursorButton === "up" && + app.state.activeTool.type === "selection" ) { - val.current = create(appState, elements); - prevAppState.current = appState; - prevElements.current = elements; + setState((prev) => { + // if selectedElementIds changed, we don't have to compare versions + // --------------------------------------------------------------------- + if ( + !isShallowEqual(prev.selectedElementIds, appState.selectedElementIds) + ) { + selectedElementVersions.current.clear(); + return getPendingElements(elements, appState.selectedElementIds); + } + // otherwise we need to check whether selected elements changed + // --------------------------------------------------------------------- + const elementsMap = app.scene.getNonDeletedElementsMap(); + for (const id of Object.keys(appState.selectedElementIds)) { + const currVersion = elementsMap.get(id)?.version; + if ( + currVersion && + currVersion !== selectedElementVersions.current.get(id) + ) { + // we can't update the selectedElementVersions in here + // because of double render in StrictMode which would overwrite + // the state in the second pass with the old `prev` state. + // Thus, we update versions in a separate effect. May create + // a race condition since current effect is not fully reactive. + return getPendingElements(elements, appState.selectedElementIds); + } + } + // nothing changed + // --------------------------------------------------------------------- + return prev; + }); } - }, [create, appState, elements]); - - return useMemo( - () => ({ - update, - value: val.current, - }), - [update, val], - ); + }, [ + app, + app.state.cursorButton, + app.state.activeTool.type, + appState.selectedElementIds, + elements, + ]); + + return state.pending; }; /** * This component is meant to be rendered inside inside our * or host apps Sidebar components. */ -export const LibraryMenu = () => { - const { library, id, onInsertElements } = useApp(); +export const LibraryMenu = memo(() => { + const app = useApp(); + const { onInsertElements } = app; const appProps = useAppProps(); const appState = useUIAppState(); - const app = useApp(); const setAppState = useExcalidrawSetAppState(); - const elements = useExcalidrawElements(); const [selectedItems, setSelectedItems] = useState([]); - const memoizedLibrary = useMemo(() => library, [library]); - // BUG: pendingElements are still causing some unnecessary rerenders because clicking into canvas returns some ids even when no element is selected. - const pendingElements = usePendingElementsMemo(appState, elements); + const memoizedLibrary = useMemo(() => app.library, [app.library]); + const pendingElements = usePendingElementsMemo(appState, app); const onInsertLibraryItems = useCallback( (libraryItems: LibraryItems) => { @@ -223,22 +273,18 @@ export const LibraryMenu = () => { }); }, [setAppState]); - useEffect(() => { - return app.onPointerUpEmitter.on(() => pendingElements.update()); - }, [app, pendingElements]); - return ( ); -}; +});