feat: Precise hit testing (#9488)

This commit is contained in:
Márk Tolmács 2025-06-07 12:56:32 +02:00 committed by GitHub
parent 56c05b3099
commit ca1a4f25e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 2223 additions and 2718 deletions

View File

@ -1,10 +1,12 @@
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math"; import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { getCenterForBounds, getElementBounds } from "@excalidraw/element";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
FontFamilyValues, FontFamilyValues,
FontString, FontString,
ExcalidrawElement, ExcalidrawElement,
ElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@ -1240,16 +1242,13 @@ export const castArray = <T>(value: T | T[]): T[] =>
export const elementCenterPoint = ( export const elementCenterPoint = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
const { x, y, width, height } = element; const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
const centerXPoint = x + width / 2 + xOffset; return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
}; };
/** hack for Array.isArray type guard not working with readonly value[] */ /** hack for Array.isArray type guard not working with readonly value[] */

View File

@ -1,8 +1,17 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math"; import {
pointFrom,
pointDistance,
type LocalPoint,
pointRotateRads,
} from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common"; import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
import type { GlobalPoint } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types"; import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
@ -20,7 +29,12 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement"; import { generateFreeDrawShape } from "./renderElement";
import { getArrowheadPoints, getDiamondPoints } from "./bounds"; import {
getArrowheadPoints,
getCenterForBounds,
getDiamondPoints,
getElementBounds,
} from "./bounds";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@ -28,10 +42,11 @@ import type {
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@ -303,6 +318,172 @@ const getArrowheadShapes = (
} }
}; };
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
) => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
disableMultiStroke: true,
disableMultiStrokeFill: true,
roughness: 0,
preserveVertices: true,
};
const center = getCenterForBounds(
getElementBounds(element, elementsMap, true),
);
switch (element.type) {
case "line":
case "arrow": {
// points array can be empty in the beginning, so it is important to add
// initial position to it
const points = element.points.length
? element.points
: [pointFrom<LocalPoint>(0, 0)];
if (isElbowArrow(element)) {
return generator.path(generateElbowArrowShape(points, 16), options)
.sets[0].ops;
} else if (!element.roundness) {
return points.map((point, idx) => {
const p = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return {
op: idx === 0 ? "move" : "lineTo",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
});
}
return generator
.curve(points as unknown as RoughPoint[], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
case "freedraw": {
if (element.points.length < 2) {
return [];
}
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
return generator
.curve(simplifiedPoints as [number, number][], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
}
};
/** /**
* Generates the roughjs shape for given element. * Generates the roughjs shape for given element.
* *

View File

@ -27,8 +27,6 @@ import {
PRECISION, PRECISION,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
@ -41,7 +39,7 @@ import {
doBoundsIntersect, doBoundsIntersect,
} from "./bounds"; } from "./bounds";
import { intersectElementWithLineSegment } from "./collision"; import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
headingForPointFromElement, headingForPointFromElement,
headingIsHorizontal, headingIsHorizontal,
@ -63,7 +61,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; import { aabbForElement } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
@ -109,7 +107,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
export const FIXED_BINDING_DISTANCE = 5; export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10; export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = ( const getNonDeletedElements = (
scene: Scene, scene: Scene,
@ -131,6 +128,7 @@ export const bindOrUnbindLinearElement = (
endBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep",
scene: Scene, scene: Scene,
): void => { ): void => {
const elementsMap = scene.getNonDeletedElementsMap();
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
@ -141,6 +139,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
scene, scene,
elementsMap,
); );
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
linearElement, linearElement,
@ -150,6 +149,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
scene, scene,
elementsMap,
); );
const onlyUnbound = Array.from(unboundFromElementIds).filter( const onlyUnbound = Array.from(unboundFromElementIds).filter(
@ -176,6 +176,7 @@ const bindOrUnbindLinearElementEdge = (
// Is mutated // Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>, unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
scene: Scene, scene: Scene,
elementsMap: ElementsMap,
): void => { ): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out // "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") { if (bindableElement === "keep") {
@ -216,43 +217,29 @@ const bindOrUnbindLinearElementEdge = (
} }
}; };
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
edge === "start"
? linearElement.startBinding?.elementId
: linearElement.endBinding?.elementId;
if (elementId) {
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
};
const getOriginalBindingsIfStillCloseToArrowEnds = ( const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] => ): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) => (["start", "end"] as const).map((edge) => {
getOriginalBindingIfStillCloseOfLinearElementEdge( const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
linearElement, const elementId =
edge as "start" | "end", edge === "start"
elementsMap, ? linearElement.startBinding?.elementId
zoom, : linearElement.endBinding?.elementId;
), if (elementId) {
); const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
});
const getBindingStrategyForDraggingArrowEndpoints = ( const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>, selectedElement: NonDeleted<ExcalidrawLinearElement>,
@ -268,7 +255,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
const start = startDragged const start = startDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
@ -279,7 +266,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
: "keep"; : "keep";
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
@ -311,7 +298,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
); );
const start = startIsClose const start = startIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
@ -322,7 +309,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
: null; : null;
const end = endIsClose const end = endIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
@ -441,22 +428,13 @@ export const maybeBindLinearElement = (
const normalizePointBinding = ( const normalizePointBinding = (
binding: { focus: number; gap: number }, binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
) => { ) => ({
let gap = binding.gap; ...binding,
const maxGap = maxBindingGap( gap: Math.min(
hoveredElement, binding.gap,
hoveredElement.width, maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
hoveredElement.height, ),
); });
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
export const bindLinearElement = ( export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
@ -488,6 +466,7 @@ export const bindLinearElement = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
scene.getNonDeletedElementsMap(),
), ),
}; };
} }
@ -703,8 +682,13 @@ const calculateFocusAndGap = (
); );
return { return {
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), focus: determineFocusDistance(
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), hoveredElement,
elementsMap,
adjacentPoint,
edgePoint,
),
gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
}; };
}; };
@ -874,6 +858,7 @@ export const getHeadingForElbowArrowSnap = (
bindableElement: ExcalidrawBindableElement | undefined | null, bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null, aabb: Bounds | undefined | null,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): Heading => { ): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@ -882,11 +867,16 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading; return otherPointHeading;
} }
const distance = getDistanceForBinding(origPoint, bindableElement, zoom); const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) { if (!distance) {
return vectorToHeading( return vectorToHeading(
vectorFromPoint(p, elementCenterPoint(bindableElement)), vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
); );
} }
@ -896,9 +886,10 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = ( const getDistanceForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
) => { ) => {
const distance = distanceToBindableElement(bindableElement, point); const distance = distanceToElement(bindableElement, elementsMap, point);
const bindDistance = maxBindingGap( const bindDistance = maxBindingGap(
bindableElement, bindableElement,
bindableElement.width, bindableElement.width,
@ -913,12 +904,13 @@ export const bindPointToSnapToElementOutline = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
if (isDevEnv() || isTestEnv()) { if (isDevEnv() || isTestEnv()) {
invariant(arrow.points.length > 1, "Arrow should have at least 2 points"); invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
} }
const aabb = aabbForElement(bindableElement); const aabb = aabbForElement(bindableElement, elementsMap);
const localP = const localP =
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1]; arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
const globalP = pointFrom<GlobalPoint>( const globalP = pointFrom<GlobalPoint>(
@ -926,7 +918,7 @@ export const bindPointToSnapToElementOutline = (
arrow.y + localP[1], arrow.y + localP[1],
); );
const edgePoint = isRectanguloidElement(bindableElement) const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, globalP) ? avoidRectangularCorner(bindableElement, elementsMap, globalP)
: globalP; : globalP;
const elbowed = isElbowArrow(arrow); const elbowed = isElbowArrow(arrow);
const center = getCenterForBounds(aabb); const center = getCenterForBounds(aabb);
@ -945,26 +937,31 @@ export const bindPointToSnapToElementOutline = (
const isHorizontal = headingIsHorizontal( const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, globalP), headingForPointFromElement(bindableElement, aabb, globalP),
); );
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : edgePoint[0], isHorizontal ? center[0] : snapPoint[0],
!isHorizontal ? center[1] : edgePoint[1], !isHorizontal ? center[1] : snapPoint[1],
);
const intersector = lineSegment(
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
); );
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
lineSegment( elementsMap,
otherPoint, intersector,
pointFromVector( FIXED_BINDING_DISTANCE,
vectorScale( ).sort(pointDistanceSq)[0];
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
),
)[0];
} else { } else {
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap,
lineSegment( lineSegment(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@ -991,31 +988,15 @@ export const bindPointToSnapToElementOutline = (
return edgePoint; return edgePoint;
} }
if (elbowed) { return elbowed ? intersection : edgePoint;
const scalar =
pointDistanceSq(edgePoint, center) -
pointDistanceSq(intersection, center) >
0
? FIXED_BINDING_DISTANCE
: -FIXED_BINDING_DISTANCE;
return pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
scalar,
),
intersection,
);
}
return edgePoint;
}; };
export const avoidRectangularCorner = ( export const avoidRectangularCorner = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
@ -1108,35 +1089,34 @@ export const avoidRectangularCorner = (
export const snapToMid = ( export const snapToMid = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
tolerance: number = 0.05, tolerance: number = 0.05,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, height, angle } = element; const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, elementsMap, -0.1, -0.1);
const center = elementCenterPoint(element, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance // above and below certain px distance
const verticalThrehsold = clamp(tolerance * height, 5, 80); const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThrehsold = clamp(tolerance * width, 5, 80); const horizontalThreshold = clamp(tolerance * width, 5, 80);
if ( if (
nonRotated[0] <= x + width / 2 && nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// LEFT // LEFT
return pointRotateRads( return pointRotateRads<GlobalPoint>(
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]), pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
} else if ( } else if (
nonRotated[1] <= y + height / 2 && nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(
@ -1146,8 +1126,8 @@ export const snapToMid = (
); );
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
@ -1157,8 +1137,8 @@ export const snapToMid = (
); );
} else if ( } else if (
nonRotated[1] >= y + height / 2 && nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
@ -1167,7 +1147,7 @@ export const snapToMid = (
angle, angle,
); );
} else if (element.type === "diamond") { } else if (element.type === "diamond") {
const distance = FIXED_BINDING_DISTANCE - 1; const distance = FIXED_BINDING_DISTANCE;
const topLeft = pointFrom<GlobalPoint>( const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance, x + width / 4 - distance,
y + height / 4 - distance, y + height / 4 - distance,
@ -1184,27 +1164,28 @@ export const snapToMid = (
x + (3 * width) / 4 + distance, x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance, y + (3 * height) / 4 + distance,
); );
if ( if (
pointDistance(topLeft, nonRotated) < pointDistance(topLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(topLeft, center, angle); return pointRotateRads(topLeft, center, angle);
} }
if ( if (
pointDistance(topRight, nonRotated) < pointDistance(topRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(topRight, center, angle); return pointRotateRads(topRight, center, angle);
} }
if ( if (
pointDistance(bottomLeft, nonRotated) < pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(bottomLeft, center, angle); return pointRotateRads(bottomLeft, center, angle);
} }
if ( if (
pointDistance(bottomRight, nonRotated) < pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(bottomRight, center, angle); return pointRotateRads(bottomRight, center, angle);
} }
@ -1239,8 +1220,9 @@ const updateBoundPoint = (
linearElement, linearElement,
bindableElement, bindableElement,
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint; ).fixedPoint;
const globalMidPoint = elementCenterPoint(bindableElement); const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
const global = pointFrom<GlobalPoint>( const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height, bindableElement.y + fixedPoint[1] * bindableElement.height,
@ -1266,6 +1248,7 @@ const updateBoundPoint = (
); );
const focusPointAbsolute = determineFocusPoint( const focusPointAbsolute = determineFocusPoint(
bindableElement, bindableElement,
elementsMap,
binding.focus, binding.focus,
adjacentPoint, adjacentPoint,
); );
@ -1284,7 +1267,7 @@ const updateBoundPoint = (
elementsMap, elementsMap,
); );
const center = elementCenterPoint(bindableElement); const center = elementCenterPoint(bindableElement, elementsMap);
const interceptorLength = const interceptorLength =
pointDistance(adjacentPoint, edgePointAbsolute) + pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) + pointDistance(adjacentPoint, center) +
@ -1292,6 +1275,7 @@ const updateBoundPoint = (
const intersections = [ const intersections = [
...intersectElementWithLineSegment( ...intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap,
lineSegment<GlobalPoint>( lineSegment<GlobalPoint>(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@ -1342,6 +1326,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>, linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
const bounds = [ const bounds = [
hoveredElement.x, hoveredElement.x,
@ -1353,6 +1338,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap,
); );
const globalMidPoint = pointFrom( const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
@ -1396,7 +1382,7 @@ const maybeCalculateNewGapWhenScaling = (
return { ...currentBinding, gap: newGap }; return { ...currentBinding, gap: newGap };
}; };
const getElligibleElementForBindingElement = ( const getEligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
@ -1548,14 +1534,38 @@ export const bindingBorderTest = (
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
): boolean => { ): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const threshold = maxBindingGap(element, element.width, element.height, zoom); const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
(fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(element);
const shape = getElementShape(element, elementsMap); // PERF: Run a cheap test to see if the binding element
return ( // is even close to the element
isPointOnShape(pointFrom(x, y), shape, threshold) || const bounds = [
(fullShape === true && x - threshold,
pointInsideBounds(pointFrom(x, y), aabbForElement(element))) y - threshold,
x + threshold,
y + threshold,
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return false;
}
// Do the intersection test against the element since it's close enough
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
); );
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside
? intersections.length === 0 || distance <= threshold
: intersections.length > 0 && distance <= threshold;
}; };
export const maxBindingGap = ( export const maxBindingGap = (
@ -1575,7 +1585,7 @@ export const maxBindingGap = (
// bigger bindable boundary for bigger elements // bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32), Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight // keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET, BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
); );
}; };
@ -1586,12 +1596,13 @@ export const maxBindingGap = (
// of the element. // of the element.
const determineFocusDistance = ( const determineFocusDistance = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// Point on the line, in absolute coordinates // Point on the line, in absolute coordinates
a: GlobalPoint, a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element) // Another point on the line, in absolute coordinates (closer to element)
b: GlobalPoint, b: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
if (pointsEqual(a, b)) { if (pointsEqual(a, b)) {
return 0; return 0;
@ -1716,12 +1727,13 @@ const determineFocusDistance = (
const determineFocusPoint = ( const determineFocusPoint = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// The oriented, relative distance from the center of `element` of the // The oriented, relative distance from the center of `element` of the
// returned focusPoint // returned focusPoint
focus: number, focus: number,
adjacentPoint: GlobalPoint, adjacentPoint: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
if (focus === 0) { if (focus === 0) {
return center; return center;
@ -2144,6 +2156,7 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = ( export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number], fixedPointRatio: [number, number],
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
@ -2152,7 +2165,7 @@ export const getGlobalFixedPointForBindableElement = (
element.x + element.width * fixedX, element.x + element.width * fixedX,
element.y + element.height * fixedY, element.y + element.height * fixedY,
), ),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
element.angle, element.angle,
); );
}; };
@ -2176,6 +2189,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint, arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement, startElement as ExcalidrawBindableElement,
elementsMap,
) )
: pointFrom<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[0][0], arrow.x + arrow.points[0][0],
@ -2186,6 +2200,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint, arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement, endElement as ExcalidrawBindableElement,
elementsMap,
) )
: pointFrom<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0], arrow.x + arrow.points[arrow.points.length - 1][0],

View File

@ -102,9 +102,23 @@ export class ElementBounds {
version: ExcalidrawElement["version"]; version: ExcalidrawElement["version"];
} }
>(); >();
private static nonRotatedBoundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) { static getBounds(
const cachedBounds = ElementBounds.boundsCache.get(element); element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
) {
const cachedBounds =
nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element)
: ElementBounds.boundsCache.get(element);
if ( if (
cachedBounds?.version && cachedBounds?.version &&
@ -115,6 +129,23 @@ export class ElementBounds {
) { ) {
return cachedBounds.bounds; return cachedBounds.bounds;
} }
if (nonRotated && element.angle !== 0) {
const nonRotatedBounds = ElementBounds.calculateBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
ElementBounds.nonRotatedBoundsCache.set(element, {
version: element.version,
bounds: nonRotatedBounds,
});
return nonRotatedBounds;
}
const bounds = ElementBounds.calculateBounds(element, elementsMap); const bounds = ElementBounds.calculateBounds(element, elementsMap);
ElementBounds.boundsCache.set(element, { ElementBounds.boundsCache.set(element, {
@ -939,8 +970,9 @@ const getLinearElementRotatedBounds = (
export const getElementBounds = ( export const getElementBounds = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
nonRotated: boolean = false,
): Bounds => { ): Bounds => {
return ElementBounds.getBounds(element, elementsMap); return ElementBounds.getBounds(element, elementsMap, nonRotated);
}; };
export const getCommonBounds = ( export const getCommonBounds = (

View File

@ -2,51 +2,60 @@ import { isTransparent, elementCenterPoint } from "@excalidraw/common";
import { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
line,
lineSegment, lineSegment,
lineSegmentIntersectionPoints, lineSegmentIntersectionPoints,
pointFrom, pointFrom,
pointFromVector,
pointRotateRads, pointRotateRads,
pointsEqual, pointsEqual,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
ellipse, ellipse,
ellipseLineIntersectionPoints, ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse"; } from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type {
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes"; import { isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds"; import {
type Bounds,
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
} from "./bounds";
import { import {
hasBoundTextElement, hasBoundTextElement,
isFreeDrawElement,
isIframeLikeElement, isIframeLikeElement,
isImageElement, isImageElement,
isLinearElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawRectangleElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
@ -72,45 +81,64 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element); return isDraggableFromInside || isImageElement(element);
}; };
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = { export type HitTestArgs = {
x: number; point: GlobalPoint;
y: number;
element: ExcalidrawElement; element: ExcalidrawElement;
shape: GeometricShape<Point>; threshold: number;
threshold?: number; elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null; frameNameBound?: FrameNameBounds | null;
}; };
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({ export const hitElementItself = ({
x, point,
y,
element, element,
shape, threshold,
threshold = 10, elementsMap,
frameNameBound = null, frameNameBound = null,
}: HitTestArgs<Point>) => { }: HitTestArgs) => {
let hit = shouldTestInside(element) // Hit test against a frame's name
? // Since `inShape` tests STRICTLY againt the insides of a shape const hitFrameName = frameNameBound
// we would need `onShape` as well to include the "borders" ? isPointWithinBounds(
isPointInShape(pointFrom(x, y), shape) || pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
isPointOnShape(pointFrom(x, y), shape, threshold) point,
: isPointOnShape(pointFrom(x, y), shape, threshold); pointFrom(
frameNameBound.x + frameNameBound.width + threshold,
frameNameBound.y + frameNameBound.height + threshold,
),
)
: false;
// hit test against a frame's name // Hit test against the extended, rotated bounding box of the element first
if (!hit && frameNameBound) { const bounds = getElementBounds(element, elementsMap, true);
hit = isPointInShape(pointFrom(x, y), { const hitBounds = isPointWithinBounds(
type: "polygon", pointFrom(bounds[0] - threshold, bounds[1] - threshold),
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) pointRotateRads(
.data as Polygon<Point>, point,
}); getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
);
// PERF: Bail out early if the point is not even in the
// rotated bounding box or not hitting the frame name (saves 99%)
if (!hitBounds && !hitFrameName) {
return false;
} }
return hit; // Do the precise (and relatively costly) hit test
const hitElement = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInElement(point, element, elementsMap) ||
isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold);
return hitElement || hitFrameName;
}; };
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
x: number, point: GlobalPoint,
y: number,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
@ -120,37 +148,42 @@ export const hitElementBoundingBox = (
y1 -= tolerance; y1 -= tolerance;
x2 += tolerance; x2 += tolerance;
y2 += tolerance; y2 += tolerance;
return isPointWithinBounds( return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
}; };
export const hitElementBoundingBoxOnly = < export const hitElementBoundingBoxOnly = (
Point extends GlobalPoint | LocalPoint, hitArgs: HitTestArgs,
>(
hitArgs: HitTestArgs<Point>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) =>
return ( !hitElementItself(hitArgs) &&
!hitElementItself(hitArgs) && // bound text is considered part of the element (even if it's outside the bounding box)
// bound text is considered part of the element (even if it's outside the bounding box) !hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
!hitElementBoundText( hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
hitArgs.x,
hitArgs.y,
getBoundTextShape(hitArgs.element, elementsMap),
) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
);
};
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>( export const hitElementBoundText = (
x: number, point: GlobalPoint,
y: number, element: ExcalidrawElement,
textShape: GeometricShape<Point> | null, elementsMap: ElementsMap,
): boolean => { ): boolean => {
return !!textShape && isPointInShape(pointFrom(x, y), textShape); const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
if (!boundTextElementCandidate) {
return false;
}
const boundTextElement = isLinearElement(element)
? {
...boundTextElementCandidate,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElementCandidate,
elementsMap,
),
}
: boundTextElementCandidate;
return isPointInElement(point, boundTextElement, elementsMap);
}; };
/** /**
@ -163,9 +196,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
*/ */
export const intersectElementWithLineSegment = ( export const intersectElementWithLineSegment = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
line: LineSegment<GlobalPoint>, line: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box
// as it is much faster than checking intersection against the element's shape
const intersectorBounds = [
Math.min(line[0][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - offset),
Math.max(line[0][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + offset),
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
return [];
}
// Do the actual intersection test against the element's shape
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "image": case "image":
@ -173,23 +223,88 @@ export const intersectElementWithLineSegment = (
case "iframe": case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "selection":
case "magicframe": case "magicframe":
return intersectRectanguloidWithLineSegment(element, line, offset); return intersectRectanguloidWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "diamond": case "diamond":
return intersectDiamondWithLineSegment(element, line, offset); return intersectDiamondWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "ellipse": case "ellipse":
return intersectEllipseWithLineSegment(element, line, offset); return intersectEllipseWithLineSegment(
default: element,
throw new Error(`Unimplemented element type '${element.type}'`); elementsMap,
line,
offset,
);
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(
element,
elementsMap,
line,
onlyFirst,
);
} }
}; };
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
segment: LineSegment<GlobalPoint>,
onlyFirst = false,
): GlobalPoint[] => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections = [];
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(intersection);
if (onlyFirst) {
return intersections;
}
}
}
for (const c of curves) {
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
intersections.push(...hits);
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectRectanguloidWithLineSegment = ( const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>( const rotatedA = pointRotateRads<GlobalPoint>(
@ -206,34 +321,37 @@ const intersectRectanguloidWithLineSegment = (
// Get the element's building components we can test against // Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element, offset); const [sides, corners] = deconstructRectanguloidElement(element, offset);
return ( const intersections: GlobalPoint[] = [];
// Test intersection against the sides, keep only the valid
// intersection points and rotate them back to scene space for (const s of sides) {
sides const intersection = lineSegmentIntersectionPoints(
.map((s) => lineSegment(rotatedA, rotatedB),
lineSegmentIntersectionPoints( s,
lineSegment<GlobalPoint>(rotatedA, rotatedB), );
s, if (intersection) {
), intersections.push(pointRotateRads(intersection, center, element.angle));
)
.filter((x) => x != null) if (onlyFirst) {
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)) return intersections;
// Test intersection against the corners which are cubic bezier curves, }
// keep only the valid intersection points and rotate them back to scene }
// space }
.concat(
corners for (const t of corners) {
.flatMap((t) => const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
) if (hits.length > 0) {
.filter((i) => i != null) for (const j of hits) {
.map((j) => pointRotateRads(j, center, element.angle)), intersections.push(pointRotateRads(j, center, element.angle));
) }
// Remove duplicates
.filter( if (onlyFirst) {
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, return intersections;
) }
); }
}
return intersections;
}; };
/** /**
@ -245,43 +363,51 @@ const intersectRectanguloidWithLineSegment = (
*/ */
const intersectDiamondWithLineSegment = ( const intersectDiamondWithLineSegment = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
const [sides, curves] = deconstructDiamondElement(element, offset); const [sides, corners] = deconstructDiamondElement(element, offset);
return ( const intersections: GlobalPoint[] = [];
sides
.map((s) => for (const s of sides) {
lineSegmentIntersectionPoints( const intersection = lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB), lineSegment(rotatedA, rotatedB),
s, s,
), );
) if (intersection) {
.filter((p): p is GlobalPoint => p != null) intersections.push(pointRotateRads(intersection, center, element.angle));
// Rotate back intersection points
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)) if (onlyFirst) {
.concat( return intersections;
curves }
.flatMap((p) => }
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), }
)
.filter((p) => p != null) for (const t of corners) {
// Rotate back intersection points const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
.map((p) => pointRotateRads(p, center, element.angle)),
) if (hits.length > 0) {
// Remove duplicates for (const j of hits) {
.filter( intersections.push(pointRotateRads(j, center, element.angle));
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, }
)
); if (onlyFirst) {
return intersections;
}
}
}
return intersections;
}; };
/** /**
@ -293,16 +419,76 @@ const intersectDiamondWithLineSegment = (
*/ */
const intersectEllipseWithLineSegment = ( const intersectEllipseWithLineSegment = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
return ellipseLineIntersectionPoints( return ellipseSegmentInterceptPoints(
ellipse(center, element.width / 2 + offset, element.height / 2 + offset), ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
line(rotatedA, rotatedB), lineSegment(rotatedA, rotatedB),
).map((p) => pointRotateRads(p, center, element.angle)); ).map((p) => pointRotateRads(p, center, element.angle));
}; };
/**
* Check if the given point is considered on the given shape's border
*
* @param point
* @param element
* @param tolerance
* @returns
*/
const isPointOnElementOutline = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 1,
) => distanceToElement(element, elementsMap, point) <= tolerance;
/**
* Check if the given point is considered inside the element's border
*
* @param point
* @param element
* @returns
*/
export const isPointInElement = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
// There isn't any "inside" for a non-looping path
return false;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
return false;
}
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const otherPoint = pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(point, center, 0.1)),
Math.max(element.width, element.height) * 2,
),
center,
);
const intersector = lineSegment(point, otherPoint);
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
intersector,
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
return intersections.length % 2 === 1;
};

