feat: Add Eraser 🎉 (#4887)

* feat: Add Eraser 🎉

* Eraser working

* remove unused state

* fix

* toggle eraser

* Support deselect with Alt/Option

* rename actionDelete -> actionErase

* Add util isEraserActive

* show eraser in mobile

* render eraser conditionally in mobile

* use selection if eraser in local storage state

* Add sampling to erase accurately

* use pointerDownState

* set eraser to false in AllowedExcalidrawElementTypes

* rename/reword fixes

* don't use updateScene

* handle bound text when erasing

* fix hover state in mobile

* consider all hitElements instead of a single

* code improvements

* revert to select if eraser active and elements selected

* show eraser in zenmode

* erase element when clicked on element while eraser active

* set groupIds to empty when eraser active

* fix test

* remove dragged distance
This commit is contained in:
Aakansha Doshi 2022-03-11 19:53:42 +05:30 committed by GitHub
parent 5c0eff50a0
commit 7daf1a7944
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 233 additions and 32 deletions

View file

@ -35,7 +35,7 @@ import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
import { ActionResult } from "../actions/types";
import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import { getDefaultAppState, isEraserActive } from "../appState";
import {
copyToClipboard,
parseClipboard,
@ -314,6 +314,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerDown: React.PointerEvent<HTMLCanvasElement> | null = null;
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
contextMenuOpen: boolean = false;
lastScenePointer: { x: number; y: number } | null = null;
constructor(props: AppProps) {
super(props);
@ -1044,6 +1045,12 @@ class App extends React.Component<AppProps, AppState> {
}
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (
Object.keys(this.state.selectedElementIds).length &&
isEraserActive(this.state)
) {
this.setState({ elementType: "selection" });
}
// Hide hyperlink popup if shown when element type is not selection
if (
prevState.elementType === "selection" &&
@ -2450,7 +2457,6 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>,
) => {
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
if (gesture.pointers.has(event.pointerId)) {
gesture.pointers.set(event.pointerId, {
x: event.clientX,
@ -2624,7 +2630,8 @@ class App extends React.Component<AppProps, AppState> {
if (
hasDeselectedButton ||
(this.state.elementType !== "selection" &&
this.state.elementType !== "text")
this.state.elementType !== "text" &&
this.state.elementType !== "eraser")
) {
return;
}
@ -2699,8 +2706,9 @@ class App extends React.Component<AppProps, AppState> {
!this.state.showHyperlinkPopup
) {
this.setState({ showHyperlinkPopup: "info" });
}
if (this.state.elementType === "text") {
} else if (isEraserActive(this.state)) {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
} else if (this.state.elementType === "text") {
setCursor(
this.canvas,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
@ -2741,6 +2749,80 @@ class App extends React.Component<AppProps, AppState> {
}
};
private handleEraser = (
event: PointerEvent,
pointerDownState: PointerDownState,
scenePointer: { x: number; y: number },
) => {
const updateElementIds = (elements: ExcalidrawElement[]) => {
elements.forEach((element) => {
idsToUpdate.push(element.id);
if (event.altKey) {
if (pointerDownState.elementIdsToErase[element.id]) {
pointerDownState.elementIdsToErase[element.id] = false;
}
} else {
pointerDownState.elementIdsToErase[element.id] = true;
}
});
};
const idsToUpdate: Array<string> = [];
const distance = distance2d(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
scenePointer.x,
scenePointer.y,
);
const threshold = 10 / this.state.zoom.value;
const point = { ...pointerDownState.lastCoords };
let samplingInterval = 0;
while (samplingInterval <= distance) {
const hitElements = this.getElementsAtPosition(point.x, point.y);
updateElementIds(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) * point.x + distanceRatio * scenePointer.x;
const nextY =
(1 - distanceRatio) * point.y + distanceRatio * scenePointer.y;
point.x = nextX;
point.y = nextY;
}
const elements = this.scene.getElements().map((ele) => {
const id =
isBoundToContainer(ele) && idsToUpdate.includes(ele.containerId)
? ele.containerId
: ele.id;
if (idsToUpdate.includes(id)) {
if (event.altKey) {
if (pointerDownState.elementIdsToErase[id] === false) {
return newElementWith(ele, {
opacity: this.state.currentItemOpacity,
});
}
} else {
return newElementWith(ele, { opacity: 20 });
}
}
return ele;
});
this.scene.replaceAllElements(elements);
pointerDownState.lastCoords.x = scenePointer.x;
pointerDownState.lastCoords.y = scenePointer.y;
};
// set touch moving for mobile context menu
private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
invalidateContextMenu = true;
@ -2773,6 +2855,7 @@ class App extends React.Component<AppProps, AppState> {
if (isPanning) {
return;
}
this.lastPointerDown = event;
this.setState({
lastPointerDownWith: event.pointerType,
@ -2865,7 +2948,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.elementType,
pointerDownState,
);
} else {
} else if (this.state.elementType !== "eraser") {
this.createGenericElementOnPointerDown(
this.state.elementType,
pointerDownState,
@ -2900,7 +2983,8 @@ class App extends React.Component<AppProps, AppState> {
) => {
this.lastPointerUp = event;
const isTouchScreen = ["pen", "touch"].includes(event.pointerType);
if (isTouchScreen) {
if (isTouchScreen || isEraserActive(this.state)) {
const scenePointer = viewportCoordsToSceneCoords(
{ clientX: event.clientX, clientY: event.clientY },
this.state,
@ -2909,10 +2993,15 @@ class App extends React.Component<AppProps, AppState> {
scenePointer.x,
scenePointer.y,
);
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
hitElement,
);
const pointerDownEvent = this.initialPointerDownState(event);
pointerDownEvent.hit.element = hitElement;
this.eraseElements(pointerDownEvent);
if (isTouchScreen) {
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
hitElement,
);
}
}
if (
this.hitLinkElement &&
@ -3139,6 +3228,7 @@ class App extends React.Component<AppProps, AppState> {
boxSelection: {
hasOccurred: false,
},
elementIdsToErase: {},
};
}
@ -3727,7 +3817,6 @@ class App extends React.Component<AppProps, AppState> {
),
);
}
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
@ -3738,6 +3827,12 @@ class App extends React.Component<AppProps, AppState> {
}
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
if (isEraserActive(this.state)) {
this.handleEraser(event, pointerDownState, pointerCoords);
return;
}
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
@ -4090,7 +4185,6 @@ class App extends React.Component<AppProps, AppState> {
isResizing,
isRotating,
} = this.state;
this.setState({
isResizing: false,
isRotating: false,
@ -4311,6 +4405,11 @@ class App extends React.Component<AppProps, AppState> {
// Code below handles selection when element(s) weren't
// drag or added to selection on pointer down phase.
const hitElement = pointerDownState.hit.element;
if (isEraserActive(this.state)) {
this.eraseElements(pointerDownState);
return;
}
if (
hitElement &&
!pointerDownState.drag.hasOccurred &&
@ -4450,6 +4549,27 @@ class App extends React.Component<AppProps, AppState> {
});
}
private eraseElements = (pointerDownState: PointerDownState) => {
const hitElement = pointerDownState.hit.element;
const elements = this.scene.getElements().map((ele) => {
if (pointerDownState.elementIdsToErase[ele.id]) {
return newElementWith(ele, { isDeleted: true });
} else if (hitElement && ele.id === hitElement.id) {
return newElementWith(ele, { isDeleted: true });
} else if (
isBoundToContainer(ele) &&
(pointerDownState.elementIdsToErase[ele.containerId] ||
(hitElement && ele.containerId === hitElement.id))
) {
return newElementWith(ele, { isDeleted: true });
}
return ele;
});
this.history.resumeRecording();
this.scene.replaceAllElements(elements);
};
private initializeImage = async ({
imageFile,
imageElement: _imageElement,