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, {
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}
/>
);
};

View File

@ -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,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<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 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 (
<div
@ -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;
}

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 { 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

View File

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

View File

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

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