View File

@ -34,6 +34,7 @@ export const MINIMAL_CROP_SIZE = 10;
export const cropElement = ( export const cropElement = (
element: ExcalidrawImageElement, element: ExcalidrawImageElement,
elementsMap: ElementsMap,
transformHandle: TransformHandleType, transformHandle: TransformHandleType,
naturalWidth: number, naturalWidth: number,
naturalHeight: number, naturalHeight: number,
@ -63,7 +64,7 @@ export const cropElement = (
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY), pointFrom(pointerX, pointerY),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
-element.angle as Radians, -element.angle as Radians,
); );

View File

@ -12,21 +12,27 @@ import type { GlobalPoint, Radians } from "@excalidraw/math";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import type { import type {
ExcalidrawBindableElement, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
export const distanceToBindableElement = ( export const distanceToElement = (
element: ExcalidrawBindableElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
switch (element.type) { switch (element.type) {
case "selection":
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
@ -34,11 +40,15 @@ export const distanceToBindableElement = (
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe": case "magicframe":
return distanceToRectanguloidElement(element, p); return distanceToRectanguloidElement(element, elementsMap, p);
case "diamond": case "diamond":
return distanceToDiamondElement(element, p); return distanceToDiamondElement(element, elementsMap, p);
case "ellipse": case "ellipse":
return distanceToEllipseElement(element, p); return distanceToEllipseElement(element, elementsMap, p);
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
} }
}; };
@ -52,9 +62,10 @@ export const distanceToBindableElement = (
*/ */
const distanceToRectanguloidElement = ( const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
*/ */
const distanceToDiamondElement = ( const distanceToDiamondElement = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
@ -108,12 +120,28 @@ const distanceToDiamondElement = (
*/ */
const distanceToEllipseElement = ( const distanceToEllipseElement = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
return ellipseDistanceFromPoint( return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle // Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, -element.angle as Radians), pointRotateRads(p, center, -element.angle as Radians),
ellipse(center, element.width / 2, element.height / 2), ellipse(center, element.width / 2, element.height / 2),
); );
}; };
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
);
};

View File

@ -29,10 +29,9 @@ import {
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap, getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
snapToMid,
getHoveredElementForBinding, getHoveredElementForBinding,
} from "./binding"; } from "./binding";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
compareHeading, compareHeading,
flipHeading, flipHeading,
@ -898,50 +897,6 @@ export const updateElbowArrowPoints = (
return { points: updates.points ?? arrow.points }; return { points: updates.points ?? arrow.points };
} }
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
// arrow size is valid. This check will be removed once the issue is identified
if (
arrow.x < -MAX_POS ||
arrow.x > MAX_POS ||
arrow.y < -MAX_POS ||
arrow.y > MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
) {
console.error(
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
{
arrow,
updates,
},
);
}
// @ts-ignore See above note
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
// @ts-ignore See above note
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
if (updates.points) {
updates.points = updates.points.map(([x, y]) =>
pointFrom<LocalPoint>(
clamp(x, -MAX_POS, MAX_POS),
clamp(y, -MAX_POS, MAX_POS),
),
);
}
if (!import.meta.env.PROD) { if (!import.meta.env.PROD) {
invariant( invariant(
!updates.points || updates.points.length >= 2, !updates.points || updates.points.length >= 2,
@ -1273,6 +1228,7 @@ const getElbowArrowData = (
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
hoveredStartElement, hoveredStartElement,
elementsMap,
options?.isDragging, options?.isDragging,
); );
const endGlobalPoint = getGlobalPoint( const endGlobalPoint = getGlobalPoint(
@ -1286,6 +1242,7 @@ const getElbowArrowData = (
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
hoveredEndElement, hoveredEndElement,
elementsMap,
options?.isDragging, options?.isDragging,
); );
const startHeading = getBindPointHeading( const startHeading = getBindPointHeading(
@ -1293,12 +1250,14 @@ const getElbowArrowData = (
endGlobalPoint, endGlobalPoint,
hoveredStartElement, hoveredStartElement,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap,
); );
const endHeading = getBindPointHeading( const endHeading = getBindPointHeading(
endGlobalPoint, endGlobalPoint,
startGlobalPoint, startGlobalPoint,
hoveredEndElement, hoveredEndElement,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap,
); );
const startPointBounds = [ const startPointBounds = [
startGlobalPoint[0] - 2, startGlobalPoint[0] - 2,
@ -1315,6 +1274,7 @@ const getElbowArrowData = (
const startElementBounds = hoveredStartElement const startElementBounds = hoveredStartElement
? aabbForElement( ? aabbForElement(
hoveredStartElement, hoveredStartElement,
elementsMap,
offsetFromHeading( offsetFromHeading(
startHeading, startHeading,
arrow.startArrowhead arrow.startArrowhead
@ -1327,6 +1287,7 @@ const getElbowArrowData = (
const endElementBounds = hoveredEndElement const endElementBounds = hoveredEndElement
? aabbForElement( ? aabbForElement(
hoveredEndElement, hoveredEndElement,
elementsMap,
offsetFromHeading( offsetFromHeading(
endHeading, endHeading,
arrow.endArrowhead arrow.endArrowhead
@ -1342,6 +1303,7 @@ const getElbowArrowData = (
hoveredEndElement hoveredEndElement
? aabbForElement( ? aabbForElement(
hoveredEndElement, hoveredEndElement,
elementsMap,
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING), offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
) )
: endPointBounds, : endPointBounds,
@ -1351,6 +1313,7 @@ const getElbowArrowData = (
hoveredStartElement hoveredStartElement
? aabbForElement( ? aabbForElement(
hoveredStartElement, hoveredStartElement,
elementsMap,
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING), offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
) )
: startPointBounds, : startPointBounds,
@ -1397,8 +1360,8 @@ const getElbowArrowData = (
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement), hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
hoveredEndElement && aabbForElement(hoveredEndElement), hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
); );
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
@ -2229,34 +2192,35 @@ const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint, initialPoint: GlobalPoint,
element?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean, isDragging?: boolean,
): GlobalPoint => { ): GlobalPoint => {
if (isDragging) { if (isDragging) {
if (element) { if (element && elementsMap) {
const snapPoint = bindPointToSnapToElementOutline( return bindPointToSnapToElementOutline(
arrow, arrow,
element, element,
startOrEnd, startOrEnd,
elementsMap,
); );
return snapToMid(element, snapPoint);
} }
return initialPoint; return initialPoint;
} }
if (element) { if (element && elementsMap) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement( const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0], fixedPointRatio || [0, 0],
element, element,
elementsMap,
); );
// NOTE: Resize scales the binding position point too, so we need to update it // NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs( return Math.abs(
distanceToBindableElement(element, fixedGlobalPoint) - distanceToElement(element, elementsMap, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
) > 0.01 ) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd) ? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
: fixedGlobalPoint; : fixedGlobalPoint;
} }
@ -2268,6 +2232,7 @@ const getBindPointHeading = (
otherPoint: GlobalPoint, otherPoint: GlobalPoint,
hoveredElement: ExcalidrawBindableElement | null | undefined, hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: ElementsMap,
): Heading => ): Heading =>
getHeadingForElbowArrowSnap( getHeadingForElbowArrowSnap(
p, p,
@ -2276,7 +2241,8 @@ const getBindPointHeading = (
hoveredElement && hoveredElement &&
aabbForElement( aabbForElement(
hoveredElement, hoveredElement,
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [ elementsMap,
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
number, number,
number, number,
number, number,
@ -2284,6 +2250,7 @@ const getBindPointHeading = (
], ],
), ),
origPoint, origPoint,
elementsMap,
); );
const getHoveredElement = ( const getHoveredElement = (

View File

@ -95,10 +95,11 @@ const getNodeRelatives = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0] type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>; ) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [ const heading = headingForPointFromElement(
edgePoint[0] + el.x, node,
edgePoint[1] + el.y, aabbForElement(node, elementsMap),
] as Readonly<GlobalPoint>); [edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
);
acc.push({ acc.push({
relative, relative,

View File

@ -291,6 +291,7 @@ export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
*/ */
export const aabbForElement = ( export const aabbForElement = (
element: Readonly<ExcalidrawElement>, element: Readonly<ExcalidrawElement>,
elementsMap: ElementsMap,
offset?: [number, number, number, number], offset?: [number, number, number, number],
) => { ) => {
const bbox = { const bbox = {
@ -302,7 +303,7 @@ export const aabbForElement = (
midY: element.y + element.height / 2, midY: element.y + element.height / 2,
}; };
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY), pointFrom(bbox.minX, bbox.minY),
center, center,

View File

@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement | ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement
| ExcalidrawSelectionElement;
/** /**
* ExcalidrawElement should be JSON serializable and (eventually) contain * ExcalidrawElement should be JSON serializable and (eventually) contain

View File

@ -1,28 +1,168 @@
import { import {
curve, curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment, lineSegment,
pointFrom, pointFrom,
pointFromVector, pointFromArray,
rectangle, rectangle,
vectorFromPoint,
vectorNormalize,
vectorScale,
type GlobalPoint, type GlobalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { elementCenterPoint } from "@excalidraw/common"; import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { Curve, LineSegment } from "@excalidraw/math";
import { getCornerRadius } from "./shapes"; import { getCornerRadius } from "./shapes";
import { getDiamondPoints } from "./bounds"; import { getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./Shape";
import type { import type {
ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
const ElementShapesCache = new WeakMap<
ExcalidrawElement,
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
>();
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
offset: number,
): ElementShape | undefined => {
const record = ElementShapesCache.get(element);
if (!record) {
return undefined;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.delete(element);
return undefined;
}
return shapes.get(offset);
};
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
shape: ElementShape,
offset: number,
) => {
const record = ElementShapesCache.get(element);
if (!record) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
shapes.set(offset, shape);
};
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
if (cachedShape) {
return cachedShape;
}
const ops = generateLinearCollisionShape(element, elementsMap) as {
op: string;
data: number[];
}[];
const lines = [];
const curves = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
const prevPoint =
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
switch (op.op) {
case "move":
continue;
case "lineTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
lines.push(
lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
),
);
continue;
case "bcurveTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
curves.push(
curve<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
),
);
continue;
default: {
console.error("Unknown op type", op.op);
}
}
}
const shape = [lines, curves] as ElementShape;
setElementShapesCacheEntry(element, shape, 0);
return shape;
}
/** /**
* Get the building components of a rectanguloid element in the form of * Get the building components of a rectanguloid element in the form of
* line segments and curves. * line segments and curves.
@ -35,175 +175,132 @@ export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const roundness = getCornerRadius( const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
let radius = getCornerRadius(
Math.min(element.width, element.height), Math.min(element.width, element.height),
element, element,
); );
if (roundness <= 0) { if (radius === 0) {
const r = rectangle( radius = 0.01;
pointFrom(element.x - offset, element.y - offset),
pointFrom(
element.x + element.width + offset,
element.y + element.height + offset,
),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
);
const sides = [top, right, bottom, left];
return [sides, []];
} }
const center = elementCenterPoint(element);
const r = rectangle( const r = rectangle(
pointFrom(element.x, element.y), pointFrom(element.x, element.y),
pointFrom(element.x + element.width, element.y + element.height), pointFrom(element.x + element.width, element.y + element.height),
); );
const top = lineSegment<GlobalPoint>( const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]), pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]), pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
); );
const right = lineSegment<GlobalPoint>( const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness), pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness), pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
); );
const bottom = lineSegment<GlobalPoint>( const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]), pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]), pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
); );
const left = lineSegment<GlobalPoint>( const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness), pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness), pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
); );
const offsets = [ const baseCorners = [
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
),
offset,
), // TOP LEFT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
),
offset,
), //TOP RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM LEFT
];
const corners = [
curve( curve(
pointFromVector(offsets[0], left[1]), left[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
pointFrom<GlobalPoint>( left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
pointFrom<GlobalPoint>( top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
),
), ),
pointFromVector(offsets[0], top[0]), top[0],
), // TOP LEFT ), // TOP LEFT
curve( curve(
pointFromVector(offsets[1], top[1]), top[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
pointFrom<GlobalPoint>( top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
pointFrom<GlobalPoint>( right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
),
), ),
pointFromVector(offsets[1], right[0]), right[0],
), // TOP RIGHT ), // TOP RIGHT
curve( curve(
pointFromVector(offsets[2], right[1]), right[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
pointFrom<GlobalPoint>( right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
pointFrom<GlobalPoint>( bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
),
), ),
pointFromVector(offsets[2], bottom[1]), bottom[1],
), // BOTTOM RIGHT ), // BOTTOM RIGHT
curve( curve(
pointFromVector(offsets[3], bottom[0]), bottom[0],
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
pointFrom<GlobalPoint>( bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
pointFrom<GlobalPoint>( left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
),
), ),
pointFromVector(offsets[3], left[0]), left[0],
), // BOTTOM LEFT ), // BOTTOM LEFT
]; ];
const sides = [ const corners =
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), offset > 0
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), ? baseCorners.map(
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), (corner) =>
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]), curveCatmullRomCubicApproxPoints(
]; curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
return [sides, corners]; const sides = [
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
} }
/** /**
@ -218,42 +315,20 @@ export function deconstructDiamondElement(
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const cachedShape = getElementShapesCacheEntry(element, offset);
getDiamondPoints(element);
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
if (element.roundness?.type == null) { if (cachedShape) {
const [top, right, bottom, left]: GlobalPoint[] = [ return cachedShape;
pointFrom(element.x + topX, element.y + topY - offset),
pointFrom(element.x + rightX + offset, element.y + rightY),
pointFrom(element.x + bottomX, element.y + bottomY + offset),
pointFrom(element.x + leftX - offset, element.y + leftY),
];
// Create the line segment parts of the diamond
// NOTE: Horizontal and vertical seems to be flipped here
const topRight = lineSegment<GlobalPoint>(
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
);
const bottomRight = lineSegment<GlobalPoint>(
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
);
const bottomLeft = lineSegment<GlobalPoint>(
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
);
const topLeft = lineSegment<GlobalPoint>(
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
);
return [[topRight, bottomRight, bottomLeft, topLeft], []];
} }
const center = elementCenterPoint(element); const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const [top, right, bottom, left]: GlobalPoint[] = [ const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY), pointFrom(element.x + topX, element.y + topY),
@ -262,94 +337,94 @@ export function deconstructDiamondElement(
pointFrom(element.x + leftX, element.y + leftY), pointFrom(element.x + leftX, element.y + leftY),
]; ];
const offsets = [ const baseCorners = [
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
];
const corners = [
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], right[0] - verticalRadius,
pointFrom<GlobalPoint>( right[1] - horizontalRadius,
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
), ),
pointFromVector(offsets[0], right), right,
pointFromVector(offsets[0], right), right,
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], right[0] - verticalRadius,
pointFrom<GlobalPoint>( right[1] + horizontalRadius,
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
), ),
), // RIGHT ), // RIGHT
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], bottom[0] + verticalRadius,
pointFrom<GlobalPoint>( bottom[1] - horizontalRadius,
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
), ),
pointFromVector(offsets[1], bottom), bottom,
pointFromVector(offsets[1], bottom), bottom,
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], bottom[0] - verticalRadius,
pointFrom<GlobalPoint>( bottom[1] - horizontalRadius,
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
), ),
), // BOTTOM ), // BOTTOM
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], left[0] + verticalRadius,
pointFrom<GlobalPoint>( left[1] + horizontalRadius,
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
), ),
pointFromVector(offsets[2], left), left,
pointFromVector(offsets[2], left), left,
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], left[0] + verticalRadius,
pointFrom<GlobalPoint>( left[1] - horizontalRadius,
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
), ),
), // LEFT ), // LEFT
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], top[0] - verticalRadius,
pointFrom<GlobalPoint>( top[1] + horizontalRadius,
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
), ),
pointFromVector(offsets[3], top), top,
pointFromVector(offsets[3], top), top,
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], top[0] + verticalRadius,
pointFrom<GlobalPoint>( top[1] + horizontalRadius,
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
), ),
), // TOP ), // TOP
]; ];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [ const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), lineSegment<GlobalPoint>(
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), corners[0][corners[0].length - 1][3],
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), corners[1][0][0],
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]), ),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
]; ];
return [sides, corners]; const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
} }

View File

@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@ -202,6 +204,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@ -215,6 +218,7 @@ describe("aligning", () => {
// Add the created group to the current selection // Add the created group to the current selection
mouse.restorePosition(0, 0); mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@ -316,6 +320,7 @@ describe("aligning", () => {
// The second rectangle is already selected because it was the last element created // The second rectangle is already selected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@ -330,7 +335,7 @@ describe("aligning", () => {
mouse.down(); mouse.down();
mouse.up(100, 100); mouse.up(100, 100);
mouse.restorePosition(200, 200); mouse.restorePosition(210, 200);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(); mouse.click();
}); });
@ -341,6 +346,7 @@ describe("aligning", () => {
// The second group is already selected because it was the last group created // The second group is already selected because it was the last group created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@ -454,6 +460,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@ -466,7 +473,7 @@ describe("aligning", () => {
mouse.up(100, 100); mouse.up(100, 100);
// Add group to current selection // Add group to current selection
mouse.restorePosition(0, 0); mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(); mouse.click();
}); });
@ -482,6 +489,7 @@ describe("aligning", () => {
// Select the nested group, the rectangle is already selected // Select the nested group, the rectangle is already selected
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };

View File

@ -172,12 +172,12 @@ describe("element binding", () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: 0, x: 0,
y: 0, y: 0,
size: 50, size: 49,
}); });
expect(arrow.endBinding).toBe(null); expect(arrow.endBinding).toBe(null);
mouse.downAt(50, 50); mouse.downAt(49, 49);
mouse.moveTo(51, 0); mouse.moveTo(51, 0);
mouse.up(0, 0); mouse.up(0, 0);

View File

@ -0,0 +1,38 @@
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils";
import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("arrow", () => {
UI.createElement("arrow", {
x: 0,
y: 0,
width: 124,
height: 302,
angle: 1.8700426423973724,
points: [
[0, 0],
[120, -198],
[-4, -302],
] as LocalPoint[],
});
//const p = [120, -211];
//const p = [0, 13];
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(87, -68),
element: window.h.elements[0],
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
});
expect(hit).toBe(true);
});
});

View File

@ -1262,7 +1262,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y); mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(204, 0); expect(arrow.width).toBeCloseTo(200, 0);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(

View File

@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]); UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
}); });
@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]); UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });
@ -819,7 +819,7 @@ describe("image element", () => {
UI.resize(image, "ne", [40, 0]); UI.resize(image, "ne", [40, 0]);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0); expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
const imageWidth = image.width; const imageWidth = image.width;
const scale = 20 / image.height; const scale = 20 / image.height;
@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50); expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(143, 0); expect(leftBoundArrow.width).toBeCloseTo(140, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0); expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull(); expect(leftBoundArrow.startBinding).toBeNull();

View File

@ -1,7 +1,5 @@
import { vi } from "vitest"; import { vi } from "vitest";
import * as constants from "@excalidraw/common";
import { getPerfectElementSize } from "../src/sizeHelpers"; import { getPerfectElementSize } from "../src/sizeHelpers";
const EPSILON_DIGITS = 3; const EPSILON_DIGITS = 3;
@ -57,13 +55,4 @@ describe("getPerfectElementSize", () => {
expect(width).toBeCloseTo(0, EPSILON_DIGITS); expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toBeCloseTo(0, EPSILON_DIGITS); expect(height).toBeCloseTo(0, EPSILON_DIGITS);
}); });
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
const { height, width } = getPerfectElementSize("arrow", 120, 185);
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
});
});
}); });

View File

@ -18,7 +18,6 @@ import {
arrayToMap, arrayToMap,
getFontFamilyString, getFontFamilyString,
getShortcutKey, getShortcutKey,
tupleToCoors,
getLineHeight, getLineHeight,
isTransparent, isTransparent,
reduceToCommonValue, reduceToCommonValue,
@ -28,9 +27,7 @@ import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
import { import {
bindLinearElement, bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding, calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
updateBoundElements, updateBoundElements,
} from "@excalidraw/element"; } from "@excalidraw/element";
@ -1661,63 +1658,16 @@ export const actionChangeArrowType = register({
-1, -1,
elementsMap, elementsMap,
); );
const startHoveredElement = const startElement =
!newElement.startBinding && newElement.startBinding &&
getHoveredElementForBinding( (elementsMap.get(
tupleToCoors(startGlobalPoint), newElement.startBinding.elementId,
elements, ) as ExcalidrawBindableElement);
elementsMap, const endElement =
appState.zoom, newElement.endBinding &&
false, (elementsMap.get(
true, newElement.endBinding.elementId,
); ) as ExcalidrawBindableElement);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
false,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
newElement,
startHoveredElement,
"start",
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
newElement,
endHoveredElement,
"end",
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
app.scene,
);
endHoveredElement &&
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
const startBinding = const startBinding =
startElement && newElement.startBinding startElement && newElement.startBinding
@ -1728,6 +1678,7 @@ export const actionChangeArrowType = register({
newElement, newElement,
startElement, startElement,
"start", "start",
elementsMap,
), ),
} }
: null; : null;
@ -1740,6 +1691,7 @@ export const actionChangeArrowType = register({
newElement, newElement,
endElement, endElement,
"end", "end",
elementsMap,
), ),
} }
: null; : null;
@ -1749,7 +1701,7 @@ export const actionChangeArrowType = register({
startBinding, startBinding,
endBinding, endBinding,
...updateElbowArrowPoints(newElement, elementsMap, { ...updateElbowArrowPoints(newElement, elementsMap, {
points: [finalStartPoint, finalEndPoint].map( points: [startGlobalPoint, endGlobalPoint].map(
(p): LocalPoint => (p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y), pointFrom(p[0] - newElement.x, p[1] - newElement.y),
), ),

View File

@ -10,6 +10,7 @@ import {
STATS_PANELS, STATS_PANELS,
THEME, THEME,
DEFAULT_GRID_STEP, DEFAULT_GRID_STEP,
isTestEnv,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { AppState, NormalizedZoomValue } from "./types"; import type { AppState, NormalizedZoomValue } from "./types";
@ -36,7 +37,7 @@ export const getDefaultAppState = (): Omit<
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness, currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round", currentItemRoundness: isTestEnv() ? "sharp" : "round",
currentItemArrowType: ARROW_TYPE.round, currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,

View File

@ -17,8 +17,6 @@ import {
vectorDot, vectorDot,
vectorNormalize, vectorNormalize,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isPointInShape } from "@excalidraw/utils/collision";
import { getSelectionBoxShape } from "@excalidraw/utils/shape";
import { import {
COLOR_PALETTE, COLOR_PALETTE,
@ -104,9 +102,9 @@ import {
Emitter, Emitter,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
import { import {
getCommonBounds,
getElementAbsoluteCoords,
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
fixBindingsAfterDeletion, fixBindingsAfterDeletion,
getHoveredElementForBinding, getHoveredElementForBinding,
@ -115,13 +113,8 @@ import {
shouldEnableBindingForPointerEvent, shouldEnableBindingForPointerEvent,
updateBoundElements, updateBoundElements,
getSuggestedBindingsForArrows, getSuggestedBindingsForArrows,
} from "@excalidraw/element"; LinearElementEditor,
newElementWith,
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import {
newFrameElement, newFrameElement,
newFreeDrawElement, newFreeDrawElement,
newEmbeddableElement, newEmbeddableElement,
@ -133,11 +126,8 @@ import {
newLinearElement, newLinearElement,
newTextElement, newTextElement,
refreshTextDimensions, refreshTextDimensions,
} from "@excalidraw/element"; deepCopyElement,
duplicateElements,
import { deepCopyElement, duplicateElements } from "@excalidraw/element";
import {
hasBoundTextElement, hasBoundTextElement,
isArrowElement, isArrowElement,
isBindingElement, isBindingElement,
@ -158,48 +148,27 @@ import {
isFlowchartNodeElement, isFlowchartNodeElement,
isBindableElement, isBindableElement,
isTextElement, isTextElement,
} from "@excalidraw/element";
import {
getLockedLinearCursorAlignSize, getLockedLinearCursorAlignSize,
getNormalizedDimensions, getNormalizedDimensions,
isElementCompletelyInViewport, isElementCompletelyInViewport,
isElementInViewport, isElementInViewport,
isInvisiblySmallElement, isInvisiblySmallElement,
} from "@excalidraw/element";
import {
getBoundTextShape,
getCornerRadius, getCornerRadius,
getElementShape,
isPathALoop, isPathALoop,
} from "@excalidraw/element";
import {
createSrcDoc, createSrcDoc,
embeddableURLValidator, embeddableURLValidator,
maybeParseEmbedSrc, maybeParseEmbedSrc,
getEmbedLink, getEmbedLink,
} from "@excalidraw/element";
import {
getInitializedImageElements, getInitializedImageElements,
loadHTMLImageElement, loadHTMLImageElement,
normalizeSVG, normalizeSVG,
updateImageCache as _updateImageCache, updateImageCache as _updateImageCache,
} from "@excalidraw/element";
import {
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerElement, getContainerElement,
isValidTextContainer, isValidTextContainer,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "@excalidraw/element"; shouldShowBoundingBox,
import { shouldShowBoundingBox } from "@excalidraw/element";
import {
getFrameChildren, getFrameChildren,
isCursorInFrame, isCursorInFrame,
addElementsToFrame, addElementsToFrame,
@ -214,29 +183,17 @@ import {
getFrameLikeTitle, getFrameLikeTitle,
getElementsOverlappingFrame, getElementsOverlappingFrame,
filterElementsEligibleAsFrameChildren, filterElementsEligibleAsFrameChildren,
} from "@excalidraw/element";
import {
hitElementBoundText, hitElementBoundText,
hitElementBoundingBoxOnly, hitElementBoundingBoxOnly,
hitElementItself, hitElementItself,
} from "@excalidraw/element"; getVisibleSceneBounds,
import { getVisibleSceneBounds } from "@excalidraw/element";
import {
FlowChartCreator, FlowChartCreator,
FlowChartNavigator, FlowChartNavigator,
getLinkDirectionFromKey, getLinkDirectionFromKey,
} from "@excalidraw/element"; cropElement,
wrapText,
import { cropElement } from "@excalidraw/element"; isElementLink,
parseElementLinkFromURL,
import { wrapText } from "@excalidraw/element";
import { isElementLink, parseElementLinkFromURL } from "@excalidraw/element";
import {
isMeasureTextSupported, isMeasureTextSupported,
normalizeText, normalizeText,
measureText, measureText,
@ -244,13 +201,8 @@ import {
getApproxMinLineWidth, getApproxMinLineWidth,
getApproxMinLineHeight, getApproxMinLineHeight,
getMinTextElementWidth, getMinTextElementWidth,
} from "@excalidraw/element"; ShapeCache,
getRenderOpacity,
import { ShapeCache } from "@excalidraw/element";
import { getRenderOpacity } from "@excalidraw/element";
import {
editGroupForSelectedElement, editGroupForSelectedElement,
getElementsInGroup, getElementsInGroup,
getSelectedGroupIdForElement, getSelectedGroupIdForElement,
@ -258,42 +210,28 @@ import {
isElementInGroup, isElementInGroup,
isSelectedViaGroup, isSelectedViaGroup,
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
} from "@excalidraw/element"; syncInvalidIndices,
syncMovedIndices,
import { syncInvalidIndices, syncMovedIndices } from "@excalidraw/element";
import {
excludeElementsInFramesFromSelection, excludeElementsInFramesFromSelection,
getSelectionStateForElements, getSelectionStateForElements,
makeNextSelectedElementIds, makeNextSelectedElementIds,
} from "@excalidraw/element";
import {
getResizeOffsetXY, getResizeOffsetXY,
getResizeArrowDirection, getResizeArrowDirection,
transformElements, transformElements,
} from "@excalidraw/element";
import {
getCursorForResizingElement, getCursorForResizingElement,
getElementWithTransformHandleType, getElementWithTransformHandleType,
getTransformHandleTypeFromCoords, getTransformHandleTypeFromCoords,
} from "@excalidraw/element";
import {
dragNewElement, dragNewElement,
dragSelectedElements, dragSelectedElements,
getDragOffsetXY, getDragOffsetXY,
isNonDeletedElement,
Scene,
Store,
CaptureUpdateAction,
type ElementUpdate,
hitElementBoundingBox,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { isNonDeletedElement } from "@excalidraw/element";
import { Scene } from "@excalidraw/element";
import { Store, CaptureUpdateAction } from "@excalidraw/element";
import type { ElementUpdate } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
@ -5095,6 +5033,7 @@ class App extends React.Component<AppProps, AppState> {
return null; return null;
} }
// NOTE: Hot path for hit testing, so avoid unnecessary computations
private getElementAtPosition( private getElementAtPosition(
x: number, x: number,
y: number, y: number,
@ -5134,16 +5073,12 @@ class App extends React.Component<AppProps, AppState> {
// If we're hitting element with highest z-index only on its bounding box // If we're hitting element with highest z-index only on its bounding box
// while also hitting other element figure, the latter should be considered. // while also hitting other element figure, the latter should be considered.
return hitElementItself({ return hitElementItself({
x, point: pointFrom(x, y),
y,
element: elementWithHighestZIndex, element: elementWithHighestZIndex,
shape: getElementShape(
elementWithHighestZIndex,
this.scene.getNonDeletedElementsMap(),
),
// when overlapping, we would like to be more precise // when overlapping, we would like to be more precise
// this also avoids the need to update past tests // this also avoids the need to update past tests
threshold: this.getElementHitThreshold() / 2, threshold: this.getElementHitThreshold(elementWithHighestZIndex) / 2,
elementsMap: this.scene.getNonDeletedElementsMap(),
frameNameBound: isFrameLikeElement(elementWithHighestZIndex) frameNameBound: isFrameLikeElement(elementWithHighestZIndex)
? this.frameNameBoundsCache.get(elementWithHighestZIndex) ? this.frameNameBoundsCache.get(elementWithHighestZIndex)
: null, : null,
@ -5158,6 +5093,7 @@ class App extends React.Component<AppProps, AppState> {
return null; return null;
} }
// NOTE: Hot path for hit testing, so avoid unnecessary computations
private getElementsAtPosition( private getElementsAtPosition(
x: number, x: number,
y: number, y: number,
@ -5208,8 +5144,14 @@ class App extends React.Component<AppProps, AppState> {
return elements; return elements;
} }
getElementHitThreshold() { getElementHitThreshold(element: ExcalidrawElement) {
return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value; return Math.max(
element.strokeWidth / 2 + 0.1,
// NOTE: Here be dragons. Do not go under the 0.63 multiplier unless you're
// willing to test extensively. The hit testing starts to become unreliable
// due to FP imprecision under 0.63 in high zoom levels.
0.85 * (DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value),
);
} }
private hitElement( private hitElement(
@ -5224,35 +5166,35 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedElementIds[element.id] && this.state.selectedElementIds[element.id] &&
shouldShowBoundingBox([element], this.state) shouldShowBoundingBox([element], this.state)
) { ) {
const selectionShape = getSelectionBoxShape(
element,
this.scene.getNonDeletedElementsMap(),
isImageElement(element) ? 0 : this.getElementHitThreshold(),
);
// if hitting the bounding box, return early // if hitting the bounding box, return early
// but if not, we should check for other cases as well (e.g. frame name) // but if not, we should check for other cases as well (e.g. frame name)
if (isPointInShape(pointFrom(x, y), selectionShape)) { if (
hitElementBoundingBox(
pointFrom(x, y),
element,
this.scene.getNonDeletedElementsMap(),
this.getElementHitThreshold(element),
)
) {
return true; return true;
} }
} }
// take bound text element into consideration for hit collision as well // take bound text element into consideration for hit collision as well
const hitBoundTextOfElement = hitElementBoundText( const hitBoundTextOfElement = hitElementBoundText(
x, pointFrom(x, y),
y, element,
getBoundTextShape(element, this.scene.getNonDeletedElementsMap()), this.scene.getNonDeletedElementsMap(),
); );
if (hitBoundTextOfElement) { if (hitBoundTextOfElement) {
return true; return true;
} }
return hitElementItself({ return hitElementItself({
x, point: pointFrom(x, y),
y,
element, element,
shape: getElementShape(element, this.scene.getNonDeletedElementsMap()), threshold: this.getElementHitThreshold(element),
threshold: this.getElementHitThreshold(), elementsMap: this.scene.getNonDeletedElementsMap(),
frameNameBound: isFrameLikeElement(element) frameNameBound: isFrameLikeElement(element)
? this.frameNameBoundsCache.get(element) ? this.frameNameBoundsCache.get(element)
: null, : null,
@ -5280,14 +5222,10 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
isArrowElement(elements[index]) && isArrowElement(elements[index]) &&
hitElementItself({ hitElementItself({
x, point: pointFrom(x, y),
y,
element: elements[index], element: elements[index],
shape: getElementShape( elementsMap: this.scene.getNonDeletedElementsMap(),
elements[index], threshold: this.getElementHitThreshold(elements[index]),
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(),
}) })
) { ) {
hitElement = elements[index]; hitElement = elements[index];
@ -5632,14 +5570,10 @@ class App extends React.Component<AppProps, AppState> {
hasBoundTextElement(container) || hasBoundTextElement(container) ||
!isTransparent(container.backgroundColor) || !isTransparent(container.backgroundColor) ||
hitElementItself({ hitElementItself({
x: sceneX, point: pointFrom(sceneX, sceneY),
y: sceneY,
element: container, element: container,
shape: getElementShape( elementsMap: this.scene.getNonDeletedElementsMap(),
container, threshold: this.getElementHitThreshold(container),
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(),
}) })
) { ) {
const midPoint = getContainerCenter( const midPoint = getContainerCenter(
@ -6329,13 +6263,10 @@ class App extends React.Component<AppProps, AppState> {
let segmentMidPointHoveredCoords = null; let segmentMidPointHoveredCoords = null;
if ( if (
hitElementItself({ hitElementItself({
x: scenePointerX, point: pointFrom(scenePointerX, scenePointerY),
y: scenePointerY,
element, element,
shape: getElementShape( elementsMap,
element, threshold: this.getElementHitThreshold(element),
this.scene.getNonDeletedElementsMap(),
),
}) })
) { ) {
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
@ -7505,7 +7436,10 @@ class App extends React.Component<AppProps, AppState> {
} }
// How many pixels off the shape boundary we still consider a hit // How many pixels off the shape boundary we still consider a hit
const threshold = this.getElementHitThreshold(); const threshold = Math.max(
DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value,
1,
);
const [x1, y1, x2, y2] = getCommonBounds(selectedElements); const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
return ( return (
point.x > x1 - threshold && point.x > x1 - threshold &&
@ -9768,14 +9702,13 @@ class App extends React.Component<AppProps, AppState> {
((hitElement && ((hitElement &&
hitElementBoundingBoxOnly( hitElementBoundingBoxOnly(
{ {
x: pointerDownState.origin.x, point: pointFrom(
y: pointerDownState.origin.y, pointerDownState.origin.x,
element: hitElement, pointerDownState.origin.y,
shape: getElementShape(
hitElement,
this.scene.getNonDeletedElementsMap(),
), ),
threshold: this.getElementHitThreshold(), element: hitElement,
elementsMap,
threshold: this.getElementHitThreshold(hitElement),
frameNameBound: isFrameLikeElement(hitElement) frameNameBound: isFrameLikeElement(hitElement)
? this.frameNameBoundsCache.get(hitElement) ? this.frameNameBoundsCache.get(hitElement)
: null, : null,
@ -10882,6 +10815,7 @@ class App extends React.Component<AppProps, AppState> {
croppingElement, croppingElement,
cropElement( cropElement(
croppingElement, croppingElement,
this.scene.getNonDeletedElementsMap(),
transformHandleType, transformHandleType,
image.naturalWidth, image.naturalWidth,
image.naturalHeight, image.naturalHeight,

View File

@ -133,7 +133,6 @@ describe("binding with linear elements", () => {
const inputX = UI.queryStatsProperty("X")?.querySelector( const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull(); expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("204")); UI.updateInput(inputX, String("204"));
@ -657,6 +656,7 @@ describe("stats for multiple elements", () => {
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });

View File

@ -463,7 +463,7 @@ const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value; const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box // hitbox to prevent hiding when hovered in element bounding box
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) { if (hitElementBoundingBox(pointFrom(sceneX, sceneY), element, elementsMap)) {
return false; return false;
} }
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);

View File

@ -92,7 +92,7 @@ export const isPointHittingLink = (
if ( if (
!isMobile && !isMobile &&
appState.viewModeEnabled && appState.viewModeEnabled &&
hitElementBoundingBox(x, y, element, elementsMap) hitElementBoundingBox(pointFrom(x, y), element, elementsMap)
) { ) {
return true; return true;
} }

View File

@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startBinding": { "startBinding": {
"elementId": "diamond-1", "elementId": "diamond-1",
"focus": 0, "focus": 0,
"gap": 4.545343408287929, "gap": 4.535423522449215,
}, },
"strokeColor": "#e67700", "strokeColor": "#e67700",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"focus": 0, "focus": 0,
"gap": 14, "gap": 16,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -1540,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endBinding": { "endBinding": {
"elementId": "B", "elementId": "B",
"focus": 0, "focus": 0,
"gap": 14, "gap": 32,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,

View File

@ -781,7 +781,7 @@ describe("Test Transform", () => {
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1", elementId: "rect-1",
focus: -0, focus: -0,
gap: 14, gap: 25,
}); });
expect(rect.boundElements).toStrictEqual([ expect(rect.boundElements).toStrictEqual([
{ {

View File

@ -1,25 +1,19 @@
import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { getElementLineSegments } from "@excalidraw/element";
import { import {
lineSegment, computeBoundTextPosition,
lineSegmentIntersectionPoints, getBoundTextElement,
pointFrom, intersectElementWithLineSegment,
} from "@excalidraw/math"; isPointInElement,
} from "@excalidraw/element";
import { lineSegment, pointFrom } from "@excalidraw/math";
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element";
import { getElementShape } from "@excalidraw/element";
import { shouldTestInside } from "@excalidraw/element"; import { shouldTestInside } from "@excalidraw/element";
import { isPointInShape } from "@excalidraw/utils/collision";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
import { getBoundTextElementId } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element";
import type { GeometricShape } from "@excalidraw/utils/shape"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type {
ElementsSegmentsMap,
GlobalPoint,
LineSegment,
} from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { AnimatedTrail } from "../animated-trail"; import { AnimatedTrail } from "../animated-trail";
@ -28,15 +22,9 @@ import type { AnimationFrameHandler } from "../animation-frame-handler";
import type App from "../components/App"; import type App from "../components/App";
// just enough to form a segment; this is sufficient for eraser
const POINTS_ON_TRAIL = 2;
export class EraserTrail extends AnimatedTrail { export class EraserTrail extends AnimatedTrail {
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set(); private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set(); private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
private segmentsCache: Map<string, LineSegment<GlobalPoint>[]> = new Map();
private geometricShapesCache: Map<string, GeometricShape<GlobalPoint>> =
new Map();
constructor(animationFrameHandler: AnimationFrameHandler, app: App) { constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, { super(animationFrameHandler, app, {
@ -79,14 +67,21 @@ export class EraserTrail extends AnimatedTrail {
} }
private updateElementsToBeErased(restoreToErase?: boolean) { private updateElementsToBeErased(restoreToErase?: boolean) {
let eraserPath: GlobalPoint[] = const eraserPath: GlobalPoint[] =
super super
.getCurrentTrail() .getCurrentTrail()
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || []; ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || [];
if (eraserPath.length < 2) {
return [];
}
// for efficiency and avoid unnecessary calculations, // for efficiency and avoid unnecessary calculations,
// take only POINTS_ON_TRAIL points to form some number of segments // take only POINTS_ON_TRAIL points to form some number of segments
eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL); const pathSegment = lineSegment<GlobalPoint>(
eraserPath[eraserPath.length - 1],
eraserPath[eraserPath.length - 2],
);
const candidateElements = this.app.visibleElements.filter( const candidateElements = this.app.visibleElements.filter(
(el) => !el.locked, (el) => !el.locked,
@ -94,28 +89,13 @@ export class EraserTrail extends AnimatedTrail {
const candidateElementsMap = arrayToMap(candidateElements); const candidateElementsMap = arrayToMap(candidateElements);
const pathSegments = eraserPath.reduce((acc, point, index) => {
if (index === 0) {
return acc;
}
acc.push(lineSegment(eraserPath[index - 1], point));
return acc;
}, [] as LineSegment<GlobalPoint>[]);
if (pathSegments.length === 0) {
return [];
}
for (const element of candidateElements) { for (const element of candidateElements) {
// restore only if already added to the to-be-erased set // restore only if already added to the to-be-erased set
if (restoreToErase && this.elementsToErase.has(element.id)) { if (restoreToErase && this.elementsToErase.has(element.id)) {
const intersects = eraserTest( const intersects = eraserTest(
pathSegments, pathSegment,
element, element,
this.segmentsCache,
this.geometricShapesCache,
candidateElementsMap, candidateElementsMap,
this.app,
); );
if (intersects) { if (intersects) {
@ -148,12 +128,9 @@ export class EraserTrail extends AnimatedTrail {
} }
} else if (!restoreToErase && !this.elementsToErase.has(element.id)) { } else if (!restoreToErase && !this.elementsToErase.has(element.id)) {
const intersects = eraserTest( const intersects = eraserTest(
pathSegments, pathSegment,
element, element,
this.segmentsCache,
this.geometricShapesCache,
candidateElementsMap, candidateElementsMap,
this.app,
); );
if (intersects) { if (intersects) {
@ -196,45 +173,37 @@ export class EraserTrail extends AnimatedTrail {
super.clearTrails(); super.clearTrails();
this.elementsToErase.clear(); this.elementsToErase.clear();
this.groupsToErase.clear(); this.groupsToErase.clear();
this.segmentsCache.clear();
} }
} }
const eraserTest = ( const eraserTest = (
pathSegments: LineSegment<GlobalPoint>[], pathSegment: LineSegment<GlobalPoint>,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap,
shapesCache: Map<string, GeometricShape<GlobalPoint>>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
app: App,
): boolean => { ): boolean => {
let shape = shapesCache.get(element.id); const lastPoint = pathSegment[1];
if (
if (!shape) { shouldTestInside(element) &&
shape = getElementShape<GlobalPoint>(element, elementsMap); isPointInElement(lastPoint, element, elementsMap)
shapesCache.set(element.id, shape); ) {
}
const lastPoint = pathSegments[pathSegments.length - 1][1];
if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) {
return true; return true;
} }
let elementSegments = elementsSegments.get(element.id); const boundTextElement = getBoundTextElement(element, elementsMap);
if (!elementSegments) { return (
elementSegments = getElementLineSegments(element, elementsMap); intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true)
elementsSegments.set(element.id, elementSegments); .length > 0 ||
} (!!boundTextElement &&
intersectElementWithLineSegment(
return pathSegments.some((pathSegment) => {
elementSegments?.some( ...boundTextElement,
(elementSegment) => ...computeBoundTextPosition(element, boundTextElement, elementsMap),
lineSegmentIntersectionPoints( },
pathSegment, elementsMap,
elementSegment, pathSegment,
app.getElementHitThreshold(), 0,
) !== null, true,
), ).length > 0)
); );
}; };

View File

@ -199,6 +199,7 @@ export class LassoTrail extends AnimatedTrail {
const { selectedElementIds } = getLassoSelectedElementIds({ const { selectedElementIds } = getLassoSelectedElementIds({
lassoPath, lassoPath,
elements: this.app.visibleElements, elements: this.app.visibleElements,
elementsMap: this.app.scene.getNonDeletedElementsMap(),
elementsSegments: this.elementsSegments, elementsSegments: this.elementsSegments,
intersectedElements: this.intersectedElements, intersectedElements: this.intersectedElements,
enclosedElements: this.enclosedElements, enclosedElements: this.enclosedElements,

View File

@ -3,20 +3,25 @@ import { simplify } from "points-on-curve";
import { import {
polygonFromPoints, polygonFromPoints,
lineSegment, lineSegment,
lineSegmentIntersectionPoints,
polygonIncludesPointNonZero, polygonIncludesPointNonZero,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { import {
ElementsSegmentsMap, type Bounds,
GlobalPoint, computeBoundTextPosition,
LineSegment, doBoundsIntersect,
} from "@excalidraw/math/types"; getBoundTextElement,
import type { ExcalidrawElement } from "@excalidraw/element/types"; getElementBounds,
intersectElementWithLineSegment,
} from "@excalidraw/element";
import type { ElementsSegmentsMap, GlobalPoint } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
export const getLassoSelectedElementIds = (input: { export const getLassoSelectedElementIds = (input: {
lassoPath: GlobalPoint[]; lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
elementsSegments: ElementsSegmentsMap; elementsSegments: ElementsSegmentsMap;
intersectedElements: Set<ExcalidrawElement["id"]>; intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>; enclosedElements: Set<ExcalidrawElement["id"]>;
@ -27,6 +32,7 @@ export const getLassoSelectedElementIds = (input: {
const { const {
lassoPath, lassoPath,
elements, elements,
elementsMap,
elementsSegments, elementsSegments,
intersectedElements, intersectedElements,
enclosedElements, enclosedElements,
@ -40,8 +46,26 @@ export const getLassoSelectedElementIds = (input: {
const unlockedElements = elements.filter((el) => !el.locked); const unlockedElements = elements.filter((el) => !el.locked);
// as the path might not enclose a shape anymore, clear before checking // as the path might not enclose a shape anymore, clear before checking
enclosedElements.clear(); enclosedElements.clear();
intersectedElements.clear();
const lassoBounds = lassoPath.reduce(
(acc, item) => {
return [
Math.min(acc[0], item[0]),
Math.min(acc[1], item[1]),
Math.max(acc[2], item[0]),
Math.max(acc[3], item[1]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
) as Bounds;
for (const element of unlockedElements) { for (const element of unlockedElements) {
// First check if the lasso segment intersects the element's axis-aligned
// bounding box as it is much faster than checking intersection against
// the element's shape
const elementBounds = getElementBounds(element, elementsMap);
if ( if (
doBoundsIntersect(lassoBounds, elementBounds) &&
!intersectedElements.has(element.id) && !intersectedElements.has(element.id) &&
!enclosedElements.has(element.id) !enclosedElements.has(element.id)
) { ) {
@ -49,7 +73,7 @@ export const getLassoSelectedElementIds = (input: {
if (enclosed) { if (enclosed) {
enclosedElements.add(element.id); enclosedElements.add(element.id);
} else { } else {
const intersects = intersectionTest(path, element, elementsSegments); const intersects = intersectionTest(path, element, elementsMap);
if (intersects) { if (intersects) {
intersectedElements.add(element.id); intersectedElements.add(element.id);
} }
@ -85,26 +109,34 @@ const enclosureTest = (
const intersectionTest = ( const intersectionTest = (
lassoPath: GlobalPoint[], lassoPath: GlobalPoint[],
element: ExcalidrawElement, element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap, elementsMap: ElementsMap,
): boolean => { ): boolean => {
const elementSegments = elementsSegments.get(element.id); const lassoSegments = lassoPath
if (!elementSegments) { .slice(1)
return false; .map((point: GlobalPoint, index) => lineSegment(lassoPath[index], point))
} .concat([lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])]);
const lassoSegments = lassoPath.reduce((acc, point, index) => { const boundTextElement = getBoundTextElement(element, elementsMap);
if (index === 0) {
return acc;
}
acc.push(lineSegment(lassoPath[index - 1], point));
return acc;
}, [] as LineSegment<GlobalPoint>[]);
return lassoSegments.some((lassoSegment) => return lassoSegments.some(
elementSegments.some( (lassoSegment) =>
(elementSegment) => intersectElementWithLineSegment(
// introduce a bit of tolerance to account for roughness and simplification of paths element,
lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null, elementsMap,
), lassoSegment,
0,
true,
).length > 0 ||
(!!boundTextElement &&
intersectElementWithLineSegment(
{
...boundTextElement,
...computeBoundTextPosition(element, boundTextElement, elementsMap),
},
elementsMap,
lassoSegment,
0,
true,
).length > 0),
); );
}; };

View File

@ -5,17 +5,14 @@ import { getDiamondPoints } from "@excalidraw/element";
import { getCornerRadius } from "@excalidraw/element"; import { getCornerRadius } from "@excalidraw/element";
import { import {
bezierEquation,
curve, curve,
curveTangent, curveCatmullRomCubicApproxPoints,
curveCatmullRomQuadraticApproxPoints,
curveOffsetPoints,
type GlobalPoint, type GlobalPoint,
offsetPointsForQuadraticBezier,
pointFrom, pointFrom,
pointFromVector,
pointRotateRads, pointRotateRads,
vector,
vectorNormal,
vectorNormalize,
vectorScale,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { import type {
@ -102,25 +99,14 @@ export const bootstrapCanvas = ({
function drawCatmullRomQuadraticApprox( function drawCatmullRomQuadraticApprox(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
points: GlobalPoint[], points: GlobalPoint[],
segments = 20, tension = 0.5,
) { ) {
ctx.lineTo(points[0][0], points[0][1]); const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < pointSets.length - 1; i++) {
const [[cpX, cpY], [p2X, p2Y]] = pointSets[i];
for (let i = 0; i < points.length - 1; i++) { ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y);
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const x =
(1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0];
const y =
(1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1];
ctx.lineTo(x, y);
} }
} }
} }
@ -128,35 +114,13 @@ function drawCatmullRomQuadraticApprox(
function drawCatmullRomCubicApprox( function drawCatmullRomCubicApprox(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
points: GlobalPoint[], points: GlobalPoint[],
segments = 20, tension = 0.5,
) { ) {
ctx.lineTo(points[0][0], points[0][1]); const pointSets = curveCatmullRomCubicApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < pointSets.length; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1]; const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i];
const p1 = points[i]; ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1[0] +
(-p0[0] + p2[0]) * t +
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
const y =
0.5 *
(2 * p1[1] +
(-p0[1] + p2[1]) * t +
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
ctx.lineTo(x, y);
} }
} }
} }
@ -168,7 +132,10 @@ export const drawHighlightForRectWithRotation = (
) => { ) => {
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element), elementCenterPoint(
element,
window.h.app.scene.getElementsMapIncludingDeleted(),
),
element.angle, element.angle,
); );
@ -187,25 +154,25 @@ export const drawHighlightForRectWithRotation = (
context.beginPath(); context.beginPath();
{ {
const topLeftApprox = offsetQuadraticBezier( const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, 0 + radius), pointFrom(0, 0 + radius),
pointFrom(0, 0), pointFrom(0, 0),
pointFrom(0 + radius, 0), pointFrom(0 + radius, 0),
padding, padding,
); );
const topRightApprox = offsetQuadraticBezier( const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, 0), pointFrom(element.width - radius, 0),
pointFrom(element.width, 0), pointFrom(element.width, 0),
pointFrom(element.width, radius), pointFrom(element.width, radius),
padding, padding,
); );
const bottomRightApprox = offsetQuadraticBezier( const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, element.height - radius), pointFrom(element.width, element.height - radius),
pointFrom(element.width, element.height), pointFrom(element.width, element.height),
pointFrom(element.width - radius, element.height), pointFrom(element.width - radius, element.height),
padding, padding,
); );
const bottomLeftApprox = offsetQuadraticBezier( const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(radius, element.height), pointFrom(radius, element.height),
pointFrom(0, element.height), pointFrom(0, element.height),
pointFrom(0, element.height - radius), pointFrom(0, element.height - radius),
@ -230,25 +197,25 @@ export const drawHighlightForRectWithRotation = (
// mask" on a filled shape for the diamond highlight, because stroking creates // mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees. // sharp inset edges on line joins < 90 degrees.
{ {
const topLeftApprox = offsetQuadraticBezier( const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0 + radius, 0), pointFrom(0 + radius, 0),
pointFrom(0, 0), pointFrom(0, 0),
pointFrom(0, 0 + radius), pointFrom(0, 0 + radius),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const topRightApprox = offsetQuadraticBezier( const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, radius), pointFrom(element.width, radius),
pointFrom(element.width, 0), pointFrom(element.width, 0),
pointFrom(element.width - radius, 0), pointFrom(element.width - radius, 0),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomRightApprox = offsetQuadraticBezier( const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, element.height), pointFrom(element.width - radius, element.height),
pointFrom(element.width, element.height), pointFrom(element.width, element.height),
pointFrom(element.width, element.height - radius), pointFrom(element.width, element.height - radius),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomLeftApprox = offsetQuadraticBezier( const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, element.height - radius), pointFrom(0, element.height - radius),
pointFrom(0, element.height), pointFrom(0, element.height),
pointFrom(radius, element.height), pointFrom(radius, element.height),
@ -325,7 +292,10 @@ export const drawHighlightForDiamondWithRotation = (
) => { ) => {
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element), elementCenterPoint(
element,
window.h.app.scene.getElementsMapIncludingDeleted(),
),
element.angle, element.angle,
); );
context.save(); context.save();
@ -343,32 +313,40 @@ export const drawHighlightForDiamondWithRotation = (
const horizontalRadius = element.roundness const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element) ? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01; : (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier( const topApprox = curveOffsetPoints(
pointFrom(topX - verticalRadius, topY + horizontalRadius), curve(
pointFrom(topX, topY), pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY), pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius), pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
),
padding, padding,
); );
const rightApprox = offsetCubicBezier( const rightApprox = curveOffsetPoints(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius), curve(
pointFrom(rightX, rightY), pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
),
padding, padding,
); );
const bottomApprox = offsetCubicBezier( const bottomApprox = curveOffsetPoints(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), curve(
pointFrom(bottomX, bottomY), pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY), pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
),
padding, padding,
); );
const leftApprox = offsetCubicBezier( const leftApprox = curveOffsetPoints(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius), curve(
pointFrom(leftX, leftY), pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
),
padding, padding,
); );
@ -376,13 +354,13 @@ export const drawHighlightForDiamondWithRotation = (
topApprox[topApprox.length - 1][0], topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1], topApprox[topApprox.length - 1][1],
); );
context.lineTo(rightApprox[0][0], rightApprox[0][1]); context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox); drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]); context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox); drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(leftApprox[0][0], leftApprox[0][1]); context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox); drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]); context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox); drawCatmullRomCubicApprox(context, topApprox);
} }
@ -398,32 +376,40 @@ export const drawHighlightForDiamondWithRotation = (
const horizontalRadius = element.roundness const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element) ? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01; : (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier( const topApprox = curveOffsetPoints(
pointFrom(topX + verticalRadius, topY + horizontalRadius), curve(
pointFrom(topX, topY), pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY), pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius), pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const rightApprox = offsetCubicBezier( const rightApprox = curveOffsetPoints(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius), curve(
pointFrom(rightX, rightY), pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomApprox = offsetCubicBezier( const bottomApprox = curveOffsetPoints(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), curve(
pointFrom(bottomX, bottomY), pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY), pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const leftApprox = offsetCubicBezier( const leftApprox = curveOffsetPoints(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius), curve(
pointFrom(leftX, leftY), pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
@ -431,66 +417,16 @@ export const drawHighlightForDiamondWithRotation = (
topApprox[topApprox.length - 1][0], topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1], topApprox[topApprox.length - 1][1],
); );
context.lineTo(leftApprox[0][0], leftApprox[0][1]); context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox); drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]); context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox); drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(rightApprox[0][0], rightApprox[0][1]); context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox); drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]); context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox); drawCatmullRomCubicApprox(context, topApprox);
} }
context.closePath(); context.closePath();
context.fill(); context.fill();
context.restore(); context.restore();
}; };
function offsetCubicBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
p3: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const c = curve(p0, p1, p2, p3);
const point = bezierEquation(c, t);
const tangent = vectorNormalize(curveTangent(c, t));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}
function offsetQuadraticBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const t1 = 1 - t;
const point = pointFrom<GlobalPoint>(
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
);
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
const tangent = vectorNormalize(vector(tangentX, tangentY));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}

View File

@ -193,16 +193,10 @@ const renderBindingHighlightForBindableElement = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"], zoom: InteractiveCanvasAppState["zoom"],
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.fillStyle = "rgba(0,0,0,.05)";
// To ensure the binding highlight doesn't overlap the element itself
const padding = maxBindingGap(element, element.width, element.height, zoom); const padding = maxBindingGap(element, element.width, element.height, zoom);
context.fillStyle = "rgba(0,0,0,.05)";
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "text": case "text":
@ -216,10 +210,13 @@ const renderBindingHighlightForBindableElement = (
case "diamond": case "diamond":
drawHighlightForDiamondWithRotation(context, padding, element); drawHighlightForDiamondWithRotation(context, padding, element);
break; break;
case "ellipse": case "ellipse": {
context.lineWidth = const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
maxBindingGap(element, element.width, element.height, zoom) - const width = x2 - x1;
FIXED_BINDING_DISTANCE; const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = padding - FIXED_BINDING_DISTANCE;
strokeEllipseWithRotation( strokeEllipseWithRotation(
context, context,
@ -230,6 +227,7 @@ const renderBindingHighlightForBindableElement = (
element.angle, element.angle,
); );
break; break;
}
} }
}; };

View File

@ -898,7 +898,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -1016,9 +1016,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1, "seed": 1,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1052,9 +1050,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1, "seed": 1,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1101,7 +1097,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -1162,7 +1158,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": true,
"searchMatches": null, "searchMatches": null,
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
@ -1205,7 +1201,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
@ -1213,20 +1209,18 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1278240551,
},
"seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1150084233, "versionNonce": 2019559783,
"width": 20, "width": 10,
"x": -10, "x": -20,
"y": 0, "y": -10,
} }
`; `;
@ -1263,23 +1257,21 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"width": 20, "width": 10,
"x": -10, "x": -20,
"y": 0, "y": -10,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@ -1317,7 +1309,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -1427,9 +1419,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1014066025, "seed": 1014066025,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1461,9 +1451,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 449462985, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1518,9 +1506,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -1572,9 +1558,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -1650,7 +1634,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -1760,9 +1744,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1014066025, "seed": 1014066025,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1794,9 +1776,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 449462985, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1851,9 +1831,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -1905,9 +1883,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -1983,7 +1959,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -2044,7 +2020,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": true,
"searchMatches": null, "searchMatches": null,
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
@ -2087,7 +2063,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
@ -2095,20 +2071,18 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1278240551,
},
"seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1150084233, "versionNonce": 2019559783,
"width": 20, "width": 10,
"x": -10, "x": -20,
"y": 0, "y": -10,
} }
`; `;
@ -2145,23 +2119,21 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"width": 20, "width": 10,
"x": -10, "x": -20,
"y": 0, "y": -10,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@ -2199,7 +2171,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -2299,7 +2271,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": true, "isDeleted": true,
@ -2307,20 +2279,18 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1278240551,
},
"seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1014066025, "versionNonce": 1116226695,
"width": 20, "width": 10,
"x": -10, "x": -20,
"y": 0, "y": -10,
} }
`; `;
@ -2357,23 +2327,21 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"width": 20, "width": 10,
"x": -10, "x": -20,
"y": 0, "y": -10,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@ -2440,7 +2408,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -2542,7 +2510,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
@ -2550,20 +2518,18 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1278240551,
},
"seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1150084233, "versionNonce": 2019559783,
"width": 20, "width": 10,
"x": -10, "x": -20,
"y": 0, "y": -10,
} }
`; `;
@ -2576,7 +2542,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"id": "id3", "id": "id3",
"index": "a1", "index": "a1",
"isDeleted": false, "isDeleted": false,
@ -2584,9 +2550,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1014066025, "seed": 1014066025,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -2595,9 +2559,9 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 400692809, "versionNonce": 400692809,
"width": 20, "width": 10,
"x": 0, "x": -10,
"y": 10, "y": 0,
} }
`; `;
@ -2634,23 +2598,21 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"width": 20, "width": 10,
"x": -10, "x": -20,
"y": 0, "y": -10,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@ -2688,23 +2650,21 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"index": "a1", "index": "a1",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"width": 20, "width": 10,
"x": 0, "x": -10,
"y": 10, "y": 0,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@ -2742,7 +2702,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -2859,9 +2819,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 449462985, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -2895,9 +2853,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1014066025, "seed": 1014066025,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -2952,9 +2908,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -3006,9 +2960,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -3114,7 +3066,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 60, "currentItemOpacity": 60,
"currentItemRoughness": 2, "currentItemRoughness": 2,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#e03131", "currentItemStrokeColor": "#e03131",
"currentItemStrokeStyle": "dotted", "currentItemStrokeStyle": "dotted",
@ -3226,9 +3178,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"locked": false, "locked": false,
"opacity": 60, "opacity": 60,
"roughness": 2, "roughness": 2,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 449462985, "seed": 449462985,
"strokeColor": "#e03131", "strokeColor": "#e03131",
"strokeStyle": "dotted", "strokeStyle": "dotted",
@ -3236,7 +3186,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 941653321, "versionNonce": 908564423,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@ -3260,17 +3210,15 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"locked": false, "locked": false,
"opacity": 60, "opacity": 60,
"roughness": 2, "roughness": 2,
"roundness": { "roundness": null,
"type": 3, "seed": 1315507081,
},
"seed": 289600103,
"strokeColor": "#e03131", "strokeColor": "#e03131",
"strokeStyle": "dotted", "strokeStyle": "dotted",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 9, "version": 9,
"versionNonce": 640725609, "versionNonce": 406373543,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@ -3317,9 +3265,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -3371,9 +3317,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -3597,7 +3541,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -3707,17 +3651,15 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1150084233,
},
"seed": 1014066025,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 23633383, "versionNonce": 1604849351,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@ -3741,17 +3683,15 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1278240551,
},
"seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1150084233, "versionNonce": 401146281,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@ -3798,9 +3738,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -3852,9 +3790,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -3922,7 +3858,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -4032,9 +3968,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1014066025, "seed": 1014066025,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -4066,9 +4000,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 449462985, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -4123,9 +4055,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -4177,9 +4107,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -4247,7 +4175,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -4360,9 +4288,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 449462985, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -4394,9 +4320,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 238820263, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -4451,9 +4375,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -4505,9 +4427,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -5528,7 +5448,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -5641,9 +5561,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 453191, "seed": 453191,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -5675,17 +5593,15 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1604849351,
},
"seed": 400692809,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 23633383, "versionNonce": 493213705,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": 0,
@ -5732,9 +5648,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -5786,9 +5700,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -6749,7 +6661,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -6866,9 +6778,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 449462985, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -6902,9 +6812,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 238820263, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -6959,9 +6867,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -7013,9 +6919,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@ -7684,7 +7588,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -8684,7 +8588,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -8745,7 +8649,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": true,
"searchMatches": null, "searchMatches": null,
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
@ -9675,7 +9579,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",
@ -9780,7 +9684,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
@ -9788,20 +9692,18 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1278240551,
},
"seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1150084233, "versionNonce": 2019559783,
"width": 20, "width": 10,
"x": -10, "x": -20,
"y": 0, "y": -10,
} }
`; `;
@ -9822,9 +9724,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1, "seed": 1,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -9856,9 +9756,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1, "seed": 1,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -9912,23 +9810,21 @@ exports[`contextMenu element > shows context menu for element > [end of test] un
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 10,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"width": 20, "width": 10,
"x": -10, "x": -20,
"y": 0, "y": -10,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,

View File

@ -71,9 +71,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -107,9 +105,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -155,9 +151,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
], ],
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -193,9 +187,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -17,9 +17,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -51,9 +49,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1604849351, "seed": 1604849351,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -85,9 +81,7 @@ exports[`move element > rectangle 5`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -124,9 +118,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -163,9 +155,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1150084233, "seed": 1150084233,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -196,7 +186,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "87.29887", "height": "81.40630",
"id": "id6", "id": "id6",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@ -210,8 +200,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
0, 0,
], ],
[ [
"86.85786", "81.00000",
"87.29887", "81.40630",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -232,8 +222,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"versionNonce": 1996028265, "versionNonce": 1996028265,
"width": "86.85786", "width": "81.00000",
"x": "107.07107", "x": "110.00000",
"y": "47.07107", "y": 50,
} }
`; `;

View File

@ -95,9 +95,7 @@ exports[`multi point mode in linear elements > line 3`] = `
], ],
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,

View File

@ -81,9 +81,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
], ],
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -117,9 +115,7 @@ exports[`select single element on the scene > diamond 1`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -151,9 +147,7 @@ exports[`select single element on the scene > ellipse 1`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -185,9 +179,7 @@ exports[`select single element on the scene > rectangle 1`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",

View File

@ -110,8 +110,8 @@ describe("contextMenu element", () => {
it("shows context menu for element", () => { it("shows context menu for element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@ -304,8 +304,8 @@ describe("contextMenu element", () => {
it("selecting 'Copy styles' in context menu copies styles", () => { it("selecting 'Copy styles' in context menu copies styles", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@ -389,8 +389,8 @@ describe("contextMenu element", () => {
it("selecting 'Delete' in context menu deletes element", () => { it("selecting 'Delete' in context menu deletes element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@ -405,8 +405,8 @@ describe("contextMenu element", () => {
it("selecting 'Add to library' in context menu adds element to library", async () => { it("selecting 'Add to library' in context menu adds element to library", async () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@ -424,8 +424,8 @@ describe("contextMenu element", () => {
it("selecting 'Duplicate' in context menu duplicates element", () => { it("selecting 'Duplicate' in context menu duplicates element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,

View File

@ -31,9 +31,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
], ],
], ],
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -193,9 +191,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
], ],
"pressures": [], "pressures": [],
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": Any<Number>, "seed": Any<Number>,
"simulatePressure": true, "simulatePressure": true,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@ -242,9 +238,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
], ],
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -292,9 +286,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
], ],
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@ -334,9 +326,7 @@ exports[`restoreElements > should restore text element correctly passing value f
"opacity": 100, "opacity": 100,
"originalText": "text", "originalText": "text",
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -378,9 +368,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"opacity": 100, "opacity": 100,
"originalText": "", "originalText": "",
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",

View File

@ -32,6 +32,7 @@ import type {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { createTestHook } from "../../components/App"; import { createTestHook } from "../../components/App";
@ -146,6 +147,7 @@ export class Keyboard {
const getElementPointForSelection = ( const getElementPointForSelection = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, angle } = element; const { x, y, width, angle } = element;
const target = pointFrom<GlobalPoint>( const target = pointFrom<GlobalPoint>(
@ -162,7 +164,7 @@ const getElementPointForSelection = (
(bounds[1] + bounds[3]) / 2, (bounds[1] + bounds[3]) / 2,
); );
} else { } else {
center = elementCenterPoint(element); center = elementCenterPoint(element, elementsMap);
} }
if (isTextElement(element)) { if (isTextElement(element)) {
@ -299,7 +301,12 @@ export class Pointer {
elements = Array.isArray(elements) ? elements : [elements]; elements = Array.isArray(elements) ? elements : [elements];
elements.forEach((element) => { elements.forEach((element) => {
this.reset(); this.reset();
this.click(...getElementPointForSelection(element)); this.click(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
}); });
}); });
@ -308,13 +315,23 @@ export class Pointer {
clickOn(element: ExcalidrawElement) { clickOn(element: ExcalidrawElement) {
this.reset(); this.reset();
this.click(...getElementPointForSelection(element)); this.click(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
this.reset(); this.reset();
} }
doubleClickOn(element: ExcalidrawElement) { doubleClickOn(element: ExcalidrawElement) {
this.reset(); this.reset();
this.doubleClick(...getElementPointForSelection(element)); this.doubleClick(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
this.reset(); this.reset();
} }
} }
@ -598,6 +615,7 @@ export class UI {
const mutations = cropElement( const mutations = cropElement(
element, element,
h.scene.getNonDeletedElementsMap(),
handle, handle,
naturalWidth, naturalWidth,
naturalHeight, naturalHeight,

View File

@ -70,6 +70,7 @@ const updatePath = (startPoint: GlobalPoint, points: LocalPoint[]) => {
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) ?? ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) ??
[], [],
elements: h.elements, elements: h.elements,
elementsMap: h.scene.getNonDeletedElementsMap(),
elementsSegments, elementsSegments,
intersectedElements: new Set(), intersectedElements: new Set(),
enclosedElements: new Set(), enclosedElements: new Set(),

View File

@ -124,8 +124,8 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]); expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[107.07, 47.07]]); expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[86.86, 87.3]]); expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]);
h.elements.forEach((element) => expect(element).toMatchSnapshot()); h.elements.forEach((element) => expect(element).toMatchSnapshot());
}); });

View File

@ -35,7 +35,7 @@ test("unselected bound arrow updates when rotating its target element", async ()
expect(arrow.endBinding?.elementId).toEqual(rectangle.id); expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.x).toBeCloseTo(-80); expect(arrow.x).toBeCloseTo(-80);
expect(arrow.y).toBeCloseTo(50); expect(arrow.y).toBeCloseTo(50);
expect(arrow.width).toBeCloseTo(116.7, 1); expect(arrow.width).toBeCloseTo(110.7, 1);
expect(arrow.height).toBeCloseTo(0); expect(arrow.height).toBeCloseTo(0);
}); });

View File

@ -682,7 +682,7 @@ describe("textWysiwyg", () => {
expect(diamond.height).toBe(70); expect(diamond.height).toBe(70);
}); });
it("should bind text to container when double clicked on center of transparent container", async () => { it("should bind text to container when double clicked inside of the transparent container", async () => {
const rectangle = API.createElement({ const rectangle = API.createElement({
type: "rectangle", type: "rectangle",
x: 10, x: 10,
@ -1500,9 +1500,7 @@ describe("textWysiwyg", () => {
locked: false, locked: false,
opacity: 100, opacity: 100,
roughness: 1, roughness: 1,
roundness: { roundness: null,
type: 3,
},
strokeColor: "#1e1e1e", strokeColor: "#1e1e1e",
strokeStyle: "solid", strokeStyle: "solid",
strokeWidth: 2, strokeWidth: 2,

View File

@ -1,8 +1,7 @@
import type { Bounds } from "@excalidraw/element"; import { doBoundsIntersect, type Bounds } from "@excalidraw/element";
import { isPoint, pointDistance, pointFrom } from "./point"; import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point";
import { rectangle, rectangleIntersectLineSegment } from "./rectangle"; import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
import { vector } from "./vector";
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types"; import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
@ -105,16 +104,15 @@ export function curveIntersectLineSegment<
Point extends GlobalPoint | LocalPoint, Point extends GlobalPoint | LocalPoint,
>(c: Curve<Point>, l: LineSegment<Point>): Point[] { >(c: Curve<Point>, l: LineSegment<Point>): Point[] {
// Optimize by doing a cheap bounding box check first // Optimize by doing a cheap bounding box check first
const bounds = curveBounds(c); const b1 = curveBounds(c);
if ( const b2 = [
rectangleIntersectLineSegment( Math.min(l[0][0], l[1][0]),
rectangle( Math.min(l[0][1], l[1][1]),
pointFrom(bounds[0], bounds[1]), Math.max(l[0][0], l[1][0]),
pointFrom(bounds[2], bounds[3]), Math.max(l[0][1], l[1][1]),
), ] as Bounds;
l,
).length === 0 if (!doBoundsIntersect(b1, b2)) {
) {
return []; return [];
} }
@ -303,3 +301,108 @@ function curveBounds<Point extends GlobalPoint | LocalPoint>(
const y = [P0[1], P1[1], P2[1], P3[1]]; const y = [P0[1], P1[1], P2[1], P3[1]];
return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)]; return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];
} }
export function curveCatmullRomQuadraticApproxPoints(
points: GlobalPoint[],
tension = 0.5,
) {
if (points.length < 2) {
return;
}
const pointSets: [GlobalPoint, GlobalPoint][] = [];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const cpX = p1[0] + ((p2[0] - p0[0]) * tension) / 2;
const cpY = p1[1] + ((p2[1] - p0[1]) * tension) / 2;
pointSets.push([
pointFrom<GlobalPoint>(cpX, cpY),
pointFrom<GlobalPoint>(p2[0], p2[1]),
]);
}
return pointSets;
}
export function curveCatmullRomCubicApproxPoints<
Point extends GlobalPoint | LocalPoint,
>(points: Point[], tension = 0.5) {
if (points.length < 2) {
return;
}
const pointSets: Curve<Point>[] = [];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
const tangent1 = [(p2[0] - p0[0]) * tension, (p2[1] - p0[1]) * tension];
const tangent2 = [(p3[0] - p1[0]) * tension, (p3[1] - p1[1]) * tension];
const cp1x = p1[0] + tangent1[0] / 3;
const cp1y = p1[1] + tangent1[1] / 3;
const cp2x = p2[0] - tangent2[0] / 3;
const cp2y = p2[1] - tangent2[1] / 3;
pointSets.push(
curve(
pointFrom(p1[0], p1[1]),
pointFrom(cp1x, cp1y),
pointFrom(cp2x, cp2y),
pointFrom(p2[0], p2[1]),
),
);
}
return pointSets;
}
export function curveOffsetPoints(
[p0, p1, p2, p3]: Curve<GlobalPoint>,
offset: number,
steps = 50,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const c = curve(p0, p1, p2, p3);
const point = bezierEquation(c, t);
const tangent = vectorNormalize(curveTangent(c, t));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offset), point));
}
return offsetPoints;
}
export function offsetPointsForQuadraticBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
offsetDist: number,
steps = 50,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const t1 = 1 - t;
const point = pointFrom<GlobalPoint>(
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
);
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
const tangent = vectorNormalize(vector(tangentX, tangentY));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}

View File

@ -1,5 +1,6 @@
export * from "./angle"; export * from "./angle";
export * from "./curve"; export * from "./curve";
export * from "./ellipse";
export * from "./line"; export * from "./line";
export * from "./point"; export * from "./point";
export * from "./polygon"; export * from "./polygon";

View File

@ -21,13 +21,23 @@ export function vector(
* *
* @param p The point to turn into a vector * @param p The point to turn into a vector
* @param origin The origin point in a given coordiante system * @param origin The origin point in a given coordiante system
* @returns The created vector from the point and the origin * @param threshold The threshold to consider the vector as 'undefined'
* @param defaultValue The default value to return if the vector is 'undefined'
* @returns The created vector from the point and the origin or default
*/ */
export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>( export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
p: Point, p: Point,
origin: Point = [0, 0] as Point, origin: Point = [0, 0] as Point,
threshold?: number,
defaultValue: Vector = [0, 1] as Vector,
): Vector { ): Vector {
return vector(p[0] - origin[0], p[1] - origin[1]); const vec = vector(p[0] - origin[0], p[1] - origin[1]);
if (threshold && vectorMagnitudeSq(vec) < threshold * threshold) {
return defaultValue;
}
return vec;
} }
/** /**

View File

@ -1,135 +0,0 @@
import {
lineSegment,
pointFrom,
polygonIncludesPoint,
pointOnLineSegment,
pointOnPolygon,
polygonFromPoints,
type GlobalPoint,
type LocalPoint,
type Polygon,
} from "@excalidraw/math";
import type { Curve } from "@excalidraw/math";
import { pointInEllipse, pointOnEllipse } from "./shape";
import type { Polycurve, Polyline, GeometricShape } from "./shape";
// check if the given point is considered on the given shape's border
export const isPointOnShape = <Point extends GlobalPoint | LocalPoint>(
point: Point,
shape: GeometricShape<Point>,
tolerance = 0,
) => {
// get the distance from the given point to the given element
// check if the distance is within the given epsilon range
switch (shape.type) {
case "polygon":
return pointOnPolygon(point, shape.data, tolerance);
case "ellipse":
return pointOnEllipse(point, shape.data, tolerance);
case "line":
return pointOnLineSegment(point, shape.data, tolerance);
case "polyline":
return pointOnPolyline(point, shape.data, tolerance);
case "curve":
return pointOnCurve(point, shape.data, tolerance);
case "polycurve":
return pointOnPolycurve(point, shape.data, tolerance);
default:
throw Error(`shape ${shape} is not implemented`);
}
};
// check if the given point is considered inside the element's border
export const isPointInShape = <Point extends GlobalPoint | LocalPoint>(
point: Point,
shape: GeometricShape<Point>,
) => {
switch (shape.type) {
case "polygon":
return polygonIncludesPoint(point, shape.data);
case "line":
return false;
case "curve":
return false;
case "ellipse":
return pointInEllipse(point, shape.data);
case "polyline": {
const polygon = polygonFromPoints(shape.data.flat());
return polygonIncludesPoint(point, polygon);
}
case "polycurve": {
return false;
}
default:
throw Error(`shape ${shape} is not implemented`);
}
};
// check if the given element is in the given bounds
export const isPointInBounds = <Point extends GlobalPoint | LocalPoint>(
point: Point,
bounds: Polygon<Point>,
) => {
return polygonIncludesPoint(point, bounds);
};
const pointOnPolycurve = <Point extends LocalPoint | GlobalPoint>(
point: Point,
polycurve: Polycurve<Point>,
tolerance: number,
) => {
return polycurve.some((curve) => pointOnCurve(point, curve, tolerance));
};
const cubicBezierEquation = <Point extends LocalPoint | GlobalPoint>(
curve: Curve<Point>,
) => {
const [p0, p1, p2, p3] = curve;
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
return (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
};
const polyLineFromCurve = <Point extends LocalPoint | GlobalPoint>(
curve: Curve<Point>,
segments = 10,
): Polyline<Point> => {
const equation = cubicBezierEquation(curve);
let startingPoint = [equation(0, 0), equation(0, 1)] as Point;
const lineSegments: Polyline<Point> = [];
let t = 0;
const increment = 1 / segments;
for (let i = 0; i < segments; i++) {
t += increment;
if (t <= 1) {
const nextPoint: Point = pointFrom(equation(t, 0), equation(t, 1));
lineSegments.push(lineSegment(startingPoint, nextPoint));
startingPoint = nextPoint;
}
}
return lineSegments;
};
export const pointOnCurve = <Point extends LocalPoint | GlobalPoint>(
point: Point,
curve: Curve<Point>,
threshold: number,
) => {
return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
};
export const pointOnPolyline = <Point extends LocalPoint | GlobalPoint>(
point: Point,
polyline: Polyline<Point>,
threshold = 10e-5,
) => {
return polyline.some((line) => pointOnLineSegment(point, line, threshold));
};

View File

@ -24,7 +24,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"currentItemFontSize": 20, "currentItemFontSize": 20,
"currentItemOpacity": 100, "currentItemOpacity": 100,
"currentItemRoughness": 1, "currentItemRoughness": 1,
"currentItemRoundness": "round", "currentItemRoundness": "sharp",
"currentItemStartArrowhead": null, "currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e", "currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid", "currentItemStrokeStyle": "solid",

View File

@ -1,90 +0,0 @@
import {
curve,
degreesToRadians,
lineSegment,
lineSegmentRotate,
pointFrom,
pointRotateDegs,
} from "@excalidraw/math";
import type { Curve, Degrees, GlobalPoint } from "@excalidraw/math";
import { pointOnCurve, pointOnPolyline } from "../src/collision";
import type { Polyline } from "../src/shape";
describe("point and curve", () => {
const c: Curve<GlobalPoint> = curve(
pointFrom(1.4, 1.65),
pointFrom(1.9, 7.9),
pointFrom(5.9, 1.65),
pointFrom(6.44, 4.84),
);
it("point on curve", () => {
expect(pointOnCurve(c[0], c, 10e-5)).toBe(true);
expect(pointOnCurve(c[3], c, 10e-5)).toBe(true);
expect(pointOnCurve(pointFrom(2, 4), c, 0.1)).toBe(true);
expect(pointOnCurve(pointFrom(4, 4.4), c, 0.1)).toBe(true);
expect(pointOnCurve(pointFrom(5.6, 3.85), c, 0.1)).toBe(true);
expect(pointOnCurve(pointFrom(5.6, 4), c, 0.1)).toBe(false);
expect(pointOnCurve(c[1], c, 0.1)).toBe(false);
expect(pointOnCurve(c[2], c, 0.1)).toBe(false);
});
});
describe("point and polylines", () => {
const polyline: Polyline<GlobalPoint> = [
lineSegment(pointFrom(1, 0), pointFrom(1, 2)),
lineSegment(pointFrom(1, 2), pointFrom(2, 2)),
lineSegment(pointFrom(2, 2), pointFrom(2, 1)),
lineSegment(pointFrom(2, 1), pointFrom(3, 1)),
];
it("point on the line", () => {
expect(pointOnPolyline(pointFrom(1, 0), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(1, 2), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2, 2), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(3, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(1, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2, 1.5), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2.5, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(0, 1), polyline)).toBe(false);
expect(pointOnPolyline(pointFrom(2.1, 1.5), polyline)).toBe(false);
});
it("point on the line with rotation", () => {
const truePoints = [
pointFrom(1, 0),
pointFrom(1, 2),
pointFrom(2, 2),
pointFrom(2, 1),
pointFrom(3, 1),
];
truePoints.forEach((p) => {
const rotation = (Math.random() * 360) as Degrees;
const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
const rotatedPolyline = polyline.map((line) =>
lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
);
expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true);
});
const falsePoints = [pointFrom(0, 1), pointFrom(2.1, 1.5)];
falsePoints.forEach((p) => {
const rotation = (Math.random() * 360) as Degrees;
const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
const rotatedPolyline = polyline.map((line) =>
lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
);
expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false);
});
});
});