浏览代码

feat: support pen erasing (#7496)

David Luzar 1 年之前
父节点
当前提交
e6c3c06c2e

+ 89 - 25
packages/excalidraw/components/App.tsx

@@ -245,6 +245,7 @@ import {
   CollaboratorPointer,
   ToolType,
   OnUserFollowedPayload,
+  UnsubscribeCallback,
 } from "../types";
 import {
   debounce,
@@ -488,7 +489,7 @@ let IS_PLAIN_PASTE = false;
 let IS_PLAIN_PASTE_TIMER = 0;
 let PLAIN_PASTE_TOAST_SHOWN = false;
 
-let lastPointerUp: ((event: any) => void) | null = null;
+let lastPointerUp: (() => void) | null = null;
 const gesture: Gesture = {
   pointers: new Map(),
   lastCenter: null,
@@ -528,6 +529,7 @@ class App extends React.Component<AppProps, AppState> {
   lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
   lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
     null;
+  lastPointerMoveEvent: PointerEvent | null = null;
   lastViewportPosition = { x: 0, y: 0 };
 
   laserPathManager: LaserPathManager = new LaserPathManager(this);
@@ -560,6 +562,9 @@ class App extends React.Component<AppProps, AppState> {
     [scrollX: number, scrollY: number, zoom: AppState["zoom"]]
   >();
 
+  missingPointerEventCleanupEmitter = new Emitter<
+    [event: PointerEvent | null]
+  >();
   onRemoveEventListenersEmitter = new Emitter<[]>();
 
   constructor(props: AppProps) {
@@ -2372,7 +2377,7 @@ class App extends React.Component<AppProps, AppState> {
     this.scene.destroy();
     this.library.destroy();
     this.laserPathManager.destroy();
-    this.onChangeEmitter.destroy();
+    this.onChangeEmitter.clear();
     ShapeCache.destroy();
     SnapCache.destroy();
     clearTimeout(touchTimeout);
@@ -2464,6 +2469,9 @@ class App extends React.Component<AppProps, AppState> {
         this.onGestureEnd as any,
         false,
       ),
+      addEventListener(window, EVENT.FOCUS, () => {
+        this.maybeCleanupAfterMissingPointerUp(null);
+      }),
     );
 
     if (this.state.viewModeEnabled) {
@@ -4616,6 +4624,7 @@ class App extends React.Component<AppProps, AppState> {
     event: React.PointerEvent<HTMLCanvasElement>,
   ) => {
     this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
+    this.lastPointerMoveEvent = event.nativeEvent;
 
     if (gesture.pointers.has(event.pointerId)) {
       gesture.pointers.set(event.pointerId, {
@@ -5203,6 +5212,7 @@ class App extends React.Component<AppProps, AppState> {
   private handleCanvasPointerDown = (
     event: React.PointerEvent<HTMLElement>,
   ) => {
+    this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
     this.maybeUnfollowRemoteUser();
 
     // since contextMenu options are potentially evaluated on each render,
@@ -5265,7 +5275,6 @@ class App extends React.Component<AppProps, AppState> {
       selection.removeAllRanges();
     }
     this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
-    this.maybeCleanupAfterMissingPointerUp(event);
 
     //fires only once, if pen is detected, penMode is enabled
     //the user can disable this by toggling the penMode button
@@ -5304,10 +5313,60 @@ class App extends React.Component<AppProps, AppState> {
     });
     this.savePointer(event.clientX, event.clientY, "down");
 
+    if (
+      event.button === POINTER_BUTTON.ERASER &&
+      this.state.activeTool.type !== TOOL_TYPE.eraser
+    ) {
+      this.setState(
+        {
+          activeTool: updateActiveTool(this.state, {
+            type: TOOL_TYPE.eraser,
+            lastActiveToolBeforeEraser: this.state.activeTool,
+          }),
+        },
+        () => {
+          this.handleCanvasPointerDown(event);
+          const onPointerUp = () => {
+            unsubPointerUp();
+            unsubCleanup?.();
+            if (isEraserActive(this.state)) {
+              this.setState({
+                activeTool: updateActiveTool(this.state, {
+                  ...(this.state.activeTool.lastActiveTool || {
+                    type: TOOL_TYPE.selection,
+                  }),
+                  lastActiveToolBeforeEraser: null,
+                }),
+              });
+            }
+          };
+
+          const unsubPointerUp = addEventListener(
+            window,
+            EVENT.POINTER_UP,
+            onPointerUp,
+            {
+              once: true,
+            },
+          );
+          let unsubCleanup: UnsubscribeCallback | undefined;
+          // subscribe inside rAF lest it'd be triggered on the same pointerdown
+          // if we start erasing while coming from blurred document since
+          // we cleanup pointer events on focus
+          requestAnimationFrame(() => {
+            unsubCleanup =
+              this.missingPointerEventCleanupEmitter.once(onPointerUp);
+          });
+        },
+      );
+      return;
+    }
+
     // only handle left mouse button or touch
     if (
       event.button !== POINTER_BUTTON.MAIN &&
-      event.button !== POINTER_BUTTON.TOUCH
+      event.button !== POINTER_BUTTON.TOUCH &&
+      event.button !== POINTER_BUTTON.ERASER
     ) {
       return;
     }
@@ -5435,7 +5494,9 @@ class App extends React.Component<AppProps, AppState> {
     const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState);
     const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState);
 
-    lastPointerUp = onPointerUp;
+    this.missingPointerEventCleanupEmitter.once((_event) =>
+      onPointerUp(_event || event.nativeEvent),
+    );
 
     if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
       window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
@@ -5546,16 +5607,15 @@ class App extends React.Component<AppProps, AppState> {
     invalidateContextMenu = false;
   };
 
-  private maybeCleanupAfterMissingPointerUp(
-    event: React.PointerEvent<HTMLElement>,
-  ): void {
-    if (lastPointerUp !== null) {
-      // Unfortunately, sometimes we don't get a pointerup after a pointerdown,
-      // this can happen when a contextual menu or alert is triggered. In order to avoid
-      // being in a weird state, we clean up on the next pointerdown
-      lastPointerUp(event);
-    }
-  }
+  /**
+   * pointerup may not fire in certian cases (user tabs away...), so in order
+   * to properly cleanup pointerdown state, we need to fire any hanging
+   * pointerup handlers manually
+   */
+  private maybeCleanupAfterMissingPointerUp = (event: PointerEvent | null) => {
+    lastPointerUp?.();
+    this.missingPointerEventCleanupEmitter.trigger(event).clear();
+  };
 
   // Returns whether the event is a panning
   private handleCanvasPanUsingWheelOrSpaceDrag = (
@@ -5758,11 +5818,10 @@ class App extends React.Component<AppProps, AppState> {
 
       this.handlePointerMoveOverScrollbars(event, pointerDownState);
     });
-
     const onPointerUp = withBatchedUpdates(() => {
+      lastPointerUp = null;
       isDraggingScrollBar = false;
       setCursorForShape(this.interactiveCanvas, this.state);
-      lastPointerUp = null;
       this.setState({
         cursorButton: "up",
       });
@@ -7208,6 +7267,7 @@ class App extends React.Component<AppProps, AppState> {
     pointerDownState: PointerDownState,
   ): (event: PointerEvent) => void {
     return withBatchedUpdates((childEvent: PointerEvent) => {
+      this.removePointer(childEvent);
       if (pointerDownState.eventListeners.onMove) {
         pointerDownState.eventListeners.onMove.flush();
       }
@@ -7310,7 +7370,7 @@ class App extends React.Component<AppProps, AppState> {
         }
       }
 
-      lastPointerUp = null;
+      this.missingPointerEventCleanupEmitter.clear();
 
       window.removeEventListener(
         EVENT.POINTER_MOVE,
@@ -7693,19 +7753,23 @@ class App extends React.Component<AppProps, AppState> {
           });
         }
       }
-      if (isEraserActive(this.state)) {
+
+      const pointerStart = this.lastPointerDownEvent;
+      const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
+
+      if (isEraserActive(this.state) && pointerStart && pointerEnd) {
         const draggedDistance = distance2d(
-          this.lastPointerDownEvent!.clientX,
-          this.lastPointerDownEvent!.clientY,
-          this.lastPointerUpEvent!.clientX,
-          this.lastPointerUpEvent!.clientY,
+          pointerStart.clientX,
+          pointerStart.clientY,
+          pointerEnd.clientX,
+          pointerEnd.clientY,
         );
 
         if (draggedDistance === 0) {
           const scenePointer = viewportCoordsToSceneCoords(
             {
-              clientX: this.lastPointerUpEvent!.clientX,
-              clientY: this.lastPointerUpEvent!.clientY,
+              clientX: pointerEnd.clientX,
+              clientY: pointerEnd.clientY,
             },
             this.state,
           );

+ 1 - 0
packages/excalidraw/constants.ts

@@ -43,6 +43,7 @@ export const POINTER_BUTTON = {
   WHEEL: 1,
   SECONDARY: 2,
   TOUCH: -1,
+  ERASER: 5,
 } as const;
 
 export const POINTER_EVENTS = {

+ 5 - 14
packages/excalidraw/emitter.ts

@@ -4,13 +4,6 @@ type Subscriber<T extends any[]> = (...payload: T) => void;
 
 export class Emitter<T extends any[] = []> {
   public subscribers: Subscriber<T>[] = [];
-  public value: T | undefined;
-  private updateOnChangeOnly: boolean;
-
-  constructor(opts?: { initialState?: T; updateOnChangeOnly?: boolean }) {
-    this.updateOnChangeOnly = opts?.updateOnChangeOnly ?? false;
-    this.value = opts?.initialState;
-  }
 
   /**
    * Attaches subscriber
@@ -45,16 +38,14 @@ export class Emitter<T extends any[] = []> {
     );
   }
 
-  trigger(...payload: T): any[] {
-    if (this.updateOnChangeOnly && this.value === payload) {
-      return [];
+  trigger(...payload: T) {
+    for (const handler of this.subscribers) {
+      handler(...payload);
     }
-    this.value = payload;
-    return this.subscribers.map((handler) => handler(...payload));
+    return this;
   }
 
-  destroy() {
+  clear() {
     this.subscribers = [];
-    this.value = undefined;
   }
 }

文件差异内容过多而无法显示
+ 116 - 116
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap


+ 13 - 13
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -1908,7 +1908,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
           "type": "ellipse",
           "updated": 1,
           "version": 2,
-          "versionNonce": 453191,
+          "versionNonce": 401146281,
           "width": 10,
           "x": 0,
           "y": 0,
@@ -1951,7 +1951,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
           "type": "ellipse",
           "updated": 1,
           "version": 3,
-          "versionNonce": 1150084233,
+          "versionNonce": 1116226695,
           "width": 10,
           "x": 25,
           "y": 25,
@@ -16160,7 +16160,7 @@ exports[`regression tests > switches from group of selected elements to another
     "roundness": {
       "type": 2,
     },
-    "seed": 493213705,
+    "seed": 915032327,
     "strokeColor": "#1e1e1e",
     "strokeStyle": "solid",
     "strokeWidth": 2,
@@ -16246,7 +16246,7 @@ exports[`regression tests > switches from group of selected elements to another
     "roundness": {
       "type": 2,
     },
-    "seed": 493213705,
+    "seed": 915032327,
     "strokeColor": "#1e1e1e",
     "strokeStyle": "solid",
     "strokeWidth": 2,
@@ -16330,7 +16330,7 @@ exports[`regression tests > switches from group of selected elements to another
           "type": "rectangle",
           "updated": 1,
           "version": 2,
-          "versionNonce": 453191,
+          "versionNonce": 401146281,
           "width": 10,
           "x": 0,
           "y": 0,
@@ -16373,7 +16373,7 @@ exports[`regression tests > switches from group of selected elements to another
           "type": "rectangle",
           "updated": 1,
           "version": 2,
-          "versionNonce": 453191,
+          "versionNonce": 401146281,
           "width": 10,
           "x": 0,
           "y": 0,
@@ -16395,14 +16395,14 @@ exports[`regression tests > switches from group of selected elements to another
           "roundness": {
             "type": 2,
           },
-          "seed": 2019559783,
+          "seed": 1150084233,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "type": "ellipse",
           "updated": 1,
           "version": 2,
-          "versionNonce": 1116226695,
+          "versionNonce": 1014066025,
           "width": 100,
           "x": 110,
           "y": 110,
@@ -16445,7 +16445,7 @@ exports[`regression tests > switches from group of selected elements to another
           "type": "rectangle",
           "updated": 1,
           "version": 2,
-          "versionNonce": 453191,
+          "versionNonce": 401146281,
           "width": 10,
           "x": 0,
           "y": 0,
@@ -16467,14 +16467,14 @@ exports[`regression tests > switches from group of selected elements to another
           "roundness": {
             "type": 2,
           },
-          "seed": 2019559783,
+          "seed": 1150084233,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "type": "ellipse",
           "updated": 1,
           "version": 2,
-          "versionNonce": 1116226695,
+          "versionNonce": 1014066025,
           "width": 100,
           "x": 110,
           "y": 110,
@@ -16496,14 +16496,14 @@ exports[`regression tests > switches from group of selected elements to another
           "roundness": {
             "type": 2,
           },
-          "seed": 238820263,
+          "seed": 400692809,
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "type": "diamond",
           "updated": 1,
           "version": 2,
-          "versionNonce": 1604849351,
+          "versionNonce": 1505387817,
           "width": 100,
           "x": 310,
           "y": 310,

部分文件因为文件数量过多而无法显示