diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/LibraryMenu.tsx | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/LibraryMenu.tsx')
| -rw-r--r-- | packages/excalidraw/components/LibraryMenu.tsx | 290 |
1 files changed, 290 insertions, 0 deletions
diff --git a/packages/excalidraw/components/LibraryMenu.tsx b/packages/excalidraw/components/LibraryMenu.tsx new file mode 100644 index 0000000..0162d93 --- /dev/null +++ b/packages/excalidraw/components/LibraryMenu.tsx @@ -0,0 +1,290 @@ +import React, { + useState, + useCallback, + useMemo, + useEffect, + memo, + useRef, +} from "react"; +import type Library from "../data/library"; +import { + distributeLibraryItemsOnSquareGrid, + libraryItemsAtom, +} from "../data/library"; +import { t } from "../i18n"; +import { randomId } from "../random"; +import type { + LibraryItems, + LibraryItem, + ExcalidrawProps, + UIAppState, + AppClassProperties, +} from "../types"; +import LibraryMenuItems from "./LibraryMenuItems"; +import { trackEvent } from "../analytics"; +import { atom, useAtom } from "../editor-jotai"; +import Spinner from "./Spinner"; +import { + useApp, + useAppProps, + useExcalidrawElements, + useExcalidrawSetAppState, +} from "./App"; +import { getSelectedElements } from "../scene"; +import { useUIAppState } from "../context/ui-appState"; + +import "./LibraryMenu.scss"; +import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; +import type { + ExcalidrawElement, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { LIBRARY_DISABLED_TYPES } from "../constants"; +import { isShallowEqual } from "../utils"; + +export const isLibraryMenuOpenAtom = atom(false); + +const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => { + return <div className="layer-ui__library">{children}</div>; +}; + +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<any, UIAppState>["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 libraryItems = useMemo( + () => libraryItemsData.libraryItems, + [libraryItemsData], + ); + + if ( + libraryItemsData.status === "loading" && + !libraryItemsData.isInitialized + ) { + return ( + <LibraryMenuWrapper> + <div className="layer-ui__library-message"> + <div> + <Spinner size="2em" /> + <span>{t("labels.libraryLoadingMessage")}</span> + </div> + </div> + </LibraryMenuWrapper> + ); + } + + const showBtn = + libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0; + + return ( + <LibraryMenuWrapper> + <LibraryMenuItems + isLoading={libraryItemsData.status === "loading"} + libraryItems={libraryItems} + onAddToLibrary={_onAddToLibrary} + onInsertLibraryItems={onInsertLibraryItems} + pendingElements={pendingElements} + id={id} + libraryReturnUrl={libraryReturnUrl} + theme={theme} + onSelectItems={onSelectItems} + selectedItems={selectedItems} + /> + {showBtn && ( + <LibraryMenuControlButtons + className="library-menu-control-buttons--at-bottom" + style={{ padding: "16px 12px 0 12px" }} + id={id} + libraryReturnUrl={libraryReturnUrl} + theme={theme} + /> + )} + </LibraryMenuWrapper> + ); + }, +); + +const getPendingElements = ( + elements: readonly NonDeletedExcalidrawElement[], + selectedElementIds: UIAppState["selectedElementIds"], +) => ({ + elements, + pending: getSelectedElements( + elements, + { selectedElementIds }, + { + includeBoundTextElement: true, + includeElementsInFrames: true, + }, + ), + selectedElementIds, +}); + +const usePendingElementsMemo = ( + appState: UIAppState, + app: AppClassProperties, +) => { + const elements = useExcalidrawElements(); + const [state, setState] = useState(() => + getPendingElements(elements, appState.selectedElementIds), + ); + + const selectedElementVersions = useRef( + new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(), + ); + + useEffect(() => { + for (const element of state.pending) { + selectedElementVersions.current.set(element.id, element.version); + } + }, [state.pending]); + + useEffect(() => { + if ( + // 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" + ) { + 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; + }); + } + }, [ + app, + app.state.cursorButton, + app.state.activeTool.type, + appState.selectedElementIds, + elements, + ]); + + return state.pending; +}; + +/** + * This component is meant to be rendered inside <Sidebar.Tab/> inside our + * <DefaultSidebar/> or host apps Sidebar components. + */ +export const LibraryMenu = memo(() => { + const app = useApp(); + const { onInsertElements } = app; + const appProps = useAppProps(); + const appState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); + const memoizedLibrary = useMemo(() => app.library, [app.library]); + const pendingElements = usePendingElementsMemo(appState, app); + + const onInsertLibraryItems = useCallback( + (libraryItems: LibraryItems) => { + onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); + }, + [onInsertElements], + ); + + const deselectItems = useCallback(() => { + setAppState({ + selectedElementIds: {}, + selectedGroupIds: {}, + activeEmbeddable: null, + }); + }, [setAppState]); + + return ( + <LibraryMenuContent + pendingElements={pendingElements} + onInsertLibraryItems={onInsertLibraryItems} + onAddToLibrary={deselectItems} + setAppState={setAppState} + libraryReturnUrl={appProps.libraryReturnUrl} + library={memoizedLibrary} + id={app.id} + theme={appState.theme} + selectedItems={selectedItems} + onSelectItems={setSelectedItems} + /> + ); +}); |
