mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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:
parent
424e94a403
commit
4f64372506
3 changed files with 212 additions and 152 deletions
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
@ -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");
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue