From 7340c70a0697bcdafe3eebab33ffecb116c8bc5f Mon Sep 17 00:00:00 2001 From: Arnost Pleskot Date: Wed, 24 May 2023 16:40:20 +0200 Subject: [PATCH] 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 --- src/components/LibraryMenu.tsx | 17 +- src/components/LibraryMenuItems.tsx | 246 ++++++++++---------------- src/components/LibraryMenuSection.tsx | 110 ++++++++++++ src/components/LibraryUnit.tsx | 27 +-- src/components/Spinner.scss | 1 + src/components/Spinner.tsx | 15 +- src/hooks/useLibraryItemSvg.ts | 59 ++++++ 7 files changed, 295 insertions(+), 180 deletions(-) create mode 100644 src/components/LibraryMenuSection.tsx create mode 100644 src/hooks/useLibraryItemSvg.ts diff --git a/src/components/LibraryMenu.tsx b/src/components/LibraryMenu.tsx index 0101d0cb..a49f3197 100644 --- a/src/components/LibraryMenu.tsx +++ b/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([]); - - 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} /> ); }; diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index 0f4dda58..74f295cd 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/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([]); + + 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,136 +91,69 @@ 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[]; - } - | null; - onClick?: () => void; - key: string; - }) => { - return ( - - {})} - 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)), - ); - }} - /> - + 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[]; - } - )[], - ) => { - 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 ( - - {rowItems} - - ); - }); + return selectedItems.includes(id); }; - const unpublishedItems = 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 publishedItems = libraryItems.filter( - (item) => item.status === "published", - ); - - const showBtn = !libraryItems.length && !pendingElements.length; - - const isLibraryEmpty = - !pendingElements.length && - !unpublishedItems.length && - !publishedItems.length; return (
)} @@ -258,28 +200,32 @@ const LibraryMenuItems = ({
)} -
- {!pendingElements.length && !unpublishedItems.length ? ( -
-
- {t("library.noItems")} -
-
- {publishedItems.length > 0 - ? t("library.hint_emptyPrivateLibrary") - : t("library.hint_emptyLibrary")} -
+ {!pendingElements.length && !unpublishedItems.length ? ( +
+
+ {t("library.noItems")}
- ) : ( - renderLibrarySection([ +
+ {publishedItems.length > 0 + ? t("library.hint_emptyPrivateLibrary") + : t("library.hint_emptyLibrary")} +
+
+ ) : ( + + ]} + onItemSelectToggle={onItemSelectToggle} + onItemDrag={onItemDrag} + onClick={onItemClick} + isItemSelected={isItemSelected} + /> + )} <> @@ -291,7 +237,13 @@ const LibraryMenuItems = ({
)} {publishedItems.length > 0 ? ( - renderLibrarySection(publishedItems) + ) : unpublishedItems.length > 0 ? (
)}
); -}; - -export default LibraryMenuItems; +} diff --git a/src/components/LibraryMenuSection.tsx b/src/components/LibraryMenuSection.tsx new file mode 100644 index 00000000..d1af8548 --- /dev/null +++ b/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[]; + } +)[]; + +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 ( + + {items.map((item) => ( + + + + ))} + + ); +} + +const EmptyLibraryRow = () => ( + + +
+ + +); + +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 ? ( + + ) : ( + + ), + )} + + ); +} + +export default LibraryMenuSection; diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 7e8181d7..68fdec14 100644 --- a/src/components/LibraryUnit.tsx +++ b/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(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 diff --git a/src/components/Spinner.scss b/src/components/Spinner.scss index fd6fd50e..e2d90f88 100644 --- a/src/components/Spinner.scss +++ b/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; } diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index c4edb65a..8bc1e591 100644 --- a/src/components/Spinner.tsx +++ b/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 (
- + >( + 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(); + + 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; +};