mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-16 22:10:48 +02:00
fix: eraser can handle dots without regressing prior performance improvements (#9946)
Co-authored-by: Márk Tolmács <mark@lazycat.hu>
This commit is contained in:

committed by
GitHub

parent
204e06b77b
commit
1161f1b8ba
@@ -1,7 +1,14 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { getStroke } from "perfect-freehand";
|
import { getStroke } from "perfect-freehand";
|
||||||
|
|
||||||
import { isRightAngleRads } from "@excalidraw/math";
|
import {
|
||||||
|
type GlobalPoint,
|
||||||
|
isRightAngleRads,
|
||||||
|
lineSegment,
|
||||||
|
pointFrom,
|
||||||
|
pointRotateRads,
|
||||||
|
type Radians,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
@@ -14,6 +21,7 @@ import {
|
|||||||
getFontString,
|
getFontString,
|
||||||
isRTL,
|
isRTL,
|
||||||
getVerticalOffset,
|
getVerticalOffset,
|
||||||
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -32,7 +40,7 @@ import type {
|
|||||||
InteractiveCanvasRenderConfig,
|
InteractiveCanvasRenderConfig,
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||||
import { getUncroppedImageElement } from "./cropElement";
|
import { getUncroppedImageElement } from "./cropElement";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import {
|
import {
|
||||||
@@ -1039,6 +1047,66 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||||
|
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFreedrawOutlineAsSegments(
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
points: [number, number][],
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
) {
|
||||||
|
const bounds = getElementBounds(
|
||||||
|
{
|
||||||
|
...element,
|
||||||
|
angle: 0 as Radians,
|
||||||
|
},
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const center = pointFrom<GlobalPoint>(
|
||||||
|
(bounds[0] + bounds[2]) / 2,
|
||||||
|
(bounds[1] + bounds[3]) / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
|
||||||
|
|
||||||
|
return points.slice(2).reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
acc.push(
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
acc[acc.length - 1][1],
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
points[0][0] + element.x,
|
||||||
|
points[0][1] + element.y,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
points[1][0] + element.x,
|
||||||
|
points[1][1] + element.y,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
|
||||||
// If input points are empty (should they ever be?) return a dot
|
// If input points are empty (should they ever be?) return a dot
|
||||||
const inputPoints = element.simulatePressure
|
const inputPoints = element.simulatePressure
|
||||||
? element.points
|
? element.points
|
||||||
@@ -1057,7 +1125,7 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
|||||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||||
};
|
};
|
||||||
|
|
||||||
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
|
return getStroke(inputPoints as number[][], options) as [number, number][];
|
||||||
}
|
}
|
||||||
|
|
||||||
function med(A: number[], B: number[]) {
|
function med(A: number[], B: number[]) {
|
||||||
|
@@ -1,11 +1,26 @@
|
|||||||
import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
|
import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeBoundTextPosition,
|
computeBoundTextPosition,
|
||||||
|
distanceToElement,
|
||||||
|
doBoundsIntersect,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
|
getElementBounds,
|
||||||
|
getFreedrawOutlineAsSegments,
|
||||||
|
getFreedrawOutlinePoints,
|
||||||
intersectElementWithLineSegment,
|
intersectElementWithLineSegment,
|
||||||
|
isArrowElement,
|
||||||
|
isFreeDrawElement,
|
||||||
|
isLineElement,
|
||||||
isPointInElement,
|
isPointInElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { lineSegment, pointFrom } from "@excalidraw/math";
|
import {
|
||||||
|
lineSegment,
|
||||||
|
lineSegmentsDistance,
|
||||||
|
pointFrom,
|
||||||
|
polygon,
|
||||||
|
polygonIncludesPoint,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { getElementsInGroup } from "@excalidraw/element";
|
import { getElementsInGroup } from "@excalidraw/element";
|
||||||
|
|
||||||
@@ -13,6 +28,8 @@ import { shouldTestInside } from "@excalidraw/element";
|
|||||||
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
|
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
|
||||||
import { getBoundTextElementId } from "@excalidraw/element";
|
import { getBoundTextElementId } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { Bounds } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
|
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
|
||||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@@ -96,6 +113,7 @@ export class EraserTrail extends AnimatedTrail {
|
|||||||
pathSegment,
|
pathSegment,
|
||||||
element,
|
element,
|
||||||
candidateElementsMap,
|
candidateElementsMap,
|
||||||
|
this.app.state.zoom.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (intersects) {
|
if (intersects) {
|
||||||
@@ -131,6 +149,7 @@ export class EraserTrail extends AnimatedTrail {
|
|||||||
pathSegment,
|
pathSegment,
|
||||||
element,
|
element,
|
||||||
candidateElementsMap,
|
candidateElementsMap,
|
||||||
|
this.app.state.zoom.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (intersects) {
|
if (intersects) {
|
||||||
@@ -180,8 +199,33 @@ const eraserTest = (
|
|||||||
pathSegment: LineSegment<GlobalPoint>,
|
pathSegment: LineSegment<GlobalPoint>,
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
zoom: number,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const lastPoint = pathSegment[1];
|
const lastPoint = pathSegment[1];
|
||||||
|
|
||||||
|
// PERF: Do a quick bounds intersection test first because it's cheap
|
||||||
|
const threshold = isFreeDrawElement(element) ? 15 : element.strokeWidth / 2;
|
||||||
|
const segmentBounds = [
|
||||||
|
Math.min(pathSegment[0][0], pathSegment[1][0]) - threshold,
|
||||||
|
Math.min(pathSegment[0][1], pathSegment[1][1]) - threshold,
|
||||||
|
Math.max(pathSegment[0][0], pathSegment[1][0]) + threshold,
|
||||||
|
Math.max(pathSegment[0][1], pathSegment[1][1]) + threshold,
|
||||||
|
] as Bounds;
|
||||||
|
const origElementBounds = getElementBounds(element, elementsMap);
|
||||||
|
const elementBounds: Bounds = [
|
||||||
|
origElementBounds[0] - threshold,
|
||||||
|
origElementBounds[1] - threshold,
|
||||||
|
origElementBounds[2] + threshold,
|
||||||
|
origElementBounds[3] + threshold,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!doBoundsIntersect(segmentBounds, elementBounds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// There are shapes where the inner area should trigger erasing
|
||||||
|
// even though the eraser path segment doesn't intersect with or
|
||||||
|
// get close to the shape's stroke
|
||||||
if (
|
if (
|
||||||
shouldTestInside(element) &&
|
shouldTestInside(element) &&
|
||||||
isPointInElement(lastPoint, element, elementsMap)
|
isPointInElement(lastPoint, element, elementsMap)
|
||||||
@@ -189,6 +233,50 @@ const eraserTest = (
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Freedraw elements are tested for erasure by measuring the distance
|
||||||
|
// of the eraser path and the freedraw shape outline lines to a tolerance
|
||||||
|
// which offers a good visual precision at various zoom levels
|
||||||
|
if (isFreeDrawElement(element)) {
|
||||||
|
const outlinePoints = getFreedrawOutlinePoints(element);
|
||||||
|
const strokeSegments = getFreedrawOutlineAsSegments(
|
||||||
|
element,
|
||||||
|
outlinePoints,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const tolerance = Math.max(2.25, 5 / zoom); // NOTE: Visually fine-tuned approximation
|
||||||
|
|
||||||
|
for (const seg of strokeSegments) {
|
||||||
|
if (lineSegmentsDistance(seg, pathSegment) <= tolerance) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const poly = polygon(
|
||||||
|
...(outlinePoints.map(([x, y]) =>
|
||||||
|
pointFrom<GlobalPoint>(element.x + x, element.y + y),
|
||||||
|
) as GlobalPoint[]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// PERF: Check only one point of the eraser segment. If the eraser segment
|
||||||
|
// start is inside the closed freedraw shape, the other point is either also
|
||||||
|
// inside or the eraser segment will intersect the shape outline anyway
|
||||||
|
if (polygonIncludesPoint(pathSegment[0], poly)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} else if (
|
||||||
|
isArrowElement(element) ||
|
||||||
|
(isLineElement(element) && !element.polygon)
|
||||||
|
) {
|
||||||
|
const tolerance = Math.max(
|
||||||
|
element.strokeWidth,
|
||||||
|
(element.strokeWidth * 2) / zoom,
|
||||||
|
);
|
||||||
|
|
||||||
|
return distanceToElement(element, elementsMap, lastPoint) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -177,3 +177,19 @@ export function lineSegmentIntersectionPoints<
|
|||||||
|
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function lineSegmentsDistance<Point extends GlobalPoint | LocalPoint>(
|
||||||
|
s1: LineSegment<Point>,
|
||||||
|
s2: LineSegment<Point>,
|
||||||
|
): number {
|
||||||
|
if (lineSegmentIntersectionPoints(s1, s2)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
distanceToLineSegment(s1[0], s2),
|
||||||
|
distanceToLineSegment(s1[1], s2),
|
||||||
|
distanceToLineSegment(s2[0], s1),
|
||||||
|
distanceToLineSegment(s2[1], s1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user