import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { serializeLibraryAsJSON } from "../data/json"; import { t } from "../i18n"; import type { ExcalidrawProps, LibraryItem, LibraryItems, UIAppState, } from "../types"; import { arrayToMap } from "../utils"; import Stack from "./Stack"; import { MIME_TYPES } from "../constants"; import Spinner from "./Spinner"; import { duplicateElements } from "../element/newElement"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent"; import { LibraryMenuSection, LibraryMenuSectionGrid, } from "./LibraryMenuSection"; import { useScrollPosition } from "../hooks/useScrollPosition"; import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import "./LibraryMenuItems.scss"; // using an odd number of items per batch so the rendering creates an irregular // pattern which looks more organic const ITEMS_RENDERED_PER_BATCH = 17; // when render outputs cached we can render many more items per batch to // speed it up const CACHED_ITEMS_RENDERED_PER_BATCH = 64; export default function LibraryMenuItems({ isLoading, libraryItems, onAddToLibrary, onInsertLibraryItems, pendingElements, theme, id, libraryReturnUrl, onSelectItems, selectedItems, }: { isLoading: boolean; libraryItems: LibraryItems; pendingElements: LibraryItem["elements"]; onInsertLibraryItems: (libraryItems: LibraryItems) => void; onAddToLibrary: (elements: LibraryItem["elements"]) => void; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; theme: UIAppState["theme"]; id: string; selectedItems: LibraryItem["id"][]; onSelectItems: (id: LibraryItem["id"][]) => void; }) { const libraryContainerRef = useRef(null); const scrollPosition = useScrollPosition(libraryContainerRef); // This effect has to be called only on first render, therefore `scrollPosition` isn't in the dependency array useEffect(() => { if (scrollPosition > 0) { libraryContainerRef.current?.scrollTo(0, scrollPosition); } }, []); // eslint-disable-line react-hooks/exhaustive-deps const { svgCache } = useLibraryCache(); const unpublishedItems = useMemo( () => libraryItems.filter((item) => item.status !== "published"), [libraryItems], ); const publishedItems = useMemo( () => libraryItems.filter((item) => item.status === "published"), [libraryItems], ); const showBtn = !libraryItems.length && !pendingElements.length; const isLibraryEmpty = !pendingElements.length && !unpublishedItems.length && !publishedItems.length; const [lastSelectedItem, setLastSelectedItem] = useState< LibraryItem["id"] | null >(null); const onItemSelectToggle = useCallback( (id: LibraryItem["id"], event: React.MouseEvent) => { const shouldSelect = !selectedItems.includes(id); const orderedItems = [...unpublishedItems, ...publishedItems]; if (shouldSelect) { if (event.shiftKey && lastSelectedItem) { const rangeStart = orderedItems.findIndex( (item) => item.id === lastSelectedItem, ); const rangeEnd = orderedItems.findIndex((item) => item.id === id); if (rangeStart === -1 || rangeEnd === -1) { onSelectItems([...selectedItems, id]); return; } const selectedItemsMap = arrayToMap(selectedItems); const nextSelectedIds = orderedItems.reduce( (acc: LibraryItem["id"][], item, idx) => { if ( (idx >= rangeStart && idx <= rangeEnd) || selectedItemsMap.has(item.id) ) { acc.push(item.id); } return acc; }, [], ); onSelectItems(nextSelectedIds); } else { onSelectItems([...selectedItems, id]); } setLastSelectedItem(id); } else { setLastSelectedItem(null); onSelectItems(selectedItems.filter((_id) => _id !== id)); } }, [ lastSelectedItem, onSelectItems, publishedItems, selectedItems, unpublishedItems, ], ); const getInsertedElements = useCallback( (id: string) => { let targetElements; if (selectedItems.includes(id)) { targetElements = libraryItems.filter((item) => selectedItems.includes(item.id), ); } else { targetElements = libraryItems.filter((item) => item.id === id); } return targetElements.map((item) => { return { ...item, // duplicate each library item before inserting on canvas to confine // ids and bindings to each library item. See #6465 elements: duplicateElements(item.elements, { randomizeSeed: true }), }; }); }, [libraryItems, selectedItems], ); const onItemDrag = useCallback( (id: LibraryItem["id"], event: React.DragEvent) => { event.dataTransfer.setData( MIME_TYPES.excalidrawlib, serializeLibraryAsJSON(getInsertedElements(id)), ); }, [getInsertedElements], ); const isItemSelected = useCallback( (id: LibraryItem["id"] | null) => { if (!id) { return false; } return selectedItems.includes(id); }, [selectedItems], ); const onAddToLibraryClick = useCallback(() => { onAddToLibrary(pendingElements); }, [pendingElements, onAddToLibrary]); const onItemClick = useCallback( (id: LibraryItem["id"] | null) => { if (id) { onInsertLibraryItems(getInsertedElements(id)); } }, [getInsertedElements, onInsertLibraryItems], ); const itemsRenderedPerBatch = svgCache.size >= libraryItems.length ? CACHED_ITEMS_RENDERED_PER_BATCH : ITEMS_RENDERED_PER_BATCH; return (
{!isLibraryEmpty && ( )} 0 ? 1 : "0 1 auto", marginBottom: 0, }} ref={libraryContainerRef} > <> {!isLibraryEmpty && (
{t("labels.personalLib")}
)} {isLoading && (
)} {!pendingElements.length && !unpublishedItems.length ? (
{t("library.noItems")}
{publishedItems.length > 0 ? t("library.hint_emptyPrivateLibrary") : t("library.hint_emptyLibrary")}
) : ( {pendingElements.length > 0 && ( )} )} <> {(publishedItems.length > 0 || pendingElements.length > 0 || unpublishedItems.length > 0) && (
{t("labels.excalidrawLib")}
)} {publishedItems.length > 0 ? ( ) : unpublishedItems.length > 0 ? (
{t("library.noItems")}
) : null} {showBtn && ( )}
); }