mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 12:54:23 +01:00 
			
		
		
		
	perf: memoize rendering of library (#6622)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
		@@ -1,4 +1,4 @@
 | 
			
		||||
import React, { useCallback } from "react";
 | 
			
		||||
import React, { useState, useCallback, useMemo, useRef } from "react";
 | 
			
		||||
import Library, {
 | 
			
		||||
  distributeLibraryItemsOnSquareGrid,
 | 
			
		||||
  libraryItemsAtom,
 | 
			
		||||
@@ -27,6 +27,8 @@ import { useUIAppState } from "../context/ui-appState";
 | 
			
		||||
 | 
			
		||||
import "./LibraryMenu.scss";
 | 
			
		||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 | 
			
		||||
import { isShallowEqual } from "../utils";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
 | 
			
		||||
export const isLibraryMenuOpenAtom = atom(false);
 | 
			
		||||
 | 
			
		||||
@@ -42,7 +44,9 @@ export const LibraryMenuContent = ({
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
  appState,
 | 
			
		||||
  theme,
 | 
			
		||||
  selectedItems,
 | 
			
		||||
  onSelectItems,
 | 
			
		||||
}: {
 | 
			
		||||
  pendingElements: LibraryItem["elements"];
 | 
			
		||||
  onInsertLibraryItems: (libraryItems: LibraryItems) => void;
 | 
			
		||||
@@ -51,33 +55,47 @@ export const LibraryMenuContent = ({
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
  appState: UIAppState;
 | 
			
		||||
  theme: UIAppState["theme"];
 | 
			
		||||
  selectedItems: LibraryItem["id"][];
 | 
			
		||||
  onSelectItems: (id: LibraryItem["id"][]) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
 | 
			
		||||
 | 
			
		||||
  const addToLibrary = useCallback(
 | 
			
		||||
    async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
 | 
			
		||||
      trackEvent("element", "addToLibrary", "ui");
 | 
			
		||||
      if (elements.some((element) => element.type === "image")) {
 | 
			
		||||
        return setAppState({
 | 
			
		||||
          errorMessage: "Support for adding images to the library coming soon!",
 | 
			
		||||
  const _onAddToLibrary = useCallback(
 | 
			
		||||
    (elements: LibraryItem["elements"]) => {
 | 
			
		||||
      const addToLibrary = async (
 | 
			
		||||
        processedElements: LibraryItem["elements"],
 | 
			
		||||
        libraryItems: LibraryItems,
 | 
			
		||||
      ) => {
 | 
			
		||||
        trackEvent("element", "addToLibrary", "ui");
 | 
			
		||||
        if (processedElements.some((element) => element.type === "image")) {
 | 
			
		||||
          return setAppState({
 | 
			
		||||
            errorMessage:
 | 
			
		||||
              "Support for adding images to the library coming soon!",
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
        const nextItems: LibraryItems = [
 | 
			
		||||
          {
 | 
			
		||||
            status: "unpublished",
 | 
			
		||||
            elements: processedElements,
 | 
			
		||||
            id: randomId(),
 | 
			
		||||
            created: Date.now(),
 | 
			
		||||
          },
 | 
			
		||||
          ...libraryItems,
 | 
			
		||||
        ];
 | 
			
		||||
        onAddToLibrary();
 | 
			
		||||
        library.setLibrary(nextItems).catch(() => {
 | 
			
		||||
          setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      const nextItems: LibraryItems = [
 | 
			
		||||
        {
 | 
			
		||||
          status: "unpublished",
 | 
			
		||||
          elements,
 | 
			
		||||
          id: randomId(),
 | 
			
		||||
          created: Date.now(),
 | 
			
		||||
        },
 | 
			
		||||
        ...libraryItems,
 | 
			
		||||
      ];
 | 
			
		||||
      onAddToLibrary();
 | 
			
		||||
      library.setLibrary(nextItems).catch(() => {
 | 
			
		||||
        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
 | 
			
		||||
      });
 | 
			
		||||
      };
 | 
			
		||||
      addToLibrary(elements, libraryItemsData.libraryItems);
 | 
			
		||||
    },
 | 
			
		||||
    [onAddToLibrary, library, setAppState],
 | 
			
		||||
    [onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const libraryItems = useMemo(
 | 
			
		||||
    () => libraryItemsData.libraryItems,
 | 
			
		||||
    [libraryItemsData],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (
 | 
			
		||||
@@ -103,15 +121,15 @@ export const LibraryMenuContent = ({
 | 
			
		||||
    <LibraryMenuWrapper>
 | 
			
		||||
      <LibraryMenuItems
 | 
			
		||||
        isLoading={libraryItemsData.status === "loading"}
 | 
			
		||||
        libraryItems={libraryItemsData.libraryItems}
 | 
			
		||||
        onAddToLibrary={(elements) =>
 | 
			
		||||
          addToLibrary(elements, libraryItemsData.libraryItems)
 | 
			
		||||
        }
 | 
			
		||||
        libraryItems={libraryItems}
 | 
			
		||||
        onAddToLibrary={_onAddToLibrary}
 | 
			
		||||
        onInsertLibraryItems={onInsertLibraryItems}
 | 
			
		||||
        pendingElements={pendingElements}
 | 
			
		||||
        id={id}
 | 
			
		||||
        libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
        theme={appState.theme}
 | 
			
		||||
        theme={theme}
 | 
			
		||||
        onSelectItems={onSelectItems}
 | 
			
		||||
        selectedItems={selectedItems}
 | 
			
		||||
      />
 | 
			
		||||
      {showBtn && (
 | 
			
		||||
        <LibraryMenuControlButtons
 | 
			
		||||
@@ -119,13 +137,36 @@ export const LibraryMenuContent = ({
 | 
			
		||||
          style={{ padding: "16px 12px 0 12px" }}
 | 
			
		||||
          id={id}
 | 
			
		||||
          libraryReturnUrl={libraryReturnUrl}
 | 
			
		||||
          theme={appState.theme}
 | 
			
		||||
          theme={theme}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </LibraryMenuWrapper>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const usePendingElementsMemo = (
 | 
			
		||||
  appState: UIAppState,
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
) => {
 | 
			
		||||
  const create = () => getSelectedElements(elements, appState, true);
 | 
			
		||||
  const val = useRef(create());
 | 
			
		||||
  const prevAppState = useRef<UIAppState>(appState);
 | 
			
		||||
  const prevElements = useRef(elements);
 | 
			
		||||
 | 
			
		||||
  if (
 | 
			
		||||
    !isShallowEqual(
 | 
			
		||||
      appState.selectedElementIds,
 | 
			
		||||
      prevAppState.current.selectedElementIds,
 | 
			
		||||
    ) ||
 | 
			
		||||
    !isShallowEqual(elements, prevElements.current)
 | 
			
		||||
  ) {
 | 
			
		||||
    val.current = create();
 | 
			
		||||
    prevAppState.current = appState;
 | 
			
		||||
    prevElements.current = elements;
 | 
			
		||||
  }
 | 
			
		||||
  return val.current;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This component is meant to be rendered inside <Sidebar.Tab/> inside our
 | 
			
		||||
 * <DefaultSidebar/> or host apps Sidebar components.
 | 
			
		||||
@@ -136,9 +177,19 @@ export const LibraryMenu = () => {
 | 
			
		||||
  const appState = useUIAppState();
 | 
			
		||||
  const setAppState = useExcalidrawSetAppState();
 | 
			
		||||
  const elements = useExcalidrawElements();
 | 
			
		||||
  const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
 | 
			
		||||
  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 onAddToLibrary = useCallback(() => {
 | 
			
		||||
    // deselect canvas elements
 | 
			
		||||
  const onInsertLibraryItems = useCallback(
 | 
			
		||||
    (libraryItems: LibraryItems) => {
 | 
			
		||||
      onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
 | 
			
		||||
    },
 | 
			
		||||
    [onInsertElements],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const deselectItems = useCallback(() => {
 | 
			
		||||
    setAppState({
 | 
			
		||||
      selectedElementIds: {},
 | 
			
		||||
      selectedGroupIds: {},
 | 
			
		||||
@@ -147,16 +198,16 @@ export const LibraryMenu = () => {
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <LibraryMenuContent
 | 
			
		||||
      pendingElements={getSelectedElements(elements, appState, true)}
 | 
			
		||||
      onInsertLibraryItems={(libraryItems) => {
 | 
			
		||||
        onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
 | 
			
		||||
      }}
 | 
			
		||||
      onAddToLibrary={onAddToLibrary}
 | 
			
		||||
      pendingElements={pendingElements}
 | 
			
		||||
      onInsertLibraryItems={onInsertLibraryItems}
 | 
			
		||||
      onAddToLibrary={deselectItems}
 | 
			
		||||
      setAppState={setAppState}
 | 
			
		||||
      libraryReturnUrl={appProps.libraryReturnUrl}
 | 
			
		||||
      library={library}
 | 
			
		||||
      library={memoizedLibrary}
 | 
			
		||||
      id={id}
 | 
			
		||||
      appState={appState}
 | 
			
		||||
      theme={appState.theme}
 | 
			
		||||
      selectedItems={selectedItems}
 | 
			
		||||
      onSelectItems={setSelectedItems}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -73,6 +73,12 @@
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__grid {
 | 
			
		||||
      display: grid;
 | 
			
		||||
      grid-template-columns: 1fr 1fr 1fr 1fr;
 | 
			
		||||
      grid-gap: 1rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .separator {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      display: flex;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,10 @@
 | 
			
		||||
import React, { useCallback, useEffect, useRef, useState } from "react";
 | 
			
		||||
import React, {
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useRef,
 | 
			
		||||
  useState,
 | 
			
		||||
} from "react";
 | 
			
		||||
import { serializeLibraryAsJSON } from "../data/json";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import {
 | 
			
		||||
@@ -14,12 +20,22 @@ import Spinner from "./Spinner";
 | 
			
		||||
import { duplicateElements } from "../element/newElement";
 | 
			
		||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 | 
			
		||||
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
 | 
			
		||||
import LibraryMenuSection from "./LibraryMenuSection";
 | 
			
		||||
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,
 | 
			
		||||
@@ -29,6 +45,8 @@ export default function LibraryMenuItems({
 | 
			
		||||
  theme,
 | 
			
		||||
  id,
 | 
			
		||||
  libraryReturnUrl,
 | 
			
		||||
  onSelectItems,
 | 
			
		||||
  selectedItems,
 | 
			
		||||
}: {
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
  libraryItems: LibraryItems;
 | 
			
		||||
@@ -38,8 +56,9 @@ export default function LibraryMenuItems({
 | 
			
		||||
  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
 | 
			
		||||
  theme: UIAppState["theme"];
 | 
			
		||||
  id: string;
 | 
			
		||||
  selectedItems: LibraryItem["id"][];
 | 
			
		||||
  onSelectItems: (id: LibraryItem["id"][]) => void;
 | 
			
		||||
}) {
 | 
			
		||||
  const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
 | 
			
		||||
  const libraryContainerRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
 | 
			
		||||
 | 
			
		||||
@@ -49,13 +68,16 @@ export default function LibraryMenuItems({
 | 
			
		||||
      libraryContainerRef.current?.scrollTo(0, scrollPosition);
 | 
			
		||||
    }
 | 
			
		||||
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
 | 
			
		||||
  const { svgCache } = useLibraryCache();
 | 
			
		||||
 | 
			
		||||
  const unpublishedItems = libraryItems.filter(
 | 
			
		||||
    (item) => item.status !== "published",
 | 
			
		||||
  const { svgCache } = useLibraryCache();
 | 
			
		||||
  const unpublishedItems = useMemo(
 | 
			
		||||
    () => libraryItems.filter((item) => item.status !== "published"),
 | 
			
		||||
    [libraryItems],
 | 
			
		||||
  );
 | 
			
		||||
  const publishedItems = libraryItems.filter(
 | 
			
		||||
    (item) => item.status === "published",
 | 
			
		||||
 | 
			
		||||
  const publishedItems = useMemo(
 | 
			
		||||
    () => libraryItems.filter((item) => item.status === "published"),
 | 
			
		||||
    [libraryItems],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const showBtn = !libraryItems.length && !pendingElements.length;
 | 
			
		||||
@@ -69,50 +91,56 @@ export default function LibraryMenuItems({
 | 
			
		||||
    LibraryItem["id"] | null
 | 
			
		||||
  >(null);
 | 
			
		||||
 | 
			
		||||
  const onItemSelectToggle = (
 | 
			
		||||
    id: LibraryItem["id"],
 | 
			
		||||
    event: React.MouseEvent,
 | 
			
		||||
  ) => {
 | 
			
		||||
    const shouldSelect = !selectedItems.includes(id);
 | 
			
		||||
  const onItemSelectToggle = useCallback(
 | 
			
		||||
    (id: LibraryItem["id"], event: React.MouseEvent) => {
 | 
			
		||||
      const shouldSelect = !selectedItems.includes(id);
 | 
			
		||||
 | 
			
		||||
    const orderedItems = [...unpublishedItems, ...publishedItems];
 | 
			
		||||
      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 (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) {
 | 
			
		||||
          setSelectedItems([...selectedItems, id]);
 | 
			
		||||
          return;
 | 
			
		||||
          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]);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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;
 | 
			
		||||
          },
 | 
			
		||||
          [],
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        setSelectedItems(nextSelectedIds);
 | 
			
		||||
        setLastSelectedItem(id);
 | 
			
		||||
      } else {
 | 
			
		||||
        setSelectedItems([...selectedItems, id]);
 | 
			
		||||
        setLastSelectedItem(null);
 | 
			
		||||
        onSelectItems(selectedItems.filter((_id) => _id !== id));
 | 
			
		||||
      }
 | 
			
		||||
      setLastSelectedItem(id);
 | 
			
		||||
    } else {
 | 
			
		||||
      setLastSelectedItem(null);
 | 
			
		||||
      setSelectedItems(selectedItems.filter((_id) => _id !== id));
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
    },
 | 
			
		||||
    [
 | 
			
		||||
      lastSelectedItem,
 | 
			
		||||
      onSelectItems,
 | 
			
		||||
      publishedItems,
 | 
			
		||||
      selectedItems,
 | 
			
		||||
      unpublishedItems,
 | 
			
		||||
    ],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const getInsertedElements = useCallback(
 | 
			
		||||
    (id: string) => {
 | 
			
		||||
@@ -136,37 +164,45 @@ export default function LibraryMenuItems({
 | 
			
		||||
    [libraryItems, selectedItems],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const onItemDrag = (id: LibraryItem["id"], event: React.DragEvent) => {
 | 
			
		||||
    event.dataTransfer.setData(
 | 
			
		||||
      MIME_TYPES.excalidrawlib,
 | 
			
		||||
      serializeLibraryAsJSON(getInsertedElements(id)),
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
  const onItemDrag = useCallback(
 | 
			
		||||
    (id: LibraryItem["id"], event: React.DragEvent) => {
 | 
			
		||||
      event.dataTransfer.setData(
 | 
			
		||||
        MIME_TYPES.excalidrawlib,
 | 
			
		||||
        serializeLibraryAsJSON(getInsertedElements(id)),
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    [getInsertedElements],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const isItemSelected = (id: LibraryItem["id"] | null) => {
 | 
			
		||||
    if (!id) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  const isItemSelected = useCallback(
 | 
			
		||||
    (id: LibraryItem["id"] | null) => {
 | 
			
		||||
      if (!id) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    return selectedItems.includes(id);
 | 
			
		||||
  };
 | 
			
		||||
      return selectedItems.includes(id);
 | 
			
		||||
    },
 | 
			
		||||
    [selectedItems],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const onAddToLibraryClick = useCallback(() => {
 | 
			
		||||
    onAddToLibrary(pendingElements);
 | 
			
		||||
  }, [pendingElements, onAddToLibrary]);
 | 
			
		||||
 | 
			
		||||
  const onItemClick = useCallback(
 | 
			
		||||
    (id: LibraryItem["id"] | null) => {
 | 
			
		||||
      if (!id) {
 | 
			
		||||
        onAddToLibrary(pendingElements);
 | 
			
		||||
      } else {
 | 
			
		||||
      if (id) {
 | 
			
		||||
        onInsertLibraryItems(getInsertedElements(id));
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    [
 | 
			
		||||
      getInsertedElements,
 | 
			
		||||
      onAddToLibrary,
 | 
			
		||||
      onInsertLibraryItems,
 | 
			
		||||
      pendingElements,
 | 
			
		||||
    ],
 | 
			
		||||
    [getInsertedElements, onInsertLibraryItems],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const itemsRenderedPerBatch =
 | 
			
		||||
    svgCache.size >= libraryItems.length
 | 
			
		||||
      ? CACHED_ITEMS_RENDERED_PER_BATCH
 | 
			
		||||
      : ITEMS_RENDERED_PER_BATCH;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className="library-menu-items-container"
 | 
			
		||||
@@ -181,7 +217,7 @@ export default function LibraryMenuItems({
 | 
			
		||||
      {!isLibraryEmpty && (
 | 
			
		||||
        <LibraryDropdownMenu
 | 
			
		||||
          selectedItems={selectedItems}
 | 
			
		||||
          onSelectItems={setSelectedItems}
 | 
			
		||||
          onSelectItems={onSelectItems}
 | 
			
		||||
          className="library-menu-dropdown-container--in-heading"
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
@@ -225,20 +261,28 @@ export default function LibraryMenuItems({
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <LibraryMenuSection
 | 
			
		||||
              items={[
 | 
			
		||||
                // append pending library item
 | 
			
		||||
                ...(pendingElements.length
 | 
			
		||||
                  ? [{ id: null, elements: pendingElements }]
 | 
			
		||||
                  : []),
 | 
			
		||||
                ...unpublishedItems,
 | 
			
		||||
              ]}
 | 
			
		||||
              onItemSelectToggle={onItemSelectToggle}
 | 
			
		||||
              onItemDrag={onItemDrag}
 | 
			
		||||
              onClick={onItemClick}
 | 
			
		||||
              isItemSelected={isItemSelected}
 | 
			
		||||
              svgCache={svgCache}
 | 
			
		||||
            />
 | 
			
		||||
            <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>
 | 
			
		||||
          )}
 | 
			
		||||
        </>
 | 
			
		||||
 | 
			
		||||
@@ -251,14 +295,17 @@ export default function LibraryMenuItems({
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
          {publishedItems.length > 0 ? (
 | 
			
		||||
            <LibraryMenuSection
 | 
			
		||||
              items={publishedItems}
 | 
			
		||||
              onItemSelectToggle={onItemSelectToggle}
 | 
			
		||||
              onItemDrag={onItemDrag}
 | 
			
		||||
              onClick={onItemClick}
 | 
			
		||||
              isItemSelected={isItemSelected}
 | 
			
		||||
              svgCache={svgCache}
 | 
			
		||||
            />
 | 
			
		||||
            <LibraryMenuSectionGrid>
 | 
			
		||||
              <LibraryMenuSection
 | 
			
		||||
                itemsRenderedPerBatch={itemsRenderedPerBatch}
 | 
			
		||||
                items={publishedItems}
 | 
			
		||||
                onItemSelectToggle={onItemSelectToggle}
 | 
			
		||||
                onItemDrag={onItemDrag}
 | 
			
		||||
                onClick={onItemClick}
 | 
			
		||||
                isItemSelected={isItemSelected}
 | 
			
		||||
                svgCache={svgCache}
 | 
			
		||||
              />
 | 
			
		||||
            </LibraryMenuSectionGrid>
 | 
			
		||||
          ) : unpublishedItems.length > 0 ? (
 | 
			
		||||
            <div
 | 
			
		||||
              style={{
 | 
			
		||||
@@ -285,7 +332,7 @@ export default function LibraryMenuItems({
 | 
			
		||||
          >
 | 
			
		||||
            <LibraryDropdownMenu
 | 
			
		||||
              selectedItems={selectedItems}
 | 
			
		||||
              onSelectItems={setSelectedItems}
 | 
			
		||||
              onSelectItems={onSelectItems}
 | 
			
		||||
            />
 | 
			
		||||
          </LibraryMenuControlButtons>
 | 
			
		||||
        )}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,10 @@
 | 
			
		||||
import React, { useEffect, useMemo, useState } from "react";
 | 
			
		||||
import { LibraryUnit } from "./LibraryUnit";
 | 
			
		||||
import React, { memo, ReactNode, useEffect, useState } from "react";
 | 
			
		||||
import { EmptyLibraryUnit, LibraryUnit } from "./LibraryUnit";
 | 
			
		||||
import { LibraryItem } from "../types";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
 | 
			
		||||
import { SvgCache } from "../hooks/useLibraryItemSvg";
 | 
			
		||||
import { useTransition } from "../hooks/useTransition";
 | 
			
		||||
 | 
			
		||||
const ITEMS_PER_ROW = 4;
 | 
			
		||||
const ROWS_RENDERED_PER_BATCH = 6;
 | 
			
		||||
const CACHED_ROWS_RENDERED_PER_BATCH = 16;
 | 
			
		||||
 | 
			
		||||
type LibraryOrPendingItem = (
 | 
			
		||||
  | LibraryItem
 | 
			
		||||
  | /* pending library item */ {
 | 
			
		||||
@@ -26,91 +20,58 @@ interface Props {
 | 
			
		||||
  onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void;
 | 
			
		||||
  isItemSelected: (id: LibraryItem["id"] | null) => boolean;
 | 
			
		||||
  svgCache: SvgCache;
 | 
			
		||||
  itemsRenderedPerBatch: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function LibraryRow({
 | 
			
		||||
  items,
 | 
			
		||||
  onItemSelectToggle,
 | 
			
		||||
  onItemDrag,
 | 
			
		||||
  isItemSelected,
 | 
			
		||||
  onClick,
 | 
			
		||||
  svgCache,
 | 
			
		||||
}: Props) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Stack.Row className="library-menu-items-container__row">
 | 
			
		||||
      {items.map((item) => (
 | 
			
		||||
        <Stack.Col key={item.id}>
 | 
			
		||||
          <LibraryUnit
 | 
			
		||||
            elements={item?.elements}
 | 
			
		||||
            isPending={!item?.id && !!item?.elements}
 | 
			
		||||
            onClick={onClick}
 | 
			
		||||
            id={item?.id || null}
 | 
			
		||||
            selected={isItemSelected(item.id)}
 | 
			
		||||
            onToggle={onItemSelectToggle}
 | 
			
		||||
            onDrag={onItemDrag}
 | 
			
		||||
            svgCache={svgCache}
 | 
			
		||||
          />
 | 
			
		||||
        </Stack.Col>
 | 
			
		||||
      ))}
 | 
			
		||||
    </Stack.Row>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
export const LibraryMenuSectionGrid = ({
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
}) => {
 | 
			
		||||
  return <div className="library-menu-items-container__grid">{children}</div>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const EmptyLibraryRow = () => (
 | 
			
		||||
  <Stack.Row className="library-menu-items-container__row" gap={1}>
 | 
			
		||||
    {Array.from({ length: ITEMS_PER_ROW }).map((_, index) => (
 | 
			
		||||
      <Stack.Col key={index}>
 | 
			
		||||
        <div className={clsx("library-unit", "library-unit--skeleton")} />
 | 
			
		||||
      </Stack.Col>
 | 
			
		||||
    ))}
 | 
			
		||||
  </Stack.Row>
 | 
			
		||||
export const LibraryMenuSection = memo(
 | 
			
		||||
  ({
 | 
			
		||||
    items,
 | 
			
		||||
    onItemSelectToggle,
 | 
			
		||||
    onItemDrag,
 | 
			
		||||
    isItemSelected,
 | 
			
		||||
    onClick,
 | 
			
		||||
    svgCache,
 | 
			
		||||
    itemsRenderedPerBatch,
 | 
			
		||||
  }: Props) => {
 | 
			
		||||
    const [, startTransition] = useTransition();
 | 
			
		||||
    const [index, setIndex] = useState(0);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      if (index < items.length) {
 | 
			
		||||
        startTransition(() => {
 | 
			
		||||
          setIndex(index + itemsRenderedPerBatch);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }, [index, items.length, startTransition, itemsRenderedPerBatch]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        {items.map((item, i) => {
 | 
			
		||||
          return i < index ? (
 | 
			
		||||
            <LibraryUnit
 | 
			
		||||
              elements={item?.elements}
 | 
			
		||||
              isPending={!item?.id && !!item?.elements}
 | 
			
		||||
              onClick={onClick}
 | 
			
		||||
              svgCache={svgCache}
 | 
			
		||||
              id={item?.id}
 | 
			
		||||
              selected={isItemSelected(item.id)}
 | 
			
		||||
              onToggle={onItemSelectToggle}
 | 
			
		||||
              onDrag={onItemDrag}
 | 
			
		||||
              key={item?.id ?? i}
 | 
			
		||||
            />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <EmptyLibraryUnit key={i} />
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
      </>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function LibraryMenuSection({
 | 
			
		||||
  items,
 | 
			
		||||
  onItemSelectToggle,
 | 
			
		||||
  onItemDrag,
 | 
			
		||||
  isItemSelected,
 | 
			
		||||
  onClick,
 | 
			
		||||
  svgCache,
 | 
			
		||||
}: Props) {
 | 
			
		||||
  const rows = Math.ceil(items.length / ITEMS_PER_ROW);
 | 
			
		||||
  const [, startTransition] = useTransition();
 | 
			
		||||
  const [index, setIndex] = useState(0);
 | 
			
		||||
 | 
			
		||||
  const rowsRenderedPerBatch = useMemo(() => {
 | 
			
		||||
    return svgCache.size === 0
 | 
			
		||||
      ? ROWS_RENDERED_PER_BATCH
 | 
			
		||||
      : CACHED_ROWS_RENDERED_PER_BATCH;
 | 
			
		||||
  }, [svgCache]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (index < rows) {
 | 
			
		||||
      startTransition(() => {
 | 
			
		||||
        setIndex(index + rowsRenderedPerBatch);
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }, [index, rows, startTransition, rowsRenderedPerBatch]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {Array.from({ length: rows }).map((_, i) =>
 | 
			
		||||
        i < index ? (
 | 
			
		||||
          <LibraryRow
 | 
			
		||||
            key={i}
 | 
			
		||||
            items={items.slice(i * ITEMS_PER_ROW, (i + 1) * ITEMS_PER_ROW)}
 | 
			
		||||
            onItemSelectToggle={onItemSelectToggle}
 | 
			
		||||
            onItemDrag={onItemDrag}
 | 
			
		||||
            onClick={onClick}
 | 
			
		||||
            isItemSelected={isItemSelected}
 | 
			
		||||
            svgCache={svgCache}
 | 
			
		||||
          />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <EmptyLibraryRow key={i} />
 | 
			
		||||
        ),
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default LibraryMenuSection;
 | 
			
		||||
 
 | 
			
		||||
@@ -30,7 +30,7 @@
 | 
			
		||||
        var(--color-gray-10)
 | 
			
		||||
      );
 | 
			
		||||
      background-size: 200% 200%;
 | 
			
		||||
      animation: library-unit__skeleton-opacity-animation 0.3s linear;
 | 
			
		||||
      animation: library-unit__skeleton-opacity-animation 0.2s linear;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { memo, useEffect, useRef, useState } from "react";
 | 
			
		||||
import { useDevice } from "../components/App";
 | 
			
		||||
import { LibraryItem } from "../types";
 | 
			
		||||
import "./LibraryUnit.scss";
 | 
			
		||||
@@ -7,96 +7,101 @@ import { CheckboxItem } from "./CheckboxItem";
 | 
			
		||||
import { PlusIcon } from "./icons";
 | 
			
		||||
import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
 | 
			
		||||
 | 
			
		||||
export const LibraryUnit = ({
 | 
			
		||||
  id,
 | 
			
		||||
  elements,
 | 
			
		||||
  isPending,
 | 
			
		||||
  onClick,
 | 
			
		||||
  selected,
 | 
			
		||||
  onToggle,
 | 
			
		||||
  onDrag,
 | 
			
		||||
  svgCache,
 | 
			
		||||
}: {
 | 
			
		||||
  id: LibraryItem["id"] | /** for pending item */ null;
 | 
			
		||||
  elements?: LibraryItem["elements"];
 | 
			
		||||
  isPending?: boolean;
 | 
			
		||||
  onClick: (id: LibraryItem["id"] | null) => void;
 | 
			
		||||
  selected: boolean;
 | 
			
		||||
  onToggle: (id: string, event: React.MouseEvent) => void;
 | 
			
		||||
  onDrag: (id: string, event: React.DragEvent) => void;
 | 
			
		||||
  svgCache: SvgCache;
 | 
			
		||||
}) => {
 | 
			
		||||
  const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  const svg = useLibraryItemSvg(id, elements, svgCache);
 | 
			
		||||
export const LibraryUnit = memo(
 | 
			
		||||
  ({
 | 
			
		||||
    id,
 | 
			
		||||
    elements,
 | 
			
		||||
    isPending,
 | 
			
		||||
    onClick,
 | 
			
		||||
    selected,
 | 
			
		||||
    onToggle,
 | 
			
		||||
    onDrag,
 | 
			
		||||
    svgCache,
 | 
			
		||||
  }: {
 | 
			
		||||
    id: LibraryItem["id"] | /** for pending item */ null;
 | 
			
		||||
    elements?: LibraryItem["elements"];
 | 
			
		||||
    isPending?: boolean;
 | 
			
		||||
    onClick: (id: LibraryItem["id"] | null) => void;
 | 
			
		||||
    selected: boolean;
 | 
			
		||||
    onToggle: (id: string, event: React.MouseEvent) => void;
 | 
			
		||||
    onDrag: (id: string, event: React.DragEvent) => void;
 | 
			
		||||
    svgCache: SvgCache;
 | 
			
		||||
  }) => {
 | 
			
		||||
    const ref = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
    const svg = useLibraryItemSvg(id, elements, svgCache);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const node = ref.current;
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      const node = ref.current;
 | 
			
		||||
 | 
			
		||||
    if (!node) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
      if (!node) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    if (svg) {
 | 
			
		||||
      svg.querySelector(".style-fonts")?.remove();
 | 
			
		||||
      node.innerHTML = svg.outerHTML;
 | 
			
		||||
    }
 | 
			
		||||
      if (svg) {
 | 
			
		||||
        node.innerHTML = svg.outerHTML;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      node.innerHTML = "";
 | 
			
		||||
    };
 | 
			
		||||
  }, [elements, svg]);
 | 
			
		||||
      return () => {
 | 
			
		||||
        node.innerHTML = "";
 | 
			
		||||
      };
 | 
			
		||||
    }, [svg]);
 | 
			
		||||
 | 
			
		||||
  const [isHovered, setIsHovered] = useState(false);
 | 
			
		||||
  const isMobile = useDevice().isMobile;
 | 
			
		||||
  const adder = isPending && (
 | 
			
		||||
    <div className="library-unit__adder">{PlusIcon}</div>
 | 
			
		||||
  );
 | 
			
		||||
    const [isHovered, setIsHovered] = useState(false);
 | 
			
		||||
    const isMobile = useDevice().isMobile;
 | 
			
		||||
    const adder = isPending && (
 | 
			
		||||
      <div className="library-unit__adder">{PlusIcon}</div>
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsx("library-unit", {
 | 
			
		||||
        "library-unit__active": elements,
 | 
			
		||||
        "library-unit--hover": elements && isHovered,
 | 
			
		||||
        "library-unit--selected": selected,
 | 
			
		||||
        "library-unit--skeleton": !svg,
 | 
			
		||||
      })}
 | 
			
		||||
      onMouseEnter={() => setIsHovered(true)}
 | 
			
		||||
      onMouseLeave={() => setIsHovered(false)}
 | 
			
		||||
    >
 | 
			
		||||
    return (
 | 
			
		||||
      <div
 | 
			
		||||
        className={clsx("library-unit__dragger", {
 | 
			
		||||
          "library-unit__pulse": !!isPending,
 | 
			
		||||
        className={clsx("library-unit", {
 | 
			
		||||
          "library-unit__active": elements,
 | 
			
		||||
          "library-unit--hover": elements && isHovered,
 | 
			
		||||
          "library-unit--selected": selected,
 | 
			
		||||
          "library-unit--skeleton": !svg,
 | 
			
		||||
        })}
 | 
			
		||||
        ref={ref}
 | 
			
		||||
        draggable={!!elements}
 | 
			
		||||
        onClick={
 | 
			
		||||
          !!elements || !!isPending
 | 
			
		||||
            ? (event) => {
 | 
			
		||||
                if (id && event.shiftKey) {
 | 
			
		||||
                  onToggle(id, event);
 | 
			
		||||
                } else {
 | 
			
		||||
                  onClick(id);
 | 
			
		||||
        onMouseEnter={() => setIsHovered(true)}
 | 
			
		||||
        onMouseLeave={() => setIsHovered(false)}
 | 
			
		||||
      >
 | 
			
		||||
        <div
 | 
			
		||||
          className={clsx("library-unit__dragger", {
 | 
			
		||||
            "library-unit__pulse": !!isPending,
 | 
			
		||||
          })}
 | 
			
		||||
          ref={ref}
 | 
			
		||||
          draggable={!!elements}
 | 
			
		||||
          onClick={
 | 
			
		||||
            !!elements || !!isPending
 | 
			
		||||
              ? (event) => {
 | 
			
		||||
                  if (id && event.shiftKey) {
 | 
			
		||||
                    onToggle(id, event);
 | 
			
		||||
                  } else {
 | 
			
		||||
                    onClick(id);
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            : undefined
 | 
			
		||||
        }
 | 
			
		||||
        onDragStart={(event) => {
 | 
			
		||||
          if (!id) {
 | 
			
		||||
            event.preventDefault();
 | 
			
		||||
            return;
 | 
			
		||||
              : undefined
 | 
			
		||||
          }
 | 
			
		||||
          setIsHovered(false);
 | 
			
		||||
          onDrag(id, event);
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      {adder}
 | 
			
		||||
      {id && elements && (isHovered || isMobile || selected) && (
 | 
			
		||||
        <CheckboxItem
 | 
			
		||||
          checked={selected}
 | 
			
		||||
          onChange={(checked, event) => onToggle(id, event)}
 | 
			
		||||
          className="library-unit__checkbox"
 | 
			
		||||
          onDragStart={(event) => {
 | 
			
		||||
            if (!id) {
 | 
			
		||||
              event.preventDefault();
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
            setIsHovered(false);
 | 
			
		||||
            onDrag(id, event);
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
        {adder}
 | 
			
		||||
        {id && elements && (isHovered || isMobile || selected) && (
 | 
			
		||||
          <CheckboxItem
 | 
			
		||||
            checked={selected}
 | 
			
		||||
            onChange={(checked, event) => onToggle(id, event)}
 | 
			
		||||
            className="library-unit__checkbox"
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const EmptyLibraryUnit = () => (
 | 
			
		||||
  <div className="library-unit library-unit--skeleton" />
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user