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, {
|
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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;
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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" />
|
||||||
|
);
|
||||||
|
@ -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);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user