feat(eraser): implement eraser feature (#221)

* feat(eraser): implement eraser feature

Introduces an eraser tool to the freehand drawing plugin, including a new Erase Icon, toolbar button, and hotkey (E). Implements erasing logic in with-freehand-delete.ts and integrates it into the freehand plugin. Updates FreehandShape enum and pointer utilities to support the eraser.

* fix(eraser): lint for better readability

* fix(eraser):  switch the order of eraser icon and freehand icon

* fix(eraser): fix multiple suggestions during code review

* fix(eraser): make all the freehand elements being erased at the same time in a eraser perform

* fix(eraser): fix type declaration and remove unused imports
This commit is contained in:
Andy Lu (Lu, Yu-An) authored and GitHub committed 2025-08-26 23:45:43 +08:00
1 parent 80a65ac469
commit 8cfb1e75a8
7 files changed
+119 -2

No files matched your search

@@ -44,6 +44,12 @@ export const TextIcon = createIcon(
</svg>
);
export const EraseIcon = createIcon(
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.086 2.207a2 2 0 0 1 2.828 0l3.879 3.879a2 2 0 0 1 0 2.828l-5.5 5.5A2 2 0 0 1 7.879 15H5.12a2 2 0 0 1-1.414-.586l-2.5-2.5a2 2 0 0 1 0-2.828zm2.121.707a1 1 0 0 0-1.414 0L4.16 7.547l5.293 5.293 4.633-4.633a1 1 0 0 0 0-1.414zM8.746 13.547 3.453 8.254 1.914 9.793a1 1 0 0 0 0 1.414l2.5 2.5a1 1 0 0 0 .707.293H7.88a1 1 0 0 0 .707-.293z"/>
</svg>
);
export const StraightArrowLineIcon = createIcon(
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" version="1.1">
<g id="straight-line" stroke="none" fill="currentColor">
@@ -8,6 +8,7 @@ import {
SelectionIcon,
ShapeIcon,
TextIcon,
EraseIcon,
StraightArrowLineIcon,
FeltTipPenIcon,
ImageIcon,
@@ -87,6 +88,11 @@ export const BUTTONS: AppToolButtonProps[] = [
pointer: FreehandShape.feltTipPen,
titleKey: 'toolbar.pen',
},
{
icon: EraseIcon,
pointer: FreehandShape.eraser,
title: 'Erase — E',
},
{
icon: StraightArrowLineIcon,
titleKey: 'toolbar.arrow',
@@ -29,6 +29,7 @@ export const FreehandThemeColors = {
};
export enum FreehandShape {
eraser = 'eraser',
nibPen = 'nibPen',
feltTipPen = 'feltTipPen',
artisticBrush = 'artisticBrush',
@@ -20,7 +20,7 @@ import {
} from '@plait/draw';
export function getFreehandPointers() {
return [FreehandShape.feltTipPen];
return [FreehandShape.feltTipPen, FreehandShape.eraser];
}
export const createFreehandElement = (
@@ -0,0 +1,98 @@
import {
PlaitBoard,
Point,
throttleRAF,
toHostPoint,
toViewBoxPoint,
} from '@plait/core';
import { isDrawingMode } from '@plait/common';
import { isHitFreehand } from './utils';
import { Freehand, FreehandShape } from './type';
import { CoreTransforms } from '@plait/core';
export const withFreehandErase = (board: PlaitBoard) => {
const { pointerDown, pointerMove, pointerUp, globalPointerUp } = board;
let isErasing = false;
const elementsToDelete = new Set<string>();
const checkAndMarkFreehandElementsForDeletion = (point: Point) => {
const viewBoxPoint = toViewBoxPoint(board, toHostPoint(board, point[0], point[1]));
const freehandElements = board.children.filter((element) =>
Freehand.isFreehand(element)
) as Freehand[];
freehandElements.forEach((element) => {
if (!elementsToDelete.has(element.id) && isHitFreehand(board, element, viewBoxPoint)) {
elementsToDelete.add(element.id);
}
});
};
const deleteMarkedElements = () => {
if (elementsToDelete.size > 0) {
const elementsToRemove = board.children.filter((element) =>
elementsToDelete.has(element.id)
);
if (elementsToRemove.length > 0) {
CoreTransforms.removeElements(board, elementsToRemove);
}
}
};
const complete = () => {
if (isErasing) {
deleteMarkedElements();
isErasing = false;
elementsToDelete.clear();
}
};
board.pointerDown = (event: PointerEvent) => {
const isEraserPointer = PlaitBoard.isInPointer(board, [FreehandShape.eraser]);
if (isEraserPointer && isDrawingMode(board)) {
isErasing = true;
elementsToDelete.clear();
const currentPoint: Point = [event.x, event.y];
checkAndMarkFreehandElementsForDeletion(currentPoint);
return;
}
pointerDown(event);
};
board.pointerMove = (event: PointerEvent) => {
if (isErasing) {
throttleRAF(board, 'with-freehand-erase', () => {
const currentPoint: Point = [event.x, event.y];
checkAndMarkFreehandElementsForDeletion(currentPoint);
});
return;
}
pointerMove(event);
};
board.pointerUp = (event: PointerEvent) => {
if (isErasing) {
complete();
return;
}
pointerUp(event);
};
board.globalPointerUp = (event: PointerEvent) => {
if (isErasing) {
complete();
return;
}
globalPointerUp(event);
};
return board;
};
@@ -16,6 +16,7 @@ import {
WithDrawOptions,
WithDrawPluginKey,
} from '@plait/draw';
import { withFreehandErase } from './with-freehand-erase';
export const withFreehand = (board: PlaitBoard) => {
const {
@@ -83,5 +84,5 @@ export const withFreehand = (board: PlaitBoard) => {
{ customGeometryTypes: [FREEHAND_TYPE] }
);
return withFreehandFragment(withFreehandCreate(board));
return withFreehandErase(withFreehandFragment(withFreehandCreate(board)));
};
@@ -61,6 +61,11 @@ export const buildDrawnixHotkeyPlugin = (
BoardTransforms.updatePointerType(board, MindPointerType.mind);
updateAppState({ pointer: MindPointerType.mind });
}
if (event.key === 'e') {
setCreationMode(board, BoardCreationMode.drawing);
BoardTransforms.updatePointerType(board, FreehandShape.eraser);
updateAppState({ pointer: FreehandShape.eraser });
}
if (event.key === 'p') {
setCreationMode(board, BoardCreationMode.drawing);
BoardTransforms.updatePointerType(board, FreehandShape.feltTipPen);