summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/LibraryMenuItems.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/components/LibraryMenuItems.tsx')
-rw-r--r--packages/excalidraw/components/LibraryMenuItems.tsx342
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>
+ );
+}