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/LibraryMenuItems.tsx | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/LibraryMenuItems.tsx')
| -rw-r--r-- | packages/excalidraw/components/LibraryMenuItems.tsx | 342 |
1 files changed, 342 insertions, 0 deletions
diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx new file mode 100644 index 0000000..aa2c3e6 --- /dev/null +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -0,0 +1,342 @@ +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<HTMLDivElement>(null); + const scrollPosition = useScrollPosition<HTMLDivElement>(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 ( + <div + className="library-menu-items-container" + style={ + pendingElements.length || + unpublishedItems.length || + publishedItems.length + ? { justifyContent: "flex-start" } + : { borderBottom: 0 } + } + > + {!isLibraryEmpty && ( + <LibraryDropdownMenu + selectedItems={selectedItems} + onSelectItems={onSelectItems} + className="library-menu-dropdown-container--in-heading" + /> + )} + <Stack.Col + className="library-menu-items-container__items" + align="start" + gap={1} + style={{ + flex: publishedItems.length > 0 ? 1 : "0 1 auto", + marginBottom: 0, + }} + ref={libraryContainerRef} + > + <> + {!isLibraryEmpty && ( + <div className="library-menu-items-container__header"> + {t("labels.personalLib")} + </div> + )} + {isLoading && ( + <div + style={{ + position: "absolute", + top: "var(--container-padding-y)", + right: "var(--container-padding-x)", + transform: "translateY(50%)", + }} + > + <Spinner /> + </div> + )} + {!pendingElements.length && !unpublishedItems.length ? ( + <div className="library-menu-items__no-items"> + <div className="library-menu-items__no-items__label"> + {t("library.noItems")} + </div> + <div className="library-menu-items__no-items__hint"> + {publishedItems.length > 0 + ? t("library.hint_emptyPrivateLibrary") + : t("library.hint_emptyLibrary")} + </div> + </div> + ) : ( + <LibraryMenuSectionGrid> + {pendingElements.length > 0 && ( + <LibraryMenuSection + itemsRenderedPerBatch={itemsRenderedPerBatch} + items={[{ id: null, elements: pendingElements }]} + onItemSelectToggle={onItemSelectToggle} + onItemDrag={onItemDrag} + onClick={onAddToLibraryClick} + isItemSelected={isItemSelected} + svgCache={svgCache} + /> + )} + <LibraryMenuSection + itemsRenderedPerBatch={itemsRenderedPerBatch} + items={unpublishedItems} + onItemSelectToggle={onItemSelectToggle} + onItemDrag={onItemDrag} + onClick={onItemClick} + isItemSelected={isItemSelected} + svgCache={svgCache} + /> + </LibraryMenuSectionGrid> + )} + </> + + <> + {(publishedItems.length > 0 || + pendingElements.length > 0 || + unpublishedItems.length > 0) && ( + <div className="library-menu-items-container__header library-menu-items-container__header--excal"> + {t("labels.excalidrawLib")} + </div> + )} + {publishedItems.length > 0 ? ( + <LibraryMenuSectionGrid> + <LibraryMenuSection + itemsRenderedPerBatch={itemsRenderedPerBatch} + items={publishedItems} + onItemSelectToggle={onItemSelectToggle} + onItemDrag={onItemDrag} + onClick={onItemClick} + isItemSelected={isItemSelected} + svgCache={svgCache} + /> + </LibraryMenuSectionGrid> + ) : unpublishedItems.length > 0 ? ( + <div + style={{ + margin: "1rem 0", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + width: "100%", + fontSize: ".9rem", + }} + > + {t("library.noItems")} + </div> + ) : null} + </> + + {showBtn && ( + <LibraryMenuControlButtons + style={{ padding: "16px 0", width: "100%" }} + id={id} + libraryReturnUrl={libraryReturnUrl} + theme={theme} + > + <LibraryDropdownMenu + selectedItems={selectedItems} + onSelectItems={onSelectItems} + /> + </LibraryMenuControlButtons> + )} + </Stack.Col> + </div> + ); +} |
