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 <luzar.david@gmail.com>
This commit is contained in:
Arnost Pleskot 2023-05-24 16:40:20 +02:00 committed by GitHub
parent a4f05339aa
commit 7340c70a06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 295 additions and 180 deletions

View File

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

View File

@ -1,6 +1,5 @@
import React, { useState } from "react"; import React, { useCallback, useState } from "react";
import { serializeLibraryAsJSON } from "../data/json"; import { serializeLibraryAsJSON } from "../data/json";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { import {
ExcalidrawProps, ExcalidrawProps,
@ -8,27 +7,23 @@ import {
LibraryItems, LibraryItems,
UIAppState, UIAppState,
} from "../types"; } from "../types";
import { arrayToMap, chunk } from "../utils"; import { arrayToMap } from "../utils";
import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack"; import Stack from "./Stack";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import Spinner from "./Spinner"; 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 "./LibraryMenuItems.scss"; import "./LibraryMenuItems.scss";
const CELLS_PER_ROW = 4; export default function LibraryMenuItems({
const LibraryMenuItems = ({
isLoading, isLoading,
libraryItems, libraryItems,
onAddToLibrary, onAddToLibrary,
onInsertLibraryItems, onInsertLibraryItems,
pendingElements, pendingElements,
selectedItems,
onSelectItems,
theme, theme,
id, id,
libraryReturnUrl, libraryReturnUrl,
@ -38,12 +33,26 @@ const LibraryMenuItems = ({
pendingElements: LibraryItem["elements"]; pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: (elements: LibraryItem["elements"]) => void; onAddToLibrary: (elements: LibraryItem["elements"]) => void;
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"]; theme: UIAppState["theme"];
id: string; 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< const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null LibraryItem["id"] | null
>(null); >(null);
@ -64,7 +73,7 @@ const LibraryMenuItems = ({
const rangeEnd = orderedItems.findIndex((item) => item.id === id); const rangeEnd = orderedItems.findIndex((item) => item.id === id);
if (rangeStart === -1 || rangeEnd === -1) { if (rangeStart === -1 || rangeEnd === -1) {
onSelectItems([...selectedItems, id]); setSelectedItems([...selectedItems, id]);
return; return;
} }
@ -82,136 +91,69 @@ const LibraryMenuItems = ({
[], [],
); );
onSelectItems(nextSelectedIds); setSelectedItems(nextSelectedIds);
} else { } else {
onSelectItems([...selectedItems, id]); setSelectedItems([...selectedItems, id]);
} }
setLastSelectedItem(id); setLastSelectedItem(id);
} else { } else {
setLastSelectedItem(null); setLastSelectedItem(null);
onSelectItems(selectedItems.filter((_id) => _id !== id)); setSelectedItems(selectedItems.filter((_id) => _id !== id));
} }
}; };
const getInsertedElements = (id: string) => { const getInsertedElements = useCallback(
let targetElements; (id: string) => {
if (selectedItems.includes(id)) { let targetElements;
targetElements = libraryItems.filter((item) => if (selectedItems.includes(id)) {
selectedItems.includes(item.id), targetElements = libraryItems.filter((item) =>
); selectedItems.includes(item.id),
} else { );
targetElements = libraryItems.filter((item) => item.id === id); } else {
} targetElements = libraryItems.filter((item) => item.id === id);
return targetElements.map((item) => { }
return { return targetElements.map((item) => {
...item, return {
// duplicate each library item before inserting on canvas to confine ...item,
// ids and bindings to each library item. See #6465 // duplicate each library item before inserting on canvas to confine
elements: duplicateElements(item.elements, { randomizeSeed: true }), // ids and bindings to each library item. See #6465
}; elements: duplicateElements(item.elements, { randomizeSeed: true }),
}); };
}; });
},
[libraryItems, selectedItems],
);
const createLibraryItemCompo = (params: { const onItemDrag = (id: LibraryItem["id"], event: React.DragEvent) => {
item: event.dataTransfer.setData(
| LibraryItem MIME_TYPES.excalidrawlib,
| /* pending library item */ { serializeLibraryAsJSON(getInsertedElements(id)),
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 renderLibrarySection = ( const isItemSelected = (id: LibraryItem["id"] | null) => {
items: ( if (!id) {
| LibraryItem return false;
| /* 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 = [[]];
} }
return rows.map((rowItems, index, rows) => { return selectedItems.includes(id);
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>
);
});
}; };
const unpublishedItems = libraryItems.filter( const onItemClick = useCallback(
(item) => item.status !== "published", (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 ( return (
<div <div
@ -227,7 +169,7 @@ const LibraryMenuItems = ({
{!isLibraryEmpty && ( {!isLibraryEmpty && (
<LibraryDropdownMenu <LibraryDropdownMenu
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={onSelectItems} onSelectItems={setSelectedItems}
className="library-menu-dropdown-container--in-heading" className="library-menu-dropdown-container--in-heading"
/> />
)} )}
@ -258,28 +200,32 @@ const LibraryMenuItems = ({
<Spinner /> <Spinner />
</div> </div>
)} )}
<div className="library-menu-items-private-library-container"> {!pendingElements.length && !unpublishedItems.length ? (
{!pendingElements.length && !unpublishedItems.length ? ( <div className="library-menu-items__no-items">
<div className="library-menu-items__no-items"> <div className="library-menu-items__no-items__label">
<div className="library-menu-items__no-items__label"> {t("library.noItems")}
{t("library.noItems")}
</div>
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div> </div>
) : ( <div className="library-menu-items__no-items__hint">
renderLibrarySection([ {publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
<LibraryMenuSection
items={[
// append pending library item // append pending library item
...(pendingElements.length ...(pendingElements.length
? [{ id: null, elements: pendingElements }] ? [{ id: null, elements: pendingElements }]
: []), : []),
...unpublishedItems, ...unpublishedItems,
]) ]}
)} onItemSelectToggle={onItemSelectToggle}
</div> onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
/>
)}
</> </>
<> <>
@ -291,7 +237,13 @@ const LibraryMenuItems = ({
</div> </div>
)} )}
{publishedItems.length > 0 ? ( {publishedItems.length > 0 ? (
renderLibrarySection(publishedItems) <LibraryMenuSection
items={publishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
/>
) : unpublishedItems.length > 0 ? ( ) : unpublishedItems.length > 0 ? (
<div <div
style={{ style={{
@ -318,13 +270,11 @@ const LibraryMenuItems = ({
> >
<LibraryDropdownMenu <LibraryDropdownMenu
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={onSelectItems} onSelectItems={setSelectedItems}
/> />
</LibraryMenuControlButtons> </LibraryMenuControlButtons>
)} )}
</Stack.Col> </Stack.Col>
</div> </div>
); );
}; }
export default LibraryMenuItems;

View File

@ -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;

View File

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

View File

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

View File

@ -5,13 +5,26 @@ import "./Spinner.scss";
const Spinner = ({ const Spinner = ({
size = "1em", size = "1em",
circleWidth = 8, circleWidth = 8,
synchronized = false,
}: { }: {
size?: string | number; size?: string | number;
circleWidth?: number; circleWidth?: number;
synchronized?: boolean;
}) => { }) => {
const mountTime = React.useRef(Date.now());
const mountDelay = -(mountTime.current % 1600);
return ( return (
<div className="Spinner"> <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 <circle
cx="50" cx="50"
cy="50" cy="50"

View File

@ -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;
};