Browse Source

perf: make eraser great again (#9352)

* perf: make eraser great again

* lint

* refactor and improve perf

* lint
Ryan Di 4 months ago
parent
commit
58f7d33d80

+ 12 - 115
packages/excalidraw/components/App.tsx

@@ -454,7 +454,6 @@ import {
 import { Emitter } from "../emitter";
 import { Emitter } from "../emitter";
 import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
 import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
 import { Store, CaptureUpdateAction } from "../store";
 import { Store, CaptureUpdateAction } from "../store";
-import { AnimatedTrail } from "../animated-trail";
 import { LaserTrails } from "../laser-trails";
 import { LaserTrails } from "../laser-trails";
 import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
 import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
 import { textWysiwyg } from "../wysiwyg/textWysiwyg";
 import { textWysiwyg } from "../wysiwyg/textWysiwyg";
@@ -464,6 +463,8 @@ import { isMaybeMermaidDefinition } from "../mermaid";
 
 
 import { LassoTrail } from "../lasso";
 import { LassoTrail } from "../lasso";
 
 
+import { EraserTrail } from "../eraser";
+
 import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
 import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
 import BraveMeasureTextError from "./BraveMeasureTextError";
 import BraveMeasureTextError from "./BraveMeasureTextError";
 import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
 import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
@@ -675,26 +676,7 @@ class App extends React.Component<AppProps, AppState> {
   animationFrameHandler = new AnimationFrameHandler();
   animationFrameHandler = new AnimationFrameHandler();
 
 
   laserTrails = new LaserTrails(this.animationFrameHandler, this);
   laserTrails = new LaserTrails(this.animationFrameHandler, this);
-  eraserTrail = new AnimatedTrail(this.animationFrameHandler, this, {
-    streamline: 0.2,
-    size: 5,
-    keepHead: true,
-    sizeMapping: (c) => {
-      const DECAY_TIME = 200;
-      const DECAY_LENGTH = 10;
-      const t = Math.max(0, 1 - (performance.now() - c.pressure) / DECAY_TIME);
-      const l =
-        (DECAY_LENGTH -
-          Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
-        DECAY_LENGTH;
-
-      return Math.min(easeOut(l), easeOut(t));
-    },
-    fill: () =>
-      this.state.theme === THEME.LIGHT
-        ? "rgba(0, 0, 0, 0.2)"
-        : "rgba(255, 255, 255, 0.2)",
-  });
+  eraserTrail = new EraserTrail(this.animationFrameHandler, this);
   lassoTrail = new LassoTrail(this.animationFrameHandler, this);
   lassoTrail = new LassoTrail(this.animationFrameHandler, this);
 
 
   onChangeEmitter = new Emitter<
   onChangeEmitter = new Emitter<
@@ -1676,8 +1658,8 @@ class App extends React.Component<AppProps, AppState> {
                         <SVGLayer
                         <SVGLayer
                           trails={[
                           trails={[
                             this.laserTrails,
                             this.laserTrails,
-                            this.eraserTrail,
                             this.lassoTrail,
                             this.lassoTrail,
+                            this.eraserTrail,
                           ]}
                           ]}
                         />
                         />
                         {selectedElements.length === 1 &&
                         {selectedElements.length === 1 &&
@@ -5163,7 +5145,7 @@ class App extends React.Component<AppProps, AppState> {
     return elements;
     return elements;
   }
   }
 
 
-  private getElementHitThreshold() {
+  getElementHitThreshold() {
     return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
     return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
   }
   }
 
 
@@ -6219,101 +6201,16 @@ class App extends React.Component<AppProps, AppState> {
 
 
   private handleEraser = (
   private handleEraser = (
     event: PointerEvent,
     event: PointerEvent,
-    pointerDownState: PointerDownState,
     scenePointer: { x: number; y: number },
     scenePointer: { x: number; y: number },
   ) => {
   ) => {
-    this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y);
-
-    let didChange = false;
-
-    const processedGroups = new Set<ExcalidrawElement["id"]>();
-    const nonDeletedElements = this.scene.getNonDeletedElements();
-
-    const processElements = (elements: ExcalidrawElement[]) => {
-      for (const element of elements) {
-        if (element.locked) {
-          return;
-        }
-
-        if (event.altKey) {
-          if (this.elementsPendingErasure.delete(element.id)) {
-            didChange = true;
-          }
-        } else if (!this.elementsPendingErasure.has(element.id)) {
-          didChange = true;
-          this.elementsPendingErasure.add(element.id);
-        }
-
-        // (un)erase groups atomically
-        if (didChange && element.groupIds?.length) {
-          const shallowestGroupId = element.groupIds.at(-1)!;
-          if (!processedGroups.has(shallowestGroupId)) {
-            processedGroups.add(shallowestGroupId);
-            const elems = getElementsInGroup(
-              nonDeletedElements,
-              shallowestGroupId,
-            );
-            for (const elem of elems) {
-              if (event.altKey) {
-                this.elementsPendingErasure.delete(elem.id);
-              } else {
-                this.elementsPendingErasure.add(elem.id);
-              }
-            }
-          }
-        }
-      }
-    };
-
-    const distance = pointDistance(
-      pointFrom(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
-      pointFrom(scenePointer.x, scenePointer.y),
+    const elementsToErase = this.eraserTrail.addPointToPath(
+      scenePointer.x,
+      scenePointer.y,
+      event.altKey,
     );
     );
-    const threshold = this.getElementHitThreshold();
-    const p = { ...pointerDownState.lastCoords };
-    let samplingInterval = 0;
-    while (samplingInterval <= distance) {
-      const hitElements = this.getElementsAtPosition(p.x, p.y);
-      processElements(hitElements);
-
-      // Exit since we reached current point
-      if (samplingInterval === distance) {
-        break;
-      }
-
-      // Calculate next point in the line at a distance of sampling interval
-      samplingInterval = Math.min(samplingInterval + threshold, distance);
-
-      const distanceRatio = samplingInterval / distance;
-      const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x;
-      const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y;
-      p.x = nextX;
-      p.y = nextY;
-    }
 
 
-    pointerDownState.lastCoords.x = scenePointer.x;
-    pointerDownState.lastCoords.y = scenePointer.y;
-
-    if (didChange) {
-      for (const element of this.scene.getNonDeletedElements()) {
-        if (
-          isBoundToContainer(element) &&
-          (this.elementsPendingErasure.has(element.id) ||
-            this.elementsPendingErasure.has(element.containerId))
-        ) {
-          if (event.altKey) {
-            this.elementsPendingErasure.delete(element.id);
-            this.elementsPendingErasure.delete(element.containerId);
-          } else {
-            this.elementsPendingErasure.add(element.id);
-            this.elementsPendingErasure.add(element.containerId);
-          }
-        }
-      }
-
-      this.elementsPendingErasure = new Set(this.elementsPendingErasure);
-      this.triggerRender();
-    }
+    this.elementsPendingErasure = new Set(elementsToErase);
+    this.triggerRender();
   };
   };
 
 
   // set touch moving for mobile context menu
   // set touch moving for mobile context menu
@@ -8159,7 +8056,7 @@ class App extends React.Component<AppProps, AppState> {
       }
       }
 
 
       if (isEraserActive(this.state)) {
       if (isEraserActive(this.state)) {
-        this.handleEraser(event, pointerDownState, pointerCoords);
+        this.handleEraser(event, pointerCoords);
         return;
         return;
       }
       }
 
 

+ 239 - 0
packages/excalidraw/eraser/index.ts

@@ -0,0 +1,239 @@
+import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
+import { getElementLineSegments } from "@excalidraw/element/bounds";
+import {
+  lineSegment,
+  lineSegmentIntersectionPoints,
+  pointFrom,
+} from "@excalidraw/math";
+
+import { getElementsInGroup } from "@excalidraw/element/groups";
+
+import { getElementShape } from "@excalidraw/element/shapes";
+import { shouldTestInside } from "@excalidraw/element/collision";
+import { isPointInShape } from "@excalidraw/utils/collision";
+import {
+  hasBoundTextElement,
+  isBoundToContainer,
+} from "@excalidraw/element/typeChecks";
+import { getBoundTextElementId } from "@excalidraw/element/textElement";
+
+import type { GeometricShape } from "@excalidraw/utils/shape";
+import type {
+  ElementsSegmentsMap,
+  GlobalPoint,
+  LineSegment,
+} from "@excalidraw/math/types";
+import type { ExcalidrawElement } from "@excalidraw/element/types";
+
+import { AnimatedTrail } from "../animated-trail";
+
+import type { AnimationFrameHandler } from "../animation-frame-handler";
+
+import type App from "../components/App";
+
+// just enough to form a segment; this is sufficient for eraser
+const POINTS_ON_TRAIL = 2;
+
+export class EraserTrail extends AnimatedTrail {
+  private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
+  private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
+  private segmentsCache: Map<string, LineSegment<GlobalPoint>[]> = new Map();
+  private geometricShapesCache: Map<string, GeometricShape<GlobalPoint>> =
+    new Map();
+
+  constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
+    super(animationFrameHandler, app, {
+      streamline: 0.2,
+      size: 5,
+      keepHead: true,
+      sizeMapping: (c) => {
+        const DECAY_TIME = 200;
+        const DECAY_LENGTH = 10;
+        const t = Math.max(
+          0,
+          1 - (performance.now() - c.pressure) / DECAY_TIME,
+        );
+        const l =
+          (DECAY_LENGTH -
+            Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
+          DECAY_LENGTH;
+
+        return Math.min(easeOut(l), easeOut(t));
+      },
+      fill: () =>
+        app.state.theme === THEME.LIGHT
+          ? "rgba(0, 0, 0, 0.2)"
+          : "rgba(255, 255, 255, 0.2)",
+    });
+  }
+
+  startPath(x: number, y: number): void {
+    this.endPath();
+    super.startPath(x, y);
+    this.elementsToErase.clear();
+  }
+
+  addPointToPath(x: number, y: number, restore = false) {
+    super.addPointToPath(x, y);
+
+    const elementsToEraser = this.updateElementsToBeErased(restore);
+
+    return elementsToEraser;
+  }
+
+  private updateElementsToBeErased(restoreToErase?: boolean) {
+    let eraserPath: GlobalPoint[] =
+      super
+        .getCurrentTrail()
+        ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || [];
+
+    // for efficiency and avoid unnecessary calculations,
+    // take only POINTS_ON_TRAIL points to form some number of segments
+    eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL);
+
+    const visibleElementsMap = arrayToMap(this.app.visibleElements);
+
+    const pathSegments = eraserPath.reduce((acc, point, index) => {
+      if (index === 0) {
+        return acc;
+      }
+      acc.push(lineSegment(eraserPath[index - 1], point));
+      return acc;
+    }, [] as LineSegment<GlobalPoint>[]);
+
+    if (pathSegments.length === 0) {
+      return [];
+    }
+
+    for (const element of this.app.visibleElements) {
+      // restore only if already added to the to-be-erased set
+      if (restoreToErase && this.elementsToErase.has(element.id)) {
+        const intersects = eraserTest(
+          pathSegments,
+          element,
+          this.segmentsCache,
+          this.geometricShapesCache,
+          visibleElementsMap,
+          this.app,
+        );
+
+        if (intersects) {
+          const shallowestGroupId = element.groupIds.at(-1)!;
+
+          if (this.groupsToErase.has(shallowestGroupId)) {
+            const elementsInGroup = getElementsInGroup(
+              this.app.scene.getNonDeletedElementsMap(),
+              shallowestGroupId,
+            );
+            for (const elementInGroup of elementsInGroup) {
+              this.elementsToErase.delete(elementInGroup.id);
+            }
+            this.groupsToErase.delete(shallowestGroupId);
+          }
+
+          if (isBoundToContainer(element)) {
+            this.elementsToErase.delete(element.containerId);
+          }
+
+          if (hasBoundTextElement(element)) {
+            const boundText = getBoundTextElementId(element);
+
+            if (boundText) {
+              this.elementsToErase.delete(boundText);
+            }
+          }
+
+          this.elementsToErase.delete(element.id);
+        }
+      } else if (!restoreToErase && !this.elementsToErase.has(element.id)) {
+        const intersects = eraserTest(
+          pathSegments,
+          element,
+          this.segmentsCache,
+          this.geometricShapesCache,
+          visibleElementsMap,
+          this.app,
+        );
+
+        if (intersects) {
+          const shallowestGroupId = element.groupIds.at(-1)!;
+
+          if (!this.groupsToErase.has(shallowestGroupId)) {
+            const elementsInGroup = getElementsInGroup(
+              this.app.scene.getNonDeletedElementsMap(),
+              shallowestGroupId,
+            );
+
+            for (const elementInGroup of elementsInGroup) {
+              this.elementsToErase.add(elementInGroup.id);
+            }
+            this.groupsToErase.add(shallowestGroupId);
+          }
+
+          if (hasBoundTextElement(element)) {
+            const boundText = getBoundTextElementId(element);
+
+            if (boundText) {
+              this.elementsToErase.add(boundText);
+            }
+          }
+
+          if (isBoundToContainer(element)) {
+            this.elementsToErase.add(element.containerId);
+          }
+
+          this.elementsToErase.add(element.id);
+        }
+      }
+    }
+
+    return Array.from(this.elementsToErase);
+  }
+
+  endPath(): void {
+    super.endPath();
+    super.clearTrails();
+    this.elementsToErase.clear();
+    this.groupsToErase.clear();
+    this.segmentsCache.clear();
+  }
+}
+
+const eraserTest = (
+  pathSegments: LineSegment<GlobalPoint>[],
+  element: ExcalidrawElement,
+  elementsSegments: ElementsSegmentsMap,
+  shapesCache = new Map<string, GeometricShape<GlobalPoint>>(),
+  visibleElementsMap = new Map<string, ExcalidrawElement>(),
+  app: App,
+): boolean => {
+  let shape = shapesCache.get(element.id);
+
+  if (!shape) {
+    shape = getElementShape<GlobalPoint>(element, visibleElementsMap);
+    shapesCache.set(element.id, shape);
+  }
+
+  const lastPoint = pathSegments[pathSegments.length - 1][1];
+  if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) {
+    return true;
+  }
+
+  let elementSegments = elementsSegments.get(element.id);
+
+  if (!elementSegments) {
+    elementSegments = getElementLineSegments(element, visibleElementsMap);
+    elementsSegments.set(element.id, elementSegments);
+  }
+
+  return pathSegments.some((pathSegment) =>
+    elementSegments?.some(
+      (elementSegment) =>
+        lineSegmentIntersectionPoints(
+          pathSegment,
+          elementSegment,
+          app.getElementHitThreshold(),
+        ) !== null,
+    ),
+  );
+};

+ 5 - 3
packages/excalidraw/lasso/utils.ts

@@ -7,11 +7,13 @@ import {
   polygonIncludesPointNonZero,
   polygonIncludesPointNonZero,
 } from "@excalidraw/math";
 } from "@excalidraw/math";
 
 
-import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
+import type {
+  ElementsSegmentsMap,
+  GlobalPoint,
+  LineSegment,
+} from "@excalidraw/math/types";
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 
-export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;
-
 export const getLassoSelectedElementIds = (input: {
 export const getLassoSelectedElementIds = (input: {
   lassoPath: GlobalPoint[];
   lassoPath: GlobalPoint[];
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];

+ 1 - 2
packages/excalidraw/tests/lasso.test.tsx

@@ -19,6 +19,7 @@ import {
   type LocalPoint,
   type LocalPoint,
   pointFrom,
   pointFrom,
   type Radians,
   type Radians,
+  type ElementsSegmentsMap,
 } from "@excalidraw/math";
 } from "@excalidraw/math";
 
 
 import { getElementLineSegments } from "@excalidraw/element/bounds";
 import { getElementLineSegments } from "@excalidraw/element/bounds";
@@ -33,8 +34,6 @@ import { getLassoSelectedElementIds } from "../lasso/utils";
 
 
 import { act, render } from "./test-utils";
 import { act, render } from "./test-utils";
 
 
-import type { ElementsSegmentsMap } from "../lasso/utils";
-
 const { h } = window;
 const { h } = window;
 
 
 beforeEach(async () => {
 beforeEach(async () => {

+ 2 - 0
packages/math/src/types.ts

@@ -138,3 +138,5 @@ export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
 } & {
 } & {
   _brand: "excalimath_ellipse";
   _brand: "excalimath_ellipse";
 };
 };
+
+export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;