mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
Compare commits
5 commits
1b033bf1f1
...
a753bf2a0a
Author | SHA1 | Date | |
---|---|---|---|
|
a753bf2a0a | ||
|
dff69e9191 | ||
|
6fc85022ae | ||
|
e48b63a0ae | ||
|
c2caf78e95 |
16 changed files with 509 additions and 194 deletions
|
@ -1,9 +1,10 @@
|
|||
import { average } from "@excalidraw/math";
|
||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
ExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
|
@ -1201,3 +1202,17 @@ export const escapeDoubleQuotes = (str: string) => {
|
|||
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
const { x, y, width, height } = element;
|
||||
|
||||
const centerXPoint = x + width / 2 + xOffset;
|
||||
|
||||
const centerYPoint = y + height / 2 + yOffset;
|
||||
|
||||
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
||||
};
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
invariant,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
elementCenterPoint,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
|
@ -55,6 +56,7 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
|||
import {
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
isBindingElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
|
@ -903,13 +905,7 @@ export const getHeadingForElbowArrowSnap = (
|
|||
|
||||
if (!distance) {
|
||||
return vectorToHeading(
|
||||
vectorFromPoint(
|
||||
p,
|
||||
pointFrom<GlobalPoint>(
|
||||
bindableElement.x + bindableElement.width / 2,
|
||||
bindableElement.y + bindableElement.height / 2,
|
||||
),
|
||||
),
|
||||
vectorFromPoint(p, elementCenterPoint(bindableElement)),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1039,10 +1035,7 @@ export const avoidRectangularCorner = (
|
|||
element: ExcalidrawBindableElement,
|
||||
p: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
|
||||
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
||||
|
@ -1139,10 +1132,9 @@ export const snapToMid = (
|
|||
tolerance: number = 0.05,
|
||||
): GlobalPoint => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
x + width / 2 - 0.1,
|
||||
y + height / 2 - 0.1,
|
||||
);
|
||||
|
||||
const center = elementCenterPoint(element, -0.1, -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
|
||||
|
@ -1227,10 +1219,7 @@ const updateBoundPoint = (
|
|||
startOrEnd === "startBinding" ? "start" : "end",
|
||||
elementsMap,
|
||||
).fixedPoint;
|
||||
const globalMidPoint = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + bindableElement.width / 2,
|
||||
bindableElement.y + bindableElement.height / 2,
|
||||
);
|
||||
const globalMidPoint = elementCenterPoint(bindableElement);
|
||||
const global = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
||||
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
||||
|
@ -1274,10 +1263,7 @@ const updateBoundPoint = (
|
|||
elementsMap,
|
||||
);
|
||||
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + bindableElement.width / 2,
|
||||
bindableElement.y + bindableElement.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(bindableElement);
|
||||
const interceptorLength =
|
||||
pointDistance(adjacentPoint, edgePointAbsolute) +
|
||||
pointDistance(adjacentPoint, center) +
|
||||
|
@ -1422,7 +1408,7 @@ const getLinearElementEdgeCoors = (
|
|||
);
|
||||
};
|
||||
|
||||
export const fixBindingsAfterDuplication = (
|
||||
export const fixDuplicatedBindingsAfterDuplication = (
|
||||
newElements: ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
duplicatedElementsMap: NonDeletedSceneElementsMap,
|
||||
|
@ -1493,6 +1479,196 @@ export const fixBindingsAfterDuplication = (
|
|||
}
|
||||
};
|
||||
|
||||
const fixReversedBindingsForBindables = (
|
||||
original: ExcalidrawBindableElement,
|
||||
duplicate: ExcalidrawBindableElement,
|
||||
originalElements: Map<string, ExcalidrawElement>,
|
||||
elementsWithClones: ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
) => {
|
||||
original.boundElements?.forEach((binding, idx) => {
|
||||
if (binding.type !== "arrow") {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldArrow = elementsWithClones.find((el) => el.id === binding.id);
|
||||
|
||||
if (!isBindingElement(oldArrow)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (originalElements.has(binding.id)) {
|
||||
// Linked arrow is in the selection, so find the duplicate pair
|
||||
const newArrowId = oldIdToDuplicatedId.get(binding.id) ?? binding.id;
|
||||
const newArrow = elementsWithClones.find(
|
||||
(el) => el.id === newArrowId,
|
||||
)! as ExcalidrawArrowElement;
|
||||
|
||||
mutateElement(newArrow, {
|
||||
startBinding:
|
||||
oldArrow.startBinding?.elementId === binding.id
|
||||
? {
|
||||
...oldArrow.startBinding,
|
||||
elementId: duplicate.id,
|
||||
}
|
||||
: newArrow.startBinding,
|
||||
endBinding:
|
||||
oldArrow.endBinding?.elementId === binding.id
|
||||
? {
|
||||
...oldArrow.endBinding,
|
||||
elementId: duplicate.id,
|
||||
}
|
||||
: newArrow.endBinding,
|
||||
});
|
||||
mutateElement(duplicate, {
|
||||
boundElements: [
|
||||
...(duplicate.boundElements ?? []).filter(
|
||||
(el) => el.id !== binding.id && el.id !== newArrowId,
|
||||
),
|
||||
{
|
||||
type: "arrow",
|
||||
id: newArrowId,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// Linked arrow is outside the selection,
|
||||
// so we move the binding to the duplicate
|
||||
mutateElement(oldArrow, {
|
||||
startBinding:
|
||||
oldArrow.startBinding?.elementId === original.id
|
||||
? {
|
||||
...oldArrow.startBinding,
|
||||
elementId: duplicate.id,
|
||||
}
|
||||
: oldArrow.startBinding,
|
||||
endBinding:
|
||||
oldArrow.endBinding?.elementId === original.id
|
||||
? {
|
||||
...oldArrow.endBinding,
|
||||
elementId: duplicate.id,
|
||||
}
|
||||
: oldArrow.endBinding,
|
||||
});
|
||||
mutateElement(duplicate, {
|
||||
boundElements: [
|
||||
...(duplicate.boundElements ?? []),
|
||||
{
|
||||
type: "arrow",
|
||||
id: oldArrow.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
mutateElement(original, {
|
||||
boundElements:
|
||||
original.boundElements?.filter((_, i) => i !== idx) ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const fixReversedBindingsForArrows = (
|
||||
original: ExcalidrawArrowElement,
|
||||
duplicate: ExcalidrawArrowElement,
|
||||
originalElements: Map<string, ExcalidrawElement>,
|
||||
bindingProp: "startBinding" | "endBinding",
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
elementsWithClones: ExcalidrawElement[],
|
||||
) => {
|
||||
const oldBindableId = original[bindingProp]?.elementId;
|
||||
|
||||
if (oldBindableId) {
|
||||
if (originalElements.has(oldBindableId)) {
|
||||
// Linked element is in the selection
|
||||
const newBindableId =
|
||||
oldIdToDuplicatedId.get(oldBindableId) ?? oldBindableId;
|
||||
const newBindable = elementsWithClones.find(
|
||||
(el) => el.id === newBindableId,
|
||||
) as ExcalidrawBindableElement;
|
||||
mutateElement(duplicate, {
|
||||
[bindingProp]: {
|
||||
...original[bindingProp],
|
||||
elementId: newBindableId,
|
||||
},
|
||||
});
|
||||
mutateElement(newBindable, {
|
||||
boundElements: [
|
||||
...(newBindable.boundElements ?? []).filter(
|
||||
(el) => el.id !== original.id && el.id !== duplicate.id,
|
||||
),
|
||||
{
|
||||
id: duplicate.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// Linked element is outside the selection
|
||||
const originalBindable = elementsWithClones.find(
|
||||
(el) => el.id === oldBindableId,
|
||||
);
|
||||
if (originalBindable) {
|
||||
mutateElement(duplicate, {
|
||||
[bindingProp]: original[bindingProp],
|
||||
});
|
||||
mutateElement(original, {
|
||||
[bindingProp]: null,
|
||||
});
|
||||
mutateElement(originalBindable, {
|
||||
boundElements: [
|
||||
...(originalBindable.boundElements?.filter(
|
||||
(el) => el.id !== original.id,
|
||||
) ?? []),
|
||||
{
|
||||
id: duplicate.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const fixReversedBindings = (
|
||||
originalElements: Map<string, ExcalidrawElement>,
|
||||
elementsWithClones: ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
) => {
|
||||
for (const original of originalElements.values()) {
|
||||
const duplicate = elementsWithClones.find(
|
||||
(el) => el.id === oldIdToDuplicatedId.get(original.id),
|
||||
)!;
|
||||
|
||||
if (isBindableElement(original) && isBindableElement(duplicate)) {
|
||||
fixReversedBindingsForBindables(
|
||||
original,
|
||||
duplicate,
|
||||
originalElements,
|
||||
elementsWithClones,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
} else if (isArrowElement(original) && isArrowElement(duplicate)) {
|
||||
fixReversedBindingsForArrows(
|
||||
original,
|
||||
duplicate,
|
||||
originalElements,
|
||||
"startBinding",
|
||||
oldIdToDuplicatedId,
|
||||
elementsWithClones,
|
||||
);
|
||||
fixReversedBindingsForArrows(
|
||||
original,
|
||||
duplicate,
|
||||
originalElements,
|
||||
"endBinding",
|
||||
oldIdToDuplicatedId,
|
||||
elementsWithClones,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const fixBindingsAfterDeletion = (
|
||||
sceneElements: readonly ExcalidrawElement[],
|
||||
deletedElements: readonly ExcalidrawElement[],
|
||||
|
@ -1580,10 +1756,7 @@ const determineFocusDistance = (
|
|||
// Another point on the line, in absolute coordinates (closer to element)
|
||||
b: GlobalPoint,
|
||||
): number => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
if (pointsEqual(a, b)) {
|
||||
return 0;
|
||||
|
@ -1713,10 +1886,7 @@ const determineFocusPoint = (
|
|||
focus: number,
|
||||
adjacentPoint: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
if (focus === 0) {
|
||||
return center;
|
||||
|
@ -2147,10 +2317,7 @@ export const getGlobalFixedPointForBindableElement = (
|
|||
element.x + element.width * fixedX,
|
||||
element.y + element.height * fixedY,
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
elementCenterPoint(element),
|
||||
element.angle,
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { isTransparent } from "@excalidraw/common";
|
||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
|
@ -16,7 +16,7 @@ import {
|
|||
} from "@excalidraw/math/ellipse";
|
||||
|
||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||
import { getPolygonShape } from "@excalidraw/utils/shape";
|
||||
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import type {
|
||||
GlobalPoint,
|
||||
|
@ -26,8 +26,6 @@ import type {
|
|||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { GeometricShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
||||
|
@ -191,10 +189,7 @@ const intersectRectanguloidWithLineSegment = (
|
|||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||
|
@ -253,10 +248,7 @@ const intersectDiamondWithLineSegment = (
|
|||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
|
@ -304,10 +296,7 @@ const intersectEllipseWithLineSegment = (
|
|||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
|
|
@ -14,6 +14,8 @@ import {
|
|||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
|
@ -61,7 +63,7 @@ export const cropElement = (
|
|||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
pointFrom(pointerX, pointerY),
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
||||
elementCenterPoint(element),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import {
|
||||
curvePointDistance,
|
||||
distanceToLineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
|
@ -53,10 +54,7 @@ const distanceToRectanguloidElement = (
|
|||
element: ExcalidrawRectanguloidElement,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
|
@ -84,10 +82,7 @@ const distanceToDiamondElement = (
|
|||
element: ExcalidrawDiamondElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
|
@ -115,10 +110,7 @@ const distanceToEllipseElement = (
|
|||
element: ExcalidrawEllipseElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = pointFrom(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
return ellipseDistanceFromPoint(
|
||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||
pointRotateRads(p, center, -element.angle as Radians),
|
||||
|
|
|
@ -36,7 +36,10 @@ import {
|
|||
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
|
||||
import { fixBindingsAfterDuplication } from "./binding";
|
||||
import {
|
||||
fixDuplicatedBindingsAfterDuplication,
|
||||
fixReversedBindings,
|
||||
} from "./binding";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
|
@ -381,12 +384,20 @@ export const duplicateElements = (
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fixBindingsAfterDuplication(
|
||||
fixDuplicatedBindingsAfterDuplication(
|
||||
newElements,
|
||||
oldIdToDuplicatedId,
|
||||
duplicatedElementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
|
||||
if (reverseOrder) {
|
||||
fixReversedBindings(
|
||||
_idsOfElementsToDuplicate,
|
||||
elementsWithClones,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
}
|
||||
|
||||
bindElementsToFramesAfterDuplication(
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointScaleFromOrigin,
|
||||
radiansToDegrees,
|
||||
pointsEqual,
|
||||
triangleIncludesPoint,
|
||||
vectorCross,
|
||||
vectorFromPoint,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
|
@ -13,7 +17,6 @@ import type {
|
|||
GlobalPoint,
|
||||
Triangle,
|
||||
Vector,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||
|
@ -26,24 +29,6 @@ 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 = <Point extends GlobalPoint | LocalPoint>(
|
||||
a: Point,
|
||||
b: Point,
|
||||
) => {
|
||||
const angle = radiansToDegrees(
|
||||
normalizeRadians(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) {
|
||||
return HEADING_RIGHT;
|
||||
} else if (angle >= 135 && angle < 225) {
|
||||
return HEADING_DOWN;
|
||||
}
|
||||
return HEADING_LEFT;
|
||||
};
|
||||
|
||||
export const vectorToHeading = (vec: Vector): Heading => {
|
||||
const [x, y] = vec;
|
||||
const absX = Math.abs(x);
|
||||
|
@ -76,6 +61,165 @@ export const headingIsHorizontal = (a: Heading) =>
|
|||
|
||||
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
|
||||
|
||||
const headingForPointFromDiamondElement = (
|
||||
element: Readonly<ExcalidrawBindableElement>,
|
||||
aabb: Readonly<Bounds>,
|
||||
point: Readonly<GlobalPoint>,
|
||||
): Heading => {
|
||||
const midPoint = getCenterForBounds(aabb);
|
||||
|
||||
if (isDevEnv() || isTestEnv()) {
|
||||
invariant(
|
||||
element.width > 0 && element.height > 0,
|
||||
"Diamond element has no width or height",
|
||||
);
|
||||
invariant(
|
||||
!pointsEqual(midPoint, point),
|
||||
"The point is too close to the element mid point to determine heading",
|
||||
);
|
||||
}
|
||||
|
||||
const SHRINK = 0.95; // Rounded elements tolerance
|
||||
const top = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
const right = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
const bottom = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
const left = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
|
||||
// Corners
|
||||
if (
|
||||
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, right)) <=
|
||||
0 &&
|
||||
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, left)) > 0
|
||||
) {
|
||||
return headingForPoint(top, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, right),
|
||||
vectorFromPoint(right, bottom),
|
||||
) <= 0 &&
|
||||
vectorCross(vectorFromPoint(point, right), vectorFromPoint(right, top)) > 0
|
||||
) {
|
||||
return headingForPoint(right, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, bottom),
|
||||
vectorFromPoint(bottom, left),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, bottom),
|
||||
vectorFromPoint(bottom, right),
|
||||
) > 0
|
||||
) {
|
||||
return headingForPoint(bottom, midPoint);
|
||||
} else if (
|
||||
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, top)) <=
|
||||
0 &&
|
||||
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, bottom)) > 0
|
||||
) {
|
||||
return headingForPoint(left, midPoint);
|
||||
}
|
||||
|
||||
// Sides
|
||||
if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(top, midPoint),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(right, midPoint),
|
||||
) > 0
|
||||
) {
|
||||
const p = element.width > element.height ? top : right;
|
||||
return headingForPoint(p, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(right, midPoint),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(bottom, midPoint),
|
||||
) > 0
|
||||
) {
|
||||
const p = element.width > element.height ? bottom : right;
|
||||
return headingForPoint(p, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(bottom, midPoint),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(left, midPoint),
|
||||
) > 0
|
||||
) {
|
||||
const p = element.width > element.height ? bottom : left;
|
||||
return headingForPoint(p, midPoint);
|
||||
}
|
||||
|
||||
const p = element.width > element.height ? top : left;
|
||||
return headingForPoint(p, midPoint);
|
||||
};
|
||||
|
||||
// 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.
|
||||
|
@ -89,74 +233,7 @@ export const headingForPointFromElement = <Point extends GlobalPoint>(
|
|||
const midPoint = getCenterForBounds(aabb);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
if (p[0] < element.x) {
|
||||
return HEADING_LEFT;
|
||||
} else if (p[1] < element.y) {
|
||||
return HEADING_UP;
|
||||
} else if (p[0] > element.x + element.width) {
|
||||
return HEADING_RIGHT;
|
||||
} else if (p[1] > element.y + element.height) {
|
||||
return HEADING_DOWN;
|
||||
}
|
||||
|
||||
const top = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x + element.width / 2, element.y),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const right = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x + element.width, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const bottom = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const left = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
if (
|
||||
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
|
||||
) {
|
||||
return headingForDiamond(top, right);
|
||||
} else if (
|
||||
triangleIncludesPoint<Point>(
|
||||
[right, bottom, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
) {
|
||||
return headingForDiamond(right, bottom);
|
||||
} else if (
|
||||
triangleIncludesPoint<Point>(
|
||||
[bottom, left, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
) {
|
||||
return headingForDiamond(bottom, left);
|
||||
}
|
||||
|
||||
return headingForDiamond(left, top);
|
||||
return headingForPointFromDiamondElement(element, aabb, p);
|
||||
}
|
||||
|
||||
const topLeft = pointScaleFromOrigin(
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
invariant,
|
||||
elementCenterPoint,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
isPoint,
|
||||
|
@ -297,7 +298,7 @@ export const aabbForElement = (
|
|||
midY: element.y + element.height / 2,
|
||||
};
|
||||
|
||||
const center = pointFrom(bbox.midX, bbox.midY);
|
||||
const center = elementCenterPoint(element);
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.minY),
|
||||
center,
|
||||
|
|
|
@ -10,6 +10,8 @@ import {
|
|||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||
|
||||
import { getCornerRadius } from "./shapes";
|
||||
|
@ -68,10 +70,7 @@ export function deconstructRectanguloidElement(
|
|||
return [sides, []];
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
const r = rectangle(
|
||||
pointFrom(element.x, element.y),
|
||||
|
@ -254,10 +253,7 @@ export function deconstructDiamondElement(
|
|||
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY),
|
||||
|
|
|
@ -14,7 +14,7 @@ import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
|
|||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
|
||||
import {
|
||||
act,
|
||||
|
@ -699,4 +699,34 @@ describe("duplication z-order", () => {
|
|||
{ id: text.id, containerId: arrow.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reverse-duplicating bindable element with bound arrow should keep the arrow on the duplicate", () => {
|
||||
const rect = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: -100,
|
||||
y: 50,
|
||||
width: 95,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(5, 5);
|
||||
mouse.up(15, 15);
|
||||
});
|
||||
|
||||
expect(window.h.elements).toHaveLength(3);
|
||||
|
||||
const newRect = window.h.elements[0];
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(newRect.id);
|
||||
expect(newRect.boundElements?.[0]?.id).toBe(arrow.id);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -191,11 +191,7 @@ export class AnimatedTrail implements Trail {
|
|||
});
|
||||
|
||||
const stroke = this.trailAnimation
|
||||
? _stroke.slice(
|
||||
// slicing from 6th point to get rid of the initial notch type of thing
|
||||
Math.min(_stroke.length, 6),
|
||||
_stroke.length / 2,
|
||||
)
|
||||
? _stroke.slice(0, _stroke.length / 2)
|
||||
: _stroke;
|
||||
|
||||
return getSvgPathFromStroke(stroke, true);
|
||||
|
|
|
@ -99,6 +99,7 @@ import {
|
|||
isShallowEqual,
|
||||
arrayToMap,
|
||||
type EXPORT_IMAGE_TYPES,
|
||||
randomInteger,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
|
@ -8418,20 +8419,26 @@ class App extends React.Component<AppProps, AppState> {
|
|||
});
|
||||
if (
|
||||
hitElement &&
|
||||
// hit element may not end up being selected
|
||||
// if we're alt-dragging a common bounding box
|
||||
// over the hit element
|
||||
pointerDownState.hit.wasAddedToSelection &&
|
||||
!selectedElements.find((el) => el.id === hitElement.id)
|
||||
) {
|
||||
selectedElements.push(hitElement);
|
||||
}
|
||||
|
||||
const idsOfElementsToDuplicate = new Map(
|
||||
selectedElements.map((el) => [el.id, el]),
|
||||
);
|
||||
|
||||
const { newElements: clonedElements, elementsWithClones } =
|
||||
duplicateElements({
|
||||
type: "in-place",
|
||||
elements,
|
||||
appState: this.state,
|
||||
randomizeSeed: true,
|
||||
idsOfElementsToDuplicate: new Map(
|
||||
selectedElements.map((el) => [el.id, el]),
|
||||
),
|
||||
idsOfElementsToDuplicate,
|
||||
overrides: (el) => {
|
||||
const origEl = pointerDownState.originalElements.get(el.id);
|
||||
|
||||
|
@ -8439,6 +8446,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
return {
|
||||
x: origEl.x,
|
||||
y: origEl.y,
|
||||
seed: origEl.seed,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -8458,7 +8466,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||
const nextSceneElements = syncMovedIndices(
|
||||
mappedNewSceneElements || elementsWithClones,
|
||||
arrayToMap(clonedElements),
|
||||
);
|
||||
).map((el) => {
|
||||
if (idsOfElementsToDuplicate.has(el.id)) {
|
||||
return newElementWith(el, {
|
||||
seed: randomInteger(),
|
||||
});
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
this.scene.replaceAllElements(nextSceneElements);
|
||||
this.maybeCacheVisibleGaps(event, selectedElements, true);
|
||||
|
|
|
@ -2,9 +2,9 @@ import { simplify } from "points-on-curve";
|
|||
|
||||
import {
|
||||
polygonFromPoints,
|
||||
polygonIncludesPoint,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
polygonIncludesPointNonZero,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
|
@ -37,8 +37,6 @@ export const getLassoSelectedElementIds = (input: {
|
|||
if (simplifyDistance) {
|
||||
path = simplify(lassoPath, simplifyDistance) as GlobalPoint[];
|
||||
}
|
||||
// close the path to form a polygon for enclosure check
|
||||
const closedPath = polygonFromPoints(path);
|
||||
// as the path might not enclose a shape anymore, clear before checking
|
||||
enclosedElements.clear();
|
||||
for (const element of elements) {
|
||||
|
@ -46,15 +44,11 @@ export const getLassoSelectedElementIds = (input: {
|
|||
!intersectedElements.has(element.id) &&
|
||||
!enclosedElements.has(element.id)
|
||||
) {
|
||||
const enclosed = enclosureTest(closedPath, element, elementsSegments);
|
||||
const enclosed = enclosureTest(path, element, elementsSegments);
|
||||
if (enclosed) {
|
||||
enclosedElements.add(element.id);
|
||||
} else {
|
||||
const intersects = intersectionTest(
|
||||
closedPath,
|
||||
element,
|
||||
elementsSegments,
|
||||
);
|
||||
const intersects = intersectionTest(path, element, elementsSegments);
|
||||
if (intersects) {
|
||||
intersectedElements.add(element.id);
|
||||
}
|
||||
|
@ -81,7 +75,9 @@ const enclosureTest = (
|
|||
}
|
||||
|
||||
return segments.some((segment) => {
|
||||
return segment.some((point) => polygonIncludesPoint(point, lassoPolygon));
|
||||
return segment.some((point) =>
|
||||
polygonIncludesPointNonZero(point, lassoPolygon),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
|||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 238820263,
|
||||
"seed": 1278240551,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
|
@ -54,14 +54,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
|
|||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"seed": 1505387817,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 5,
|
||||
"versionNonce": 23633383,
|
||||
"version": 6,
|
||||
"versionNonce": 915032327,
|
||||
"width": 30,
|
||||
"x": -10,
|
||||
"y": 60,
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
isTextElement,
|
||||
isFrameLikeElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||
import { KEYS, arrayToMap, elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
|
@ -151,7 +151,7 @@ export class Keyboard {
|
|||
const getElementPointForSelection = (
|
||||
element: ExcalidrawElement,
|
||||
): GlobalPoint => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const { x, y, width, angle } = element;
|
||||
const target = pointFrom<GlobalPoint>(
|
||||
x +
|
||||
(isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2),
|
||||
|
@ -166,7 +166,7 @@ const getElementPointForSelection = (
|
|||
(bounds[1] + bounds[3]) / 2,
|
||||
);
|
||||
} else {
|
||||
center = pointFrom(x + width / 2, y + height / 2);
|
||||
center = elementCenterPoint(element);
|
||||
}
|
||||
|
||||
if (isTextElement(element)) {
|
||||
|
|
|
@ -41,6 +41,34 @@ export const polygonIncludesPoint = <Point extends LocalPoint | GlobalPoint>(
|
|||
return inside;
|
||||
};
|
||||
|
||||
export const polygonIncludesPointNonZero = <Point extends [number, number]>(
|
||||
point: Point,
|
||||
polygon: Point[],
|
||||
): boolean => {
|
||||
const [x, y] = point;
|
||||
let windingNumber = 0;
|
||||
|
||||
for (let i = 0; i < polygon.length; i++) {
|
||||
const j = (i + 1) % polygon.length;
|
||||
const [xi, yi] = polygon[i];
|
||||
const [xj, yj] = polygon[j];
|
||||
|
||||
if (yi <= y) {
|
||||
if (yj > y) {
|
||||
if ((xj - xi) * (y - yi) - (x - xi) * (yj - yi) > 0) {
|
||||
windingNumber++;
|
||||
}
|
||||
}
|
||||
} else if (yj <= y) {
|
||||
if ((xj - xi) * (y - yi) - (x - xi) * (yj - yi) < 0) {
|
||||
windingNumber--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return windingNumber !== 0;
|
||||
};
|
||||
|
||||
export const pointOnPolygon = <Point extends LocalPoint | GlobalPoint>(
|
||||
p: Point,
|
||||
poly: Polygon<Point>,
|
||||
|
|
Loading…
Add table
Reference in a new issue