perf: Improved pointer events related performance when the sidebar is docked with a large library open (#9086)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
tothatt81 2025-02-04 22:05:56 +01:00 committed by GitHub
parent 424e94a403
commit 4f64372506
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 212 additions and 152 deletions

View file

@ -1522,13 +1522,17 @@ class App extends React.Component<AppProps, AppState> {
const allElementsMap = this.scene.getNonDeletedElementsMap(); const allElementsMap = this.scene.getNonDeletedElementsMap();
const shouldBlockPointerEvents = const shouldBlockPointerEvents =
this.state.selectionElement || // default back to `--ui-pointerEvents` flow if setPointerCapture
this.state.newElement || // not supported
this.state.selectedElementsAreBeingDragged || "setPointerCapture" in HTMLElement.prototype
this.state.resizingElement || ? false
(this.state.activeTool.type === "laser" && : this.state.selectionElement ||
// technically we can just test on this once we make it more safe this.state.newElement ||
this.state.cursorButton === "down"); this.state.selectedElementsAreBeingDragged ||
this.state.resizingElement ||
(this.state.activeTool.type === "laser" &&
// technically we can just test on this once we make it more safe
this.state.cursorButton === "down");
const firstSelectedElement = selectedElements[0]; const firstSelectedElement = selectedElements[0];
@ -6295,6 +6299,13 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = ( private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
const target = event.target as HTMLElement;
// capture subsequent pointer events to the canvas
// this makes other elements non-interactive until pointer up
if (target.setPointerCapture) {
target.setPointerCapture(event.pointerId);
}
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent); this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser(); this.maybeUnfollowRemoteUser();

View file

@ -2,8 +2,9 @@ import React, {
useState, useState,
useCallback, useCallback,
useMemo, useMemo,
useRef,
useEffect, useEffect,
memo,
useRef,
} from "react"; } from "react";
import type Library from "../data/library"; import type Library from "../data/library";
import { import {
@ -17,6 +18,7 @@ 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";
@ -33,9 +35,12 @@ 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 type {
import type { NonDeletedExcalidrawElement } from "../element/types"; ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { LIBRARY_DISABLED_TYPES } from "../constants"; import { LIBRARY_DISABLED_TYPES } from "../constants";
import { isShallowEqual } from "../utils";
export const isLibraryMenuOpenAtom = atom(false); export const isLibraryMenuOpenAtom = atom(false);
@ -43,170 +48,215 @@ const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
return <div className="layer-ui__library">{children}</div>; return <div className="layer-ui__library">{children}</div>;
}; };
export const LibraryMenuContent = ({ const LibraryMenuContent = memo(
onInsertLibraryItems, ({
pendingElements, onInsertLibraryItems,
onAddToLibrary, pendingElements,
setAppState, onAddToLibrary,
libraryReturnUrl, setAppState,
library, libraryReturnUrl,
id, library,
theme, id,
selectedItems, theme,
onSelectItems, selectedItems,
}: { onSelectItems,
pendingElements: LibraryItem["elements"]; }: {
onInsertLibraryItems: (libraryItems: LibraryItems) => void; pendingElements: LibraryItem["elements"];
onAddToLibrary: () => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
setAppState: React.Component<any, UIAppState>["setState"]; onAddToLibrary: () => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; setAppState: React.Component<any, UIAppState>["setState"];
library: Library; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
id: string; library: Library;
theme: UIAppState["theme"]; id: string;
selectedItems: LibraryItem["id"][]; theme: UIAppState["theme"];
onSelectItems: (id: LibraryItem["id"][]) => void; selectedItems: LibraryItem["id"][];
}) => { 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",
status: "unpublished", elements: processedElements,
elements: processedElements, id: randomId(),
id: randomId(), created: Date.now(),
created: Date.now(), },
}, ...libraryItems,
...libraryItems, ];
]; onAddToLibrary();
onAddToLibrary(); library.setLibrary(nextItems).catch(() => {
library.setLibrary(nextItems).catch(() => { setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
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 (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return (
<LibraryMenuWrapper>
<div className="layer-ui__library-message">
<div>
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</div>
</LibraryMenuWrapper>
);
}
const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
if (
libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return ( return (
<LibraryMenuWrapper> <LibraryMenuWrapper>
<div className="layer-ui__library-message"> <LibraryMenuItems
<div> isLoading={libraryItemsData.status === "loading"}
<Spinner size="2em" /> libraryItems={libraryItems}
<span>{t("labels.libraryLoadingMessage")}</span> onAddToLibrary={_onAddToLibrary}
</div> onInsertLibraryItems={onInsertLibraryItems}
</div> pendingElements={pendingElements}
</LibraryMenuWrapper>
);
}
const showBtn =
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
return (
<LibraryMenuWrapper>
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItems}
onAddToLibrary={_onAddToLibrary}
onInsertLibraryItems={onInsertLibraryItems}
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 && (
</LibraryMenuWrapper> <LibraryMenuControlButtons
); 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,
elements: readonly NonDeletedExcalidrawElement[], app: AppClassProperties,
) => { ) => {
const create = useCallback( const elements = useExcalidrawElements();
(appState: UIAppState, elements: readonly NonDeletedExcalidrawElement[]) => const [state, setState] = useState(() =>
getSelectedElements(elements, appState, { getPendingElements(elements, appState.selectedElementIds),
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
[],
); );
const val = useRef(create(appState, elements)); const selectedElementVersions = useRef(
const prevAppState = useRef<UIAppState>(appState); new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(),
const prevElements = useRef(elements); );
const update = useCallback(() => { useEffect(() => {
if ( for (const element of state.pending) {
!isShallowEqual( selectedElementVersions.current.set(element.id, element.version);
appState.selectedElementIds,
prevAppState.current.selectedElementIds,
) ||
!isShallowEqual(elements, prevElements.current)
) {
val.current = create(appState, elements);
prevAppState.current = appState;
prevElements.current = elements;
} }
}, [create, appState, elements]); }, [state.pending]);
return useMemo( useEffect(() => {
() => ({ if (
update, // Only update once pointer is released.
value: val.current, // Reading directly from app.state to make it clear it's not reactive
}), // (hence, there's potential for stale state)
[update, val], app.state.cursorButton === "up" &&
); app.state.activeTool.type === "selection"
) {
setState((prev) => {
// if selectedElementIds changed, we don't have to compare versions
// ---------------------------------------------------------------------
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;
});
}
}, [
app,
app.state.cursorButton,
app.state.activeTool.type,
appState.selectedElementIds,
elements,
]);
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 = () => { export const LibraryMenu = memo(() => {
const { library, id, onInsertElements } = useApp(); const app = 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(() => library, [library]); const memoizedLibrary = useMemo(() => app.library, [app.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, app);
const pendingElements = usePendingElementsMemo(appState, elements);
const onInsertLibraryItems = useCallback( const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => { (libraryItems: LibraryItems) => {
@ -223,22 +273,18 @@ export const LibraryMenu = () => {
}); });
}, [setAppState]); }, [setAppState]);
useEffect(() => {
return app.onPointerUpEmitter.on(() => pendingElements.update());
}, [app, pendingElements]);
return ( return (
<LibraryMenuContent <LibraryMenuContent
pendingElements={pendingElements.value} pendingElements={pendingElements}
onInsertLibraryItems={onInsertLibraryItems} onInsertLibraryItems={onInsertLibraryItems}
onAddToLibrary={deselectItems} onAddToLibrary={deselectItems}
setAppState={setAppState} setAppState={setAppState}
libraryReturnUrl={appProps.libraryReturnUrl} libraryReturnUrl={appProps.libraryReturnUrl}
library={memoizedLibrary} library={memoizedLibrary}
id={id} id={app.id}
theme={appState.theme} theme={appState.theme}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={setSelectedItems} onSelectItems={setSelectedItems}
/> />
); );
}; });

View file

@ -7,6 +7,9 @@ import polyfill from "./packages/excalidraw/polyfill";
import { testPolyfills } from "./packages/excalidraw/tests/helpers/polyfills"; import { testPolyfills } from "./packages/excalidraw/tests/helpers/polyfills";
import { yellow } from "./packages/excalidraw/tests/helpers/colorize"; import { yellow } from "./packages/excalidraw/tests/helpers/colorize";
// mock for pep.js not working with setPointerCapture()
HTMLElement.prototype.setPointerCapture = vi.fn();
Object.assign(globalThis, testPolyfills); Object.assign(globalThis, testPolyfills);
require("fake-indexeddb/auto"); require("fake-indexeddb/auto");