mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge branch 'master' into mrazator/scene-static-methods-removal
This commit is contained in:
commit
d9b96e8f6c
19 changed files with 482 additions and 233 deletions
|
@ -112,6 +112,7 @@ export const YOUTUBE_STATES = {
|
||||||
export const ENV = {
|
export const ENV = {
|
||||||
TEST: "test",
|
TEST: "test",
|
||||||
DEVELOPMENT: "development",
|
DEVELOPMENT: "development",
|
||||||
|
PRODUCTION: "production",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CLASSES = {
|
export const CLASSES = {
|
||||||
|
@ -318,6 +319,9 @@ export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||||
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
`;
|
||||||
|
|
||||||
export const ENCRYPTION_KEY_BITS = 128;
|
export const ENCRYPTION_KEY_BITS = 128;
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { average } from "@excalidraw/math";
|
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
FontString,
|
FontString,
|
||||||
|
ExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -738,6 +739,8 @@ export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
||||||
|
|
||||||
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||||
|
|
||||||
|
export const isProdEnv = () => import.meta.env.MODE === ENV.PRODUCTION;
|
||||||
|
|
||||||
export const isServerEnv = () =>
|
export const isServerEnv = () =>
|
||||||
typeof process !== "undefined" && !!process?.env?.NODE_ENV;
|
typeof process !== "undefined" && !!process?.env?.NODE_ENV;
|
||||||
|
|
||||||
|
@ -1201,3 +1204,17 @@ export const escapeDoubleQuotes = (str: string) => {
|
||||||
|
|
||||||
export const castArray = <T>(value: T | T[]): T[] =>
|
export const castArray = <T>(value: T | T[]): T[] =>
|
||||||
Array.isArray(value) ? value : [value];
|
Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
|
export const elementCenterPoint = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
xOffset: number = 0,
|
||||||
|
yOffset: number = 0,
|
||||||
|
) => {
|
||||||
|
const { x, y, width, height } = element;
|
||||||
|
|
||||||
|
const centerXPoint = x + width / 2 + xOffset;
|
||||||
|
|
||||||
|
const centerYPoint = y + height / 2 + yOffset;
|
||||||
|
|
||||||
|
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
||||||
|
};
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
invariant,
|
invariant,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
|
elementCenterPoint,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -887,13 +888,7 @@ export const getHeadingForElbowArrowSnap = (
|
||||||
|
|
||||||
if (!distance) {
|
if (!distance) {
|
||||||
return vectorToHeading(
|
return vectorToHeading(
|
||||||
vectorFromPoint(
|
vectorFromPoint(p, elementCenterPoint(bindableElement)),
|
||||||
p,
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
bindableElement.x + bindableElement.width / 2,
|
|
||||||
bindableElement.y + bindableElement.height / 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1022,10 +1017,7 @@ export const avoidRectangularCorner = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||||
|
|
||||||
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
||||||
|
@ -1122,10 +1114,9 @@ export const snapToMid = (
|
||||||
tolerance: number = 0.05,
|
tolerance: number = 0.05,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const { x, y, width, height, angle } = element;
|
const { x, y, width, height, angle } = element;
|
||||||
const center = pointFrom<GlobalPoint>(
|
|
||||||
x + width / 2 - 0.1,
|
const center = elementCenterPoint(element, -0.1, -0.1);
|
||||||
y + height / 2 - 0.1,
|
|
||||||
);
|
|
||||||
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||||
|
|
||||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||||
|
@ -1209,10 +1200,7 @@ const updateBoundPoint = (
|
||||||
bindableElement,
|
bindableElement,
|
||||||
startOrEnd === "startBinding" ? "start" : "end",
|
startOrEnd === "startBinding" ? "start" : "end",
|
||||||
).fixedPoint;
|
).fixedPoint;
|
||||||
const globalMidPoint = pointFrom<GlobalPoint>(
|
const globalMidPoint = elementCenterPoint(bindableElement);
|
||||||
bindableElement.x + bindableElement.width / 2,
|
|
||||||
bindableElement.y + bindableElement.height / 2,
|
|
||||||
);
|
|
||||||
const global = pointFrom<GlobalPoint>(
|
const global = pointFrom<GlobalPoint>(
|
||||||
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
||||||
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
||||||
|
@ -1256,10 +1244,7 @@ const updateBoundPoint = (
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(bindableElement);
|
||||||
bindableElement.x + bindableElement.width / 2,
|
|
||||||
bindableElement.y + bindableElement.height / 2,
|
|
||||||
);
|
|
||||||
const interceptorLength =
|
const interceptorLength =
|
||||||
pointDistance(adjacentPoint, edgePointAbsolute) +
|
pointDistance(adjacentPoint, edgePointAbsolute) +
|
||||||
pointDistance(adjacentPoint, center) +
|
pointDistance(adjacentPoint, center) +
|
||||||
|
@ -1755,10 +1740,7 @@ const determineFocusDistance = (
|
||||||
// Another point on the line, in absolute coordinates (closer to element)
|
// Another point on the line, in absolute coordinates (closer to element)
|
||||||
b: GlobalPoint,
|
b: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (pointsEqual(a, b)) {
|
if (pointsEqual(a, b)) {
|
||||||
return 0;
|
return 0;
|
||||||
|
@ -1888,10 +1870,7 @@ const determineFocusPoint = (
|
||||||
focus: number,
|
focus: number,
|
||||||
adjacentPoint: GlobalPoint,
|
adjacentPoint: GlobalPoint,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (focus === 0) {
|
if (focus === 0) {
|
||||||
return center;
|
return center;
|
||||||
|
@ -2322,10 +2301,7 @@ export const getGlobalFixedPointForBindableElement = (
|
||||||
element.x + element.width * fixedX,
|
element.x + element.width * fixedX,
|
||||||
element.y + element.height * fixedY,
|
element.y + element.height * fixedY,
|
||||||
),
|
),
|
||||||
pointFrom<GlobalPoint>(
|
elementCenterPoint(element),
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
),
|
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { isTransparent } from "@excalidraw/common";
|
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
curveIntersectLineSegment,
|
curveIntersectLineSegment,
|
||||||
isPointWithinBounds,
|
isPointWithinBounds,
|
||||||
|
@ -16,7 +16,7 @@ import {
|
||||||
} from "@excalidraw/math/ellipse";
|
} from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||||
import { getPolygonShape } from "@excalidraw/utils/shape";
|
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
GlobalPoint,
|
GlobalPoint,
|
||||||
|
@ -26,8 +26,6 @@ import type {
|
||||||
Radians,
|
Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { GeometricShape } from "@excalidraw/utils/shape";
|
|
||||||
|
|
||||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
import { getBoundTextShape, isPathALoop } from "./shapes";
|
||||||
|
@ -191,10 +189,7 @@ const intersectRectanguloidWithLineSegment = (
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||||
|
@ -253,10 +248,7 @@ const intersectDiamondWithLineSegment = (
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
|
@ -304,10 +296,7 @@ const intersectEllipseWithLineSegment = (
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||||
|
|
|
@ -14,6 +14,8 @@ import {
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { type Point } from "points-on-curve";
|
import { type Point } from "points-on-curve";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
|
@ -61,7 +63,7 @@ export const cropElement = (
|
||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
const rotatedPointer = pointRotateRads(
|
||||||
pointFrom(pointerX, pointerY),
|
pointFrom(pointerX, pointerY),
|
||||||
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
elementCenterPoint(element),
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import {
|
import {
|
||||||
curvePointDistance,
|
curvePointDistance,
|
||||||
distanceToLineSegment,
|
distanceToLineSegment,
|
||||||
pointFrom,
|
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -53,10 +54,7 @@ const distanceToRectanguloidElement = (
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
) => {
|
) => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||||
|
@ -84,10 +82,7 @@ const distanceToDiamondElement = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
|
@ -115,10 +110,7 @@ const distanceToEllipseElement = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = pointFrom(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
return ellipseDistanceFromPoint(
|
return ellipseDistanceFromPoint(
|
||||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||||
pointRotateRads(p, center, -element.angle as Radians),
|
pointRotateRads(p, center, -element.angle as Radians),
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
LINE_CONFIRM_THRESHOLD,
|
LINE_CONFIRM_THRESHOLD,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
invariant,
|
invariant,
|
||||||
|
elementCenterPoint,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
isPoint,
|
isPoint,
|
||||||
|
@ -297,7 +298,7 @@ export const aabbForElement = (
|
||||||
midY: element.y + element.height / 2,
|
midY: element.y + element.height / 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const center = pointFrom(bbox.midX, bbox.midY);
|
const center = elementCenterPoint(element);
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
pointFrom(bbox.minX, bbox.minY),
|
pointFrom(bbox.minX, bbox.minY),
|
||||||
center,
|
center,
|
||||||
|
|
|
@ -6,12 +6,16 @@ import {
|
||||||
TEXT_ALIGN,
|
TEXT_ALIGN,
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
getFontString,
|
getFontString,
|
||||||
|
isProdEnv,
|
||||||
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
|
@ -47,13 +51,25 @@ export const redrawTextBoundingBox = (
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
let maxWidth = undefined;
|
let maxWidth = undefined;
|
||||||
|
|
||||||
|
if (!isProdEnv()) {
|
||||||
|
invariant(
|
||||||
|
!container || !isArrowElement(container) || textElement.angle === 0,
|
||||||
|
"text element angle must be 0 if bound to arrow container",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const boundTextUpdates = {
|
const boundTextUpdates = {
|
||||||
x: textElement.x,
|
x: textElement.x,
|
||||||
y: textElement.y,
|
y: textElement.y,
|
||||||
text: textElement.text,
|
text: textElement.text,
|
||||||
width: textElement.width,
|
width: textElement.width,
|
||||||
height: textElement.height,
|
height: textElement.height,
|
||||||
angle: container?.angle ?? textElement.angle,
|
angle: (container
|
||||||
|
? isArrowElement(container)
|
||||||
|
? 0
|
||||||
|
: container.angle
|
||||||
|
: textElement.angle) as Radians,
|
||||||
};
|
};
|
||||||
|
|
||||||
boundTextUpdates.text = textElement.text;
|
boundTextUpdates.text = textElement.text;
|
||||||
|
@ -343,7 +359,10 @@ export const getTextElementAngle = (
|
||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
container: ExcalidrawTextContainer | null,
|
container: ExcalidrawTextContainer | null,
|
||||||
) => {
|
) => {
|
||||||
if (!container || isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!container) {
|
||||||
return textElement.angle;
|
return textElement.angle;
|
||||||
}
|
}
|
||||||
return container.angle;
|
return container.angle;
|
||||||
|
|
|
@ -10,6 +10,8 @@ import {
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||||
|
|
||||||
import { getCornerRadius } from "./shapes";
|
import { getCornerRadius } from "./shapes";
|
||||||
|
@ -68,10 +70,7 @@ export function deconstructRectanguloidElement(
|
||||||
return [sides, []];
|
return [sides, []];
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
const r = rectangle(
|
const r = rectangle(
|
||||||
pointFrom(element.x, element.y),
|
pointFrom(element.x, element.y),
|
||||||
|
@ -254,10 +253,7 @@ export function deconstructDiamondElement(
|
||||||
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = pointFrom<GlobalPoint>(
|
const center = elementCenterPoint(element);
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||||
pointFrom(element.x + topX, element.y + topY),
|
pointFrom(element.x + topX, element.y + topY),
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
|
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
|
isArrowElement,
|
||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
|
@ -41,6 +42,8 @@ import type {
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "../store";
|
import { CaptureUpdateAction } from "../store";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
@ -154,6 +157,7 @@ export const actionBindText = register({
|
||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
textAlign: TEXT_ALIGN.CENTER,
|
textAlign: TEXT_ALIGN.CENTER,
|
||||||
autoResize: true,
|
autoResize: true,
|
||||||
|
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
|
||||||
});
|
});
|
||||||
app.scene.mutateElement(container, {
|
app.scene.mutateElement(container, {
|
||||||
boundElements: (container.boundElements || []).concat({
|
boundElements: (container.boundElements || []).concat({
|
||||||
|
|
|
@ -455,7 +455,6 @@ import {
|
||||||
import { Emitter } from "../emitter";
|
import { Emitter } from "../emitter";
|
||||||
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
|
||||||
import { Store, CaptureUpdateAction } from "../store";
|
import { Store, CaptureUpdateAction } from "../store";
|
||||||
import { AnimatedTrail } from "../animated-trail";
|
|
||||||
import { LaserTrails } from "../laser-trails";
|
import { LaserTrails } from "../laser-trails";
|
||||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||||
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
||||||
|
@ -465,6 +464,8 @@ import { isMaybeMermaidDefinition } from "../mermaid";
|
||||||
|
|
||||||
import { LassoTrail } from "../lasso";
|
import { LassoTrail } from "../lasso";
|
||||||
|
|
||||||
|
import { EraserTrail } from "../eraser";
|
||||||
|
|
||||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||||
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
|
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
|
||||||
|
@ -676,26 +677,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
animationFrameHandler = new AnimationFrameHandler();
|
animationFrameHandler = new AnimationFrameHandler();
|
||||||
|
|
||||||
laserTrails = new LaserTrails(this.animationFrameHandler, this);
|
laserTrails = new LaserTrails(this.animationFrameHandler, this);
|
||||||
eraserTrail = new AnimatedTrail(this.animationFrameHandler, this, {
|
eraserTrail = new EraserTrail(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)",
|
|
||||||
});
|
|
||||||
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
|
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
|
||||||
|
|
||||||
onChangeEmitter = new Emitter<
|
onChangeEmitter = new Emitter<
|
||||||
|
@ -1678,8 +1660,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
<SVGLayer
|
<SVGLayer
|
||||||
trails={[
|
trails={[
|
||||||
this.laserTrails,
|
this.laserTrails,
|
||||||
this.eraserTrail,
|
|
||||||
this.lassoTrail,
|
this.lassoTrail,
|
||||||
|
this.eraserTrail,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{selectedElements.length === 1 &&
|
{selectedElements.length === 1 &&
|
||||||
|
@ -5178,7 +5160,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElementHitThreshold() {
|
getElementHitThreshold() {
|
||||||
return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
|
return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5370,37 +5352,37 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
y: sceneY,
|
y: sceneY,
|
||||||
});
|
});
|
||||||
|
|
||||||
const element = existingTextElement
|
const element =
|
||||||
? existingTextElement
|
existingTextElement ||
|
||||||
: newTextElement({
|
newTextElement({
|
||||||
x: parentCenterPosition
|
x: parentCenterPosition ? parentCenterPosition.elementCenterX : sceneX,
|
||||||
? parentCenterPosition.elementCenterX
|
y: parentCenterPosition ? parentCenterPosition.elementCenterY : sceneY,
|
||||||
: sceneX,
|
strokeColor: this.state.currentItemStrokeColor,
|
||||||
y: parentCenterPosition
|
backgroundColor: this.state.currentItemBackgroundColor,
|
||||||
? parentCenterPosition.elementCenterY
|
fillStyle: this.state.currentItemFillStyle,
|
||||||
: sceneY,
|
strokeWidth: this.state.currentItemStrokeWidth,
|
||||||
strokeColor: this.state.currentItemStrokeColor,
|
strokeStyle: this.state.currentItemStrokeStyle,
|
||||||
backgroundColor: this.state.currentItemBackgroundColor,
|
roughness: this.state.currentItemRoughness,
|
||||||
fillStyle: this.state.currentItemFillStyle,
|
opacity: this.state.currentItemOpacity,
|
||||||
strokeWidth: this.state.currentItemStrokeWidth,
|
text: "",
|
||||||
strokeStyle: this.state.currentItemStrokeStyle,
|
fontSize,
|
||||||
roughness: this.state.currentItemRoughness,
|
fontFamily,
|
||||||
opacity: this.state.currentItemOpacity,
|
textAlign: parentCenterPosition
|
||||||
text: "",
|
? "center"
|
||||||
fontSize,
|
: this.state.currentItemTextAlign,
|
||||||
fontFamily,
|
verticalAlign: parentCenterPosition
|
||||||
textAlign: parentCenterPosition
|
? VERTICAL_ALIGN.MIDDLE
|
||||||
? "center"
|
: DEFAULT_VERTICAL_ALIGN,
|
||||||
: this.state.currentItemTextAlign,
|
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||||
verticalAlign: parentCenterPosition
|
groupIds: container?.groupIds ?? [],
|
||||||
? VERTICAL_ALIGN.MIDDLE
|
lineHeight,
|
||||||
: DEFAULT_VERTICAL_ALIGN,
|
angle: container
|
||||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
? isArrowElement(container)
|
||||||
groupIds: container?.groupIds ?? [],
|
? (0 as Radians)
|
||||||
lineHeight,
|
: container.angle
|
||||||
angle: container?.angle ?? (0 as Radians),
|
: (0 as Radians),
|
||||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existingTextElement && shouldBindToContainer && container) {
|
if (!existingTextElement && shouldBindToContainer && container) {
|
||||||
this.scene.mutateElement(container, {
|
this.scene.mutateElement(container, {
|
||||||
|
@ -6244,101 +6226,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
|
|
||||||
private handleEraser = (
|
private handleEraser = (
|
||||||
event: PointerEvent,
|
event: PointerEvent,
|
||||||
pointerDownState: PointerDownState,
|
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
) => {
|
) => {
|
||||||
this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y);
|
const elementsToErase = this.eraserTrail.addPointToPath(
|
||||||
|
scenePointer.x,
|
||||||
let didChange = false;
|
scenePointer.y,
|
||||||
|
event.altKey,
|
||||||
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 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
|
this.elementsPendingErasure = new Set(elementsToErase);
|
||||||
if (samplingInterval === distance) {
|
this.triggerRender();
|
||||||
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();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// set touch moving for mobile context menu
|
// set touch moving for mobile context menu
|
||||||
|
@ -8184,7 +8081,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEraserActive(this.state)) {
|
if (isEraserActive(this.state)) {
|
||||||
this.handleEraser(event, pointerDownState, pointerCoords);
|
this.handleEraser(event, pointerCoords);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
isFirefox,
|
isFirefox,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
cloneJSON,
|
cloneJSON,
|
||||||
|
SVG_DOCUMENT_PREAMBLE,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
@ -134,7 +135,11 @@ export const exportCanvas = async (
|
||||||
if (type === "svg") {
|
if (type === "svg") {
|
||||||
return fileSave(
|
return fileSave(
|
||||||
svgPromise.then((svg) => {
|
svgPromise.then((svg) => {
|
||||||
return new Blob([svg.outerHTML], { type: MIME_TYPES.svg });
|
// adding SVG preamble so that older software parse the SVG file
|
||||||
|
// properly
|
||||||
|
return new Blob([SVG_DOCUMENT_PREAMBLE + svg.outerHTML], {
|
||||||
|
type: MIME_TYPES.svg,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
description: "Export to SVG",
|
description: "Export to SVG",
|
||||||
|
|
|
@ -439,7 +439,7 @@ const repairContainerElement = (
|
||||||
// if defined, lest boundElements is stale
|
// if defined, lest boundElements is stale
|
||||||
!boundElement.containerId
|
!boundElement.containerId
|
||||||
) {
|
) {
|
||||||
(boundElement as Mutable<ExcalidrawTextElement>).containerId =
|
(boundElement as Mutable<typeof boundElement>).containerId =
|
||||||
container.id;
|
container.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -464,6 +464,10 @@ const repairBoundElement = (
|
||||||
? elementsMap.get(boundElement.containerId)
|
? elementsMap.get(boundElement.containerId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
(boundElement as Mutable<typeof boundElement>).angle = (
|
||||||
|
isArrowElement(container) ? 0 : container?.angle ?? 0
|
||||||
|
) as Radians;
|
||||||
|
|
||||||
if (!container) {
|
if (!container) {
|
||||||
boundElement.containerId = null;
|
boundElement.containerId = null;
|
||||||
return;
|
return;
|
||||||
|
|
243
packages/excalidraw/eraser/index.ts
Normal file
243
packages/excalidraw/eraser/index.ts
Normal file
|
@ -0,0 +1,243 @@
|
||||||
|
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 { ElementsMap, 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 candidateElements = this.app.visibleElements.filter(
|
||||||
|
(el) => !el.locked,
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidateElementsMap = arrayToMap(candidateElements);
|
||||||
|
|
||||||
|
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 candidateElements) {
|
||||||
|
// 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,
|
||||||
|
candidateElementsMap,
|
||||||
|
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,
|
||||||
|
candidateElementsMap,
|
||||||
|
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: Map<string, GeometricShape<GlobalPoint>>,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
app: App,
|
||||||
|
): boolean => {
|
||||||
|
let shape = shapesCache.get(element.id);
|
||||||
|
|
||||||
|
if (!shape) {
|
||||||
|
shape = getElementShape<GlobalPoint>(element, elementsMap);
|
||||||
|
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, elementsMap);
|
||||||
|
elementsSegments.set(element.id, elementSegments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathSegments.some((pathSegment) =>
|
||||||
|
elementSegments?.some(
|
||||||
|
(elementSegment) =>
|
||||||
|
lineSegmentIntersectionPoints(
|
||||||
|
pathSegment,
|
||||||
|
elementSegment,
|
||||||
|
app.getElementHitThreshold(),
|
||||||
|
) !== null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
|
@ -7,11 +7,13 @@ import {
|
||||||
polygonIncludesPointNonZero,
|
polygonIncludesPointNonZero,
|
||||||
} from "@excalidraw/math";
|
} 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";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;
|
|
||||||
|
|
||||||
export const getLassoSelectedElementIds = (input: {
|
export const getLassoSelectedElementIds = (input: {
|
||||||
lassoPath: GlobalPoint[];
|
lassoPath: GlobalPoint[];
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
|
|
@ -19,7 +19,7 @@ import {
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
} from "@excalidraw/element/typeChecks";
|
} from "@excalidraw/element/typeChecks";
|
||||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
import { KEYS, arrayToMap, elementCenterPoint } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
|
@ -150,7 +150,7 @@ export class Keyboard {
|
||||||
const getElementPointForSelection = (
|
const getElementPointForSelection = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const { x, y, width, height, angle } = element;
|
const { x, y, width, angle } = element;
|
||||||
const target = pointFrom<GlobalPoint>(
|
const target = pointFrom<GlobalPoint>(
|
||||||
x +
|
x +
|
||||||
(isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2),
|
(isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2),
|
||||||
|
@ -165,7 +165,7 @@ const getElementPointForSelection = (
|
||||||
(bounds[1] + bounds[3]) / 2,
|
(bounds[1] + bounds[3]) / 2,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
center = pointFrom(x + width / 2, y + height / 2);
|
center = elementCenterPoint(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
type Radians,
|
type Radians,
|
||||||
|
type ElementsSegmentsMap,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { getElementLineSegments } from "@excalidraw/element/bounds";
|
import { getElementLineSegments } from "@excalidraw/element/bounds";
|
||||||
|
@ -33,8 +34,6 @@ import { getLassoSelectedElementIds } from "../lasso/utils";
|
||||||
|
|
||||||
import { act, render } from "./test-utils";
|
import { act, render } from "./test-utils";
|
||||||
|
|
||||||
import type { ElementsSegmentsMap } from "../lasso/utils";
|
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -31,6 +31,7 @@ import {
|
||||||
mockBoundingClientRect,
|
mockBoundingClientRect,
|
||||||
restoreOriginalGetBoundingClientRect,
|
restoreOriginalGetBoundingClientRect,
|
||||||
} from "../tests/test-utils";
|
} from "../tests/test-utils";
|
||||||
|
import { actionBindText } from "../actions";
|
||||||
|
|
||||||
unmountComponent();
|
unmountComponent();
|
||||||
|
|
||||||
|
@ -1568,5 +1569,101 @@ describe("textWysiwyg", () => {
|
||||||
expect(text.containerId).toBe(null);
|
expect(text.containerId).toBe(null);
|
||||||
expect(text.text).toBe("Excalidraw");
|
expect(text.text).toBe("Excalidraw");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reset the text element angle to the container's when binding to rotated non-arrow container", async () => {
|
||||||
|
const text = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
text: "Hello World!",
|
||||||
|
angle: 45,
|
||||||
|
});
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 90,
|
||||||
|
height: 75,
|
||||||
|
angle: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle, text]);
|
||||||
|
|
||||||
|
API.setSelectedElements([rectangle, text]);
|
||||||
|
|
||||||
|
h.app.actionManager.executeAction(actionBindText);
|
||||||
|
|
||||||
|
expect(text.angle).toBe(30);
|
||||||
|
expect(rectangle.angle).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reset the text element angle to 0 when binding to rotated arrow container", async () => {
|
||||||
|
const text = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
text: "Hello World!",
|
||||||
|
angle: 45,
|
||||||
|
});
|
||||||
|
const arrow = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
width: 90,
|
||||||
|
height: 75,
|
||||||
|
angle: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([arrow, text]);
|
||||||
|
|
||||||
|
API.setSelectedElements([arrow, text]);
|
||||||
|
|
||||||
|
h.app.actionManager.executeAction(actionBindText);
|
||||||
|
|
||||||
|
expect(text.angle).toBe(0);
|
||||||
|
expect(arrow.angle).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep the text label at 0 degrees when used as an arrow label", async () => {
|
||||||
|
const arrow = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
width: 90,
|
||||||
|
height: 75,
|
||||||
|
angle: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([arrow]);
|
||||||
|
API.setSelectedElements([arrow]);
|
||||||
|
|
||||||
|
mouse.doubleClickAt(
|
||||||
|
arrow.x + arrow.width / 2,
|
||||||
|
arrow.y + arrow.height / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
|
||||||
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
|
expect(h.elements[1].angle).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep the text label at the same degrees when used as a non-arrow label", async () => {
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 90,
|
||||||
|
height: 75,
|
||||||
|
angle: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
mouse.doubleClickAt(
|
||||||
|
rectangle.x + rectangle.width / 2,
|
||||||
|
rectangle.y + rectangle.height / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = await getTextEditor(textEditorSelector, true);
|
||||||
|
|
||||||
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
|
expect(h.elements[1].angle).toBe(30);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -138,3 +138,5 @@ export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
|
||||||
} & {
|
} & {
|
||||||
_brand: "excalimath_ellipse";
|
_brand: "excalimath_ellipse";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue