Browse Source

perf: memoize rendering of library (#6622)

Co-authored-by: dwelle <[email protected]>
Arnost Pleskot 2 years ago
parent
commit
253c5c7866

+ 90 - 39
src/components/LibraryMenu.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from "react";
+import React, { useState, useCallback, useMemo, useRef } from "react";
 import Library, {
 import Library, {
   distributeLibraryItemsOnSquareGrid,
   distributeLibraryItemsOnSquareGrid,
   libraryItemsAtom,
   libraryItemsAtom,
@@ -27,6 +27,8 @@ import { useUIAppState } from "../context/ui-appState";
 
 
 import "./LibraryMenu.scss";
 import "./LibraryMenu.scss";
 import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
+import { isShallowEqual } from "../utils";
+import { NonDeletedExcalidrawElement } from "../element/types";
 
 
 export const isLibraryMenuOpenAtom = atom(false);
 export const isLibraryMenuOpenAtom = atom(false);
 
 
@@ -42,7 +44,9 @@ export const LibraryMenuContent = ({
   libraryReturnUrl,
   libraryReturnUrl,
   library,
   library,
   id,
   id,
-  appState,
+  theme,
+  selectedItems,
+  onSelectItems,
 }: {
 }: {
   pendingElements: LibraryItem["elements"];
   pendingElements: LibraryItem["elements"];
   onInsertLibraryItems: (libraryItems: LibraryItems) => void;
   onInsertLibraryItems: (libraryItems: LibraryItems) => void;
@@ -51,33 +55,47 @@ export const LibraryMenuContent = ({
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   library: Library;
   library: Library;
   id: string;
   id: string;
-  appState: UIAppState;
+  theme: UIAppState["theme"];
+  selectedItems: LibraryItem["id"][];
+  onSelectItems: (id: LibraryItem["id"][]) => void;
 }) => {
 }) => {
   const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
   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 (
   if (
@@ -103,15 +121,15 @@ export const LibraryMenuContent = ({
     <LibraryMenuWrapper>
     <LibraryMenuWrapper>
       <LibraryMenuItems
       <LibraryMenuItems
         isLoading={libraryItemsData.status === "loading"}
         isLoading={libraryItemsData.status === "loading"}
-        libraryItems={libraryItemsData.libraryItems}
-        onAddToLibrary={(elements) =>
-          addToLibrary(elements, libraryItemsData.libraryItems)
-        }
+        libraryItems={libraryItems}
+        onAddToLibrary={_onAddToLibrary}
         onInsertLibraryItems={onInsertLibraryItems}
         onInsertLibraryItems={onInsertLibraryItems}
         pendingElements={pendingElements}
         pendingElements={pendingElements}
         id={id}
         id={id}
         libraryReturnUrl={libraryReturnUrl}
         libraryReturnUrl={libraryReturnUrl}
-        theme={appState.theme}
+        theme={theme}
+        onSelectItems={onSelectItems}
+        selectedItems={selectedItems}
       />
       />
       {showBtn && (
       {showBtn && (
         <LibraryMenuControlButtons
         <LibraryMenuControlButtons
@@ -119,13 +137,36 @@ export const LibraryMenuContent = ({
           style={{ padding: "16px 12px 0 12px" }}
           style={{ padding: "16px 12px 0 12px" }}
           id={id}
           id={id}
           libraryReturnUrl={libraryReturnUrl}
           libraryReturnUrl={libraryReturnUrl}
-          theme={appState.theme}
+          theme={theme}
         />
         />
       )}
       )}
     </LibraryMenuWrapper>
     </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
  * This component is meant to be rendered inside <Sidebar.Tab/> inside our
  * <DefaultSidebar/> or host apps Sidebar components.
  * <DefaultSidebar/> or host apps Sidebar components.
@@ -136,9 +177,19 @@ export const LibraryMenu = () => {
   const appState = useUIAppState();
   const appState = useUIAppState();
   const setAppState = useExcalidrawSetAppState();
   const setAppState = useExcalidrawSetAppState();
   const elements = useExcalidrawElements();
   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 onInsertLibraryItems = useCallback(
+    (libraryItems: LibraryItems) => {
+      onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
+    },
+    [onInsertElements],
+  );
 
 
-  const onAddToLibrary = useCallback(() => {
-    // deselect canvas elements
+  const deselectItems = useCallback(() => {
     setAppState({
     setAppState({
       selectedElementIds: {},
       selectedElementIds: {},
       selectedGroupIds: {},
       selectedGroupIds: {},
@@ -147,16 +198,16 @@ export const LibraryMenu = () => {
 
 
   return (
   return (
     <LibraryMenuContent
     <LibraryMenuContent
-      pendingElements={getSelectedElements(elements, appState, true)}
-      onInsertLibraryItems={(libraryItems) => {
-        onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
-      }}
-      onAddToLibrary={onAddToLibrary}
+      pendingElements={pendingElements}
+      onInsertLibraryItems={onInsertLibraryItems}
+      onAddToLibrary={deselectItems}
       setAppState={setAppState}
       setAppState={setAppState}
       libraryReturnUrl={appProps.libraryReturnUrl}
       libraryReturnUrl={appProps.libraryReturnUrl}
-      library={library}
+      library={memoizedLibrary}
       id={id}
       id={id}
-      appState={appState}
+      theme={appState.theme}
+      selectedItems={selectedItems}
+      onSelectItems={setSelectedItems}
     />
     />
   );
   );
 };
 };

+ 6 - 0
src/components/LibraryMenuItems.scss

@@ -73,6 +73,12 @@
       }
       }
     }
     }
 
 
+    &__grid {
+      display: grid;
+      grid-template-columns: 1fr 1fr 1fr 1fr;
+      grid-gap: 1rem;
+    }
+
     .separator {
     .separator {
       width: 100%;
       width: 100%;
       display: flex;
       display: flex;

+ 137 - 90
src/components/LibraryMenuItems.tsx

@@ -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 { serializeLibraryAsJSON } from "../data/json";
 import { t } from "../i18n";
 import { t } from "../i18n";
 import {
 import {
@@ -14,12 +20,22 @@ import Spinner from "./Spinner";
 import { duplicateElements } from "../element/newElement";
 import { duplicateElements } from "../element/newElement";
 import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
 import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
-import LibraryMenuSection from "./LibraryMenuSection";
+import {
+  LibraryMenuSection,
+  LibraryMenuSectionGrid,
+} from "./LibraryMenuSection";
 import { useScrollPosition } from "../hooks/useScrollPosition";
 import { useScrollPosition } from "../hooks/useScrollPosition";
 import { useLibraryCache } from "../hooks/useLibraryItemSvg";
 import { useLibraryCache } from "../hooks/useLibraryItemSvg";
 
 
 import "./LibraryMenuItems.scss";
 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({
 export default function LibraryMenuItems({
   isLoading,
   isLoading,
   libraryItems,
   libraryItems,
@@ -29,6 +45,8 @@ export default function LibraryMenuItems({
   theme,
   theme,
   id,
   id,
   libraryReturnUrl,
   libraryReturnUrl,
+  onSelectItems,
+  selectedItems,
 }: {
 }: {
   isLoading: boolean;
   isLoading: boolean;
   libraryItems: LibraryItems;
   libraryItems: LibraryItems;
@@ -38,8 +56,9 @@ export default function LibraryMenuItems({
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   theme: UIAppState["theme"];
   theme: UIAppState["theme"];
   id: string;
   id: string;
+  selectedItems: LibraryItem["id"][];
+  onSelectItems: (id: LibraryItem["id"][]) => void;
 }) {
 }) {
-  const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
   const libraryContainerRef = useRef<HTMLDivElement>(null);
   const libraryContainerRef = useRef<HTMLDivElement>(null);
   const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
   const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
 
 
@@ -49,13 +68,16 @@ export default function LibraryMenuItems({
       libraryContainerRef.current?.scrollTo(0, scrollPosition);
       libraryContainerRef.current?.scrollTo(0, scrollPosition);
     }
     }
   }, []); // eslint-disable-line react-hooks/exhaustive-deps
   }, []); // 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;
   const showBtn = !libraryItems.length && !pendingElements.length;
@@ -69,50 +91,56 @@ export default function LibraryMenuItems({
     LibraryItem["id"] | null
     LibraryItem["id"] | null
   >(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;
-          },
-          [],
-        );
+          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);
+          onSelectItems(nextSelectedIds);
+        } else {
+          onSelectItems([...selectedItems, id]);
+        }
+        setLastSelectedItem(id);
       } else {
       } 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(
   const getInsertedElements = useCallback(
     (id: string) => {
     (id: string) => {
@@ -136,37 +164,45 @@ export default function LibraryMenuItems({
     [libraryItems, selectedItems],
     [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 = useCallback(
+    (id: LibraryItem["id"] | null) => {
+      if (!id) {
+        return false;
+      }
 
 
-  const isItemSelected = (id: LibraryItem["id"] | null) => {
-    if (!id) {
-      return false;
-    }
+      return selectedItems.includes(id);
+    },
+    [selectedItems],
+  );
 
 
-    return selectedItems.includes(id);
-  };
+  const onAddToLibraryClick = useCallback(() => {
+    onAddToLibrary(pendingElements);
+  }, [pendingElements, onAddToLibrary]);
 
 
   const onItemClick = useCallback(
   const onItemClick = useCallback(
     (id: LibraryItem["id"] | null) => {
     (id: LibraryItem["id"] | null) => {
-      if (!id) {
-        onAddToLibrary(pendingElements);
-      } else {
+      if (id) {
         onInsertLibraryItems(getInsertedElements(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 (
   return (
     <div
     <div
       className="library-menu-items-container"
       className="library-menu-items-container"
@@ -181,7 +217,7 @@ export default function LibraryMenuItems({
       {!isLibraryEmpty && (
       {!isLibraryEmpty && (
         <LibraryDropdownMenu
         <LibraryDropdownMenu
           selectedItems={selectedItems}
           selectedItems={selectedItems}
-          onSelectItems={setSelectedItems}
+          onSelectItems={onSelectItems}
           className="library-menu-dropdown-container--in-heading"
           className="library-menu-dropdown-container--in-heading"
         />
         />
       )}
       )}
@@ -225,20 +261,28 @@ export default function LibraryMenuItems({
               </div>
               </div>
             </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>
             </div>
           )}
           )}
           {publishedItems.length > 0 ? (
           {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 ? (
           ) : unpublishedItems.length > 0 ? (
             <div
             <div
               style={{
               style={{
@@ -285,7 +332,7 @@ export default function LibraryMenuItems({
           >
           >
             <LibraryDropdownMenu
             <LibraryDropdownMenu
               selectedItems={selectedItems}
               selectedItems={selectedItems}
-              onSelectItems={setSelectedItems}
+              onSelectItems={onSelectItems}
             />
             />
           </LibraryMenuControlButtons>
           </LibraryMenuControlButtons>
         )}
         )}

+ 52 - 91
src/components/LibraryMenuSection.tsx

@@ -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 { LibraryItem } from "../types";
-import Stack from "./Stack";
-import clsx from "clsx";
 import { ExcalidrawElement, NonDeleted } from "../element/types";
 import { ExcalidrawElement, NonDeleted } from "../element/types";
 import { SvgCache } from "../hooks/useLibraryItemSvg";
 import { SvgCache } from "../hooks/useLibraryItemSvg";
 import { useTransition } from "../hooks/useTransition";
 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 = (
 type LibraryOrPendingItem = (
   | LibraryItem
   | LibraryItem
   | /* pending library item */ {
   | /* pending library item */ {
@@ -26,91 +20,58 @@ interface Props {
   onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void;
   onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void;
   isItemSelected: (id: LibraryItem["id"] | null) => boolean;
   isItemSelected: (id: LibraryItem["id"] | null) => boolean;
   svgCache: SvgCache;
   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);
 
 
-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);
+    useEffect(() => {
+      if (index < items.length) {
+        startTransition(() => {
+          setIndex(index + itemsRenderedPerBatch);
+        });
+      }
+    }, [index, items.length, startTransition, itemsRenderedPerBatch]);
 
 
-  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;
+    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} />
+          );
+        })}
+      </>
+    );
+  },
+);

+ 1 - 1
src/components/LibraryUnit.scss

@@ -30,7 +30,7 @@
         var(--color-gray-10)
         var(--color-gray-10)
       );
       );
       background-size: 200% 200%;
       background-size: 200% 200%;
-      animation: library-unit__skeleton-opacity-animation 0.3s linear;
+      animation: library-unit__skeleton-opacity-animation 0.2s linear;
     }
     }
   }
   }
 
 

+ 88 - 83
src/components/LibraryUnit.tsx

@@ -1,5 +1,5 @@
 import clsx from "clsx";
 import clsx from "clsx";
-import { useEffect, useRef, useState } from "react";
+import { memo, useEffect, useRef, useState } from "react";
 import { useDevice } from "../components/App";
 import { useDevice } from "../components/App";
 import { LibraryItem } from "../types";
 import { LibraryItem } from "../types";
 import "./LibraryUnit.scss";
 import "./LibraryUnit.scss";
@@ -7,96 +7,101 @@ import { CheckboxItem } from "./CheckboxItem";
 import { PlusIcon } from "./icons";
 import { PlusIcon } from "./icons";
 import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
 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
       <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" />
+);

+ 1 - 0
src/hooks/useLibraryItemSvg.ts

@@ -39,6 +39,7 @@ export const useLibraryItemSvg = (
           // When there is no svg in cache export it and save to cache
           // When there is no svg in cache export it and save to cache
           (async () => {
           (async () => {
             const exportedSvg = await exportLibraryItemToSvg(elements);
             const exportedSvg = await exportLibraryItemToSvg(elements);
+            exportedSvg.querySelector(".style-fonts")?.remove();
 
 
             if (exportedSvg) {
             if (exportedSvg) {
               svgCache.set(id, exportedSvg);
               svgCache.set(id, exportedSvg);