chore: Unify math types, utils and functions (#8389)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács 2024-09-03 00:23:38 +02:00 committed by GitHub
parent e3d1dee9d0
commit f4dd23fc31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
98 changed files with 4291 additions and 3661 deletions

View file

@ -1,8 +1,8 @@
import * as GA from "../ga";
import * as GAPoint from "../gapoints";
import * as GADirection from "../gadirections";
import * as GALine from "../galines";
import * as GATransform from "../gatransforms";
import * as GA from "../../math/ga/ga";
import * as GAPoint from "../../math/ga/gapoints";
import * as GADirection from "../../math/ga/gadirections";
import * as GALine from "../../math/ga/galines";
import * as GATransform from "../../math/ga/gatransforms";
import type {
ExcalidrawBindableElement,
@ -10,7 +10,6 @@ import type {
ExcalidrawRectangleElement,
ExcalidrawDiamondElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawFrameLikeElement,
ExcalidrawIframeLikeElement,
@ -26,11 +25,12 @@ import type {
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
ExcalidrawRectanguloidElement,
} from "./types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import type { AppState, Point } from "../types";
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
import type { AppState } from "../types";
import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene";
import {
@ -51,17 +51,7 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getElementShape } from "../shapes";
import {
aabbForElement,
clamp,
distanceSq2d,
getCenterForBounds,
getCenterForElement,
pointInsideBounds,
pointToVector,
rotatePoint,
} from "../math";
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
import {
compareHeading,
HEADING_DOWN,
@ -72,7 +62,18 @@ import {
vectorToHeading,
type Heading,
} from "./heading";
import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry";
import type { LocalPoint, Radians } from "../../math";
import {
lineSegment,
point,
pointRotateRads,
type GlobalPoint,
vectorFromPoint,
pointFromPair,
pointDistanceSq,
clamp,
} from "../../math";
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@ -649,7 +650,7 @@ export const updateBoundElements = (
update,
): update is NonNullable<{
index: number;
point: Point;
point: LocalPoint;
isDragging?: boolean;
}> => update !== null,
);
@ -695,14 +696,14 @@ const getSimultaneouslyUpdatedElementIds = (
};
export const getHeadingForElbowArrowSnap = (
point: Readonly<Point>,
otherPoint: Readonly<Point>,
p: Readonly<GlobalPoint>,
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: Point,
origPoint: GlobalPoint,
): Heading => {
const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
if (!bindableElement || !aabb) {
return otherPointHeading;
@ -716,17 +717,23 @@ export const getHeadingForElbowArrowSnap = (
if (!distance) {
return vectorToHeading(
pointToVector(point, getCenterForElement(bindableElement)),
vectorFromPoint(
p,
point<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
),
),
);
}
const pointHeading = headingForPointFromElement(bindableElement, aabb, point);
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
return pointHeading;
};
const getDistanceForBinding = (
point: Readonly<Point>,
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
) => {
@ -745,89 +752,87 @@ const getDistanceForBinding = (
};
export const bindPointToSnapToElementOutline = (
point: Readonly<Point>,
otherPoint: Readonly<Point>,
p: Readonly<GlobalPoint>,
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined,
elementsMap: ElementsMap,
): Point => {
): GlobalPoint => {
const aabb = bindableElement && aabbForElement(bindableElement);
if (bindableElement && aabb) {
// TODO: Dirty hacks until tangents are properly calculated
const heading = headingForPointFromElement(bindableElement, aabb, point);
const heading = headingForPointFromElement(bindableElement, aabb, p);
const intersections = [
...intersectElementWithLine(
...(intersectElementWithLine(
bindableElement,
[point[0], point[1] - 2 * bindableElement.height],
[point[0], point[1] + 2 * bindableElement.height],
point(p[0], p[1] - 2 * bindableElement.height),
point(p[0], p[1] + 2 * bindableElement.height),
FIXED_BINDING_DISTANCE,
elementsMap,
),
...intersectElementWithLine(
) ?? []),
...(intersectElementWithLine(
bindableElement,
[point[0] - 2 * bindableElement.width, point[1]],
[point[0] + 2 * bindableElement.width, point[1]],
point(p[0] - 2 * bindableElement.width, p[1]),
point(p[0] + 2 * bindableElement.width, p[1]),
FIXED_BINDING_DISTANCE,
elementsMap,
),
) ?? []),
];
const isVertical =
compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT);
const dist = Math.abs(
distanceToBindableElement(bindableElement, point, elementsMap),
distanceToBindableElement(bindableElement, p, elementsMap),
);
const isInner = isVertical
? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1;
intersections.sort(
(a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
);
intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p));
return isInner
? headingToMidBindPoint(otherPoint, bindableElement, aabb)
: intersections.filter((i) =>
isVertical
? Math.abs(point[1] - i[1]) < 0.1
: Math.abs(point[0] - i[0]) < 0.1,
? Math.abs(p[1] - i[1]) < 0.1
: Math.abs(p[0] - i[0]) < 0.1,
)[0] ?? point;
}
return point;
return p;
};
const headingToMidBindPoint = (
point: Point,
p: GlobalPoint,
bindableElement: ExcalidrawBindableElement,
aabb: Bounds,
): Point => {
): GlobalPoint => {
const center = getCenterForBounds(aabb);
const heading = vectorToHeading(pointToVector(point, center));
const heading = vectorToHeading(vectorFromPoint(p, center));
switch (true) {
case compareHeading(heading, HEADING_UP):
return rotatePoint(
[(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
return pointRotateRads(
point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_RIGHT):
return rotatePoint(
[aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
return pointRotateRads(
point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_DOWN):
return rotatePoint(
[(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
return pointRotateRads(
point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
center,
bindableElement.angle,
);
default:
return rotatePoint(
[aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
return pointRotateRads(
point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
center,
bindableElement.angle,
);
@ -836,22 +841,25 @@ const headingToMidBindPoint = (
export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
p: Point,
): Point => {
const center = getCenterForElement(element);
const nonRotatedPoint = rotatePoint(p, center, -element.angle);
p: GlobalPoint,
): GlobalPoint => {
const center = point<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
return rotatePoint(
[element.x - FIXED_BINDING_DISTANCE, element.y],
return pointRotateRads<GlobalPoint>(
point(element.x - FIXED_BINDING_DISTANCE, element.y),
center,
element.angle,
);
}
return rotatePoint(
[element.x, element.y - FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(element.x, element.y - FIXED_BINDING_DISTANCE),
center,
element.angle,
);
@ -861,14 +869,14 @@ export const avoidRectangularCorner = (
) {
// Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
return rotatePoint(
[element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
center,
element.angle,
);
}
return rotatePoint(
[element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
return pointRotateRads(
point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
center,
element.angle,
);
@ -881,20 +889,20 @@ export const avoidRectangularCorner = (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return rotatePoint(
[
return pointRotateRads(
point(
element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE,
],
),
center,
element.angle,
);
}
return rotatePoint(
[
return pointRotateRads(
point(
element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height,
],
),
center,
element.angle,
);
@ -907,14 +915,14 @@ export const avoidRectangularCorner = (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return rotatePoint(
[element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
center,
element.angle,
);
}
return rotatePoint(
[element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
return pointRotateRads(
point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
center,
element.angle,
);
@ -925,12 +933,12 @@ export const avoidRectangularCorner = (
export const snapToMid = (
element: ExcalidrawBindableElement,
p: Point,
p: GlobalPoint,
tolerance: number = 0.05,
): Point => {
): GlobalPoint => {
const { x, y, width, height, angle } = element;
const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
const nonRotated = rotatePoint(p, center, -angle);
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
@ -943,22 +951,30 @@ export const snapToMid = (
nonRotated[1] < center[1] + verticalThrehsold
) {
// LEFT
return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
return pointRotateRads(
point(x - FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
) {
// TOP
return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
return pointRotateRads(
point(center[0], y - FIXED_BINDING_DISTANCE),
center,
angle,
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
) {
// RIGHT
return rotatePoint(
[x + width + FIXED_BINDING_DISTANCE, center[1]],
return pointRotateRads(
point(x + width + FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
@ -968,8 +984,8 @@ export const snapToMid = (
nonRotated[0] < center[0] + horizontalThrehsold
) {
// DOWN
return rotatePoint(
[center[0], y + height + FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(center[0], y + height + FIXED_BINDING_DISTANCE),
center,
angle,
);
@ -984,7 +1000,7 @@ const updateBoundPoint = (
binding: PointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): Point | null => {
): LocalPoint | null => {
if (
binding == null ||
// We only need to update the other end if this is a 2 point line element
@ -1006,15 +1022,15 @@ const updateBoundPoint = (
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint;
const globalMidPoint = [
const globalMidPoint = point<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
] as Point;
const global = [
);
const global = point<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height,
] as Point;
const rotatedGlobal = rotatePoint(
);
const rotatedGlobal = pointRotateRads(
global,
globalMidPoint,
bindableElement.angle,
@ -1040,7 +1056,7 @@ const updateBoundPoint = (
elementsMap,
);
let newEdgePoint: Point;
let newEdgePoint: GlobalPoint;
// The linear element was not originally pointing inside the bound shape,
// we can point directly at the focus point
@ -1054,7 +1070,7 @@ const updateBoundPoint = (
binding.gap,
elementsMap,
);
if (intersections.length === 0) {
if (!intersections || intersections.length === 0) {
// This should never happen, since focusPoint should always be
// inside the element, but just in case, bail out
newEdgePoint = focusPointAbsolute;
@ -1101,15 +1117,15 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement,
elementsMap,
);
const globalMidPoint = [
const globalMidPoint = point(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
] as Point;
const nonRotatedSnappedGlobalPoint = rotatePoint(
);
const nonRotatedSnappedGlobalPoint = pointRotateRads(
snappedPoint,
globalMidPoint,
-hoveredElement.angle,
) as Point;
-hoveredElement.angle as Radians,
);
return {
fixedPoint: normalizeFixedPoint([
@ -1320,8 +1336,9 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height);
const shape = getElementShape(element, elementsMap);
return (
isPointOnShape([x, y], shape, threshold) ||
(fullShape === true && pointInsideBounds([x, y], aabbForElement(element)))
isPointOnShape(point(x, y), shape, threshold) ||
(fullShape === true &&
pointInsideBounds(point(x, y), aabbForElement(element)))
);
};
@ -1339,7 +1356,7 @@ export const maxBindingGap = (
export const distanceToBindableElement = (
element: ExcalidrawBindableElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
switch (element.type) {
@ -1359,19 +1376,13 @@ export const distanceToBindableElement = (
};
const distanceToRectangle = (
element:
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement,
point: Point,
element: ExcalidrawRectanguloidElement,
p: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
element,
point,
p,
elementsMap,
);
return Math.max(
@ -1382,7 +1393,7 @@ const distanceToRectangle = (
const distanceToDiamond = (
element: ExcalidrawDiamondElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
@ -1396,7 +1407,7 @@ const distanceToDiamond = (
const distanceToEllipse = (
element: ExcalidrawEllipseElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
@ -1405,7 +1416,7 @@ const distanceToEllipse = (
const ellipseParamsForTest = (
element: ExcalidrawEllipseElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): [GA.Point, GA.Line] => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
@ -1467,7 +1478,7 @@ const ellipseParamsForTest = (
// so we only need to perform hit tests for the positive quadrant.
const pointRelativeToElement = (
element: ExcalidrawElement,
pointTuple: Point,
pointTuple: GlobalPoint,
elementsMap: ElementsMap,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
@ -1516,9 +1527,9 @@ const coordsCenter = (
const determineFocusDistance = (
element: ExcalidrawBindableElement,
// Point on the line, in absolute coordinates
a: Point,
a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element)
b: Point,
b: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const relateToCenter = relativizationToElementCenter(element, elementsMap);
@ -1559,13 +1570,13 @@ const determineFocusPoint = (
// The oriented, relative distance from the center of `element` of the
// returned focusPoint
focus: number,
adjecentPoint: Point,
adjecentPoint: GlobalPoint,
elementsMap: ElementsMap,
): Point => {
): GlobalPoint => {
if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const center = coordsCenter(x1, y1, x2, y2);
return GAPoint.toTuple(center);
return pointFromPair(GAPoint.toTuple(center));
}
const relateToCenter = relativizationToElementCenter(element, elementsMap);
const adjecentPointRel = GATransform.apply(
@ -1589,7 +1600,9 @@ const determineFocusPoint = (
point = findFocusPointForEllipse(element, focus, adjecentPointRel);
break;
}
return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
return pointFromPair(
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
);
};
// Returns 2 or 0 intersection points between line going through `a` and `b`
@ -1597,15 +1610,15 @@ const determineFocusPoint = (
const intersectElementWithLine = (
element: ExcalidrawBindableElement,
// Point on the line, in absolute coordinates
a: Point,
a: GlobalPoint,
// Another point on the line, in absolute coordinates
b: Point,
b: GlobalPoint,
// If given, the element is inflated by this value
gap: number = 0,
elementsMap: ElementsMap,
): Point[] => {
): GlobalPoint[] | undefined => {
if (isRectangularElement(element)) {
return segmentIntersectRectangleElement(element, [a, b], gap);
return segmentIntersectRectangleElement(element, lineSegment(a, b), gap);
}
const relateToCenter = relativizationToElementCenter(element, elementsMap);
@ -1619,8 +1632,14 @@ const intersectElementWithLine = (
aRel,
gap,
);
return intersections.map((point) =>
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
return intersections.map(
(point) =>
pointFromPair(
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
),
// pointFromArray(
// ,
// ),
);
};
@ -2173,12 +2192,18 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number],
element: ExcalidrawBindableElement,
) => {
): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
return rotatePoint(
[element.x + element.width * fixedX, element.y + element.height * fixedY],
getCenterForElement(element),
return pointRotateRads(
point(
element.x + element.width * fixedX,
element.y + element.height * fixedY,
),
point<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
),
element.angle,
);
};
@ -2186,7 +2211,7 @@ export const getGlobalFixedPointForBindableElement = (
const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap,
) => {
): [GlobalPoint, GlobalPoint] => {
const startElement =
arrow.startBinding &&
(elementsMap.get(arrow.startBinding.elementId) as
@ -2197,23 +2222,26 @@ const getGlobalFixedPoints = (
(elementsMap.get(arrow.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const startPoint: Point =
const startPoint =
startElement && arrow.startBinding
? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement,
)
: [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
const endPoint: Point =
: point<GlobalPoint>(
arrow.x + arrow.points[0][0],
arrow.y + arrow.points[0][1],
);
const endPoint =
endElement && arrow.endBinding
? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement,
)
: [
: point<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1],
];
);
return [startPoint, endPoint];
};

View file

@ -1,3 +1,5 @@
import type { LocalPoint } from "../../math";
import { point } from "../../math";
import { ROUNDNESS } from "../constants";
import { arrayToMap } from "../utils";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
@ -123,9 +125,9 @@ describe("getElementBounds", () => {
a: 0.6447741904932416,
}),
points: [
[0, 0] as [number, number],
[67.33984375, 92.48828125] as [number, number],
[-102.7890625, 52.15625] as [number, number],
point<LocalPoint>(0, 0),
point<LocalPoint>(67.33984375, 92.48828125),
point<LocalPoint>(-102.7890625, 52.15625),
],
} as ExcalidrawLinearElement;

View file

@ -7,10 +7,10 @@ import type {
ExcalidrawTextElementWithContainer,
ElementsMap,
} from "./types";
import { distance2d, rotate, rotatePoint } from "../math";
import rough from "roughjs/bin/rough";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Op } from "roughjs/bin/core";
import type { AppState, Point } from "../types";
import type { AppState } from "../types";
import { generateRoughOptions } from "../scene/Shape";
import {
isArrowElement,
@ -22,9 +22,24 @@ import {
import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { arrayToMap } from "../utils";
import { arrayToMap, invariant } from "../utils";
import type {
Degrees,
GlobalPoint,
LineSegment,
LocalPoint,
Radians,
} from "../../math";
import {
degreesToRadians,
lineSegment,
point,
pointDistance,
pointFromArray,
pointRotateRads,
} from "../../math";
import type { Mutable } from "../utility-types";
export type RectangleBox = {
x: number;
@ -97,7 +112,11 @@ export class ElementBounds {
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
pointRotateRads(
point(x, y),
point(cx - element.x, cy - element.y),
element.angle,
),
),
);
@ -110,10 +129,26 @@ export class ElementBounds {
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const [x11, y11] = pointRotateRads(
point(cx, y1),
point(cx, cy),
element.angle,
);
const [x12, y12] = pointRotateRads(
point(cx, y2),
point(cx, cy),
element.angle,
);
const [x22, y22] = pointRotateRads(
point(x1, cy),
point(cx, cy),
element.angle,
);
const [x21, y21] = pointRotateRads(
point(x2, cy),
point(cx, cy),
element.angle,
);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
@ -128,10 +163,26 @@ export class ElementBounds {
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const [x11, y11] = pointRotateRads(
point(x1, y1),
point(cx, cy),
element.angle,
);
const [x12, y12] = pointRotateRads(
point(x1, y2),
point(cx, cy),
element.angle,
);
const [x22, y22] = pointRotateRads(
point(x2, y2),
point(cx, cy),
element.angle,
);
const [x21, y21] = pointRotateRads(
point(x2, y1),
point(cx, cy),
element.angle,
);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
@ -165,18 +216,18 @@ export const getElementAbsoluteCoords = (
? getContainerElement(element, elementsMap)
: null;
if (isArrowElement(container)) {
const coords = LinearElementEditor.getBoundTextElementPosition(
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
elementsMap,
);
return [
coords.x,
coords.y,
coords.x + element.width,
coords.y + element.height,
coords.x + element.width / 2,
coords.y + element.height / 2,
x,
y,
x + element.width,
y + element.height,
x + element.width / 2,
y + element.height / 2,
];
}
}
@ -198,38 +249,40 @@ export const getElementAbsoluteCoords = (
export const getElementLineSegments = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): [Point, Point][] => {
): LineSegment<GlobalPoint>[] => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const center: Point = [cx, cy];
const center: GlobalPoint = point(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: [Point, Point][] = [];
const segments: LineSegment<GlobalPoint>[] = [];
let i = 0;
while (i < element.points.length - 1) {
segments.push([
rotatePoint(
[
element.points[i][0] + element.x,
element.points[i][1] + element.y,
] as Point,
center,
element.angle,
segments.push(
lineSegment(
pointRotateRads(
point(
element.points[i][0] + element.x,
element.points[i][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
point(
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
),
center,
element.angle,
),
),
rotatePoint(
[
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
] as Point,
center,
element.angle,
),
]);
);
i++;
}
@ -246,40 +299,40 @@ export const getElementLineSegments = (
[cx, y2],
[x1, cy],
[x2, cy],
] as Point[]
).map((point) => rotatePoint(point, center, element.angle));
] as GlobalPoint[]
).map((point) => pointRotateRads(point, center, element.angle));
if (element.type === "diamond") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
if (element.type === "ellipse") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
[n, w],
[n, e],
[s, w],
[s, e],
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
return [
[nw, ne],
[sw, se],
[nw, sw],
[ne, se],
[nw, e],
[sw, e],
[ne, w],
[se, w],
lineSegment(nw, ne),
lineSegment(sw, se),
lineSegment(nw, sw),
lineSegment(ne, se),
lineSegment(nw, e),
lineSegment(sw, e),
lineSegment(ne, w),
lineSegment(se, w),
];
};
@ -386,10 +439,10 @@ const solveQuadratic = (
};
const getCubicBezierCurveBound = (
p0: Point,
p1: Point,
p2: Point,
p3: Point,
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
p3: GlobalPoint,
): Bounds => {
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
@ -415,9 +468,9 @@ const getCubicBezierCurveBound = (
export const getMinMaxXYFromCurvePathOps = (
ops: Op[],
transformXY?: (x: number, y: number) => [number, number],
transformXY?: (p: GlobalPoint) => GlobalPoint,
): Bounds => {
let currentP: Point = [0, 0];
let currentP: GlobalPoint = point(0, 0);
const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => {
@ -425,19 +478,21 @@ export const getMinMaxXYFromCurvePathOps = (
// move, bcurveTo, lineTo, and curveTo
if (op === "move") {
// change starting point
currentP = data as unknown as Point;
const p: GlobalPoint | undefined = pointFromArray(data);
invariant(p != null, "Op data is not a point");
currentP = p;
// move operation does not draw anything; so, it always
// returns false
} else if (op === "bcurveTo") {
const _p1 = [data[0], data[1]] as Point;
const _p2 = [data[2], data[3]] as Point;
const _p3 = [data[4], data[5]] as Point;
const _p1 = point<GlobalPoint>(data[0], data[1]);
const _p2 = point<GlobalPoint>(data[2], data[3]);
const _p3 = point<GlobalPoint>(data[4], data[5]);
const p1 = transformXY ? transformXY(..._p1) : _p1;
const p2 = transformXY ? transformXY(..._p2) : _p2;
const p3 = transformXY ? transformXY(..._p3) : _p3;
const p1 = transformXY ? transformXY(_p1) : _p1;
const p2 = transformXY ? transformXY(_p2) : _p2;
const p3 = transformXY ? transformXY(_p3) : _p3;
const p0 = transformXY ? transformXY(...currentP) : currentP;
const p0 = transformXY ? transformXY(currentP) : currentP;
currentP = _p3;
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
@ -507,14 +562,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
};
/** @returns number in degrees */
export const getArrowheadAngle = (arrowhead: Arrowhead): number => {
export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => {
switch (arrowhead) {
case "bar":
return 90;
return 90 as Degrees;
case "arrow":
return 20;
return 20 as Degrees;
default:
return 25;
return 25 as Degrees;
}
};
@ -533,19 +588,24 @@ export const getArrowheadPoints = (
const index = position === "start" ? 1 : ops.length - 1;
const data = ops[index].data;
const p3 = [data[4], data[5]] as Point;
const p2 = [data[2], data[3]] as Point;
const p1 = [data[0], data[1]] as Point;
invariant(data.length === 6, "Op data length is not 6");
const p3 = point(data[4], data[5]);
const p2 = point(data[2], data[3]);
const p1 = point(data[0], data[1]);
// We need to find p0 of the bezier curve.
// It is typically the last point of the previous
// curve; it can also be the position of moveTo operation.
const prevOp = ops[index - 1];
let p0: Point = [0, 0];
let p0 = point(0, 0);
if (prevOp.op === "move") {
p0 = prevOp.data as unknown as Point;
const p = pointFromArray(prevOp.data);
invariant(p != null, "Op data is not a point");
p0 = p;
} else if (prevOp.op === "bcurveTo") {
p0 = [prevOp.data[4], prevOp.data[5]];
p0 = point(prevOp.data[4], prevOp.data[5]);
}
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
@ -610,8 +670,16 @@ export const getArrowheadPoints = (
const angle = getArrowheadAngle(arrowhead);
// Return points
const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
const [x3, y3] = pointRotateRads(
point(xs, ys),
point(x2, y2),
((-angle * Math.PI) / 180) as Radians,
);
const [x4, y4] = pointRotateRads(
point(xs, ys),
point(x2, y2),
degreesToRadians(angle),
);
if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
// point opposite to the arrowhead point
@ -621,12 +689,10 @@ export const getArrowheadPoints = (
if (position === "start") {
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = rotate(
x2 + minSize * 2,
y2,
x2,
y2,
Math.atan2(py - y2, px - x2),
[ox, oy] = pointRotateRads(
point(x2 + minSize * 2, y2),
point(x2, y2),
Math.atan2(py - y2, px - x2) as Radians,
);
} else {
const [px, py] =
@ -634,12 +700,10 @@ export const getArrowheadPoints = (
? element.points[element.points.length - 2]
: [0, 0];
[ox, oy] = rotate(
x2 - minSize * 2,
y2,
x2,
y2,
Math.atan2(y2 - py, x2 - px),
[ox, oy] = pointRotateRads(
point(x2 - minSize * 2, y2),
point(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians,
);
}
@ -665,7 +729,10 @@ const generateLinearElementShape = (
return "linearPath";
})();
return generator[method](element.points as Mutable<Point>[], options);
return generator[method](
element.points as Mutable<LocalPoint>[] as RoughPoint[],
options,
);
};
const getLinearElementRotatedBounds = (
@ -678,11 +745,9 @@ const getLinearElementRotatedBounds = (
if (element.points.length < 2) {
const [pointX, pointY] = element.points[0];
const [x, y] = rotate(
element.x + pointX,
element.y + pointY,
cx,
cy,
const [x, y] = pointRotateRads(
point(element.x + pointX, element.y + pointY),
point(cx, cy),
element.angle,
);
@ -708,8 +773,12 @@ const getLinearElementRotatedBounds = (
const cachedShape = ShapeCache.get(element)?.[0];
const shape = cachedShape ?? generateLinearElementShape(element);
const ops = getCurvePathOps(shape);
const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle);
const transformXY = ([x, y]: GlobalPoint) =>
pointRotateRads<GlobalPoint>(
point(element.x + x, element.y + y),
point(cx, cy),
element.angle,
);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: Bounds = [res[0], res[1], res[2], res[3]];
if (boundTextElement) {
@ -861,7 +930,10 @@ export const getClosestElementBounds = (
const elementsMap = arrayToMap(elements);
elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y);
const distance = pointDistance(
point((x1 + x2) / 2, (y1 + y2) / 2),
point(from.x, from.y),
);
if (distance < minDistance) {
minDistance = distance;
@ -916,3 +988,9 @@ export const getVisibleSceneBounds = ({
-scrollY + height / zoom.value,
];
};
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
point(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
);

View file

@ -1,14 +1,11 @@
import { isPathALoop, isPointWithinBounds } from "../math";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawRectangleElement,
} from "./types";
import { getElementBounds } from "./bounds";
import type { FrameNameBounds } from "../types";
import type { Polygon, GeometricShape } from "../../utils/geometry/shape";
import type { GeometricShape } from "../../utils/geometry/shape";
import { getPolygonShape } from "../../utils/geometry/shape";
import { isPointInShape, isPointOnShape } from "../../utils/collision";
import { isTransparent } from "../utils";
@ -18,7 +15,9 @@ import {
isImageElement,
isTextElement,
} from "./typeChecks";
import { getBoundTextShape } from "../shapes";
import { getBoundTextShape, isPathALoop } from "../shapes";
import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
import { isPointWithinBounds, point } from "../../math";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
@ -42,35 +41,36 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element);
};
export type HitTestArgs = {
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
x: number;
y: number;
element: ExcalidrawElement;
shape: GeometricShape;
shape: GeometricShape<Point>;
threshold?: number;
frameNameBound?: FrameNameBounds | null;
};
export const hitElementItself = ({
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
x,
y,
element,
shape,
threshold = 10,
frameNameBound = null,
}: HitTestArgs) => {
}: HitTestArgs<Point>) => {
let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInShape([x, y], shape) || isPointOnShape([x, y], shape, threshold)
: isPointOnShape([x, y], shape, threshold);
isPointInShape(point(x, y), shape) ||
isPointOnShape(point(x, y), shape, threshold)
: isPointOnShape(point(x, y), shape, threshold);
// hit test against a frame's name
if (!hit && frameNameBound) {
hit = isPointInShape([x, y], {
hit = isPointInShape(point(x, y), {
type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
.data as Polygon,
.data as Polygon<Point>,
});
}
@ -89,11 +89,13 @@ export const hitElementBoundingBox = (
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds([x1, y1], [x, y], [x2, y2]);
return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
};
export const hitElementBoundingBoxOnly = (
hitArgs: HitTestArgs,
export const hitElementBoundingBoxOnly = <
Point extends GlobalPoint | LocalPoint,
>(
hitArgs: HitTestArgs<Point>,
elementsMap: ElementsMap,
) => {
return (
@ -108,10 +110,10 @@ export const hitElementBoundingBoxOnly = (
);
};
export const hitElementBoundText = (
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
x: number,
y: number,
textShape: GeometricShape | null,
textShape: GeometricShape<Point> | null,
): boolean => {
return !!textShape && isPointInShape([x, y], textShape);
return !!textShape && isPointInShape(point(x, y), textShape);
};

View file

@ -11,7 +11,6 @@ import type {
PointerDownState,
} from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
import { getGridPoint } from "../math";
import type Scene from "../scene/Scene";
import {
isArrowElement,
@ -21,6 +20,7 @@ import {
} from "./typeChecks";
import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
import { getGridPoint } from "../snapping";
export const dragSelectedElements = (
pointerDownState: PointerDownState,

View file

@ -10,7 +10,6 @@ import {
import { bindLinearElement } from "./binding";
import { LinearElementEditor } from "./linearElementEditor";
import { newArrowElement, newElement } from "./newElement";
import { aabbForElement } from "../math";
import type {
ElementsMap,
ExcalidrawBindableElement,
@ -20,7 +19,7 @@ import type {
OrderedExcalidrawElement,
} from "./types";
import { KEYS } from "../keys";
import type { AppState, PendingExcalidrawElements, Point } from "../types";
import type { AppState, PendingExcalidrawElements } from "../types";
import { mutateElement } from "./mutateElement";
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
import {
@ -30,6 +29,8 @@ import {
isFlowchartNodeElement,
} from "./typeChecks";
import { invariant } from "../utils";
import { point, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes";
type LinkDirection = "up" | "right" | "down" | "left";
@ -81,13 +82,14 @@ const getNodeRelatives = (
"not an ExcalidrawBindableElement",
);
const edgePoint: Point =
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
const edgePoint = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [
edgePoint[0] + el.x,
edgePoint[1] + el.y,
]);
] as Readonly<LocalPoint>);
acc.push({
relative,
@ -419,10 +421,7 @@ const createBindingArrow = (
strokeColor: appState.currentItemStrokeColor,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: appState.currentItemStrokeWidth,
points: [
[0, 0],
[endX, endY],
],
points: [point(0, 0), point(endX, endY)],
elbowed: true,
});

View file

@ -1,12 +1,18 @@
import { lineAngle } from "../../utils/geometry/geometry";
import type { Point, Vector } from "../../utils/geometry/shape";
import type {
LocalPoint,
GlobalPoint,
Triangle,
Vector,
Radians,
} from "../../math";
import {
getCenterForBounds,
PointInTriangle,
rotatePoint,
scalePointFromOrigin,
} from "../math";
import type { Bounds } from "./bounds";
point,
pointRotateRads,
pointScaleFromOrigin,
radiansToDegrees,
triangleIncludesPoint,
} from "../../math";
import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
@ -15,8 +21,13 @@ export const HEADING_LEFT = [-1, 0] as Heading;
export const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
export const headingForDiamond = (a: Point, b: Point) => {
const angle = lineAngle([a, b]);
export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
a: Point,
b: Point,
) => {
const angle = radiansToDegrees(
Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
);
if (angle >= 315 || angle < 45) {
return HEADING_UP;
} else if (angle >= 45 && angle < 135) {
@ -47,56 +58,58 @@ export const compareHeading = (a: Heading, b: Heading) =>
// Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
export const headingForPointFromElement = (
export const headingForPointFromElement = <
Point extends GlobalPoint | LocalPoint,
>(
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
point: Readonly<Point>,
p: Readonly<LocalPoint | GlobalPoint>,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") {
if (point[0] < element.x) {
if (p[0] < element.x) {
return HEADING_LEFT;
} else if (point[1] < element.y) {
} else if (p[1] < element.y) {
return HEADING_UP;
} else if (point[0] > element.x + element.width) {
} else if (p[0] > element.x + element.width) {
return HEADING_RIGHT;
} else if (point[1] > element.y + element.height) {
} else if (p[1] > element.y + element.height) {
return HEADING_DOWN;
}
const top = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y],
const top = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width / 2, element.y),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const right = rotatePoint(
scalePointFromOrigin(
[element.x + element.width, element.y + element.height / 2],
const right = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const bottom = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y + element.height],
const bottom = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width / 2, element.y + element.height),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const left = rotatePoint(
scalePointFromOrigin(
[element.x, element.y + element.height / 2],
const left = pointRotateRads(
pointScaleFromOrigin(
point(element.x, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
@ -104,43 +117,62 @@ export const headingForPointFromElement = (
element.angle,
);
if (PointInTriangle(point, top, right, midPoint)) {
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
return headingForDiamond(top, right);
} else if (PointInTriangle(point, right, bottom, midPoint)) {
} else if (
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(right, bottom);
} else if (PointInTriangle(point, bottom, left, midPoint)) {
} else if (
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(bottom, left);
}
return headingForDiamond(left, top);
}
const topLeft = scalePointFromOrigin(
[aabb[0], aabb[1]],
const topLeft = pointScaleFromOrigin(
point(aabb[0], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const topRight = scalePointFromOrigin(
[aabb[2], aabb[1]],
) as Point;
const topRight = pointScaleFromOrigin(
point(aabb[2], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomLeft = scalePointFromOrigin(
[aabb[0], aabb[3]],
) as Point;
const bottomLeft = pointScaleFromOrigin(
point(aabb[0], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomRight = scalePointFromOrigin(
[aabb[2], aabb[3]],
) as Point;
const bottomRight = pointScaleFromOrigin(
point(aabb[2], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
) as Point;
return PointInTriangle(point, topLeft, topRight, midPoint)
return triangleIncludesPoint(
[topLeft, topRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_UP
: PointInTriangle(point, topRight, bottomRight, midPoint)
: triangleIncludesPoint(
[topRight, bottomRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_RIGHT
: PointInTriangle(point, bottomRight, bottomLeft, midPoint)
: triangleIncludesPoint(
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
p,
)
? HEADING_DOWN
: HEADING_LEFT;
};
export const flipHeading = (h: Heading): Heading =>
[
h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
] as Heading;

View file

@ -11,19 +11,6 @@ import type {
FixedPointBinding,
SceneElementsMap,
} from "./types";
import {
distance2d,
rotate,
isPathALoop,
getGridPoint,
rotatePoint,
centerPoint,
getControlPointsForBezierCurve,
getBezierXY,
getBezierCurveLength,
mapIntervalToBezierT,
arePointsEqual,
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import type { Bounds } from "./bounds";
import {
@ -32,7 +19,6 @@ import {
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import type {
Point,
AppState,
PointerCoords,
InteractiveCanvasAppState,
@ -46,7 +32,7 @@ import {
getHoveredElementForBinding,
isBindingEnabled,
} from "./binding";
import { toBrandedType, tupleToCoors } from "../utils";
import { invariant, toBrandedType, tupleToCoors } from "../utils";
import {
isBindingElement,
isElbowArrow,
@ -60,10 +46,29 @@ import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store";
import { mutateElbowArrow } from "./routing";
import type Scene from "../scene/Scene";
import type { Radians } from "../../math";
import {
pointCenter,
point,
pointRotateRads,
pointsEqual,
vector,
type GlobalPoint,
type LocalPoint,
pointDistance,
} from "../../math";
import {
getBezierCurveLength,
getBezierXY,
getControlPointsForBezierCurve,
isPathALoop,
mapIntervalToBezierT,
} from "../shapes";
import { getGridPoint } from "../snapping";
const editorMidPointsCache: {
version: number | null;
points: (Point | null)[];
points: (GlobalPoint | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
export class LinearElementEditor {
@ -80,7 +85,7 @@ export class LinearElementEditor {
lastClickedIsEndPoint: boolean;
origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: {
value: Point | null;
value: GlobalPoint | null;
index: number | null;
added: boolean;
};
@ -88,7 +93,7 @@ export class LinearElementEditor {
/** whether you're dragging a point */
public readonly isDragging: boolean;
public readonly lastUncommittedPoint: Point | null;
public readonly lastUncommittedPoint: LocalPoint | null;
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly startBindingElement:
| ExcalidrawBindableElement
@ -96,13 +101,13 @@ export class LinearElementEditor {
| "keep";
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: Point | null;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
};
if (!arePointsEqual(element.points[0], [0, 0])) {
if (!pointsEqual(element.points[0], point(0, 0))) {
console.error("Linear element is not normalized", Error().stack);
}
@ -280,7 +285,7 @@ export class LinearElementEditor {
element,
elementsMap,
referencePoint,
[scenePointerX, scenePointerY],
point(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
@ -289,7 +294,10 @@ export class LinearElementEditor {
[
{
index: selectedIndex,
point: [width + referencePoint[0], height + referencePoint[1]],
point: point(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
],
@ -310,7 +318,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition =
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
@ -319,10 +327,10 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
)
: ([
: point(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
] as const);
);
return {
index: pointIndex,
point: newPointPosition,
@ -515,7 +523,7 @@ export class LinearElementEditor {
);
let index = 0;
const midpoints: (Point | null)[] = [];
const midpoints: (GlobalPoint | null)[] = [];
while (index < points.length - 1) {
if (
LinearElementEditor.isSegmentTooShort(
@ -549,7 +557,7 @@ export class LinearElementEditor {
scenePointer: { x: number; y: number },
appState: AppState,
elementsMap: ElementsMap,
) => {
): GlobalPoint | null => {
const { elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
@ -579,11 +587,12 @@ export class LinearElementEditor {
const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) {
const distance = distance2d(
existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1],
scenePointer.x,
scenePointer.y,
const distance = pointDistance(
point(
existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1],
),
point(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
return existingSegmentMidpointHitCoords;
@ -594,11 +603,9 @@ export class LinearElementEditor {
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
while (index < midPoints.length) {
if (midPoints[index] !== null) {
const distance = distance2d(
midPoints[index]![0],
midPoints[index]![1],
scenePointer.x,
scenePointer.y,
const distance = pointDistance(
point(midPoints[index]![0], midPoints[index]![1]),
point(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
return midPoints[index];
@ -612,15 +619,13 @@ export class LinearElementEditor {
static isSegmentTooShort(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point,
endPoint: Point,
startPoint: GlobalPoint | LocalPoint,
endPoint: GlobalPoint | LocalPoint,
zoom: AppState["zoom"],
) {
let distance = distance2d(
startPoint[0],
startPoint[1],
endPoint[0],
endPoint[1],
let distance = pointDistance(
point(startPoint[0], startPoint[1]),
point(endPoint[0], endPoint[1]),
);
if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint);
@ -631,12 +636,12 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point,
endPoint: Point,
startPoint: GlobalPoint,
endPoint: GlobalPoint,
endPointIndex: number,
elementsMap: ElementsMap,
) {
let segmentMidPoint = centerPoint(startPoint, endPoint);
): GlobalPoint {
let segmentMidPoint = pointCenter(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
const controlPoints = getControlPointsForBezierCurve(
element,
@ -649,16 +654,15 @@ export class LinearElementEditor {
0.5,
);
const [tx, ty] = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
[tx, ty],
getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
),
elementsMap,
);
}
@ -670,7 +674,7 @@ export class LinearElementEditor {
static getSegmentMidPointIndex(
linearElementEditor: LinearElementEditor,
appState: AppState,
midPoint: Point,
midPoint: GlobalPoint,
elementsMap: ElementsMap,
) {
const element = LinearElementEditor.getElement(
@ -822,11 +826,12 @@ export class LinearElementEditor {
const cy = (y1 + y2) / 2;
const targetPoint =
clickedPointIndex > -1 &&
rotate(
element.x + element.points[clickedPointIndex][0],
element.y + element.points[clickedPointIndex][1],
cx,
cy,
pointRotateRads(
point(
element.x + element.points[clickedPointIndex][0],
element.y + element.points[clickedPointIndex][1],
),
point(cx, cy),
element.angle,
);
@ -865,14 +870,17 @@ export class LinearElementEditor {
return ret;
}
static arePointsEqual(point1: Point | null, point2: Point | null) {
static arePointsEqual<Point extends LocalPoint | GlobalPoint>(
point1: Point | null,
point2: Point | null,
) {
if (!point1 && !point2) {
return true;
}
if (!point1 || !point2) {
return false;
}
return arePointsEqual(point1, point2);
return pointsEqual(point1, point2);
}
static handlePointerMove(
@ -909,7 +917,7 @@ export class LinearElementEditor {
};
}
let newPoint: Point;
let newPoint: LocalPoint;
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2];
@ -918,14 +926,14 @@ export class LinearElementEditor {
element,
elementsMap,
lastCommittedPoint,
[scenePointerX, scenePointerY],
point(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
newPoint = [
newPoint = point(
width + lastCommittedPoint[0],
height + lastCommittedPoint[1],
];
);
} else {
newPoint = LinearElementEditor.createPointAt(
element,
@ -965,30 +973,36 @@ export class LinearElementEditor {
/** scene coords */
static getPointGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
point: Point,
p: LocalPoint,
elementsMap: ElementsMap,
) {
): GlobalPoint {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
return [x, y] as const;
const { x, y } = element;
return pointRotateRads(
point(x + p[0], y + p[1]),
point(cx, cy),
element.angle,
);
}
/** scene coords */
static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
): Point[] {
): GlobalPoint[] {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
return element.points.map((point) => {
let { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
return [x, y] as const;
return element.points.map((p) => {
const { x, y } = element;
return pointRotateRads(
point(x + p[0], y + p[1]),
point(cx, cy),
element.angle,
);
});
}
@ -997,7 +1011,7 @@ export class LinearElementEditor {
indexMaybeFromEnd: number, // -1 for last element
elementsMap: ElementsMap,
): Point {
): GlobalPoint {
const index =
indexMaybeFromEnd < 0
? element.points.length + indexMaybeFromEnd
@ -1005,35 +1019,36 @@ export class LinearElementEditor {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const point = element.points[index];
const p = element.points[index];
const { x, y } = element;
return point
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
: rotate(x, y, cx, cy, element.angle);
return p
? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
: pointRotateRads(point(x, y), point(cx, cy), element.angle);
}
static pointFromAbsoluteCoords(
element: NonDeleted<ExcalidrawLinearElement>,
absoluteCoords: Point,
absoluteCoords: GlobalPoint,
elementsMap: ElementsMap,
): Point {
): LocalPoint {
if (isElbowArrow(element)) {
// No rotation for elbow arrows
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
return point(
absoluteCoords[0] - element.x,
absoluteCoords[1] - element.y,
);
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [x, y] = rotate(
absoluteCoords[0],
absoluteCoords[1],
cx,
cy,
-element.angle,
const [x, y] = pointRotateRads(
point(absoluteCoords[0], absoluteCoords[1]),
point(cx, cy),
-element.angle as Radians,
);
return [x - element.x, y - element.y];
return point(x - element.x, y - element.y);
}
static getPointIndexUnderCursor(
@ -1052,9 +1067,9 @@ export class LinearElementEditor {
// points on the left, thus should take precedence when clicking, if they
// overlap
while (--idx > -1) {
const point = pointHandles[idx];
const p = pointHandles[idx];
if (
distance2d(x, y, point[0], point[1]) * zoom.value <
pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
// +1px to account for outline stroke
LinearElementEditor.POINT_HANDLE_SIZE + 1
) {
@ -1070,20 +1085,18 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
gridSize: NullableGridSize,
): Point {
): LocalPoint {
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [rotatedX, rotatedY] = rotate(
pointerOnGrid[0],
pointerOnGrid[1],
cx,
cy,
-element.angle,
const [rotatedX, rotatedY] = pointRotateRads(
point(pointerOnGrid[0], pointerOnGrid[1]),
point(cx, cy),
-element.angle as Radians,
);
return [rotatedX - element.x, rotatedY - element.y];
return point(rotatedX - element.x, rotatedY - element.y);
}
/**
@ -1091,15 +1104,19 @@ export class LinearElementEditor {
* expected in various parts of the codebase. Also returns new x/y to account
* for the potential normalization.
*/
static getNormalizedPoints(element: ExcalidrawLinearElement) {
static getNormalizedPoints(element: ExcalidrawLinearElement): {
points: LocalPoint[];
x: number;
y: number;
} {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
return {
points: points.map((point) => {
return [point[0] - offsetX, point[1] - offsetY] as const;
points: points.map((p) => {
return point(p[0] - offsetX, p[1] - offsetY);
}),
x: element.x + offsetX,
y: element.y + offsetY,
@ -1116,17 +1133,23 @@ export class LinearElementEditor {
static duplicateSelectedPoints(
appState: AppState,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
if (!appState.editingLinearElement) {
return false;
}
): AppState {
invariant(
appState.editingLinearElement,
"Not currently editing a linear element",
);
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element || selectedPointsIndices === null) {
return false;
}
invariant(
element,
"The linear element does not exist in the provided Scene",
);
invariant(
selectedPointsIndices != null,
"There are no selected points to duplicate",
);
const { points } = element;
@ -1134,9 +1157,9 @@ export class LinearElementEditor {
let pointAddedToEnd = false;
let indexCursor = -1;
const nextPoints = points.reduce((acc: Point[], point, index) => {
const nextPoints = points.reduce((acc: LocalPoint[], p, index) => {
++indexCursor;
acc.push(point);
acc.push(p);
const isSelected = selectedPointsIndices.includes(index);
if (isSelected) {
@ -1147,8 +1170,8 @@ export class LinearElementEditor {
}
acc.push(
nextPoint
? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
: [point[0], point[1]],
? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
: point(p[0], p[1]),
);
nextSelectedIndices.push(indexCursor + 1);
@ -1169,7 +1192,7 @@ export class LinearElementEditor {
[
{
index: element.points.length - 1,
point: [lastPoint[0] + 30, lastPoint[1] + 30],
point: point(lastPoint[0] + 30, lastPoint[1] + 30),
},
],
elementsMap,
@ -1177,12 +1200,10 @@ export class LinearElementEditor {
}
return {
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
selectedPointsIndices: nextSelectedIndices,
},
...appState,
editingLinearElement: {
...appState.editingLinearElement,
selectedPointsIndices: nextSelectedIndices,
},
};
}
@ -1209,10 +1230,10 @@ export class LinearElementEditor {
}
}
const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
if (!pointIndices.includes(idx)) {
acc.push(
!acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
!acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
);
}
return acc;
@ -1229,7 +1250,7 @@ export class LinearElementEditor {
static addPoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: Point }[],
targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
const offsetX = 0;
@ -1247,7 +1268,7 @@ export class LinearElementEditor {
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: {
startBinding?: PointBinding | null;
@ -1277,11 +1298,11 @@ export class LinearElementEditor {
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
}
const nextPoints = points.map((point, idx) => {
const selectedPointData = targetPoints.find((p) => p.index === idx);
const nextPoints: LocalPoint[] = points.map((p, idx) => {
const selectedPointData = targetPoints.find((t) => t.index === idx);
if (selectedPointData) {
if (selectedPointData.index === 0) {
return point;
return p;
}
const deltaX =
@ -1289,14 +1310,9 @@ export class LinearElementEditor {
const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1];
return [
point[0] + deltaX - offsetX,
point[1] + deltaY - offsetY,
] as const;
return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
}
return offsetX || offsetY
? ([point[0] - offsetX, point[1] - offsetY] as const)
: point;
return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
});
LinearElementEditor._updatePoints(
@ -1349,11 +1365,9 @@ export class LinearElementEditor {
}
const origin = linearElementEditor.pointerDownState.origin!;
const dist = distance2d(
origin.x,
origin.y,
pointerCoords.x,
pointerCoords.y,
const dist = pointDistance(
point(origin.x, origin.y),
point(pointerCoords.x, pointerCoords.y),
);
if (
!appState.editingLinearElement &&
@ -1418,7 +1432,7 @@ export class LinearElementEditor {
private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>,
nextPoints: readonly Point[],
nextPoints: readonly LocalPoint[],
offsetX: number,
offsetY: number,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
@ -1461,7 +1475,7 @@ export class LinearElementEditor {
element,
mergedElementsMap,
nextPoints,
[offsetX, offsetY],
vector(offsetX, offsetY),
bindings,
options,
);
@ -1474,7 +1488,11 @@ export class LinearElementEditor {
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
const rotated = pointRotateRads(
point(offsetX, offsetY),
point(dX, dY),
element.angle,
);
mutateElement(element, {
...otherUpdates,
points: nextPoints,
@ -1487,8 +1505,8 @@ export class LinearElementEditor {
private static _getShiftLockedDelta(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
referencePoint: Point,
scenePointer: Point,
referencePoint: LocalPoint,
scenePointer: GlobalPoint,
gridSize: NullableGridSize,
) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
@ -1517,7 +1535,11 @@ export class LinearElementEditor {
gridY,
);
return rotatePoint([width, height], [0, 0], -element.angle);
return pointRotateRads(
point(width, height),
point(0, 0),
-element.angle as Radians,
);
}
static getBoundTextElementPosition = (
@ -1548,7 +1570,7 @@ export class LinearElementEditor {
let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) {
midSegmentMidpoint = centerPoint(points[0], points[1]);
midSegmentMidpoint = pointCenter(points[0], points[1]);
}
if (
!midSegmentMidpoint ||
@ -1585,37 +1607,38 @@ export class LinearElementEditor {
);
const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height;
const centerPoint = point(cx, cy);
const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
const counterRotateBoundTextTopLeft = rotatePoint(
[boundTextX1, boundTextY1],
[cx, cy],
-element.angle,
const topLeftRotatedPoint = pointRotateRads(
point(x1, y1),
centerPoint,
element.angle,
);
const counterRotateBoundTextTopRight = rotatePoint(
[boundTextX2, boundTextY1],
[cx, cy],
-element.angle,
const topRightRotatedPoint = pointRotateRads(
point(x2, y1),
centerPoint,
element.angle,
);
const counterRotateBoundTextBottomLeft = rotatePoint(
[boundTextX1, boundTextY2],
[cx, cy],
-element.angle,
const counterRotateBoundTextTopLeft = pointRotateRads(
point(boundTextX1, boundTextY1),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomRight = rotatePoint(
[boundTextX2, boundTextY2],
[cx, cy],
-element.angle,
const counterRotateBoundTextTopRight = pointRotateRads(
point(boundTextX2, boundTextY1),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomLeft = pointRotateRads(
point(boundTextX1, boundTextY2),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomRight = pointRotateRads(
point(boundTextX2, boundTextY2),
centerPoint,
-element.angle as Radians,
);
if (

View file

@ -2,7 +2,6 @@ import type { ExcalidrawElement } from "./types";
import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
import type { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
@ -59,8 +58,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
let didChangePoints = false;
let index = prevPoints.length;
while (--index) {
const prevPoint: Point = prevPoints[index];
const nextPoint: Point = nextPoints[index];
const prevPoint = prevPoints[index];
const nextPoint = nextPoints[index];
if (
prevPoint[0] !== nextPoint[0] ||
prevPoint[1] !== nextPoint[1]

View file

@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api";
import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils";
import type { ExcalidrawLinearElement } from "./types";
import type { LocalPoint } from "../../math";
import { point } from "../../math";
const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) {
@ -36,10 +38,7 @@ describe("duplicating single elements", () => {
element.__proto__ = { hello: "world" };
mutateElement(element, {
points: [
[1, 2],
[3, 4],
],
points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
});
const copy = duplicateElement(null, new Map(), element);

View file

@ -30,7 +30,6 @@ import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
import type { AppState } from "../types";
import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
measureText,
@ -48,6 +47,7 @@ import {
} from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts";
import type { Radians } from "../../math";
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@ -88,7 +88,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
opacity = DEFAULT_ELEMENT_PROPS.opacity,
width = 0,
height = 0,
angle = 0,
angle = 0 as Radians,
groupIds = [],
frameId = null,
index = null,
@ -348,6 +348,53 @@ const getAdjustedDimensions = (
};
};
const adjustXYWithRotation = (
sides: {
n?: boolean;
e?: boolean;
s?: boolean;
w?: boolean;
},
x: number,
y: number,
angle: number,
deltaX1: number,
deltaY1: number,
deltaX2: number,
deltaY2: number,
): [number, number] => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
if (sides.e && sides.w) {
x += deltaX1 + deltaX2;
} else if (sides.e) {
x += deltaX1 * (1 + cos);
y += deltaX1 * sin;
x += deltaX2 * (1 - cos);
y += deltaX2 * -sin;
} else if (sides.w) {
x += deltaX1 * (1 - cos);
y += deltaX1 * -sin;
x += deltaX2 * (1 + cos);
y += deltaX2 * sin;
}
if (sides.n && sides.s) {
y += deltaY1 + deltaY2;
} else if (sides.n) {
x += deltaY1 * sin;
y += deltaY1 * (1 - cos);
x += deltaY2 * -sin;
y += deltaY2 * (1 + cos);
} else if (sides.s) {
x += deltaY1 * -sin;
y += deltaY1 * (1 + cos);
x += deltaY2 * sin;
y += deltaY2 * (1 - cos);
}
return [x, y];
};
export const refreshTextDimensions = (
textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,

View file

@ -1,7 +1,5 @@
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import { rotate, centerPoint, rotatePoint } from "../math";
import type {
ExcalidrawLinearElement,
ExcalidrawTextElement,
@ -38,7 +36,7 @@ import type {
MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles";
import type { Point, PointerDownState } from "../types";
import type { PointerDownState } from "../types";
import Scene from "../scene/Scene";
import {
getApproxMinLineWidth,
@ -55,16 +53,15 @@ import {
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
export const normalizeAngle = (angle: number): number => {
if (angle < 0) {
return angle + 2 * Math.PI;
}
if (angle >= 2 * Math.PI) {
return angle - 2 * Math.PI;
}
return angle;
};
import type { GlobalPoint } from "../../math";
import {
pointCenter,
normalizeRadians,
point,
pointFromPair,
pointRotateRads,
type Radians,
} from "../../math";
// Returns true when transform (resizing/rotation) happened
export const transformElements = (
@ -158,16 +155,17 @@ const rotateSingleElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle: number;
let angle: Radians;
if (isFrameLikeElement(element)) {
angle = 0;
angle = 0 as Radians;
} else {
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
angle = ((5 * Math.PI) / 2 +
Math.atan2(pointerY - cy, pointerX - cx)) as Radians;
if (shouldRotateWithDiscreteAngle) {
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
}
angle = normalizeAngle(angle);
angle = normalizeRadians(angle as Radians);
}
const boundTextElementId = getBoundTextElementId(element);
@ -240,12 +238,10 @@ const resizeSingleTextElement = (
elementsMap,
);
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate(
pointerX,
pointerY,
cx,
cy,
-element.angle,
const [rotatedX, rotatedY] = pointRotateRads(
point(pointerX, pointerY),
point(cx, cy),
-element.angle as Radians,
);
let scaleX = 0;
let scaleY = 0;
@ -279,20 +275,26 @@ const resizeSingleTextElement = (
const startBottomRight = [x2, y2];
const startCenter = [cx, cy];
let newTopLeft = [x1, y1] as [number, number];
let newTopLeft = point<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = [
newTopLeft = point<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight),
];
);
}
if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)];
newTopLeft = point<GlobalPoint>(
bottomLeft[0],
bottomLeft[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]];
newTopLeft = point<GlobalPoint>(
topRight[0] - Math.abs(nextWidth),
topRight[1],
);
}
if (["s", "n"].includes(transformHandleType)) {
@ -308,13 +310,17 @@ const resizeSingleTextElement = (
}
const angle = element.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
const newCenter: Point = [
const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
const newCenter = point<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
);
const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const [nextX, nextY] = newTopLeft;
mutateElement(element, {
@ -334,14 +340,14 @@ const resizeSingleTextElement = (
stateAtResizeStart.height,
true,
);
const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2];
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
const startTopLeft = point<GlobalPoint>(x1, y1);
const startBottomRight = point<GlobalPoint>(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
const rotatedPointer = rotatePoint(
[pointerX, pointerY],
const rotatedPointer = pointRotateRads(
point(pointerX, pointerY),
startCenter,
-stateAtResizeStart.angle,
-stateAtResizeStart.angle as Radians,
);
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
@ -407,13 +413,21 @@ const resizeSingleTextElement = (
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
const newCenter: Point = [
const rotatedTopLeft = pointRotateRads(
pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = point(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
);
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const resizedElement: Partial<ExcalidrawTextElement> = {
width: Math.abs(newWidth),
@ -446,15 +460,15 @@ export const resizeSingleElement = (
stateAtResizeStart.height,
true,
);
const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2];
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
const startTopLeft = point(x1, y1);
const startBottomRight = point(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
// Calculate new dimensions based on cursor position
const rotatedPointer = rotatePoint(
[pointerX, pointerY],
const rotatedPointer = pointRotateRads(
point(pointerX, pointerY),
startCenter,
-stateAtResizeStart.angle,
-stateAtResizeStart.angle as Radians,
);
// Get bounds corners rendered on screen
@ -628,13 +642,21 @@ export const resizeSingleElement = (
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
const newCenter: Point = [
const rotatedTopLeft = pointRotateRads(
pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = point(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
);
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be
@ -793,21 +815,21 @@ export const resizeMultipleElements = (
const direction = transformHandleType;
const anchorsMap: Record<TransformHandleDirection, Point> = {
ne: [minX, maxY],
se: [minX, minY],
sw: [maxX, minY],
nw: [maxX, maxY],
e: [minX, minY + height / 2],
w: [maxX, minY + height / 2],
n: [minX + width / 2, maxY],
s: [minX + width / 2, minY],
const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
ne: point(minX, maxY),
se: point(minX, minY),
sw: point(maxX, minY),
nw: point(maxX, maxY),
e: point(minX, minY + height / 2),
w: point(maxX, minY + height / 2),
n: point(minX + width / 2, maxY),
s: point(minX + width / 2, minY),
};
// anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if shouldResizeFromCenter
const [anchorX, anchorY]: Point = shouldResizeFromCenter
? [midX, midY]
const [anchorX, anchorY] = shouldResizeFromCenter
? point(midX, midY)
: anchorsMap[direction];
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
@ -898,7 +920,9 @@ export const resizeMultipleElements = (
const width = orig.width * scaleX;
const height = orig.height * scaleY;
const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
const angle = normalizeRadians(
(orig.angle * flipFactorX * flipFactorY) as Radians,
);
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
const offsetX = orig.x - anchorX;
@ -1029,12 +1053,10 @@ const rotateMultipleElements = (
const cy = (y1 + y2) / 2;
const origAngle =
originalElements.get(element.id)?.angle ?? element.angle;
const [rotatedCX, rotatedCY] = rotate(
cx,
cy,
centerX,
centerY,
centerAngle + origAngle - element.angle,
const [rotatedCX, rotatedCY] = pointRotateRads(
point(cx, cy),
point(centerX, centerY),
(centerAngle + origAngle - element.angle) as Radians,
);
if (isArrowElement(element) && isElbowArrow(element)) {
@ -1046,7 +1068,7 @@ const rotateMultipleElements = (
{
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
@ -1063,7 +1085,7 @@ const rotateMultipleElements = (
{
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
@ -1086,25 +1108,43 @@ export const getResizeOffsetXY = (
: getCommonBounds(selectedElements);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0;
[x, y] = rotate(x, y, cx, cy, -angle);
const angle = (
selectedElements.length === 1 ? selectedElements[0].angle : 0
) as Radians;
[x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
switch (transformHandleType) {
case "n":
return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle);
return pointRotateRads(
point(x - (x1 + x2) / 2, y - y1),
point(0, 0),
angle,
);
case "s":
return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle);
return pointRotateRads(
point(x - (x1 + x2) / 2, y - y2),
point(0, 0),
angle,
);
case "w":
return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle);
return pointRotateRads(
point(x - x1, y - (y1 + y2) / 2),
point(0, 0),
angle,
);
case "e":
return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle);
return pointRotateRads(
point(x - x2, y - (y1 + y2) / 2),
point(0, 0),
angle,
);
case "nw":
return rotate(x - x1, y - y1, 0, 0, angle);
return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
case "ne":
return rotate(x - x2, y - y1, 0, 0, angle);
return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
case "sw":
return rotate(x - x1, y - y2, 0, 0, angle);
return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
case "se":
return rotate(x - x2, y - y2, 0, 0, angle);
return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
default:
return [0, 0];
}

View file

@ -20,13 +20,14 @@ import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants";
import {
angleToDegrees,
pointOnLine,
pointRotate,
} from "../../utils/geometry/geometry";
import type { Line, Point } from "../../utils/geometry/shape";
import { isLinearElement } from "./typeChecks";
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
import {
point,
pointOnLineSegment,
pointRotateRads,
type Radians,
} from "../../math";
const isInsideTransformHandle = (
transformHandle: TransformHandle,
@ -38,7 +39,7 @@ const isInsideTransformHandle = (
y >= transformHandle[1] &&
y <= transformHandle[1] + transformHandle[3];
export const resizeTest = (
export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
@ -91,15 +92,17 @@ export const resizeTest = (
if (!(isLinearElement(element) && element.points.length <= 2)) {
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING],
[x2 + SPACING, y2 + SPACING],
[cx, cy],
angleToDegrees(element.angle),
point(x1 - SPACING, y1 - SPACING),
point(x2 + SPACING, y2 + SPACING),
point(cx, cy),
element.angle,
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (pointOnLine([x, y], side as Line, SPACING)) {
if (
pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING)
) {
return dir as TransformHandleType;
}
}
@ -137,7 +140,9 @@ export const getElementWithTransformHandleType = (
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
};
export const getTransformHandleTypeFromCoords = (
export const getTransformHandleTypeFromCoords = <
Point extends GlobalPoint | LocalPoint,
>(
[x1, y1, x2, y2]: Bounds,
scenePointerX: number,
scenePointerY: number,
@ -147,7 +152,7 @@ export const getTransformHandleTypeFromCoords = (
): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
0 as Radians,
zoom,
pointerType,
getOmitSidesForDevice(device),
@ -173,15 +178,21 @@ export const getTransformHandleTypeFromCoords = (
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING],
[x2 + SPACING, y2 + SPACING],
[cx, cy],
angleToDegrees(0),
point(x1 - SPACING, y1 - SPACING),
point(x2 + SPACING, y2 + SPACING),
point(cx, cy),
0 as Radians,
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) {
if (
pointOnLineSegment(
point(scenePointerX, scenePointerY),
side as LineSegment<Point>,
SPACING,
)
) {
return dir as TransformHandleType;
}
}
@ -248,16 +259,16 @@ export const getCursorForResizingElement = (resizingElement: {
return cursor ? `${cursor}-resize` : "";
};
const getSelectionBorders = (
const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
[x1, y1]: Point,
[x2, y2]: Point,
center: Point,
angleInDegrees: number,
angle: Radians,
) => {
const topLeft = pointRotate([x1, y1], angleInDegrees, center);
const topRight = pointRotate([x2, y1], angleInDegrees, center);
const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
const topLeft = pointRotateRads(point(x1, y1), center, angle);
const topRight = pointRotateRads(point(x2, y1), center, angle);
const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
const bottomRight = pointRotateRads(point(x2, y2), center, angle);
return {
n: [topLeft, topRight],

View file

@ -17,6 +17,7 @@ import type {
ExcalidrawElbowArrowElement,
} from "./types";
import { ARROW_TYPE } from "../constants";
import { point } from "../../math";
const { h } = window;
@ -31,8 +32,8 @@ describe("elbow arrow routing", () => {
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
[-45 - arrow.x, -100.1 - arrow.y],
[45 - arrow.x, 99.9 - arrow.y],
point(-45 - arrow.x, -100.1 - arrow.y),
point(45 - arrow.x, 99.9 - arrow.y),
]);
expect(arrow.points).toEqual([
[0, 0],
@ -68,10 +69,7 @@ describe("elbow arrow routing", () => {
y: -100.1,
width: 90,
height: 200,
points: [
[0, 0],
[90, 200],
],
points: [point(0, 0), point(90, 200)],
}) as ExcalidrawElbowArrowElement;
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
@ -83,10 +81,7 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [
[0, 0],
[90, 200],
]);
mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
expect(arrow.points).toEqual([
[0, 0],

View file

@ -1,16 +1,19 @@
import { cross } from "../../utils/geometry/geometry";
import BinaryHeap from "../binaryheap";
import type { Radians } from "../../math";
import {
aabbForElement,
arePointsEqual,
pointInsideBounds,
pointToVector,
scalePointFromOrigin,
scaleVector,
translatePoint,
} from "../math";
point,
pointScaleFromOrigin,
pointTranslate,
vector,
vectorCross,
vectorFromPoint,
vectorScale,
type GlobalPoint,
type LocalPoint,
type Vector,
} from "../../math";
import BinaryHeap from "../binaryheap";
import { getSizeFromPoints } from "../points";
import type { Point } from "../types";
import { aabbForElement, pointInsideBounds } from "../shapes";
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
import {
bindPointToSnapToElementOutline,
@ -25,6 +28,8 @@ import {
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
import {
compareHeading,
flipHeading,
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
@ -41,6 +46,8 @@ import type {
} from "./types";
import type { ElementsMap, ExcalidrawBindableElement } from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" };
type Node = {
f: number;
g: number;
@ -48,8 +55,8 @@ type Node = {
closed: boolean;
visited: boolean;
parent: Node | null;
pos: Point;
addr: [number, number];
pos: GlobalPoint;
addr: GridAddress;
};
type Grid = {
@ -63,8 +70,8 @@ const BASE_PADDING = 40;
export const mutateElbowArrow = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly Point[],
offset?: Point,
nextPoints: readonly LocalPoint[],
offset?: Vector,
otherUpdates?: {
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
@ -75,14 +82,20 @@ export const mutateElbowArrow = (
informMutation?: boolean;
},
) => {
const origStartGlobalPoint = translatePoint(nextPoints[0], [
arrow.x + (offset ? offset[0] : 0),
arrow.y + (offset ? offset[1] : 0),
]);
const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [
arrow.x + (offset ? offset[0] : 0),
arrow.y + (offset ? offset[1] : 0),
]);
const origStartGlobalPoint: GlobalPoint = pointTranslate(
pointTranslate<LocalPoint, GlobalPoint>(
nextPoints[0],
vector(arrow.x, arrow.y),
),
offset,
);
const origEndGlobalPoint: GlobalPoint = pointTranslate(
pointTranslate<LocalPoint, GlobalPoint>(
nextPoints[nextPoints.length - 1],
vector(arrow.x, arrow.y),
),
offset,
);
const startElement =
arrow.startBinding &&
@ -275,7 +288,10 @@ export const mutateElbowArrow = (
);
if (path) {
const points = path.map((node) => [node.pos[0], node.pos[1]]) as Point[];
const points = path.map((node) => [
node.pos[0],
node.pos[1],
]) as GlobalPoint[];
startDongle && points.unshift(startGlobalPoint);
endDongle && points.push(endGlobalPoint);
@ -284,7 +300,7 @@ export const mutateElbowArrow = (
{
...otherUpdates,
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
angle: 0,
angle: 0 as Radians,
},
options?.informMutation,
);
@ -363,7 +379,7 @@ const astar = (
}
// Intersect
const neighborHalfPoint = scalePointFromOrigin(
const neighborHalfPoint = pointScaleFromOrigin(
neighbor.pos,
current.pos,
0.5,
@ -380,17 +396,17 @@ const astar = (
// We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet.
const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
const previousDirection = current.parent
? vectorToHeading(pointToVector(current.pos, current.parent.pos))
? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
: startHeading;
// Do not allow going in reverse
const reverseHeading = scaleVector(previousDirection, -1);
const reverseHeading = flipHeading(previousDirection);
const neighborIsReverseRoute =
arePointsEqual(reverseHeading, neighborHeading) ||
(arePointsEqual(start.addr, neighbor.addr) &&
arePointsEqual(neighborHeading, startHeading)) ||
(arePointsEqual(end.addr, neighbor.addr) &&
arePointsEqual(neighborHeading, endHeading));
compareHeading(reverseHeading, neighborHeading) ||
(gridAddressesEqual(start.addr, neighbor.addr) &&
compareHeading(neighborHeading, startHeading)) ||
(gridAddressesEqual(end.addr, neighbor.addr) &&
compareHeading(neighborHeading, endHeading));
if (neighborIsReverseRoute) {
continue;
}
@ -444,7 +460,7 @@ const pathTo = (start: Node, node: Node) => {
return path;
};
const m_dist = (a: Point, b: Point) =>
const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) =>
Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
/**
@ -541,7 +557,12 @@ const generateDynamicAABBs = (
const cX = first[2] + (second[0] - first[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2;
if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[2] - endCenterX, a[1] - endCenterY),
vector(a[0] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[first[0], first[1], cX, first[3]],
[cX, second[1], second[2], second[3]],
@ -557,7 +578,12 @@ const generateDynamicAABBs = (
const cX = first[2] + (second[0] - first[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 2;
if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[0] - endCenterX, a[1] - endCenterY),
vector(a[2] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[first[0], first[1], first[2], cY],
[second[0], cY, second[2], second[3]],
@ -573,7 +599,12 @@ const generateDynamicAABBs = (
const cX = second[2] + (first[0] - second[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 2;
if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[2] - endCenterX, a[1] - endCenterY),
vector(a[0] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[cX, first[1], first[2], first[3]],
[second[0], second[1], cX, second[3]],
@ -589,7 +620,12 @@ const generateDynamicAABBs = (
const cX = second[2] + (first[0] - second[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2;
if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[0] - endCenterX, a[1] - endCenterY),
vector(a[2] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[cX, first[1], first[2], first[3]],
[second[0], second[1], cX, second[3]],
@ -615,9 +651,9 @@ const generateDynamicAABBs = (
*/
const calculateGrid = (
aabbs: Bounds[],
start: Point,
start: GlobalPoint,
startHeading: Heading,
end: Point,
end: GlobalPoint,
endHeading: Heading,
common: Bounds,
): Grid => {
@ -662,8 +698,8 @@ const calculateGrid = (
closed: false,
visited: false,
parent: null,
addr: [col, row] as [number, number],
pos: [x, y] as Point,
addr: [col, row] as GridAddress,
pos: [x, y] as GlobalPoint,
}),
),
),
@ -673,17 +709,17 @@ const calculateGrid = (
const getDonglePosition = (
bounds: Bounds,
heading: Heading,
point: Point,
): Point => {
p: GlobalPoint,
): GlobalPoint => {
switch (heading) {
case HEADING_UP:
return [point[0], bounds[1]];
return point(p[0], bounds[1]);
case HEADING_RIGHT:
return [bounds[2], point[1]];
return point(bounds[2], p[1]);
case HEADING_DOWN:
return [point[0], bounds[3]];
return point(p[0], bounds[3]);
}
return [bounds[0], point[1]];
return point(bounds[0], p[1]);
};
const estimateSegmentCount = (
@ -826,7 +862,7 @@ const gridNodeFromAddr = (
/**
* Get node for global point on canvas (if exists)
*/
const pointToGridNode = (point: Point, grid: Grid): Node | null => {
const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => {
for (let col = 0; col < grid.col; col++) {
for (let row = 0; row < grid.row; row++) {
const candidate = gridNodeFromAddr([col, row], grid);
@ -865,15 +901,24 @@ const getBindableElementForId = (
};
const normalizedArrowElementUpdate = (
global: Point[],
global: GlobalPoint[],
externalOffsetX?: number,
externalOffsetY?: number,
) => {
): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
} => {
const offsetX = global[0][0];
const offsetY = global[0][1];
const points = global.map(
(point) => [point[0] - offsetX, point[1] - offsetY] as const,
const points = global.map((p) =>
pointTranslate<GlobalPoint, LocalPoint>(
p,
vectorScale(vectorFromPoint(global[0]), -1),
),
);
return {
@ -885,19 +930,22 @@ const normalizedArrowElementUpdate = (
};
/// If last and current segments have the same heading, skip the middle point
const simplifyElbowArrowPoints = (points: Point[]): Point[] =>
const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] =>
points
.slice(2)
.reduce(
(result, point) =>
arePointsEqual(
(result, p) =>
compareHeading(
vectorToHeading(
pointToVector(result[result.length - 1], result[result.length - 2]),
vectorFromPoint(
result[result.length - 1],
result[result.length - 2],
),
),
vectorToHeading(pointToVector(point, result[result.length - 1])),
vectorToHeading(vectorFromPoint(p, result[result.length - 1])),
)
? [...result.slice(0, -1), point]
: [...result, point],
? [...result.slice(0, -1), p]
: [...result, p],
[points[0] ?? [0, 0], points[1] ?? [1, 0]],
);
@ -915,13 +963,13 @@ const neighborIndexToHeading = (idx: number): Heading => {
const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null,
initialPoint: Point,
otherPoint: Point,
initialPoint: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
boundElement?: ExcalidrawBindableElement | null,
hoveredElement?: ExcalidrawBindableElement | null,
isDragging?: boolean,
): Point => {
): GlobalPoint => {
if (isDragging) {
if (hoveredElement) {
const snapPoint = getSnapPoint(
@ -956,36 +1004,34 @@ const getGlobalPoint = (
};
const getSnapPoint = (
point: Point,
otherPoint: Point,
p: GlobalPoint,
otherPoint: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
) =>
bindPointToSnapToElementOutline(
isRectanguloidElement(element)
? avoidRectangularCorner(element, point)
: point,
isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
otherPoint,
element,
elementsMap,
);
const getBindPointHeading = (
point: Point,
otherPoint: Point,
p: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: Point,
origPoint: GlobalPoint,
) =>
getHeadingForElbowArrowSnap(
point,
p,
otherPoint,
hoveredElement,
hoveredElement &&
aabbForElement(
hoveredElement,
Array(4).fill(
distanceToBindableElement(hoveredElement, point, elementsMap),
distanceToBindableElement(hoveredElement, p, elementsMap),
) as [number, number, number, number],
),
elementsMap,
@ -993,8 +1039,8 @@ const getBindPointHeading = (
);
const getHoveredElements = (
origStartGlobalPoint: Point,
origEndGlobalPoint: Point,
origStartGlobalPoint: GlobalPoint,
origEndGlobalPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) => {
// TODO: Might be a performance bottleneck and the Map type
@ -1018,3 +1064,6 @@ const getHoveredElements = (
),
];
};
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
a[0] === b[0] && a[1] === b[1];

View file

@ -19,6 +19,7 @@ import type {
import { API } from "../tests/helpers/api";
import { getOriginalContainerHeightFromCache } from "./containerCache";
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
import { point } from "../../math";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -41,10 +42,7 @@ describe("textWysiwyg", () => {
type: "line",
width: 100,
height: 0,
points: [
[0, 0],
[100, 0],
],
points: [point(0, 0), point(100, 0)],
});
const textSize = 20;
const text = API.createElement({

View file

@ -7,7 +7,6 @@ import type {
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import {
isElbowArrow,
@ -19,6 +18,8 @@ import {
isAndroid,
isIOS,
} from "../constants";
import type { Radians } from "../../math";
import { point, pointRotateRads } from "../../math";
export type TransformHandleDirection =
| "n"
@ -91,9 +92,13 @@ const generateTransformHandle = (
height: number,
cx: number,
cy: number,
angle: number,
angle: Radians,
): TransformHandle => {
const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle);
const [xx, yy] = pointRotateRads(
point(x + width / 2, y + height / 2),
point(cx, cy),
angle,
);
return [xx - width / 2, yy - height / 2, width, height];
};
@ -119,7 +124,7 @@ export const getOmitSidesForDevice = (device: Device) => {
export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
angle: number,
angle: Radians,
zoom: Zoom,
pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {},

View file

@ -1,6 +1,5 @@
import type { LineSegment } from "../../utils";
import { ROUNDNESS } from "../constants";
import type { ElementOrToolType, Point } from "../types";
import type { ElementOrToolType } from "../types";
import type { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
import type { Bounds } from "./bounds";
@ -191,7 +190,8 @@ export const isRectangularElement = (
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe")
element.type === "magicframe" ||
element.type === "freedraw")
);
};
@ -325,10 +325,6 @@ export const isFixedPointBinding = (
return binding.fixedPoint != null;
};
// TODO: Move this to @excalidraw/math
export const isPoint = (point: unknown): point is Point =>
Array.isArray(point) && point.length === 2;
// TODO: Move this to @excalidraw/math
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
@ -337,10 +333,3 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
// TODO: Move this to @excalidraw/math
export const isLineSegment = (segment: unknown): segment is LineSegment =>
Array.isArray(segment) &&
segment.length === 2 &&
isPoint(segment[0]) &&
isPoint(segment[0]);

View file

@ -1,4 +1,4 @@
import type { Point } from "../types";
import type { LocalPoint, Radians } from "../../math";
import type {
FONT_FAMILY,
ROUNDNESS,
@ -49,7 +49,7 @@ type _ExcalidrawElementBase = Readonly<{
opacity: number;
width: number;
height: number;
angle: number;
angle: Radians;
/** Random integer used to seed shape generation so that the roughjs shape
doesn't differ across renders. */
seed: number;
@ -175,6 +175,15 @@ export type ExcalidrawFlowchartNodeElement =
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
export type ExcalidrawRectanguloidElement =
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement;
/**
* ExcalidrawElement should be JSON serializable and (eventually) contain
* no computed data. The list of all ExcalidrawElements should be shareable
@ -283,8 +292,8 @@ export type Arrowhead =
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "line" | "arrow";
points: readonly Point[];
lastCommittedPoint: Point | null;
points: readonly LocalPoint[];
lastCommittedPoint: LocalPoint | null;
startBinding: PointBinding | null;
endBinding: PointBinding | null;
startArrowhead: Arrowhead | null;
@ -309,10 +318,10 @@ export type ExcalidrawElbowArrowElement = Merge<
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
points: readonly Point[];
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
lastCommittedPoint: Point | null;
lastCommittedPoint: LocalPoint | null;
}>;
export type FileId = string & { _brand: "FileId" };