perf: memoize rendering of library (#6622)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Arnost Pleskot 2023-05-31 15:37:13 +02:00 committed by GitHub
parent 82d8d02697
commit 253c5c7866
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 377 additions and 306 deletions

View File

@ -1,4 +1,4 @@
import React, { useCallback } from "react"; import React, { useState, useCallback, useMemo, useRef } from "react";
import Library, { import Library, {
distributeLibraryItemsOnSquareGrid, distributeLibraryItemsOnSquareGrid,
libraryItemsAtom, libraryItemsAtom,
@ -27,6 +27,8 @@ import { useUIAppState } from "../context/ui-appState";
import "./LibraryMenu.scss"; import "./LibraryMenu.scss";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { isShallowEqual } from "../utils";
import { NonDeletedExcalidrawElement } from "../element/types";
export const isLibraryMenuOpenAtom = atom(false); export const isLibraryMenuOpenAtom = atom(false);
@ -42,7 +44,9 @@ export const LibraryMenuContent = ({
libraryReturnUrl, libraryReturnUrl,
library, library,
id, id,
appState, theme,
selectedItems,
onSelectItems,
}: { }: {
pendingElements: LibraryItem["elements"]; pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
@ -51,33 +55,47 @@ export const LibraryMenuContent = ({
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library; library: Library;
id: string; id: string;
appState: UIAppState; theme: UIAppState["theme"];
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => { }) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const addToLibrary = useCallback( const _onAddToLibrary = useCallback(
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => { (elements: LibraryItem["elements"]) => {
trackEvent("element", "addToLibrary", "ui"); const addToLibrary = async (
if (elements.some((element) => element.type === "image")) { processedElements: LibraryItem["elements"],
return setAppState({ libraryItems: LibraryItems,
errorMessage: "Support for adding images to the library coming soon!", ) => {
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 = [ addToLibrary(elements, libraryItemsData.libraryItems);
{
status: "unpublished",
elements,
id: randomId(),
created: Date.now(),
},
...libraryItems,
];
onAddToLibrary();
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
}, },
[onAddToLibrary, library, setAppState], [onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
);
const libraryItems = useMemo(
() => libraryItemsData.libraryItems,
[libraryItemsData],
); );
if ( if (
@ -103,15 +121,15 @@ export const LibraryMenuContent = ({
<LibraryMenuWrapper> <LibraryMenuWrapper>
<LibraryMenuItems <LibraryMenuItems
isLoading={libraryItemsData.status === "loading"} isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItemsData.libraryItems} libraryItems={libraryItems}
onAddToLibrary={(elements) => onAddToLibrary={_onAddToLibrary}
addToLibrary(elements, libraryItemsData.libraryItems)
}
onInsertLibraryItems={onInsertLibraryItems} onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements} pendingElements={pendingElements}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={appState.theme} theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/> />
{showBtn && ( {showBtn && (
<LibraryMenuControlButtons <LibraryMenuControlButtons
@ -119,13 +137,36 @@ export const LibraryMenuContent = ({
style={{ padding: "16px 12px 0 12px" }} style={{ padding: "16px 12px 0 12px" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={appState.theme} theme={theme}
/> />
)} )}
</LibraryMenuWrapper> </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 * This component is meant to be rendered inside <Sidebar.Tab/> inside our
* <DefaultSidebar/> or host apps Sidebar components. * <DefaultSidebar/> or host apps Sidebar components.
@ -136,9 +177,19 @@ export const LibraryMenu = () => {
const appState = useUIAppState(); const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements(); 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(() => { const onInsertLibraryItems = useCallback(
// deselect canvas elements (libraryItems: LibraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
},
[onInsertElements],
);
const deselectItems = useCallback(() => {
setAppState({ setAppState({
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
@ -147,16 +198,16 @@ export const LibraryMenu = () => {
return ( return (
<LibraryMenuContent <LibraryMenuContent
pendingElements={getSelectedElements(elements, appState, true)} pendingElements={pendingElements}
onInsertLibraryItems={(libraryItems) => { onInsertLibraryItems={onInsertLibraryItems}
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); onAddToLibrary={deselectItems}
}}
onAddToLibrary={onAddToLibrary}
setAppState={setAppState} setAppState={setAppState}
libraryReturnUrl={appProps.libraryReturnUrl} libraryReturnUrl={appProps.libraryReturnUrl}
library={library} library={memoizedLibrary}
id={id} id={id}
appState={appState} theme={appState.theme}
selectedItems={selectedItems}
onSelectItems={setSelectedItems}
/> />
); );
}; };

View File

@ -73,6 +73,12 @@
} }
} }
&__grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-gap: 1rem;
}
.separator { .separator {
width: 100%; width: 100%;
display: flex; display: flex;

View File

@ -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 { serializeLibraryAsJSON } from "../data/json";
import { t } from "../i18n"; import { t } from "../i18n";
import { import {
@ -14,12 +20,22 @@ 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 {
LibraryMenuSection,
LibraryMenuSectionGrid,
} from "./LibraryMenuSection";
import { useScrollPosition } from "../hooks/useScrollPosition"; import { useScrollPosition } from "../hooks/useScrollPosition";
import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useLibraryCache } from "../hooks/useLibraryItemSvg";
import "./LibraryMenuItems.scss"; 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({ export default function LibraryMenuItems({
isLoading, isLoading,
libraryItems, libraryItems,
@ -29,6 +45,8 @@ export default function LibraryMenuItems({
theme, theme,
id, id,
libraryReturnUrl, libraryReturnUrl,
onSelectItems,
selectedItems,
}: { }: {
isLoading: boolean; isLoading: boolean;
libraryItems: LibraryItems; libraryItems: LibraryItems;
@ -38,8 +56,9 @@ export default function LibraryMenuItems({
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"]; theme: UIAppState["theme"];
id: string; id: string;
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) { }) {
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const libraryContainerRef = useRef<HTMLDivElement>(null); const libraryContainerRef = useRef<HTMLDivElement>(null);
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef); const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
@ -49,13 +68,16 @@ export default function LibraryMenuItems({
libraryContainerRef.current?.scrollTo(0, scrollPosition); libraryContainerRef.current?.scrollTo(0, scrollPosition);
} }
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const { svgCache } = useLibraryCache();
const unpublishedItems = libraryItems.filter( const { svgCache } = useLibraryCache();
(item) => item.status !== "published", 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; const showBtn = !libraryItems.length && !pendingElements.length;
@ -69,50 +91,56 @@ export default function LibraryMenuItems({
LibraryItem["id"] | null LibraryItem["id"] | null
>(null); >(null);
const onItemSelectToggle = ( const onItemSelectToggle = useCallback(
id: LibraryItem["id"], (id: LibraryItem["id"], event: React.MouseEvent) => {
event: React.MouseEvent, const shouldSelect = !selectedItems.includes(id);
) => {
const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems]; const orderedItems = [...unpublishedItems, ...publishedItems];
if (shouldSelect) { if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) { if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex( const rangeStart = orderedItems.findIndex(
(item) => item.id === lastSelectedItem, (item) => item.id === lastSelectedItem,
); );
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) {
setSelectedItems([...selectedItems, id]); onSelectItems([...selectedItems, id]);
return; 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]);
} }
setLastSelectedItem(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);
} else { } else {
setSelectedItems([...selectedItems, id]); setLastSelectedItem(null);
onSelectItems(selectedItems.filter((_id) => _id !== id));
} }
setLastSelectedItem(id); },
} else { [
setLastSelectedItem(null); lastSelectedItem,
setSelectedItems(selectedItems.filter((_id) => _id !== id)); onSelectItems,
} publishedItems,
}; selectedItems,
unpublishedItems,
],
);
const getInsertedElements = useCallback( const getInsertedElements = useCallback(
(id: string) => { (id: string) => {
@ -136,37 +164,45 @@ export default function LibraryMenuItems({
[libraryItems, selectedItems], [libraryItems, selectedItems],
); );
const onItemDrag = (id: LibraryItem["id"], event: React.DragEvent) => { const onItemDrag = useCallback(
event.dataTransfer.setData( (id: LibraryItem["id"], event: React.DragEvent) => {
MIME_TYPES.excalidrawlib, event.dataTransfer.setData(
serializeLibraryAsJSON(getInsertedElements(id)), MIME_TYPES.excalidrawlib,
); serializeLibraryAsJSON(getInsertedElements(id)),
}; );
},
[getInsertedElements],
);
const isItemSelected = (id: LibraryItem["id"] | null) => { const isItemSelected = useCallback(
if (!id) { (id: LibraryItem["id"] | null) => {
return false; if (!id) {
} return false;
}
return selectedItems.includes(id); return selectedItems.includes(id);
}; },
[selectedItems],
);
const onAddToLibraryClick = useCallback(() => {
onAddToLibrary(pendingElements);
}, [pendingElements, onAddToLibrary]);
const onItemClick = useCallback( const onItemClick = useCallback(
(id: LibraryItem["id"] | null) => { (id: LibraryItem["id"] | null) => {
if (!id) { if (id) {
onAddToLibrary(pendingElements);
} else {
onInsertLibraryItems(getInsertedElements(id)); onInsertLibraryItems(getInsertedElements(id));
} }
}, },
[ [getInsertedElements, onInsertLibraryItems],
getInsertedElements,
onAddToLibrary,
onInsertLibraryItems,
pendingElements,
],
); );
const itemsRenderedPerBatch =
svgCache.size >= libraryItems.length
? CACHED_ITEMS_RENDERED_PER_BATCH
: ITEMS_RENDERED_PER_BATCH;
return ( return (
<div <div
className="library-menu-items-container" className="library-menu-items-container"
@ -181,7 +217,7 @@ export default function LibraryMenuItems({
{!isLibraryEmpty && ( {!isLibraryEmpty && (
<LibraryDropdownMenu <LibraryDropdownMenu
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={setSelectedItems} onSelectItems={onSelectItems}
className="library-menu-dropdown-container--in-heading" className="library-menu-dropdown-container--in-heading"
/> />
)} )}
@ -225,20 +261,28 @@ export default function LibraryMenuItems({
</div> </div>
</div> </div>
) : ( ) : (
<LibraryMenuSection <LibraryMenuSectionGrid>
items={[ {pendingElements.length > 0 && (
// append pending library item <LibraryMenuSection
...(pendingElements.length itemsRenderedPerBatch={itemsRenderedPerBatch}
? [{ id: null, elements: pendingElements }] items={[{ id: null, elements: pendingElements }]}
: []), onItemSelectToggle={onItemSelectToggle}
...unpublishedItems, onItemDrag={onItemDrag}
]} onClick={onAddToLibraryClick}
onItemSelectToggle={onItemSelectToggle} isItemSelected={isItemSelected}
onItemDrag={onItemDrag} svgCache={svgCache}
onClick={onItemClick} />
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> </div>
)} )}
{publishedItems.length > 0 ? ( {publishedItems.length > 0 ? (
<LibraryMenuSection <LibraryMenuSectionGrid>
items={publishedItems} <LibraryMenuSection
onItemSelectToggle={onItemSelectToggle} itemsRenderedPerBatch={itemsRenderedPerBatch}
onItemDrag={onItemDrag} items={publishedItems}
onClick={onItemClick} onItemSelectToggle={onItemSelectToggle}
isItemSelected={isItemSelected} onItemDrag={onItemDrag}
svgCache={svgCache} onClick={onItemClick}
/> isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
) : unpublishedItems.length > 0 ? ( ) : unpublishedItems.length > 0 ? (
<div <div
style={{ style={{
@ -285,7 +332,7 @@ export default function LibraryMenuItems({
> >
<LibraryDropdownMenu <LibraryDropdownMenu
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={setSelectedItems} onSelectItems={onSelectItems}
/> />
</LibraryMenuControlButtons> </LibraryMenuControlButtons>
)} )}

View File

@ -1,16 +1,10 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { memo, ReactNode, useEffect, useState } from "react";
import { LibraryUnit } from "./LibraryUnit"; import { EmptyLibraryUnit, LibraryUnit } from "./LibraryUnit";
import { LibraryItem } from "../types"; import { LibraryItem } from "../types";
import Stack from "./Stack";
import clsx from "clsx";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import { ExcalidrawElement, NonDeleted } from "../element/types";
import { SvgCache } from "../hooks/useLibraryItemSvg"; import { SvgCache } from "../hooks/useLibraryItemSvg";
import { useTransition } from "../hooks/useTransition"; 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 = ( type LibraryOrPendingItem = (
| LibraryItem | LibraryItem
| /* pending library item */ { | /* pending library item */ {
@ -26,91 +20,58 @@ interface Props {
onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void; onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void;
isItemSelected: (id: LibraryItem["id"] | null) => boolean; isItemSelected: (id: LibraryItem["id"] | null) => boolean;
svgCache: SvgCache; svgCache: SvgCache;
itemsRenderedPerBatch: number;
} }
function LibraryRow({ export const LibraryMenuSectionGrid = ({
items, children,
onItemSelectToggle, }: {
onItemDrag, children: ReactNode;
isItemSelected, }) => {
onClick, return <div className="library-menu-items-container__grid">{children}</div>;
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>
);
}
const EmptyLibraryRow = () => ( export const LibraryMenuSection = memo(
<Stack.Row className="library-menu-items-container__row" gap={1}> ({
{Array.from({ length: ITEMS_PER_ROW }).map((_, index) => ( items,
<Stack.Col key={index}> onItemSelectToggle,
<div className={clsx("library-unit", "library-unit--skeleton")} /> onItemDrag,
</Stack.Col> isItemSelected,
))} onClick,
</Stack.Row> 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;

View File

@ -30,7 +30,7 @@
var(--color-gray-10) var(--color-gray-10)
); );
background-size: 200% 200%; background-size: 200% 200%;
animation: library-unit__skeleton-opacity-animation 0.3s linear; animation: library-unit__skeleton-opacity-animation 0.2s linear;
} }
} }

View File

@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useRef, useState } from "react"; import { memo, useEffect, useRef, useState } from "react";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { LibraryItem } from "../types"; import { LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
@ -7,96 +7,101 @@ import { CheckboxItem } from "./CheckboxItem";
import { PlusIcon } from "./icons"; import { PlusIcon } from "./icons";
import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
export const LibraryUnit = ({ export const LibraryUnit = memo(
id, ({
elements, id,
isPending, elements,
onClick, isPending,
selected, onClick,
onToggle, selected,
onDrag, onToggle,
svgCache, onDrag,
}: { svgCache,
id: LibraryItem["id"] | /** for pending item */ null; }: {
elements?: LibraryItem["elements"]; id: LibraryItem["id"] | /** for pending item */ null;
isPending?: boolean; elements?: LibraryItem["elements"];
onClick: (id: LibraryItem["id"] | null) => void; isPending?: boolean;
selected: boolean; onClick: (id: LibraryItem["id"] | null) => void;
onToggle: (id: string, event: React.MouseEvent) => void; selected: boolean;
onDrag: (id: string, event: React.DragEvent) => void; onToggle: (id: string, event: React.MouseEvent) => void;
svgCache: SvgCache; onDrag: (id: string, event: React.DragEvent) => void;
}) => { svgCache: SvgCache;
const ref = useRef<HTMLDivElement | null>(null); }) => {
const svg = useLibraryItemSvg(id, elements, svgCache); const ref = useRef<HTMLDivElement | null>(null);
const svg = useLibraryItemSvg(id, elements, svgCache);
useEffect(() => { useEffect(() => {
const node = ref.current; const node = ref.current;
if (!node) { if (!node) {
return; return;
} }
if (svg) { if (svg) {
svg.querySelector(".style-fonts")?.remove(); node.innerHTML = svg.outerHTML;
node.innerHTML = svg.outerHTML; }
}
return () => { return () => {
node.innerHTML = ""; node.innerHTML = "";
}; };
}, [elements, svg]); }, [svg]);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile; const isMobile = useDevice().isMobile;
const adder = isPending && ( const adder = isPending && (
<div className="library-unit__adder">{PlusIcon}</div> <div className="library-unit__adder">{PlusIcon}</div>
); );
return ( 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)}
>
<div <div
className={clsx("library-unit__dragger", { className={clsx("library-unit", {
"library-unit__pulse": !!isPending, "library-unit__active": elements,
"library-unit--hover": elements && isHovered,
"library-unit--selected": selected,
"library-unit--skeleton": !svg,
})} })}
ref={ref} onMouseEnter={() => setIsHovered(true)}
draggable={!!elements} onMouseLeave={() => setIsHovered(false)}
onClick={ >
!!elements || !!isPending <div
? (event) => { className={clsx("library-unit__dragger", {
if (id && event.shiftKey) { "library-unit__pulse": !!isPending,
onToggle(id, event); })}
} else { ref={ref}
onClick(id); draggable={!!elements}
onClick={
!!elements || !!isPending
? (event) => {
if (id && event.shiftKey) {
onToggle(id, event);
} else {
onClick(id);
}
} }
} : undefined
: undefined
}
onDragStart={(event) => {
if (!id) {
event.preventDefault();
return;
} }
setIsHovered(false); onDragStart={(event) => {
onDrag(id, event); if (!id) {
}} event.preventDefault();
/> return;
{adder} }
{id && elements && (isHovered || isMobile || selected) && ( setIsHovered(false);
<CheckboxItem onDrag(id, event);
checked={selected} }}
onChange={(checked, event) => onToggle(id, event)}
className="library-unit__checkbox"
/> />
)} {adder}
</div> {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" />
);

View File

@ -39,6 +39,7 @@ export const useLibraryItemSvg = (
// When there is no svg in cache export it and save to cache // When there is no svg in cache export it and save to cache
(async () => { (async () => {
const exportedSvg = await exportLibraryItemToSvg(elements); const exportedSvg = await exportLibraryItemToSvg(elements);
exportedSvg.querySelector(".style-fonts")?.remove();
if (exportedSvg) { if (exportedSvg) {
svgCache.set(id, exportedSvg); svgCache.set(id, exportedSvg);