Compare commits

...

4 commits

Author SHA1 Message Date
pgautame
96cf3f1771
Merge 90ec0fa40a into dff69e9191 2025-04-11 00:23:15 -04:00
jhanma17dev
dff69e9191
chore: Element center point util (#9298)
All checks were successful
Tests / test (push) Successful in 5m4s
2025-04-09 17:04:51 +02:00
Ryan Di
6fc85022ae
fix: lasso selection issues (#9353)
Some checks failed
Tests / test (push) Successful in 7m13s
Auto release excalidraw next / Auto-release-excalidraw-next (push) Failing after 1m45s
Build Docker image / build-docker (push) Failing after 6s
Cancel previous runs / cancel (push) Failing after 1s
Publish Docker / publish-docker (push) Failing after 20s
New Sentry production release / sentry (push) Failing after 2m5s
* revert stroke slicing hack for knot

* fix incorrect closing of path

* nonzero enclosure

* lint
2025-04-08 00:50:52 +10:00
pgautame
90ec0fa40a chore: renamed the Align section to Align & Distribute 2024-10-20 00:53:06 +05:30
12 changed files with 85 additions and 94 deletions

View file

@ -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 {
@ -1201,3 +1202,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);
};

View file

@ -6,6 +6,7 @@ import {
invariant, invariant,
isDevEnv, isDevEnv,
isTestEnv, isTestEnv,
elementCenterPoint,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -904,13 +905,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,
),
),
); );
} }
@ -1040,10 +1035,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) {
@ -1140,10 +1132,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
@ -1228,10 +1219,7 @@ const updateBoundPoint = (
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap, elementsMap,
).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,
@ -1275,10 +1263,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) +
@ -1771,10 +1756,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;
@ -1904,10 +1886,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;
@ -2338,10 +2317,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,
); );
}; };

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

@ -191,11 +191,7 @@ export class AnimatedTrail implements Trail {
}); });
const stroke = this.trailAnimation const stroke = this.trailAnimation
? _stroke.slice( ? _stroke.slice(0, _stroke.length / 2)
// slicing from 6th point to get rid of the initial notch type of thing
Math.min(_stroke.length, 6),
_stroke.length / 2,
)
: _stroke; : _stroke;
return getSvgPathFromStroke(stroke, true); return getSvgPathFromStroke(stroke, true);

View file

@ -2,9 +2,9 @@ import { simplify } from "points-on-curve";
import { import {
polygonFromPoints, polygonFromPoints,
polygonIncludesPoint,
lineSegment, lineSegment,
lineSegmentIntersectionPoints, lineSegmentIntersectionPoints,
polygonIncludesPointNonZero,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
@ -35,8 +35,6 @@ export const getLassoSelectedElementIds = (input: {
if (simplifyDistance) { if (simplifyDistance) {
path = simplify(lassoPath, simplifyDistance) as GlobalPoint[]; path = simplify(lassoPath, simplifyDistance) as GlobalPoint[];
} }
// close the path to form a polygon for enclosure check
const closedPath = polygonFromPoints(path);
// as the path might not enclose a shape anymore, clear before checking // as the path might not enclose a shape anymore, clear before checking
enclosedElements.clear(); enclosedElements.clear();
for (const element of elements) { for (const element of elements) {
@ -44,15 +42,11 @@ export const getLassoSelectedElementIds = (input: {
!intersectedElements.has(element.id) && !intersectedElements.has(element.id) &&
!enclosedElements.has(element.id) !enclosedElements.has(element.id)
) { ) {
const enclosed = enclosureTest(closedPath, element, elementsSegments); const enclosed = enclosureTest(path, element, elementsSegments);
if (enclosed) { if (enclosed) {
enclosedElements.add(element.id); enclosedElements.add(element.id);
} else { } else {
const intersects = intersectionTest( const intersects = intersectionTest(path, element, elementsSegments);
closedPath,
element,
elementsSegments,
);
if (intersects) { if (intersects) {
intersectedElements.add(element.id); intersectedElements.add(element.id);
} }
@ -79,7 +73,9 @@ const enclosureTest = (
} }
return segments.some((segment) => { return segments.some((segment) => {
return segment.some((point) => polygonIncludesPoint(point, lassoPolygon)); return segment.some((point) =>
polygonIncludesPointNonZero(point, lassoPolygon),
);
}); });
}; };

View file

@ -102,7 +102,7 @@
"libraries": "Browse libraries", "libraries": "Browse libraries",
"loadingScene": "Loading scene…", "loadingScene": "Loading scene…",
"loadScene": "Load scene from file", "loadScene": "Load scene from file",
"align": "Align", "align": "Align & Distribute",
"alignTop": "Align top", "alignTop": "Align top",
"alignBottom": "Align bottom", "alignBottom": "Align bottom",
"alignLeft": "Align left", "alignLeft": "Align left",

View file

@ -20,7 +20,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";
@ -151,7 +151,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),
@ -166,7 +166,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)) {

View file

@ -41,6 +41,34 @@ export const polygonIncludesPoint = <Point extends LocalPoint | GlobalPoint>(
return inside; return inside;
}; };
export const polygonIncludesPointNonZero = <Point extends [number, number]>(
point: Point,
polygon: Point[],
): boolean => {
const [x, y] = point;
let windingNumber = 0;
for (let i = 0; i < polygon.length; i++) {
const j = (i + 1) % polygon.length;
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
if (yi <= y) {
if (yj > y) {
if ((xj - xi) * (y - yi) - (x - xi) * (yj - yi) > 0) {
windingNumber++;
}
}
} else if (yj <= y) {
if ((xj - xi) * (y - yi) - (x - xi) * (yj - yi) < 0) {
windingNumber--;
}
}
}
return windingNumber !== 0;
};
export const pointOnPolygon = <Point extends LocalPoint | GlobalPoint>( export const pointOnPolygon = <Point extends LocalPoint | GlobalPoint>(
p: Point, p: Point,
poly: Polygon<Point>, poly: Polygon<Point>,