feat: improve library sidebar performance (#9060)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
pull/9042/head
Are 4 weeks ago committed by Mark Tolmacs
parent 941a389250
commit 9925d238b5
No known key found for this signature in database

@ -2,9 +2,8 @@ import React, {
useState, useState,
useCallback, useCallback,
useMemo, useMemo,
useEffect,
memo,
useRef, useRef,
useEffect,
} from "react"; } from "react";
import type Library from "../data/library"; import type Library from "../data/library";
import { import {
@ -18,7 +17,6 @@ import type {
LibraryItem, LibraryItem,
ExcalidrawProps, ExcalidrawProps,
UIAppState, UIAppState,
AppClassProperties,
} from "../types"; } from "../types";
import LibraryMenuItems from "./LibraryMenuItems"; import LibraryMenuItems from "./LibraryMenuItems";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
@ -35,12 +33,9 @@ import { useUIAppState } from "../context/ui-appState";
import "./LibraryMenu.scss"; import "./LibraryMenu.scss";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { LIBRARY_DISABLED_TYPES } from "../constants";
import { isShallowEqual } from "../utils"; import { isShallowEqual } from "../utils";
import type { NonDeletedExcalidrawElement } from "../element/types";
import { LIBRARY_DISABLED_TYPES } from "../constants";
export const isLibraryMenuOpenAtom = atom(false); export const isLibraryMenuOpenAtom = atom(false);
@ -48,215 +43,170 @@ const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
return <div className="layer-ui__library">{children}</div>; return <div className="layer-ui__library">{children}</div>;
}; };
const LibraryMenuContent = memo( export const LibraryMenuContent = ({
({ onInsertLibraryItems,
onInsertLibraryItems, pendingElements,
pendingElements, onAddToLibrary,
onAddToLibrary, setAppState,
setAppState, libraryReturnUrl,
libraryReturnUrl, library,
library, id,
id, theme,
theme, selectedItems,
selectedItems, onSelectItems,
onSelectItems, }: {
}: { pendingElements: LibraryItem["elements"];
pendingElements: LibraryItem["elements"]; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onInsertLibraryItems: (libraryItems: LibraryItems) => void; onAddToLibrary: () => void;
onAddToLibrary: () => void; setAppState: React.Component<any, UIAppState>["setState"];
setAppState: React.Component<any, UIAppState>["setState"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; library: Library;
library: Library; id: string;
id: string; theme: UIAppState["theme"];
theme: UIAppState["theme"]; selectedItems: LibraryItem["id"][];
selectedItems: LibraryItem["id"][]; onSelectItems: (id: LibraryItem["id"][]) => void;
onSelectItems: (id: LibraryItem["id"][]) => void; }) => {
}) => { const [libraryItemsData] = useAtom(libraryItemsAtom);
const [libraryItemsData] = useAtom(libraryItemsAtom);
const _onAddToLibrary = useCallback(
const _onAddToLibrary = useCallback( (elements: LibraryItem["elements"]) => {
(elements: LibraryItem["elements"]) => { const addToLibrary = async (
const addToLibrary = async ( processedElements: LibraryItem["elements"],
processedElements: LibraryItem["elements"], libraryItems: LibraryItems,
libraryItems: LibraryItems, ) => {
) => { trackEvent("element", "addToLibrary", "ui");
trackEvent("element", "addToLibrary", "ui"); for (const type of LIBRARY_DISABLED_TYPES) {
for (const type of LIBRARY_DISABLED_TYPES) { if (processedElements.some((element) => element.type === type)) {
if (processedElements.some((element) => element.type === type)) { return setAppState({
return setAppState({ errorMessage: t(`errors.libraryElementTypeError.${type}`),
errorMessage: t(`errors.libraryElementTypeError.${type}`), });
});
}
} }
const nextItems: LibraryItems = [ }
{ const nextItems: LibraryItems = [
status: "unpublished", {
elements: processedElements, status: "unpublished",
id: randomId(), elements: processedElements,
created: Date.now(), id: randomId(),
}, created: Date.now(),
...libraryItems, },
]; ...libraryItems,
onAddToLibrary(); ];
library.setLibrary(nextItems).catch(() => { onAddToLibrary();
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); library.setLibrary(nextItems).catch(() => {
}); setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
}; });
addToLibrary(elements, libraryItemsData.libraryItems); };
}, addToLibrary(elements, libraryItemsData.libraryItems);
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems], },
); [onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
);
const libraryItems = useMemo( const libraryItems = useMemo(
() => libraryItemsData.libraryItems, () => libraryItemsData.libraryItems,
[libraryItemsData], [libraryItemsData],
); );
if ( if (
libraryItemsData.status === "loading" && libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized !libraryItemsData.isInitialized
) { ) {
return ( return (
<LibraryMenuWrapper> <LibraryMenuWrapper>
<div className="layer-ui__library-message"> <div className="layer-ui__library-message">
<div> <div>
<Spinner size="2em" /> <Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span> <span>{t("labels.libraryLoadingMessage")}</span>
</div>
</div> </div>
</LibraryMenuWrapper> </div>
); </LibraryMenuWrapper>
} );
}
const showBtn = const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0; libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
return ( return (
<LibraryMenuWrapper> <LibraryMenuWrapper>
<LibraryMenuItems <LibraryMenuItems
isLoading={libraryItemsData.status === "loading"} isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItems} libraryItems={libraryItems}
onAddToLibrary={_onAddToLibrary} onAddToLibrary={_onAddToLibrary}
onInsertLibraryItems={onInsertLibraryItems} onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements} pendingElements={pendingElements}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/>
{showBtn && (
<LibraryMenuControlButtons
className="library-menu-control-buttons--at-bottom"
style={{ padding: "16px 12px 0 12px" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={theme} theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/> />
{showBtn && ( )}
<LibraryMenuControlButtons </LibraryMenuWrapper>
className="library-menu-control-buttons--at-bottom" );
style={{ padding: "16px 12px 0 12px" }} };
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
)}
</LibraryMenuWrapper>
);
},
);
const getPendingElements = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElementIds: UIAppState["selectedElementIds"],
) => ({
elements,
pending: getSelectedElements(
elements,
{ selectedElementIds },
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
),
selectedElementIds,
});
const usePendingElementsMemo = ( const usePendingElementsMemo = (
appState: UIAppState, appState: UIAppState,
app: AppClassProperties, elements: readonly NonDeletedExcalidrawElement[],
) => { ) => {
const elements = useExcalidrawElements(); const create = useCallback(
const [state, setState] = useState(() => (appState: UIAppState, elements: readonly NonDeletedExcalidrawElement[]) =>
getPendingElements(elements, appState.selectedElementIds), getSelectedElements(elements, appState, {
); includeBoundTextElement: true,
includeElementsInFrames: true,
const selectedElementVersions = useRef( }),
new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(), [],
); );
useEffect(() => { const val = useRef(create(appState, elements));
for (const element of state.pending) { const prevAppState = useRef<UIAppState>(appState);
selectedElementVersions.current.set(element.id, element.version); const prevElements = useRef(elements);
}
}, [state.pending]);
useEffect(() => { const update = useCallback(() => {
if ( if (
// Only update once pointer is released. !isShallowEqual(
// Reading directly from app.state to make it clear it's not reactive appState.selectedElementIds,
// (hence, there's potential for stale state) prevAppState.current.selectedElementIds,
app.state.cursorButton === "up" && ) ||
app.state.activeTool.type === "selection" !isShallowEqual(elements, prevElements.current)
) { ) {
setState((prev) => { val.current = create(appState, elements);
// if selectedElementIds changed, we don't have to compare versions prevAppState.current = appState;
// --------------------------------------------------------------------- prevElements.current = elements;
if (
!isShallowEqual(prev.selectedElementIds, appState.selectedElementIds)
) {
selectedElementVersions.current.clear();
return getPendingElements(elements, appState.selectedElementIds);
}
// otherwise we need to check whether selected elements changed
// ---------------------------------------------------------------------
const elementsMap = app.scene.getNonDeletedElementsMap();
for (const id of Object.keys(appState.selectedElementIds)) {
const currVersion = elementsMap.get(id)?.version;
if (
currVersion &&
currVersion !== selectedElementVersions.current.get(id)
) {
// we can't update the selectedElementVersions in here
// because of double render in StrictMode which would overwrite
// the state in the second pass with the old `prev` state.
// Thus, we update versions in a separate effect. May create
// a race condition since current effect is not fully reactive.
return getPendingElements(elements, appState.selectedElementIds);
}
}
// nothing changed
// ---------------------------------------------------------------------
return prev;
});
} }
}, [ }, [create, appState, elements]);
app,
app.state.cursorButton, return useMemo(
app.state.activeTool.type, () => ({
appState.selectedElementIds, update,
elements, value: val.current,
]); }),
[update, val],
return state.pending; );
}; };
/** /**
* 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.
*/ */
export const LibraryMenu = memo(() => { export const LibraryMenu = () => {
const app = useApp(); const { library, id, onInsertElements } = useApp();
const { onInsertElements } = app;
const appProps = useAppProps(); const appProps = useAppProps();
const appState = useUIAppState(); const appState = useUIAppState();
const app = useApp();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const memoizedLibrary = useMemo(() => app.library, [app.library]); const memoizedLibrary = useMemo(() => library, [library]);
const pendingElements = usePendingElementsMemo(appState, app); // 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 onInsertLibraryItems = useCallback( const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => { (libraryItems: LibraryItems) => {
@ -273,18 +223,22 @@ export const LibraryMenu = memo(() => {
}); });
}, [setAppState]); }, [setAppState]);
useEffect(() => {
return app.onPointerUpEmitter.on(() => pendingElements.update());
}, [app, pendingElements]);
return ( return (
<LibraryMenuContent <LibraryMenuContent
pendingElements={pendingElements} pendingElements={pendingElements.value}
onInsertLibraryItems={onInsertLibraryItems} onInsertLibraryItems={onInsertLibraryItems}
onAddToLibrary={deselectItems} onAddToLibrary={deselectItems}
setAppState={setAppState} setAppState={setAppState}
libraryReturnUrl={appProps.libraryReturnUrl} libraryReturnUrl={appProps.libraryReturnUrl}
library={memoizedLibrary} library={memoizedLibrary}
id={app.id} id={id}
theme={appState.theme} theme={appState.theme}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={setSelectedItems} onSelectItems={setSelectedItems}
/> />
); );
}); };

Loading…
Cancel
Save