Преглед на файлове

perf: improve rendering performance for Library (#6587)

* perf: improve rendering performance for Library

* fix: return onDrag and onToggle functionality to Library Items

* perf: cache exportToSvg output

* fix: lint warning

* fix: add onClick handler into LibraryUnit

* feat: better spinner

* fix: useCallback for getInsertedElements to fix linter error

* feat: different batch size when svgs are cached

* fix: library items alignment in row

* feat: skeleton instead of spinner

* fix: remove unused variables

* feat: use css vars instead of hadcoded colors

* feat: reverting skeleton, removing spinner

* cleanup and unrelated refactor

* change ROWS_RENDERED_PER_BATCH to 6

---------

Co-authored-by: dwelle <[email protected]>
Arnost Pleskot преди 2 години
родител
ревизия
7340c70a06

+ 4 - 13
src/components/LibraryMenu.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useCallback } from "react";
+import React, { useCallback } from "react";
 import Library, {
   distributeLibraryItemsOnSquareGrid,
   libraryItemsAtom,
@@ -43,8 +43,6 @@ export const LibraryMenuContent = ({
   library,
   id,
   appState,
-  selectedItems,
-  onSelectItems,
 }: {
   pendingElements: LibraryItem["elements"];
   onInsertLibraryItems: (libraryItems: LibraryItems) => void;
@@ -54,8 +52,6 @@ export const LibraryMenuContent = ({
   library: Library;
   id: string;
   appState: UIAppState;
-  selectedItems: LibraryItem["id"][];
-  onSelectItems: (id: LibraryItem["id"][]) => void;
 }) => {
   const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
 
@@ -113,8 +109,6 @@ export const LibraryMenuContent = ({
         }
         onInsertLibraryItems={onInsertLibraryItems}
         pendingElements={pendingElements}
-        selectedItems={selectedItems}
-        onSelectItems={onSelectItems}
         id={id}
         libraryReturnUrl={libraryReturnUrl}
         theme={appState.theme}
@@ -143,9 +137,8 @@ export const LibraryMenu = () => {
   const setAppState = useExcalidrawSetAppState();
   const elements = useExcalidrawElements();
 
-  const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
-
-  const deselectItems = useCallback(() => {
+  const onAddToLibrary = useCallback(() => {
+    // deselect canvas elements
     setAppState({
       selectedElementIds: {},
       selectedGroupIds: {},
@@ -158,14 +151,12 @@ export const LibraryMenu = () => {
       onInsertLibraryItems={(libraryItems) => {
         onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
       }}
-      onAddToLibrary={deselectItems}
+      onAddToLibrary={onAddToLibrary}
       setAppState={setAppState}
       libraryReturnUrl={appProps.libraryReturnUrl}
       library={library}
       id={id}
       appState={appState}
-      selectedItems={selectedItems}
-      onSelectItems={setSelectedItems}
     />
   );
 };

+ 98 - 148
src/components/LibraryMenuItems.tsx

@@ -1,6 +1,5 @@
-import React, { useState } from "react";
+import React, { useCallback, useState } from "react";
 import { serializeLibraryAsJSON } from "../data/json";
-import { ExcalidrawElement, NonDeleted } from "../element/types";
 import { t } from "../i18n";
 import {
   ExcalidrawProps,
@@ -8,27 +7,23 @@ import {
   LibraryItems,
   UIAppState,
 } from "../types";
-import { arrayToMap, chunk } from "../utils";
-import { LibraryUnit } from "./LibraryUnit";
+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 from "./LibraryMenuSection";
 
 import "./LibraryMenuItems.scss";
 
-const CELLS_PER_ROW = 4;
-
-const LibraryMenuItems = ({
+export default function LibraryMenuItems({
   isLoading,
   libraryItems,
   onAddToLibrary,
   onInsertLibraryItems,
   pendingElements,
-  selectedItems,
-  onSelectItems,
   theme,
   id,
   libraryReturnUrl,
@@ -38,12 +33,26 @@ const LibraryMenuItems = ({
   pendingElements: LibraryItem["elements"];
   onInsertLibraryItems: (libraryItems: LibraryItems) => void;
   onAddToLibrary: (elements: LibraryItem["elements"]) => void;
-  selectedItems: LibraryItem["id"][];
-  onSelectItems: (id: LibraryItem["id"][]) => void;
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   theme: UIAppState["theme"];
   id: string;
-}) => {
+}) {
+  const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
+
+  const unpublishedItems = libraryItems.filter(
+    (item) => item.status !== "published",
+  );
+  const publishedItems = libraryItems.filter(
+    (item) => item.status === "published",
+  );
+
+  const showBtn = !libraryItems.length && !pendingElements.length;
+
+  const isLibraryEmpty =
+    !pendingElements.length &&
+    !unpublishedItems.length &&
+    !publishedItems.length;
+
   const [lastSelectedItem, setLastSelectedItem] = useState<
     LibraryItem["id"] | null
   >(null);
@@ -64,7 +73,7 @@ const LibraryMenuItems = ({
         const rangeEnd = orderedItems.findIndex((item) => item.id === id);
 
         if (rangeStart === -1 || rangeEnd === -1) {
-          onSelectItems([...selectedItems, id]);
+          setSelectedItems([...selectedItems, id]);
           return;
         }
 
@@ -82,137 +91,70 @@ const LibraryMenuItems = ({
           [],
         );
 
-        onSelectItems(nextSelectedIds);
+        setSelectedItems(nextSelectedIds);
       } else {
-        onSelectItems([...selectedItems, id]);
+        setSelectedItems([...selectedItems, id]);
       }
       setLastSelectedItem(id);
     } else {
       setLastSelectedItem(null);
-      onSelectItems(selectedItems.filter((_id) => _id !== id));
+      setSelectedItems(selectedItems.filter((_id) => _id !== id));
     }
   };
 
-  const getInsertedElements = (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 }),
-      };
-    });
-  };
+  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 createLibraryItemCompo = (params: {
-    item:
-      | LibraryItem
-      | /* pending library item */ {
-          id: null;
-          elements: readonly NonDeleted<ExcalidrawElement>[];
-        }
-      | null;
-    onClick?: () => void;
-    key: string;
-  }) => {
-    return (
-      <Stack.Col key={params.key}>
-        <LibraryUnit
-          elements={params.item?.elements}
-          isPending={!params.item?.id && !!params.item?.elements}
-          onClick={params.onClick || (() => {})}
-          id={params.item?.id || null}
-          selected={!!params.item?.id && selectedItems.includes(params.item.id)}
-          onToggle={onItemSelectToggle}
-          onDrag={(id, event) => {
-            event.dataTransfer.setData(
-              MIME_TYPES.excalidrawlib,
-              serializeLibraryAsJSON(getInsertedElements(id)),
-            );
-          }}
-        />
-      </Stack.Col>
+  const onItemDrag = (id: LibraryItem["id"], event: React.DragEvent) => {
+    event.dataTransfer.setData(
+      MIME_TYPES.excalidrawlib,
+      serializeLibraryAsJSON(getInsertedElements(id)),
     );
   };
 
-  const renderLibrarySection = (
-    items: (
-      | LibraryItem
-      | /* pending library item */ {
-          id: null;
-          elements: readonly NonDeleted<ExcalidrawElement>[];
-        }
-    )[],
-  ) => {
-    const _items = items.map((item) => {
-      if (item.id) {
-        return createLibraryItemCompo({
-          item,
-          onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
-          key: item.id,
-        });
-      }
-      return createLibraryItemCompo({
-        key: "__pending__item__",
-        item,
-        onClick: () => onAddToLibrary(pendingElements),
-      });
-    });
-
-    // ensure we render all empty cells if no items are present
-    let rows = chunk(_items, CELLS_PER_ROW);
-    if (!rows.length) {
-      rows = [[]];
+  const isItemSelected = (id: LibraryItem["id"] | null) => {
+    if (!id) {
+      return false;
     }
 
-    return rows.map((rowItems, index, rows) => {
-      if (index === rows.length - 1) {
-        // pad row with empty cells
-        rowItems = rowItems.concat(
-          new Array(CELLS_PER_ROW - rowItems.length)
-            .fill(null)
-            .map((_, index) => {
-              return createLibraryItemCompo({
-                key: `empty_${index}`,
-                item: null,
-              });
-            }),
-        );
-      }
-      return (
-        <Stack.Row
-          align="center"
-          key={index}
-          className="library-menu-items-container__row"
-        >
-          {rowItems}
-        </Stack.Row>
-      );
-    });
+    return selectedItems.includes(id);
   };
 
-  const unpublishedItems = libraryItems.filter(
-    (item) => item.status !== "published",
-  );
-  const publishedItems = libraryItems.filter(
-    (item) => item.status === "published",
+  const onItemClick = useCallback(
+    (id: LibraryItem["id"] | null) => {
+      if (!id) {
+        onAddToLibrary(pendingElements);
+      } else {
+        onInsertLibraryItems(getInsertedElements(id));
+      }
+    },
+    [
+      getInsertedElements,
+      onAddToLibrary,
+      onInsertLibraryItems,
+      pendingElements,
+    ],
   );
 
-  const showBtn = !libraryItems.length && !pendingElements.length;
-
-  const isLibraryEmpty =
-    !pendingElements.length &&
-    !unpublishedItems.length &&
-    !publishedItems.length;
-
   return (
     <div
       className="library-menu-items-container"
@@ -227,7 +169,7 @@ const LibraryMenuItems = ({
       {!isLibraryEmpty && (
         <LibraryDropdownMenu
           selectedItems={selectedItems}
-          onSelectItems={onSelectItems}
+          onSelectItems={setSelectedItems}
           className="library-menu-dropdown-container--in-heading"
         />
       )}
@@ -258,28 +200,32 @@ const LibraryMenuItems = ({
               <Spinner />
             </div>
           )}
-          <div className="library-menu-items-private-library-container">
-            {!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>
+          {!pendingElements.length && !unpublishedItems.length ? (
+            <div className="library-menu-items__no-items">
+              <div className="library-menu-items__no-items__label">
+                {t("library.noItems")}
               </div>
-            ) : (
-              renderLibrarySection([
+              <div className="library-menu-items__no-items__hint">
+                {publishedItems.length > 0
+                  ? t("library.hint_emptyPrivateLibrary")
+                  : t("library.hint_emptyLibrary")}
+              </div>
+            </div>
+          ) : (
+            <LibraryMenuSection
+              items={[
                 // append pending library item
                 ...(pendingElements.length
                   ? [{ id: null, elements: pendingElements }]
                   : []),
                 ...unpublishedItems,
-              ])
-            )}
-          </div>
+              ]}
+              onItemSelectToggle={onItemSelectToggle}
+              onItemDrag={onItemDrag}
+              onClick={onItemClick}
+              isItemSelected={isItemSelected}
+            />
+          )}
         </>
 
         <>
@@ -291,7 +237,13 @@ const LibraryMenuItems = ({
             </div>
           )}
           {publishedItems.length > 0 ? (
-            renderLibrarySection(publishedItems)
+            <LibraryMenuSection
+              items={publishedItems}
+              onItemSelectToggle={onItemSelectToggle}
+              onItemDrag={onItemDrag}
+              onClick={onItemClick}
+              isItemSelected={isItemSelected}
+            />
           ) : unpublishedItems.length > 0 ? (
             <div
               style={{
@@ -318,13 +270,11 @@ const LibraryMenuItems = ({
           >
             <LibraryDropdownMenu
               selectedItems={selectedItems}
-              onSelectItems={onSelectItems}
+              onSelectItems={setSelectedItems}
             />
           </LibraryMenuControlButtons>
         )}
       </Stack.Col>
     </div>
   );
-};
-
-export default LibraryMenuItems;
+}

+ 110 - 0
src/components/LibraryMenuSection.tsx

@@ -0,0 +1,110 @@
+import React, { useEffect, useMemo, useState, useTransition } from "react";
+import { LibraryUnit } from "./LibraryUnit";
+import { LibraryItem } from "../types";
+import Stack from "./Stack";
+import clsx from "clsx";
+import { ExcalidrawElement, NonDeleted } from "../element/types";
+import { useAtom } from "jotai";
+import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
+
+const ITEMS_PER_ROW = 4;
+const ROWS_RENDERED_PER_BATCH = 6;
+const CACHED_ROWS_RENDERED_PER_BATCH = 16;
+
+type LibraryOrPendingItem = (
+  | LibraryItem
+  | /* pending library item */ {
+      id: null;
+      elements: readonly NonDeleted<ExcalidrawElement>[];
+    }
+)[];
+
+interface Props {
+  items: LibraryOrPendingItem;
+  onClick: (id: LibraryItem["id"] | null) => void;
+  onItemSelectToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
+  onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void;
+  isItemSelected: (id: LibraryItem["id"] | null) => boolean;
+}
+
+function LibraryRow({
+  items,
+  onItemSelectToggle,
+  onItemDrag,
+  isItemSelected,
+  onClick,
+}: 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}
+          />
+        </Stack.Col>
+      ))}
+    </Stack.Row>
+  );
+}
+
+const EmptyLibraryRow = () => (
+  <Stack.Row className="library-menu-items-container__row" gap={1}>
+    <Stack.Col>
+      <div className={clsx("library-unit")} />
+    </Stack.Col>
+  </Stack.Row>
+);
+
+function LibraryMenuSection({
+  items,
+  onItemSelectToggle,
+  onItemDrag,
+  isItemSelected,
+  onClick,
+}: Props) {
+  const rows = Math.ceil(items.length / ITEMS_PER_ROW);
+  const [, startTransition] = useTransition();
+  const [index, setIndex] = useState(0);
+  const [svgCache] = useAtom(libraryItemSvgsCache);
+
+  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}
+          />
+        ) : (
+          <EmptyLibraryRow key={i} />
+        ),
+      )}
+    </>
+  );
+}
+
+export default LibraryMenuSection;

+ 9 - 18
src/components/LibraryUnit.tsx

@@ -1,12 +1,11 @@
 import clsx from "clsx";
 import { useEffect, useRef, useState } from "react";
 import { useDevice } from "../components/App";
-import { exportToSvg } from "../packages/utils";
 import { LibraryItem } from "../types";
 import "./LibraryUnit.scss";
 import { CheckboxItem } from "./CheckboxItem";
 import { PlusIcon } from "./icons";
-import { COLOR_PALETTE } from "../colors";
+import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
 
 export const LibraryUnit = ({
   id,
@@ -20,38 +19,30 @@ export const LibraryUnit = ({
   id: LibraryItem["id"] | /** for pending item */ null;
   elements?: LibraryItem["elements"];
   isPending?: boolean;
-  onClick: () => void;
+  onClick: (id: LibraryItem["id"] | null) => void;
   selected: boolean;
   onToggle: (id: string, event: React.MouseEvent) => void;
   onDrag: (id: string, event: React.DragEvent) => void;
 }) => {
   const ref = useRef<HTMLDivElement | null>(null);
+  const svg = useLibraryItemSvg(id, elements);
+
   useEffect(() => {
     const node = ref.current;
+
     if (!node) {
       return;
     }
 
-    (async () => {
-      if (!elements) {
-        return;
-      }
-      const svg = await exportToSvg({
-        elements,
-        appState: {
-          exportBackground: false,
-          viewBackgroundColor: COLOR_PALETTE.white,
-        },
-        files: null,
-      });
+    if (svg) {
       svg.querySelector(".style-fonts")?.remove();
       node.innerHTML = svg.outerHTML;
-    })();
+    }
 
     return () => {
       node.innerHTML = "";
     };
-  }, [elements]);
+  }, [elements, svg]);
 
   const [isHovered, setIsHovered] = useState(false);
   const isMobile = useDevice().isMobile;
@@ -81,7 +72,7 @@ export const LibraryUnit = ({
                 if (id && event.shiftKey) {
                   onToggle(id, event);
                 } else {
-                  onClick();
+                  onClick(id);
                 }
               }
             : undefined

+ 1 - 0
src/components/Spinner.scss

@@ -15,6 +15,7 @@ $duration: 1.6s;
 
     svg {
       animation: rotate $duration linear infinite;
+      animation-delay: var(--spinner-delay);
       transform-origin: center center;
     }
 

+ 14 - 1
src/components/Spinner.tsx

@@ -5,13 +5,26 @@ import "./Spinner.scss";
 const Spinner = ({
   size = "1em",
   circleWidth = 8,
+  synchronized = false,
 }: {
   size?: string | number;
   circleWidth?: number;
+  synchronized?: boolean;
 }) => {
+  const mountTime = React.useRef(Date.now());
+  const mountDelay = -(mountTime.current % 1600);
+
   return (
     <div className="Spinner">
-      <svg viewBox="0 0 100 100" style={{ width: size, height: size }}>
+      <svg
+        viewBox="0 0 100 100"
+        style={{
+          width: size,
+          height: size,
+          // fix for remounting causing spinner flicker
+          ["--spinner-delay" as any]: synchronized ? `${mountDelay}ms` : 0,
+        }}
+      >
         <circle
           cx="50"
           cy="50"

+ 59 - 0
src/hooks/useLibraryItemSvg.ts

@@ -0,0 +1,59 @@
+import { atom, useAtom } from "jotai";
+import { useEffect, useState } from "react";
+import { COLOR_PALETTE } from "../colors";
+import { exportToSvg } from "../packages/utils";
+import { LibraryItem } from "../types";
+
+export const libraryItemSvgsCache = atom<Map<LibraryItem["id"], SVGSVGElement>>(
+  new Map(),
+);
+
+const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
+  return await exportToSvg({
+    elements,
+    appState: {
+      exportBackground: false,
+      viewBackgroundColor: COLOR_PALETTE.white,
+    },
+    files: null,
+  });
+};
+
+export const useLibraryItemSvg = (
+  id: LibraryItem["id"] | null,
+  elements: LibraryItem["elements"] | undefined,
+): SVGSVGElement | undefined => {
+  const [svgCache, setSvgCache] = useAtom(libraryItemSvgsCache);
+  const [svg, setSvg] = useState<SVGSVGElement>();
+
+  useEffect(() => {
+    if (elements) {
+      if (id) {
+        // Try to load cached svg
+        const cachedSvg = svgCache.get(id);
+
+        if (cachedSvg) {
+          setSvg(cachedSvg);
+        } else {
+          // When there is no svg in cache export it and save to cache
+          (async () => {
+            const exportedSvg = await exportLibraryItemToSvg(elements);
+
+            if (exportedSvg) {
+              setSvgCache(svgCache.set(id, exportedSvg));
+              setSvg(exportedSvg);
+            }
+          })();
+        }
+      } else {
+        // When we have no id (usualy selected items from canvas) just export the svg
+        (async () => {
+          const exportedSvg = await exportLibraryItemToSvg(elements);
+          setSvg(exportedSvg);
+        })();
+      }
+    }
+  }, [id, elements, svgCache, setSvgCache, setSvg]);
+
+  return svg;
+};