diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 2e56954e..ae3baf57 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -1,5 +1,5 @@ import { ColorPicker } from "../components/ColorPicker"; -import { zoomIn, zoomOut } from "../components/icons"; +import { eraser, zoomIn, zoomOut } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { DarkModeToggle } from "../components/DarkModeToggle"; import { THEME, ZOOM_STEP } from "../constants"; @@ -15,8 +15,9 @@ import { getShortcutKey } from "../utils"; import { register } from "./register"; import { Tooltip } from "../components/Tooltip"; import { newElementWith } from "../element/mutateElement"; -import { getDefaultAppState } from "../appState"; +import { getDefaultAppState, isEraserActive } from "../appState"; import ClearCanvas from "../components/ClearCanvas"; +import clsx from "clsx"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", @@ -289,3 +290,31 @@ export const actionToggleTheme = register({ ), keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, }); + +export const actionErase = register({ + name: "eraser", + perform: (elements, appState) => { + return { + appState: { + ...appState, + selectedElementIds: {}, + selectedGroupIds: {}, + elementType: isEraserActive(appState) ? "selection" : "eraser", + }, + commitToHistory: true, + }; + }, + PanelComponent: ({ elements, appState, updateData, data }) => ( + { + updateData(null); + }} + size={data?.size || "medium"} + > + ), +}); diff --git a/src/actions/types.ts b/src/actions/types.ts index 23bd0c5e..4009822f 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -106,7 +106,8 @@ export type ActionName = | "increaseFontSize" | "decreaseFontSize" | "unbindText" - | "hyperlink"; + | "hyperlink" + | "eraser"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/src/appState.ts b/src/appState.ts index 96851a68..3515463c 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -213,3 +213,9 @@ export const cleanAppStateForExport = (appState: Partial) => { export const clearAppStateForDatabase = (appState: Partial) => { return _clearAppStateForStorage(appState, "server"); }; + +export const isEraserActive = ({ + elementType, +}: { + elementType: AppState["elementType"]; +}) => elementType === "eraser"; diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 420cbfd6..fd9156c6 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -30,7 +30,7 @@ export const SelectedShapeActions = ({ appState: AppState; elements: readonly ExcalidrawElement[]; renderAction: ActionManager["renderAction"]; - elementType: ExcalidrawElement["type"]; + elementType: AppState["elementType"]; }) => { const targetElements = getTargetElements( getNonDeletedElements(elements), @@ -187,7 +187,7 @@ export const ShapesSwitcher = ({ onImageAction, }: { canvas: HTMLCanvasElement | null; - elementType: ExcalidrawElement["type"]; + elementType: AppState["elementType"]; setAppState: React.Component["setState"]; onImageAction: (data: { pointerType: PointerType | null }) => void; }) => ( diff --git a/src/components/App.tsx b/src/components/App.tsx index b235973b..11ece6c4 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 { lastPointerDown: React.PointerEvent | null = null; lastPointerUp: React.PointerEvent | 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 { } 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 { event: React.PointerEvent, ) => { 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 { 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 { !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 { } }; + 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 = []; + + 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) => { invalidateContextMenu = true; @@ -2773,6 +2855,7 @@ class App extends React.Component { if (isPanning) { return; } + this.lastPointerDown = event; this.setState({ lastPointerDownWith: event.pointerType, @@ -2865,7 +2948,7 @@ class App extends React.Component { 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 { ) => { 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 { 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 { boxSelection: { hasOccurred: false, }, + elementIdsToErase: {}, }; } @@ -3727,7 +3817,6 @@ class App extends React.Component { ), ); } - const target = event.target; if (!(target instanceof HTMLElement)) { return; @@ -3738,6 +3827,12 @@ class App extends React.Component { } 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 { isResizing, isRotating, } = this.state; - this.setState({ isResizing: false, isRotating: false, @@ -4311,6 +4405,11 @@ class App extends React.Component { // 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 { }); } + 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, diff --git a/src/components/HintViewer.tsx b/src/components/HintViewer.tsx index 259b960c..250f904d 100644 --- a/src/components/HintViewer.tsx +++ b/src/components/HintViewer.tsx @@ -11,6 +11,7 @@ import { isTextElement, } from "../element/typeChecks"; import { getShortcutKey } from "../utils"; +import { isEraserActive } from "../appState"; interface HintViewerProps { appState: AppState; @@ -22,6 +23,9 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => { const { elementType, isResizing, isRotating, lastPointerDownWith } = appState; const multiMode = appState.multiElement !== null; + if (isEraserActive(appState)) { + return t("hints.eraserRevert"); + } if (elementType === "arrow" || elementType === "line") { if (!multiMode) { return t("hints.linearElement"); diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 65c8551d..97190b43 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -428,6 +428,14 @@ const LayerUI = ({ {actionManager.renderAction("redo", { size: "small" })} )} +
+ {actionManager.renderAction("eraser", { size: "small" })} +
diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 6a2d7b1a..73ed47e4 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement } from "../element/types"; import { FixedSideContainer } from "./FixedSideContainer"; import { Island } from "./Island"; import { HintViewer } from "./HintViewer"; -import { calculateScrollCenter } from "../scene"; +import { calculateScrollCenter, getSelectedElements } from "../scene"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { Section } from "./Section"; import CollabButton from "./CollabButton"; @@ -113,6 +113,12 @@ export const MobileMenu = ({ }; const renderAppToolbar = () => { + // Render eraser conditionally in mobile + const showEraser = + !appState.viewModeEnabled && + !appState.editingElement && + getSelectedElements(elements, appState).length === 0; + if (viewModeEnabled) { return (
@@ -120,12 +126,16 @@ export const MobileMenu = ({
); } + return (
{actionManager.renderAction("toggleCanvasMenu")} {actionManager.renderAction("toggleEditMenu")} + {actionManager.renderAction("undo")} {actionManager.renderAction("redo")} + {showEraser && actionManager.renderAction("eraser")} + {actionManager.renderAction( appState.multiElement ? "finalize" : "duplicateSelection", )} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index cc371259..9e0ce7e8 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -934,3 +934,7 @@ export const editIcon = createIcon( >, { width: 640, height: 512 }, ); + +export const eraser = createIcon( + , +); diff --git a/src/css/styles.scss b/src/css/styles.scss index 18f02b95..aef449a4 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -290,6 +290,16 @@ width: 100%; box-sizing: border-box; + + .eraser { + &.ToolIcon:hover { + --icon-fill-color: #fff; + --keybinding-color: #fff; + } + &.active { + background-color: var(--color-primary); + } + } } .App-toolbar-content { @@ -467,7 +477,8 @@ font-family: var(--ui-font); } - .undo-redo-buttons { + .undo-redo-buttons, + .eraser-buttons { display: grid; grid-auto-flow: column; gap: 0.4em; diff --git a/src/data/restore.ts b/src/data/restore.ts index b55bd952..c8e18c02 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -31,8 +31,8 @@ type RestoredAppState = Omit< >; export const AllowedExcalidrawElementTypes: Record< - ExcalidrawElement["type"], - true + AppState["elementType"], + boolean > = { selection: true, text: true, @@ -43,6 +43,7 @@ export const AllowedExcalidrawElementTypes: Record< image: true, arrow: true, freedraw: true, + eraser: false, }; export type RestoredDataState = { diff --git a/src/element/dragElements.ts b/src/element/dragElements.ts index b6c5383a..42990b51 100644 --- a/src/element/dragElements.ts +++ b/src/element/dragElements.ts @@ -1,4 +1,3 @@ -import { SHAPES } from "../shapes"; import { updateBoundElements } from "./binding"; import { getCommonBounds } from "./bounds"; import { mutateElement } from "./mutateElement"; @@ -93,7 +92,7 @@ export const getDragOffsetXY = ( export const dragNewElement = ( draggingElement: NonDeletedExcalidrawElement, - elementType: typeof SHAPES[number]["value"], + elementType: AppState["elementType"], originX: number, originY: number, x: number, diff --git a/src/element/showSelectedShapeActions.ts b/src/element/showSelectedShapeActions.ts index 54528981..7dce2628 100644 --- a/src/element/showSelectedShapeActions.ts +++ b/src/element/showSelectedShapeActions.ts @@ -10,5 +10,6 @@ export const showSelectedShapeActions = ( !appState.viewModeEnabled && (appState.editingElement || getSelectedElements(elements, appState).length || - appState.elementType !== "selection"), + (appState.elementType !== "selection" && + appState.elementType !== "eraser")), ); diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts index 8f5627bf..4776fd87 100644 --- a/src/element/typeChecks.ts +++ b/src/element/typeChecks.ts @@ -1,3 +1,4 @@ +import { AppState } from "../types"; import { ExcalidrawElement, ExcalidrawTextElement, @@ -60,7 +61,7 @@ export const isLinearElement = ( }; export const isLinearElementType = ( - elementType: ExcalidrawElement["type"], + elementType: AppState["elementType"], ): boolean => { return ( elementType === "arrow" || elementType === "line" // || elementType === "freedraw" @@ -74,7 +75,7 @@ export const isBindingElement = ( }; export const isBindingElementType = ( - elementType: ExcalidrawElement["type"], + elementType: AppState["elementType"], ): boolean => { return elementType === "arrow"; }; diff --git a/src/locales/en.json b/src/locales/en.json index e7b2e572..2ce8ea6b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -196,7 +196,8 @@ "library": "Library", "lock": "Keep selected tool active after drawing", "penMode": "Prevent pinch-zoom and accept freedraw input only from pen", - "link": "Add/ Update link for a selected shape" + "link": "Add/ Update link for a selected shape", + "eraser": "Eraser" }, "headings": { "canvasActions": "Canvas actions", @@ -221,7 +222,8 @@ "placeImage": "Click to place the image, or click and drag to set its size manually", "publishLibrary": "Publish your own library", "bindTextToElement": "Press enter to add text", - "deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging" + "deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging", + "eraserRevert": "Hold Alt to revert the elements marked for deletion" }, "canvasError": { "cannotShowPreview": "Cannot show preview", diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index 2b4eee9e..fc8f16e9 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -16860,7 +16860,7 @@ Object { exports[`regression tests two-finger scroll works: [end of test] number of elements 1`] = `0`; -exports[`regression tests two-finger scroll works: [end of test] number of renders 1`] = `12`; +exports[`regression tests two-finger scroll works: [end of test] number of renders 1`] = `16`; exports[`regression tests undo/redo drawing an element: [end of test] appState 1`] = ` Object { @@ -17592,4 +17592,4 @@ Object { exports[`regression tests zoom hotkeys: [end of test] number of elements 1`] = `0`; -exports[`regression tests zoom hotkeys: [end of test] number of renders 1`] = `6`; \ No newline at end of file +exports[`regression tests zoom hotkeys: [end of test] number of renders 1`] = `6`; diff --git a/src/types.ts b/src/types.ts index 376cd615..71c2b53e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,7 +77,7 @@ export type AppState = { // (e.g. text element when typing into the input) editingElement: NonDeletedExcalidrawElement | null; editingLinearElement: LinearElementEditor | null; - elementType: typeof SHAPES[number]["value"]; + elementType: typeof SHAPES[number]["value"] | "eraser"; elementLocked: boolean; penMode: boolean; penDetected: boolean; @@ -384,6 +384,7 @@ export type PointerDownState = Readonly<{ boxSelection: { hasOccurred: boolean; }; + elementIdsToErase: { [key: ExcalidrawElement["id"]]: boolean }; }>; export type ExcalidrawImperativeAPI = { diff --git a/src/utils.ts b/src/utils.ts index 3d3cecd5..6c5e735d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -224,6 +224,9 @@ export const setCursorForShape = ( } if (shape === "selection") { resetCursor(canvas); + } else if (shape === "eraser") { + resetCursor(canvas); + // do nothing if image tool is selected which suggests there's // a image-preview set as the cursor } else if (shape !== "image") {