perf: memoize rendering of library (#6622)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
82d8d02697
commit
253c5c7866
@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useState, useCallback, useMemo, useRef } from "react";
|
||||
import Library, {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
libraryItemsAtom,
|
||||
@ -27,6 +27,8 @@ import { useUIAppState } from "../context/ui-appState";
|
||||
|
||||
import "./LibraryMenu.scss";
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
|
||||
@ -42,7 +44,9 @@ export const LibraryMenuContent = ({
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
id,
|
||||
appState,
|
||||
theme,
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
@ -51,33 +55,47 @@ export const LibraryMenuContent = ({
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
appState: UIAppState;
|
||||
theme: UIAppState["theme"];
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) => {
|
||||
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 (
|
||||
@ -103,15 +121,15 @@ export const LibraryMenuContent = ({
|
||||
<LibraryMenuWrapper>
|
||||
<LibraryMenuItems
|
||||
isLoading={libraryItemsData.status === "loading"}
|
||||
libraryItems={libraryItemsData.libraryItems}
|
||||
onAddToLibrary={(elements) =>
|
||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||
}
|
||||
libraryItems={libraryItems}
|
||||
onAddToLibrary={_onAddToLibrary}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
pendingElements={pendingElements}
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={appState.theme}
|
||||
theme={theme}
|
||||
onSelectItems={onSelectItems}
|
||||
selectedItems={selectedItems}
|
||||
/>
|
||||
{showBtn && (
|
||||
<LibraryMenuControlButtons
|
||||
@ -119,13 +137,36 @@ export const LibraryMenuContent = ({
|
||||
style={{ padding: "16px 12px 0 12px" }}
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={appState.theme}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
* <DefaultSidebar/> or host apps Sidebar components.
|
||||
@ -136,9 +177,19 @@ export const LibraryMenu = () => {
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
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 onAddToLibrary = useCallback(() => {
|
||||
// deselect canvas elements
|
||||
const onInsertLibraryItems = useCallback(
|
||||
(libraryItems: LibraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
},
|
||||
[onInsertElements],
|
||||
);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
setAppState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
@ -147,16 +198,16 @@ export const LibraryMenu = () => {
|
||||
|
||||
return (
|
||||
<LibraryMenuContent
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onInsertLibraryItems={(libraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
}}
|
||||
onAddToLibrary={onAddToLibrary}
|
||||
pendingElements={pendingElements}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={appProps.libraryReturnUrl}
|
||||
library={library}
|
||||
library={memoizedLibrary}
|
||||
id={id}
|
||||
appState={appState}
|
||||
theme={appState.theme}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -73,6 +73,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
@ -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 { t } from "../i18n";
|
||||
import {
|
||||
@ -14,12 +20,22 @@ import Spinner from "./Spinner";
|
||||
import { duplicateElements } from "../element/newElement";
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
|
||||
import LibraryMenuSection from "./LibraryMenuSection";
|
||||
import {
|
||||
LibraryMenuSection,
|
||||
LibraryMenuSectionGrid,
|
||||
} from "./LibraryMenuSection";
|
||||
import { useScrollPosition } from "../hooks/useScrollPosition";
|
||||
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
|
||||
|
||||
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({
|
||||
isLoading,
|
||||
libraryItems,
|
||||
@ -29,6 +45,8 @@ export default function LibraryMenuItems({
|
||||
theme,
|
||||
id,
|
||||
libraryReturnUrl,
|
||||
onSelectItems,
|
||||
selectedItems,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
libraryItems: LibraryItems;
|
||||
@ -38,8 +56,9 @@ export default function LibraryMenuItems({
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
theme: UIAppState["theme"];
|
||||
id: string;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) {
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const libraryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
|
||||
|
||||
@ -49,13 +68,16 @@ export default function LibraryMenuItems({
|
||||
libraryContainerRef.current?.scrollTo(0, scrollPosition);
|
||||
}
|
||||
}, []); // 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;
|
||||
@ -69,50 +91,56 @@ export default function LibraryMenuItems({
|
||||
LibraryItem["id"] | 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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
onSelectItems(nextSelectedIds);
|
||||
} else {
|
||||
onSelectItems([...selectedItems, id]);
|
||||
}
|
||||
|
||||
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);
|
||||
setLastSelectedItem(id);
|
||||
} 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(
|
||||
(id: string) => {
|
||||
@ -136,37 +164,45 @@ export default function LibraryMenuItems({
|
||||
[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 = (id: LibraryItem["id"] | null) => {
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
const isItemSelected = useCallback(
|
||||
(id: LibraryItem["id"] | null) => {
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return selectedItems.includes(id);
|
||||
};
|
||||
return selectedItems.includes(id);
|
||||
},
|
||||
[selectedItems],
|
||||
);
|
||||
|
||||
const onAddToLibraryClick = useCallback(() => {
|
||||
onAddToLibrary(pendingElements);
|
||||
}, [pendingElements, onAddToLibrary]);
|
||||
|
||||
const onItemClick = useCallback(
|
||||
(id: LibraryItem["id"] | null) => {
|
||||
if (!id) {
|
||||
onAddToLibrary(pendingElements);
|
||||
} else {
|
||||
if (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 (
|
||||
<div
|
||||
className="library-menu-items-container"
|
||||
@ -181,7 +217,7 @@ export default function LibraryMenuItems({
|
||||
{!isLibraryEmpty && (
|
||||
<LibraryDropdownMenu
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
className="library-menu-dropdown-container--in-heading"
|
||||
/>
|
||||
)}
|
||||
@ -225,20 +261,28 @@ export default function LibraryMenuItems({
|
||||
</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>
|
||||
)}
|
||||
{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 ? (
|
||||
<div
|
||||
style={{
|
||||
@ -285,7 +332,7 @@ export default function LibraryMenuItems({
|
||||
>
|
||||
<LibraryDropdownMenu
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
/>
|
||||
</LibraryMenuControlButtons>
|
||||
)}
|
||||
|
@ -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 Stack from "./Stack";
|
||||
import clsx from "clsx";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { SvgCache } from "../hooks/useLibraryItemSvg";
|
||||
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 = (
|
||||
| LibraryItem
|
||||
| /* pending library item */ {
|
||||
@ -26,91 +20,58 @@ interface Props {
|
||||
onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void;
|
||||
isItemSelected: (id: LibraryItem["id"] | null) => boolean;
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (index < items.length) {
|
||||
startTransition(() => {
|
||||
setIndex(index + itemsRenderedPerBatch);
|
||||
});
|
||||
}
|
||||
}, [index, items.length, startTransition, itemsRenderedPerBatch]);
|
||||
|
||||
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} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
|
@ -30,7 +30,7 @@
|
||||
var(--color-gray-10)
|
||||
);
|
||||
background-size: 200% 200%;
|
||||
animation: library-unit__skeleton-opacity-animation 0.3s linear;
|
||||
animation: library-unit__skeleton-opacity-animation 0.2s linear;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { useDevice } from "../components/App";
|
||||
import { LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
@ -7,96 +7,101 @@ import { CheckboxItem } from "./CheckboxItem";
|
||||
import { PlusIcon } from "./icons";
|
||||
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
|
||||
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" />
|
||||
);
|
||||
|
@ -39,6 +39,7 @@ export const useLibraryItemSvg = (
|
||||
// When there is no svg in cache export it and save to cache
|
||||
(async () => {
|
||||
const exportedSvg = await exportLibraryItemToSvg(elements);
|
||||
exportedSvg.querySelector(".style-fonts")?.remove();
|
||||
|
||||
if (exportedSvg) {
|
||||
svgCache.set(id, exportedSvg);
|
||||
|
Loading…
x
Reference in New Issue
Block a user