Fix performance bug (#984)

This commit is contained in:
Pete Hunt 2020-03-16 19:07:47 -07:00 committed by GitHub
parent f4cc4253b8
commit e9f5175f51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 121 additions and 95 deletions

View File

@ -97,6 +97,16 @@ import { ScrollBars } from "../scene/types";
import { generateCollaborationLink, getCollaborationLinkData } from "../data"; import { generateCollaborationLink, getCollaborationLinkData } from "../data";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { invalidateShapeForElement } from "../renderer/renderElement"; import { invalidateShapeForElement } from "../renderer/renderElement";
import { unstable_batchedUpdates } from "react-dom";
import { SceneStateCallbackRemover } from "../scene/globalScene";
function withBatchedUpdates<
TFunction extends ((event: any) => void) | (() => void)
>(func: TFunction) {
return (event => {
unstable_batchedUpdates(func, event);
}) as TFunction;
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// TEST HOOKS // TEST HOOKS
@ -159,6 +169,7 @@ export class App extends React.Component<any, AppState> {
roomID: string | null = null; roomID: string | null = null;
roomKey: string | null = null; roomKey: string | null = null;
lastBroadcastedOrReceivedSceneVersion: number = -1; lastBroadcastedOrReceivedSceneVersion: number = -1;
removeSceneCallback: SceneStateCallbackRemover | null = null;
actionManager: ActionManager; actionManager: ActionManager;
canvasOnlyActions = ["selectAll"]; canvasOnlyActions = ["selectAll"];
@ -201,7 +212,7 @@ export class App extends React.Component<any, AppState> {
} }
}; };
private onCut = (event: ClipboardEvent) => { private onCut = withBatchedUpdates((event: ClipboardEvent) => {
if (isWritableElement(event.target)) { if (isWritableElement(event.target)) {
return; return;
} }
@ -214,20 +225,21 @@ export class App extends React.Component<any, AppState> {
history.resumeRecording(); history.resumeRecording();
this.setState({ ...appState }); this.setState({ ...appState });
event.preventDefault(); event.preventDefault();
}; });
private onCopy = (event: ClipboardEvent) => {
private onCopy = withBatchedUpdates((event: ClipboardEvent) => {
if (isWritableElement(event.target)) { if (isWritableElement(event.target)) {
return; return;
} }
copyToAppClipboard(globalSceneState.getAllElements(), this.state); copyToAppClipboard(globalSceneState.getAllElements(), this.state);
event.preventDefault(); event.preventDefault();
}; });
private onUnload = () => { private onUnload = withBatchedUpdates(() => {
isHoldingSpace = false; isHoldingSpace = false;
this.saveDebounced(); this.saveDebounced();
this.saveDebounced.flush(); this.saveDebounced.flush();
}; });
private disableEvent: EventHandlerNonNull = event => { private disableEvent: EventHandlerNonNull = event => {
event.preventDefault(); event.preventDefault();
@ -454,7 +466,7 @@ export class App extends React.Component<any, AppState> {
} }
} }
private handleSceneCallback = () => { private onSceneUpdated = () => {
this.setState({}); this.setState({});
}; };
@ -469,7 +481,9 @@ export class App extends React.Component<any, AppState> {
}); });
} }
globalSceneState.addCallback(this.handleSceneCallback); this.removeSceneCallback = globalSceneState.addCallback(
this.onSceneUpdated,
);
document.addEventListener("copy", this.onCopy); document.addEventListener("copy", this.onCopy);
document.addEventListener("paste", this.pasteFromClipboard); document.addEventListener("paste", this.pasteFromClipboard);
@ -528,6 +542,8 @@ export class App extends React.Component<any, AppState> {
public componentWillUnmount() { public componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
this.removeSceneCallback!();
document.removeEventListener("copy", this.onCopy); document.removeEventListener("copy", this.onCopy);
document.removeEventListener("paste", this.pasteFromClipboard); document.removeEventListener("paste", this.pasteFromClipboard);
document.removeEventListener("cut", this.onCut); document.removeEventListener("cut", this.onCut);
@ -561,19 +577,21 @@ export class App extends React.Component<any, AppState> {
public state: AppState = getDefaultAppState(); public state: AppState = getDefaultAppState();
private onResize = () => { private onResize = withBatchedUpdates(() => {
globalSceneState globalSceneState
.getAllElements() .getAllElements()
.forEach(element => invalidateShapeForElement(element)); .forEach(element => invalidateShapeForElement(element));
this.setState({}); this.setState({});
}; });
private updateCurrentCursorPosition = (event: MouseEvent) => { private updateCurrentCursorPosition = withBatchedUpdates(
cursorX = event.x; (event: MouseEvent) => {
cursorY = event.y; cursorX = event.x;
}; cursorY = event.y;
},
);
private onKeyDown = (event: KeyboardEvent) => { private onKeyDown = withBatchedUpdates((event: KeyboardEvent) => {
if ( if (
(isWritableElement(event.target) && event.key !== KEYS.ESCAPE) || (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) ||
// case: using arrows to move between buttons // case: using arrows to move between buttons
@ -629,9 +647,9 @@ export class App extends React.Component<any, AppState> {
isHoldingSpace = true; isHoldingSpace = true;
document.documentElement.style.cursor = CURSOR_TYPE.GRABBING; document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
} }
}; });
private onKeyUp = (event: KeyboardEvent) => { private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
if (event.key === KEYS.SPACE) { if (event.key === KEYS.SPACE) {
if (this.state.elementType === "selection") { if (this.state.elementType === "selection") {
resetCursor(); resetCursor();
@ -644,7 +662,7 @@ export class App extends React.Component<any, AppState> {
} }
isHoldingSpace = false; isHoldingSpace = false;
} }
}; });
private copyToAppClipboard = () => { private copyToAppClipboard = () => {
copyToAppClipboard(globalSceneState.getAllElements(), this.state); copyToAppClipboard(globalSceneState.getAllElements(), this.state);
@ -666,55 +684,57 @@ export class App extends React.Component<any, AppState> {
); );
}; };
private pasteFromClipboard = async (event: ClipboardEvent | null) => { private pasteFromClipboard = withBatchedUpdates(
// #686 async (event: ClipboardEvent | null) => {
const target = document.activeElement; // #686
const elementUnderCursor = document.elementFromPoint(cursorX, cursorY); const target = document.activeElement;
if ( const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
// if no ClipboardEvent supplied, assume we're pasting via contextMenu if (
// thus these checks don't make sense // if no ClipboardEvent supplied, assume we're pasting via contextMenu
!event || // thus these checks don't make sense
(elementUnderCursor instanceof HTMLCanvasElement && !event ||
!isWritableElement(target)) (elementUnderCursor instanceof HTMLCanvasElement &&
) { !isWritableElement(target))
const data = await getClipboardContent(event); ) {
if (data.elements) { const data = await getClipboardContent(event);
this.addElementsFromPaste(data.elements); if (data.elements) {
} else if (data.text) { this.addElementsFromPaste(data.elements);
const { x, y } = viewportCoordsToSceneCoords( } else if (data.text) {
{ clientX: cursorX, clientY: cursorY }, const { x, y } = viewportCoordsToSceneCoords(
this.state, { clientX: cursorX, clientY: cursorY },
this.canvas, this.state,
window.devicePixelRatio, this.canvas,
); window.devicePixelRatio,
);
const element = newTextElement( const element = newTextElement(
newElement( newElement(
"text", "text",
x, x,
y, y,
this.state.currentItemStrokeColor, this.state.currentItemStrokeColor,
this.state.currentItemBackgroundColor, this.state.currentItemBackgroundColor,
this.state.currentItemFillStyle, this.state.currentItemFillStyle,
this.state.currentItemStrokeWidth, this.state.currentItemStrokeWidth,
this.state.currentItemRoughness, this.state.currentItemRoughness,
this.state.currentItemOpacity, this.state.currentItemOpacity,
), ),
data.text, data.text,
this.state.currentItemFont, this.state.currentItemFont,
); );
globalSceneState.replaceAllElements([ globalSceneState.replaceAllElements([
...globalSceneState.getAllElements(), ...globalSceneState.getAllElements(),
element, element,
]); ]);
this.setState({ selectedElementIds: { [element.id]: true } }); this.setState({ selectedElementIds: { [element.id]: true } });
history.resumeRecording(); history.resumeRecording();
}
this.selectShapeTool("selection");
event?.preventDefault();
} }
this.selectShapeTool("selection"); },
event?.preventDefault(); );
}
};
private selectShapeTool(elementType: AppState["elementType"]) { private selectShapeTool(elementType: AppState["elementType"]) {
if (!isHoldingSpace) { if (!isHoldingSpace) {
@ -730,21 +750,23 @@ export class App extends React.Component<any, AppState> {
} }
} }
private onGestureStart = (event: GestureEvent) => { private onGestureStart = withBatchedUpdates((event: GestureEvent) => {
event.preventDefault(); event.preventDefault();
gesture.initialScale = this.state.zoom; gesture.initialScale = this.state.zoom;
}; });
private onGestureChange = (event: GestureEvent) => {
private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
event.preventDefault(); event.preventDefault();
this.setState({ this.setState({
zoom: getNormalizedZoom(gesture.initialScale! * event.scale), zoom: getNormalizedZoom(gesture.initialScale! * event.scale),
}); });
}; });
private onGestureEnd = (event: GestureEvent) => {
private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
event.preventDefault(); event.preventDefault();
gesture.initialScale = null; gesture.initialScale = null;
}; });
setAppState = (obj: any) => { setAppState = (obj: any) => {
this.setState(obj); this.setState(obj);
@ -1174,7 +1196,7 @@ export class App extends React.Component<any, AppState> {
isPanning = true; isPanning = true;
document.documentElement.style.cursor = CURSOR_TYPE.GRABBING; document.documentElement.style.cursor = CURSOR_TYPE.GRABBING;
let { clientX: lastX, clientY: lastY } = event; let { clientX: lastX, clientY: lastY } = event;
const onPointerMove = (event: PointerEvent) => { const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
const deltaX = lastX - event.clientX; const deltaX = lastX - event.clientX;
const deltaY = lastY - event.clientY; const deltaY = lastY - event.clientY;
lastX = event.clientX; lastX = event.clientX;
@ -1188,17 +1210,19 @@ export class App extends React.Component<any, AppState> {
this.state.scrollY - deltaY / this.state.zoom, this.state.scrollY - deltaY / this.state.zoom,
), ),
}); });
};
const teardown = (lastPointerUp = () => {
lastPointerUp = null;
isPanning = false;
if (!isHoldingSpace) {
setCursorForShape(this.state.elementType);
}
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", teardown);
window.removeEventListener("blur", teardown);
}); });
const teardown = withBatchedUpdates(
(lastPointerUp = () => {
lastPointerUp = null;
isPanning = false;
if (!isHoldingSpace) {
setCursorForShape(this.state.elementType);
}
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", teardown);
window.removeEventListener("blur", teardown);
}),
);
window.addEventListener("blur", teardown); window.addEventListener("blur", teardown);
window.addEventListener("pointermove", onPointerMove, { window.addEventListener("pointermove", onPointerMove, {
passive: true, passive: true,
@ -1264,7 +1288,7 @@ export class App extends React.Component<any, AppState> {
isDraggingScrollBar = true; isDraggingScrollBar = true;
lastX = event.clientX; lastX = event.clientX;
lastY = event.clientY; lastY = event.clientY;
const onPointerMove = (event: PointerEvent) => { const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
const target = event.target; const target = event.target;
if (!(target instanceof HTMLElement)) { if (!(target instanceof HTMLElement)) {
return; return;
@ -1288,15 +1312,15 @@ export class App extends React.Component<any, AppState> {
}); });
lastY = y; lastY = y;
} }
}; });
const onPointerUp = () => { const onPointerUp = withBatchedUpdates(() => {
isDraggingScrollBar = false; isDraggingScrollBar = false;
setCursorForShape(this.state.elementType); setCursorForShape(this.state.elementType);
lastPointerUp = null; lastPointerUp = null;
window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp); window.removeEventListener("pointerup", onPointerUp);
}; });
lastPointerUp = onPointerUp; lastPointerUp = onPointerUp;
@ -1624,7 +1648,7 @@ export class App extends React.Component<any, AppState> {
} }
}; };
const onPointerMove = (event: PointerEvent) => { const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
const target = event.target; const target = event.target;
if (!(target instanceof HTMLElement)) { if (!(target instanceof HTMLElement)) {
return; return;
@ -1997,9 +2021,9 @@ export class App extends React.Component<any, AppState> {
}, },
})); }));
} }
}; });
const onPointerUp = (event: PointerEvent) => { const onPointerUp = withBatchedUpdates((event: PointerEvent) => {
const { const {
draggingElement, draggingElement,
resizingElement, resizingElement,
@ -2150,7 +2174,7 @@ export class App extends React.Component<any, AppState> {
draggingElement: null, draggingElement: null,
}); });
} }
}; });
lastPointerUp = onPointerUp; lastPointerUp = onPointerUp;
@ -2158,7 +2182,7 @@ export class App extends React.Component<any, AppState> {
window.addEventListener("pointerup", onPointerUp); window.addEventListener("pointerup", onPointerUp);
}; };
private handleWheel = (event: WheelEvent) => { private handleWheel = withBatchedUpdates((event: WheelEvent) => {
event.preventDefault(); event.preventDefault();
const { deltaX, deltaY } = event; const { deltaX, deltaY } = event;
@ -2181,9 +2205,9 @@ export class App extends React.Component<any, AppState> {
scrollX: normalizeScroll(scrollX - deltaX / zoom), scrollX: normalizeScroll(scrollX - deltaX / zoom),
scrollY: normalizeScroll(scrollY - deltaY / zoom), scrollY: normalizeScroll(scrollY - deltaY / zoom),
})); }));
}; });
private beforeUnload = (event: BeforeUnloadEvent) => { private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
if ( if (
this.state.isCollaborating && this.state.isCollaborating &&
hasNonDeletedElements(globalSceneState.getAllElements()) hasNonDeletedElements(globalSceneState.getAllElements())
@ -2192,7 +2216,7 @@ export class App extends React.Component<any, AppState> {
// NOTE: modern browsers no longer allow showing a custom message here // NOTE: modern browsers no longer allow showing a custom message here
event.returnValue = ""; event.returnValue = "";
} }
}; });
private addElementsFromPaste = ( private addElementsFromPaste = (
clipboardElements: readonly ExcalidrawElement[], clipboardElements: readonly ExcalidrawElement[],

View File

@ -1,5 +1,6 @@
import React, { useLayoutEffect, useRef, useEffect } from "react"; import React, { useLayoutEffect, useRef, useEffect } from "react";
import "./Popover.css"; import "./Popover.css";
import { unstable_batchedUpdates } from "react-dom";
type Props = { type Props = {
top?: number; top?: number;
@ -39,7 +40,7 @@ export function Popover({
if (onCloseRequest) { if (onCloseRequest) {
const handler = (e: Event) => { const handler = (e: Event) => {
if (!popoverRef.current?.contains(e.target as Node)) { if (!popoverRef.current?.contains(e.target as Node)) {
onCloseRequest(); unstable_batchedUpdates(() => onCloseRequest());
} }
}; };
document.addEventListener("pointerdown", handler, false); document.addEventListener("pointerdown", handler, false);

View File

@ -10,7 +10,8 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
// This function tracks updates of text elements for the purposes for collaboration. // This function tracks updates of text elements for the purposes for collaboration.
// The version is used to compare updates when more than one user is working in // The version is used to compare updates when more than one user is working in
// the same drawing. // the same drawing. Note: this will trigger the component to update. Make sure you
// are calling it either from a React event handler or within unstable_batchedUpdates().
export function mutateElement<TElement extends ExcalidrawElement>( export function mutateElement<TElement extends ExcalidrawElement>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,