This commit is contained in:
Ryan Di 2025-04-10 00:15:59 +09:00 committed by GitHub
commit a753bf2a0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 259 additions and 120 deletions

View file

@ -454,7 +454,6 @@ import {
import { Emitter } from "../emitter";
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { Store, CaptureUpdateAction } from "../store";
import { AnimatedTrail } from "../animated-trail";
import { LaserTrails } from "../laser-trails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
@ -464,6 +463,8 @@ import { isMaybeMermaidDefinition } from "../mermaid";
import { LassoTrail } from "../lasso";
import { EraserTrail } from "../eraser";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
@ -675,26 +676,7 @@ class App extends React.Component<AppProps, AppState> {
animationFrameHandler = new AnimationFrameHandler();
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);
onChangeEmitter = new Emitter<
@ -1676,8 +1658,8 @@ class App extends React.Component<AppProps, AppState> {
<SVGLayer
trails={[
this.laserTrails,
this.eraserTrail,
this.lassoTrail,
this.eraserTrail,
]}
/>
{selectedElements.length === 1 &&
@ -5163,7 +5145,7 @@ class App extends React.Component<AppProps, AppState> {
return elements;
}
private getElementHitThreshold() {
getElementHitThreshold() {
return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
}
@ -6219,101 +6201,16 @@ class App extends React.Component<AppProps, AppState> {
private handleEraser = (
event: PointerEvent,
pointerDownState: PointerDownState,
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
@ -8159,7 +8056,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (isEraserActive(this.state)) {
this.handleEraser(event, pointerDownState, pointerCoords);
this.handleEraser(event, pointerCoords);
return;
}

View file

@ -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,
),
);
};

View file

@ -7,11 +7,13 @@ import {
polygonIncludesPointNonZero,
} 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";
export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;
export const getLassoSelectedElementIds = (input: {
lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[];

View file

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

View file

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