perf: improve new element drawing (#8340)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di 2024-08-24 02:27:57 +08:00 committed by GitHub
parent b5d7f5b4ba
commit 5e1ff7cafe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 749 additions and 495 deletions

View file

@ -103,7 +103,9 @@ export const SelectedShapeActions = ({
) {
isSingleElementBoundContainer = true;
}
const isEditing = Boolean(appState.editingElement);
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const device = useDevice();
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
@ -233,7 +235,7 @@ export const SelectedShapeActions = ({
</div>
</fieldset>
)}
{!isEditing && targetElements.length > 0 && (
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">

View file

@ -432,6 +432,7 @@ import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid";
import NewElementCanvas from "./canvases/NewElementCanvas";
import { mutateElbowArrow } from "../element/routing";
import {
FlowChartCreator,
@ -1472,25 +1473,21 @@ class App extends React.Component<AppProps, AppState> {
scrollY: this.state.scrollY,
height: this.state.height,
width: this.state.width,
editingElement: this.state.editingElement,
editingTextElement: this.state.editingTextElement,
newElementId: this.state.newElement?.id,
pendingImageElementId: this.state.pendingImageElementId,
});
const allElementsMap = this.scene.getNonDeletedElementsMap();
const shouldBlockPointerEvents =
!(
this.state.editingElement && isLinearElement(this.state.editingElement)
) &&
(this.state.selectionElement ||
this.state.newElement ||
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") ||
(this.state.editingElement &&
!isTextElement(this.state.editingElement)));
this.state.selectionElement ||
this.state.newElement ||
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];
@ -1697,6 +1694,27 @@ class App extends React.Component<AppProps, AppState> {
this.flowChartCreator.pendingNodes,
}}
/>
{this.state.newElement && (
<NewElementCanvas
appState={this.state}
scale={window.devicePixelRatio}
rc={this.rc}
elementsMap={elementsMap}
allElementsMap={allElementsMap}
renderConfig={{
imageCache: this.imageCache,
isExporting: false,
renderGrid: false,
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus:
this.embedsValidationStatus,
elementsPendingErasure:
this.elementsPendingErasure,
pendingFlowchartNodes: null,
}}
/>
)}
<InteractiveCanvas
containerRef={this.excalidrawContainerRef}
canvas={this.interactiveCanvas}
@ -2073,7 +2091,7 @@ class App extends React.Component<AppProps, AppState> {
let didUpdate = false;
let editingElement: AppState["editingElement"] | null = null;
let editingTextElement: AppState["editingTextElement"] | null = null;
if (actionResult.elements) {
this.scene.replaceAllElements(actionResult.elements);
didUpdate = true;
@ -2086,7 +2104,7 @@ class App extends React.Component<AppProps, AppState> {
this.addNewImagesToImageCache();
}
if (actionResult.appState || editingElement || this.state.contextMenu) {
if (actionResult.appState || editingTextElement || this.state.contextMenu) {
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
const theme =
@ -2102,23 +2120,24 @@ class App extends React.Component<AppProps, AppState> {
zenModeEnabled = this.props.zenModeEnabled;
}
editingElement = actionResult.appState?.editingElement || null;
editingTextElement = actionResult.appState?.editingTextElement || null;
// make sure editingElement points to latest element reference
if (actionResult.elements && editingElement) {
// make sure editingTextElement points to latest element reference
if (actionResult.elements && editingTextElement) {
actionResult.elements.forEach((element) => {
if (
editingElement?.id === element.id &&
editingElement !== element &&
isNonDeletedElement(element)
editingTextElement?.id === element.id &&
editingTextElement !== element &&
isNonDeletedElement(element) &&
isTextElement(element)
) {
editingElement = element;
editingTextElement = element;
}
});
}
if (editingElement?.isDeleted) {
editingElement = null;
if (editingTextElement?.isDeleted) {
editingTextElement = null;
}
this.setState((state) => {
@ -2130,7 +2149,7 @@ class App extends React.Component<AppProps, AppState> {
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
editingTextElement,
viewModeEnabled,
zenModeEnabled,
theme,
@ -2709,9 +2728,9 @@ class App extends React.Component<AppProps, AppState> {
}
// failsafe in case the state is being updated in incorrect order resulting
// in the editingElement being now a deleted element
if (this.state.editingElement?.isDeleted) {
this.setState({ editingElement: null });
// in the editingTextElement being now a deleted element
if (this.state.editingTextElement?.isDeleted) {
this.setState({ editingTextElement: null });
}
if (
@ -2767,7 +2786,7 @@ class App extends React.Component<AppProps, AppState> {
}
const scrolledOutside =
// hide when editing text
isTextElement(this.state.editingElement)
this.state.editingTextElement
? false
: !atLeastOneVisibleElement && elementsMap.size > 0;
if (this.state.scrolledOutside !== scrolledOutside) {
@ -4636,7 +4655,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
newElement: null,
editingElement: null,
editingTextElement: null,
});
if (this.state.activeTool.locked) {
setCursorForShape(this.interactiveCanvas, this.state);
@ -5010,7 +5029,7 @@ class App extends React.Component<AppProps, AppState> {
}),
});
}
this.setState({ editingElement: element });
this.setState({ editingTextElement: element });
if (!existingTextElement) {
if (container && shouldBindToContainer) {
@ -5467,9 +5486,13 @@ class App extends React.Component<AppProps, AppState> {
lastPoint[1],
) >= LINE_CONFIRM_THRESHOLD
) {
mutateElement(multiElement, {
points: [...points, [scenePointerX - rx, scenePointerY - ry]],
});
mutateElement(
multiElement,
{
points: [...points, [scenePointerX - rx, scenePointerY - ry]],
},
false,
);
} else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
// in this branch, we're inside the commit zone, and no uncommitted
@ -5486,9 +5509,13 @@ class App extends React.Component<AppProps, AppState> {
) < LINE_CONFIRM_THRESHOLD
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
mutateElement(multiElement, {
points: points.slice(0, -1),
});
mutateElement(
multiElement,
{
points: points.slice(0, -1),
},
false,
);
} else {
const [gridX, gridY] = getGridPoint(
scenePointerX,
@ -5534,20 +5561,30 @@ class App extends React.Component<AppProps, AppState> {
undefined,
{
isDragging: true,
informMutation: false,
},
);
} else {
// update last uncommitted point
mutateElement(multiElement, {
points: [
...points.slice(0, -1),
[
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
mutateElement(
multiElement,
{
points: [
...points.slice(0, -1),
[
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
],
],
],
});
},
false,
);
}
// in this path, we're mutating multiElement to reflect
// how it will be after adding pointer position as the next point
// trigger update here so that new element canvas renders again to reflect this
this.triggerRender(false);
}
return;
@ -5950,7 +5987,7 @@ class App extends React.Component<AppProps, AppState> {
: {}),
appState: {
newElement: null,
editingElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds: makeNextSelectedElementIds(
@ -6133,7 +6170,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
newElement: pendingImageElement as ExcalidrawNonSelectionElement,
editingElement: pendingImageElement,
pendingImageElementId: null,
multiElement: null,
});
@ -6339,7 +6375,7 @@ class App extends React.Component<AppProps, AppState> {
isHandToolActive(this.state) ||
this.state.viewModeEnabled)
) ||
isTextElement(this.state.editingElement)
this.state.editingTextElement
) {
return false;
}
@ -6883,7 +6919,7 @@ class App extends React.Component<AppProps, AppState> {
// if we're currently still editing text, clicking outside
// should only finalize it, not create another (irrespective
// of state.activeTool.locked)
if (isTextElement(this.state.editingElement)) {
if (this.state.editingTextElement) {
return;
}
let sceneX = pointerDownState.origin.x;
@ -6934,6 +6970,8 @@ class App extends React.Component<AppProps, AppState> {
y: gridY,
});
const simulatePressure = event.pressure === 0.5;
const element = newFreeDrawElement({
type: elementType,
x: gridX,
@ -6946,11 +6984,15 @@ class App extends React.Component<AppProps, AppState> {
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness: null,
simulatePressure: event.pressure === 0.5,
simulatePressure,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
points: [[0, 0]],
pressures: simulatePressure ? [] : [event.pressure],
});
this.scene.insertElement(element);
this.setState((prevState) => {
const nextSelectedElementIds = {
...prevState.selectedElementIds,
@ -6964,21 +7006,12 @@ class App extends React.Component<AppProps, AppState> {
};
});
const pressures = element.simulatePressure
? element.pressures
: [...element.pressures, event.pressure];
mutateElement(element, {
points: [[0, 0]],
pressures,
});
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
);
this.scene.insertElement(element);
this.setState({
newElement: element,
startBoundElement: boundElement,
@ -7279,7 +7312,6 @@ class App extends React.Component<AppProps, AppState> {
this.scene.insertElement(element);
this.setState({
newElement: element,
editingElement: element,
startBoundElement: boundElement,
suggestedBindings: [],
});
@ -7671,11 +7703,11 @@ class App extends React.Component<AppProps, AppState> {
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen)
// Checking for editingElement to avoid jump while editing on mobile #6503
// Checking for editingTextElement to avoid jump while editing on mobile #6503
if (
selectedElements.length > 0 &&
!pointerDownState.withCmdOrCtrl &&
!this.state.editingElement &&
!this.state.editingTextElement &&
this.state.activeEmbeddable?.state !== "active"
) {
const dragOffset = {
@ -7870,9 +7902,17 @@ class App extends React.Component<AppProps, AppState> {
? newElement.pressures
: [...newElement.pressures, event.pressure];
mutateElement(newElement, {
points: [...points, [dx, dy]],
pressures,
mutateElement(
newElement,
{
points: [...points, [dx, dy]],
pressures,
},
false,
);
this.setState({
newElement,
});
}
} else if (isLinearElement(newElement)) {
@ -7891,9 +7931,13 @@ class App extends React.Component<AppProps, AppState> {
}
if (points.length === 1) {
mutateElement(newElement, {
points: [...points, [dx, dy]],
});
mutateElement(
newElement,
{
points: [...points, [dx, dy]],
},
false,
);
} else if (points.length > 1 && isElbowArrow(newElement)) {
mutateElbowArrow(
newElement,
@ -7903,14 +7947,23 @@ class App extends React.Component<AppProps, AppState> {
undefined,
{
isDragging: true,
informMutation: false,
},
);
} else if (points.length === 2) {
mutateElement(newElement, {
points: [...points.slice(0, -1), [dx, dy]],
});
mutateElement(
newElement,
{
points: [...points.slice(0, -1), [dx, dy]],
},
false,
);
}
this.setState({
newElement,
});
if (isBindingElement(newElement, false)) {
// When creating a linear element by dragging
this.maybeSuggestBindingsForLinearElementAtCoords(
@ -7922,7 +7975,7 @@ class App extends React.Component<AppProps, AppState> {
} else {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
this.maybeDragNewGenericElement(pointerDownState, event);
this.maybeDragNewGenericElement(pointerDownState, event, false);
}
}
@ -8080,12 +8133,6 @@ class App extends React.Component<AppProps, AppState> {
frameToHighlight: null,
elementsToHighlight: null,
cursorButton: "up",
// text elements are reset on finalize, and resetting on pointerup
// may cause issues with double taps
editingElement:
multiElement || isTextElement(this.state.editingElement)
? this.state.editingElement
: null,
snapLines: updateStable(prevState.snapLines, []),
originSnapOffset: null,
}));
@ -8270,7 +8317,7 @@ class App extends React.Component<AppProps, AppState> {
});
this.setState({
multiElement: newElement,
editingElement: this.state.newElement,
newElement,
});
} else if (pointerDownState.drag.hasOccurred && !multiElement) {
if (
@ -8307,6 +8354,8 @@ class App extends React.Component<AppProps, AppState> {
newElement: null,
}));
}
// so that the scene gets rendered again to display the newly drawn linear as well
this.scene.triggerUpdate();
}
return;
}
@ -8371,6 +8420,8 @@ class App extends React.Component<AppProps, AppState> {
if (newElement) {
mutateElement(newElement, getNormalizedDimensions(newElement));
// the above does not guarantee the scene to be rendered again, hence the trigger below
this.scene.triggerUpdate();
}
if (pointerDownState.drag.hasOccurred) {
@ -9179,7 +9230,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState(
{
pendingImageElementId: null,
editingElement: null,
newElement: null,
activeTool: updateActiveTool(this.state, { type: "selection" }),
},
() => {
@ -9675,23 +9726,25 @@ class App extends React.Component<AppProps, AppState> {
private maybeDragNewGenericElement = (
pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent,
informMutation = true,
): void => {
const selectionElement = this.state.selectionElement;
const pointerCoords = pointerDownState.lastCoords;
if (selectionElement && this.state.activeTool.type !== "eraser") {
dragNewElement(
selectionElement,
this.state.activeTool.type,
pointerDownState.origin.x,
pointerDownState.origin.y,
pointerCoords.x,
pointerCoords.y,
distance(pointerDownState.origin.x, pointerCoords.x),
distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
this.state.zoom.value,
);
dragNewElement({
newElement: selectionElement,
elementType: this.state.activeTool.type,
originX: pointerDownState.origin.x,
originY: pointerDownState.origin.y,
x: pointerCoords.x,
y: pointerCoords.y,
width: distance(pointerDownState.origin.x, pointerCoords.x),
height: distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: shouldResizeFromCenter(event),
zoom: this.state.zoom.value,
informMutation,
});
return;
}
@ -9740,23 +9793,28 @@ class App extends React.Component<AppProps, AppState> {
snapLines,
});
dragNewElement(
dragNewElement({
newElement,
this.state.activeTool.type,
pointerDownState.originInGrid.x,
pointerDownState.originInGrid.y,
gridX,
gridY,
distance(pointerDownState.originInGrid.x, gridX),
distance(pointerDownState.originInGrid.y, gridY),
isImageElement(newElement)
elementType: this.state.activeTool.type,
originX: pointerDownState.originInGrid.x,
originY: pointerDownState.originInGrid.y,
x: gridX,
y: gridY,
width: distance(pointerDownState.originInGrid.x, gridX),
height: distance(pointerDownState.originInGrid.y, gridY),
shouldMaintainAspectRatio: isImageElement(newElement)
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
this.state.zoom.value,
aspectRatio,
this.state.originSnapOffset,
);
shouldResizeFromCenter: shouldResizeFromCenter(event),
zoom: this.state.zoom.value,
widthAspectRatio: aspectRatio,
originOffset: this.state.originSnapOffset,
informMutation,
});
this.setState({
newElement,
});
// highlight elements that are to be added to frames on frames creation
if (

View file

@ -87,7 +87,7 @@ const getHints = ({
return t("hints.text_selected");
}
if (appState.editingElement && isTextElement(appState.editingElement)) {
if (appState.editingTextElement) {
return t("hints.text_editing");
}
@ -95,7 +95,7 @@ const getHints = ({
if (
appState.selectionElement &&
!selectedElements.length &&
!appState.editingElement &&
!appState.editingTextElement &&
!appState.editingLinearElement
) {
return t("hints.deepBoxSelect");

View file

@ -202,7 +202,7 @@ const getRelevantAppStateProps = (
activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
editingElement: appState.editingElement,
editingTextElement: appState.editingTextElement,
});
const areEqual = (

View file

@ -0,0 +1,56 @@
import { useEffect, useRef } from "react";
import type { NonDeletedSceneElementsMap } from "../../element/types";
import type { AppState } from "../../types";
import type {
RenderableElementsMap,
StaticCanvasRenderConfig,
} from "../../scene/types";
import type { RoughCanvas } from "roughjs/bin/canvas";
import { renderNewElementScene } from "../../renderer/renderNewElementScene";
import { isRenderThrottlingEnabled } from "../../reactUtils";
interface NewElementCanvasProps {
appState: AppState;
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
scale: number;
rc: RoughCanvas;
renderConfig: StaticCanvasRenderConfig;
}
const NewElementCanvas = (props: NewElementCanvasProps) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!canvasRef.current) {
return;
}
renderNewElementScene(
{
canvas: canvasRef.current,
scale: props.scale,
newElement: props.appState.newElement,
elementsMap: props.elementsMap,
allElementsMap: props.allElementsMap,
rc: props.rc,
renderConfig: props.renderConfig,
appState: props.appState,
},
isRenderThrottlingEnabled(),
);
});
return (
<canvas
className="excalidraw__canvas"
style={{
width: props.appState.width,
height: props.appState.height,
}}
width={props.appState.width * props.scale}
height={props.appState.height * props.scale}
ref={canvasRef}
/>
);
};
export default NewElementCanvas;