fix(packages/excalidraw): consolidate bounds checks (#11275)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
type Bounds,
|
type Bounds,
|
||||||
@@ -7,7 +6,6 @@ import {
|
|||||||
rescalePoints,
|
rescalePoints,
|
||||||
sizeOf,
|
sizeOf,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
degreesToRadians,
|
degreesToRadians,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
@@ -16,9 +14,7 @@ import {
|
|||||||
pointFromArray,
|
pointFromArray,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||||
|
|
||||||
import { pointsOnBezierCurves } from "points-on-curve";
|
import { pointsOnBezierCurves } from "points-on-curve";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -29,9 +25,7 @@ import type {
|
|||||||
LocalPoint,
|
LocalPoint,
|
||||||
Radians,
|
Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { generateRoughOptions } from "./shape";
|
import { generateRoughOptions } from "./shape";
|
||||||
@@ -41,18 +35,20 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
|||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
|
isFrameLikeElement,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isLineElement,
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
|
isExcalidrawElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { getElementShape } from "./shape";
|
import { getElementShape } from "./shape";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
import { intersectElementWithLineSegment } from "./collision";
|
||||||
|
import { elementOverlapsWithFrame, getContainingFrame } from "./frame";
|
||||||
|
|
||||||
import type { Drawable, Op } from "roughjs/bin/core";
|
import type { Drawable, Op } from "roughjs/bin/core";
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
@@ -67,6 +63,7 @@ import type {
|
|||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export type RectangleBox = {
|
export type RectangleBox = {
|
||||||
@@ -1295,6 +1292,295 @@ export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
|
|||||||
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
|
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
|
||||||
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
|
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High level helper to get elements overlapping a bounding box.
|
||||||
|
* It can be used to get elements overlapping a selection box, for example.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const elementsOverlappingBBox = ({
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
bounds,
|
||||||
|
type,
|
||||||
|
excludeElementsInFrames,
|
||||||
|
shouldIgnoreElementFromSelection,
|
||||||
|
}: {
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
elementsMap?: ElementsMap;
|
||||||
|
bounds: Bounds | ExcalidrawElement;
|
||||||
|
/**
|
||||||
|
* - overlap: elements overlapping or inside bounds
|
||||||
|
* - contain: elements inside bounds
|
||||||
|
**/
|
||||||
|
type: "contain" | "overlap";
|
||||||
|
excludeElementsInFrames?: boolean;
|
||||||
|
shouldIgnoreElementFromSelection?: (
|
||||||
|
element: NonDeletedExcalidrawElement,
|
||||||
|
) => boolean;
|
||||||
|
}) => {
|
||||||
|
if (!elementsMap) {
|
||||||
|
elementsMap = arrayToMap(elements) as ElementsMap;
|
||||||
|
}
|
||||||
|
const selectionBounds = isExcalidrawElement(bounds)
|
||||||
|
? getElementBounds(bounds, elementsMap)
|
||||||
|
: bounds;
|
||||||
|
const [selectionX1, selectionY1, selectionX2, selectionY2] = selectionBounds;
|
||||||
|
const selectionEdges = [
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointFrom(selectionX1, selectionY1),
|
||||||
|
pointFrom(selectionX2, selectionY1),
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointFrom(selectionX2, selectionY1),
|
||||||
|
pointFrom(selectionX2, selectionY2),
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointFrom(selectionX2, selectionY2),
|
||||||
|
pointFrom(selectionX1, selectionY2),
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointFrom(selectionX1, selectionY2),
|
||||||
|
pointFrom(selectionX1, selectionY1),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const framesInSelection = excludeElementsInFrames
|
||||||
|
? new Set<NonDeletedExcalidrawElement["id"]>()
|
||||||
|
: null;
|
||||||
|
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
|
||||||
|
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
if (shouldIgnoreElementFromSelection?.(element)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track only selectable top-level group members, so ignored elements such
|
||||||
|
// as bound text and locked elements don't affect group selection.
|
||||||
|
const groupId = element.groupIds.at(-1);
|
||||||
|
if (groupId) {
|
||||||
|
if (!groups[groupId]) {
|
||||||
|
groups[groupId] = [];
|
||||||
|
}
|
||||||
|
groups[groupId].push(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
const strokeWidth = element.strokeWidth;
|
||||||
|
let labelAABB: Bounds | null = null;
|
||||||
|
let elementAABB = getElementBounds(element, elementsMap);
|
||||||
|
|
||||||
|
elementAABB = [
|
||||||
|
elementAABB[0] - strokeWidth / 2,
|
||||||
|
elementAABB[1] - strokeWidth / 2,
|
||||||
|
elementAABB[2] + strokeWidth / 2,
|
||||||
|
elementAABB[3] + strokeWidth / 2,
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
// Whether the element bounds should include the bound text element bounds
|
||||||
|
const boundTextElement =
|
||||||
|
isArrowElement(element) && getBoundTextElement(element, elementsMap);
|
||||||
|
if (boundTextElement) {
|
||||||
|
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
|
||||||
|
element,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
labelAABB = [
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
x + boundTextElement.width,
|
||||||
|
y + boundTextElement.height,
|
||||||
|
] as Bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clip element bounds by its containing frame (if any), since only the
|
||||||
|
// visible (frame-clipped) portion of the element is relevant for selection.
|
||||||
|
const associatedFrame = getContainingFrame(element, elementsMap);
|
||||||
|
if (
|
||||||
|
associatedFrame &&
|
||||||
|
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
|
||||||
|
) {
|
||||||
|
const frameAABB = getElementBounds(associatedFrame, elementsMap);
|
||||||
|
elementAABB = [
|
||||||
|
Math.max(elementAABB[0], frameAABB[0]),
|
||||||
|
Math.max(elementAABB[1], frameAABB[1]),
|
||||||
|
Math.min(elementAABB[2], frameAABB[2]),
|
||||||
|
Math.min(elementAABB[3], frameAABB[3]),
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
labelAABB = labelAABB
|
||||||
|
? ([
|
||||||
|
Math.max(labelAABB[0], frameAABB[0]),
|
||||||
|
Math.max(labelAABB[1], frameAABB[1]),
|
||||||
|
Math.min(labelAABB[2], frameAABB[2]),
|
||||||
|
Math.min(labelAABB[3], frameAABB[3]),
|
||||||
|
] as Bounds)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonAABB = labelAABB
|
||||||
|
? ([
|
||||||
|
Math.min(labelAABB[0], elementAABB[0]),
|
||||||
|
Math.min(labelAABB[1], elementAABB[1]),
|
||||||
|
Math.max(labelAABB[2], elementAABB[2]),
|
||||||
|
Math.max(labelAABB[3], elementAABB[3]),
|
||||||
|
] as Bounds)
|
||||||
|
: elementAABB;
|
||||||
|
|
||||||
|
// ============== Evaluation ==============
|
||||||
|
|
||||||
|
// 1. If the selection box WRAPs the element's AABB, then add it to the
|
||||||
|
// selection and move on, regardless of the selection mode.
|
||||||
|
//
|
||||||
|
// PERF: This trick only works with axis-aligned box selection and the
|
||||||
|
// current convex element shapes!
|
||||||
|
if (boundsContainBounds(selectionBounds, commonAABB)) {
|
||||||
|
if (framesInSelection && isFrameLikeElement(element)) {
|
||||||
|
framesInSelection.add(element.id);
|
||||||
|
}
|
||||||
|
elementsInSelection.add(element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Handle the case where the label is overlapped by the selection box
|
||||||
|
if (
|
||||||
|
type === "overlap" &&
|
||||||
|
labelAABB &&
|
||||||
|
doBoundsIntersect(selectionBounds, labelAABB)
|
||||||
|
) {
|
||||||
|
elementsInSelection.add(element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Handle the case where the selection is not wrapping the element, but
|
||||||
|
// it does intersect the element's outline (non-AABB).
|
||||||
|
if (type === "overlap" && doBoundsIntersect(selectionBounds, elementAABB)) {
|
||||||
|
let hasIntersection = false;
|
||||||
|
|
||||||
|
// Preliminary check potential intersection imprecision
|
||||||
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
hasIntersection = element.points.some((point) => {
|
||||||
|
const rotatedPoint = pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return pointInsideBounds(rotatedPoint, selectionBounds);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const nonRotatedElementBounds = getElementBounds(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
hasIntersection = [
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
||||||
|
nonRotatedElementBounds[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
nonRotatedElementBounds[2],
|
||||||
|
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
||||||
|
nonRotatedElementBounds[3],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
nonRotatedElementBounds[0],
|
||||||
|
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
].some((point) => {
|
||||||
|
return pointInsideBounds(
|
||||||
|
pointRotateRads(point, center, element.angle),
|
||||||
|
selectionBounds,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasIntersection) {
|
||||||
|
hasIntersection = selectionEdges.some(
|
||||||
|
(selectionEdge) =>
|
||||||
|
intersectElementWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
selectionEdge,
|
||||||
|
strokeWidth / 2,
|
||||||
|
true, // Stop at first hit for better performance
|
||||||
|
).length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasIntersection) {
|
||||||
|
if (framesInSelection && isFrameLikeElement(element)) {
|
||||||
|
framesInSelection.add(element.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
elementsInSelection.add(element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. We don't need to handle when the selection is inside the element
|
||||||
|
// as it is separately handled in App.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (framesInSelection) {
|
||||||
|
elementsInSelection.forEach((element) => {
|
||||||
|
if (element.frameId && framesInSelection.has(element.frameId)) {
|
||||||
|
elementsInSelection.delete(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "overlap") {
|
||||||
|
Array.from(elementsInSelection).forEach((element) => {
|
||||||
|
const groupId = element.groupIds.at(-1);
|
||||||
|
const group = groupId ? groups[groupId] : null;
|
||||||
|
|
||||||
|
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
|
||||||
|
});
|
||||||
|
} else if (type === "contain") {
|
||||||
|
elementsInSelection.forEach((element) => {
|
||||||
|
// note: currently we only support top-level group handling since
|
||||||
|
// we don't support box selecting while editing the group/subgroup
|
||||||
|
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
|
||||||
|
const groupId = element.groupIds.at(-1);
|
||||||
|
|
||||||
|
const group = groupId ? groups[groupId] : null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
group &&
|
||||||
|
!group.every((groupElement) => elementsInSelection.has(groupElement))
|
||||||
|
) {
|
||||||
|
elementsInSelection.delete(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// to maintain original order elements (namely for group selection)
|
||||||
|
return elements.filter((element) => elementsInSelection.has(element));
|
||||||
|
};
|
||||||
|
|
||||||
export const elementCenterPoint = (
|
export const elementCenterPoint = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
|
import {
|
||||||
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
|
isPointWithinBounds,
|
||||||
|
pointFrom,
|
||||||
|
segmentsIntersectAt,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
@@ -78,7 +81,7 @@ export function isElementIntersectingFrame(
|
|||||||
|
|
||||||
const intersecting = frameLineSegments.some((frameLineSegment) =>
|
const intersecting = frameLineSegments.some((frameLineSegment) =>
|
||||||
elementLineSegments.some((elementLineSegment) =>
|
elementLineSegments.some((elementLineSegment) =>
|
||||||
doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
|
segmentsIntersectAt(frameLineSegment, elementLineSegment),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
|
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
|
||||||
import {
|
|
||||||
lineSegment,
|
|
||||||
pointFrom,
|
|
||||||
pointRotateRads,
|
|
||||||
type GlobalPoint,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@@ -12,33 +6,18 @@ import type {
|
|||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import {
|
import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds";
|
||||||
boundsContainBounds,
|
|
||||||
doBoundsIntersect,
|
|
||||||
elementCenterPoint,
|
|
||||||
getElementAbsoluteCoords,
|
|
||||||
getElementBounds,
|
|
||||||
pointInsideBounds,
|
|
||||||
} from "./bounds";
|
|
||||||
import { intersectElementWithLineSegment } from "./collision";
|
|
||||||
import { isElementInViewport } from "./sizeHelpers";
|
import { isElementInViewport } from "./sizeHelpers";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isFreeDrawElement,
|
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import {
|
import { getFrameChildren } from "./frame";
|
||||||
elementOverlapsWithFrame,
|
|
||||||
getContainingFrame,
|
|
||||||
getFrameChildren,
|
|
||||||
} from "./frame";
|
|
||||||
|
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { selectGroupsForSelectedElements } from "./groups";
|
import { selectGroupsForSelectedElements } from "./groups";
|
||||||
import { getBoundTextElement } from "./textElement";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
@@ -107,263 +86,15 @@ export const getElementsWithinSelection = (
|
|||||||
selectionX2,
|
selectionX2,
|
||||||
selectionY2,
|
selectionY2,
|
||||||
] as Bounds;
|
] as Bounds;
|
||||||
const selectionEdges = [
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(selectionX1, selectionY1),
|
|
||||||
pointFrom(selectionX2, selectionY1),
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(selectionX2, selectionY1),
|
|
||||||
pointFrom(selectionX2, selectionY2),
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(selectionX2, selectionY2),
|
|
||||||
pointFrom(selectionX1, selectionY2),
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(selectionX1, selectionY2),
|
|
||||||
pointFrom(selectionX1, selectionY1),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
const framesInSelection = excludeElementsInFrames
|
return elementsOverlappingBBox({
|
||||||
? new Set<NonDeletedExcalidrawElement["id"]>()
|
elements,
|
||||||
: null;
|
bounds: selectionBounds,
|
||||||
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
|
elementsMap,
|
||||||
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
|
type: boxSelectionMode,
|
||||||
|
shouldIgnoreElementFromSelection,
|
||||||
for (const element of elements) {
|
excludeElementsInFrames,
|
||||||
if (shouldIgnoreElementFromSelection(element)) {
|
});
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track only selectable top-level group members, so ignored elements such
|
|
||||||
// as bound text and locked elements don't affect group selection.
|
|
||||||
const groupId = element.groupIds.at(-1);
|
|
||||||
if (groupId) {
|
|
||||||
if (!groups[groupId]) {
|
|
||||||
groups[groupId] = [];
|
|
||||||
}
|
|
||||||
groups[groupId].push(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
const strokeWidth = element.strokeWidth;
|
|
||||||
let labelAABB: Bounds | null = null;
|
|
||||||
let elementAABB = getElementBounds(element, elementsMap);
|
|
||||||
|
|
||||||
elementAABB = [
|
|
||||||
elementAABB[0] - strokeWidth / 2,
|
|
||||||
elementAABB[1] - strokeWidth / 2,
|
|
||||||
elementAABB[2] + strokeWidth / 2,
|
|
||||||
elementAABB[3] + strokeWidth / 2,
|
|
||||||
] as Bounds;
|
|
||||||
|
|
||||||
// Whether the element bounds should include the bound text element bounds
|
|
||||||
const boundTextElement =
|
|
||||||
isArrowElement(element) && getBoundTextElement(element, elementsMap);
|
|
||||||
if (boundTextElement) {
|
|
||||||
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
|
|
||||||
element,
|
|
||||||
boundTextElement,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
labelAABB = [
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
x + boundTextElement.width,
|
|
||||||
y + boundTextElement.height,
|
|
||||||
] as Bounds;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clip element bounds by its containing frame (if any), since only the
|
|
||||||
// visible (frame-clipped) portion of the element is relevant for selection.
|
|
||||||
const associatedFrame = getContainingFrame(element, elementsMap);
|
|
||||||
if (
|
|
||||||
associatedFrame &&
|
|
||||||
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
|
|
||||||
) {
|
|
||||||
const frameAABB = getElementBounds(associatedFrame, elementsMap);
|
|
||||||
elementAABB = [
|
|
||||||
Math.max(elementAABB[0], frameAABB[0]),
|
|
||||||
Math.max(elementAABB[1], frameAABB[1]),
|
|
||||||
Math.min(elementAABB[2], frameAABB[2]),
|
|
||||||
Math.min(elementAABB[3], frameAABB[3]),
|
|
||||||
] as Bounds;
|
|
||||||
|
|
||||||
labelAABB = labelAABB
|
|
||||||
? ([
|
|
||||||
Math.max(labelAABB[0], frameAABB[0]),
|
|
||||||
Math.max(labelAABB[1], frameAABB[1]),
|
|
||||||
Math.min(labelAABB[2], frameAABB[2]),
|
|
||||||
Math.min(labelAABB[3], frameAABB[3]),
|
|
||||||
] as Bounds)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commonAABB = labelAABB
|
|
||||||
? ([
|
|
||||||
Math.min(labelAABB[0], elementAABB[0]),
|
|
||||||
Math.min(labelAABB[1], elementAABB[1]),
|
|
||||||
Math.max(labelAABB[2], elementAABB[2]),
|
|
||||||
Math.max(labelAABB[3], elementAABB[3]),
|
|
||||||
] as Bounds)
|
|
||||||
: elementAABB;
|
|
||||||
|
|
||||||
// ============== Evaluation ==============
|
|
||||||
|
|
||||||
// 1. If the selection box WRAPs the element's AABB, then add it to the
|
|
||||||
// selection and move on, regardless of the selection mode.
|
|
||||||
//
|
|
||||||
// PERF: This trick only works with axis-aligned box selection and the
|
|
||||||
// current convex element shapes!
|
|
||||||
if (boundsContainBounds(selectionBounds, commonAABB)) {
|
|
||||||
if (framesInSelection && isFrameLikeElement(element)) {
|
|
||||||
framesInSelection.add(element.id);
|
|
||||||
}
|
|
||||||
elementsInSelection.add(element);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Handle the case where the label is overlapped by the selection box
|
|
||||||
if (
|
|
||||||
boxSelectionMode === "overlap" &&
|
|
||||||
labelAABB &&
|
|
||||||
doBoundsIntersect(selectionBounds, labelAABB)
|
|
||||||
) {
|
|
||||||
elementsInSelection.add(element);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Handle the case where the selection is not wrapping the element, but
|
|
||||||
// it does intersect the element's outline (non-AABB).
|
|
||||||
if (
|
|
||||||
boxSelectionMode === "overlap" &&
|
|
||||||
doBoundsIntersect(selectionBounds, elementAABB)
|
|
||||||
) {
|
|
||||||
let hasIntersection = false;
|
|
||||||
|
|
||||||
// Preliminary check potential intersection imprecision
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
|
||||||
const center = elementCenterPoint(element, elementsMap);
|
|
||||||
hasIntersection = element.points.some((point) => {
|
|
||||||
const rotatedPoint = pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
return pointInsideBounds(rotatedPoint, selectionBounds);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const nonRotatedElementBounds = getElementBounds(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const center = elementCenterPoint(element, elementsMap);
|
|
||||||
hasIntersection = [
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
|
||||||
nonRotatedElementBounds[1],
|
|
||||||
),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
nonRotatedElementBounds[2],
|
|
||||||
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
|
||||||
),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
|
||||||
nonRotatedElementBounds[3],
|
|
||||||
),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
nonRotatedElementBounds[0],
|
|
||||||
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
|
||||||
),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
].some((point) => {
|
|
||||||
return pointInsideBounds(
|
|
||||||
pointRotateRads(point, center, element.angle),
|
|
||||||
selectionBounds,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasIntersection) {
|
|
||||||
hasIntersection = selectionEdges.some(
|
|
||||||
(selectionEdge) =>
|
|
||||||
intersectElementWithLineSegment(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
selectionEdge,
|
|
||||||
strokeWidth / 2,
|
|
||||||
true, // Stop at first hit for better performance
|
|
||||||
).length > 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasIntersection) {
|
|
||||||
if (framesInSelection && isFrameLikeElement(element)) {
|
|
||||||
framesInSelection.add(element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
elementsInSelection.add(element);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. We don't need to handle when the selection is inside the element
|
|
||||||
// as it is separately handled in App.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (framesInSelection) {
|
|
||||||
elementsInSelection.forEach((element) => {
|
|
||||||
if (element.frameId && framesInSelection.has(element.frameId)) {
|
|
||||||
elementsInSelection.delete(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boxSelectionMode === "overlap") {
|
|
||||||
Array.from(elementsInSelection).forEach((element) => {
|
|
||||||
const groupId = element.groupIds.at(-1);
|
|
||||||
const group = groupId ? groups[groupId] : null;
|
|
||||||
|
|
||||||
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
|
|
||||||
});
|
|
||||||
} else if (boxSelectionMode === "contain") {
|
|
||||||
elementsInSelection.forEach((element) => {
|
|
||||||
// note: currently we only support top-level group handling since
|
|
||||||
// we don't support box selecting while editing the group/subgroup
|
|
||||||
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
|
|
||||||
const groupId = element.groupIds.at(-1);
|
|
||||||
|
|
||||||
const group = groupId ? groups[groupId] : null;
|
|
||||||
|
|
||||||
if (
|
|
||||||
group &&
|
|
||||||
!group.every((groupElement) => elementsInSelection.has(groupElement))
|
|
||||||
) {
|
|
||||||
elementsInSelection.delete(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// to maintain original order elements (namely for group selection)
|
|
||||||
return elements.filter((element) => elementsInSelection.has(element));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getVisibleAndNonSelectedElements = (
|
export const getVisibleAndNonSelectedElements = (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
pointFrom,
|
pointFrom,
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
|
type LineSegment,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { type Bounds, isBounds } from "@excalidraw/common";
|
import { type Bounds, isBounds } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
import type { LineSegment } from "@excalidraw/utils";
|
|
||||||
|
|
||||||
// The global data holder to collect the debug operations
|
// The global data holder to collect the debug operations
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { pointFrom } from "@excalidraw/math";
|
import { pointFrom } from "@excalidraw/math";
|
||||||
|
import { arrayToMap, type Bounds, ROUNDNESS } from "@excalidraw/common";
|
||||||
import { arrayToMap, ROUNDNESS } from "@excalidraw/common";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds";
|
import {
|
||||||
|
elementsOverlappingBBox,
|
||||||
|
getElementAbsoluteCoords,
|
||||||
|
getElementBounds,
|
||||||
|
} from "../src/bounds";
|
||||||
|
|
||||||
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
|
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
|
||||||
|
|
||||||
@@ -141,3 +145,65 @@ describe("getElementBounds", () => {
|
|||||||
expect(y2).toEqual(319.8162855827246);
|
expect(y2).toEqual(319.8162855827246);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeElement = (x: number, y: number, width: number, height: number) =>
|
||||||
|
API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeBBox = (
|
||||||
|
minX: number,
|
||||||
|
minY: number,
|
||||||
|
maxX: number,
|
||||||
|
maxY: number,
|
||||||
|
): Bounds => [minX, minY, maxX, maxY];
|
||||||
|
|
||||||
|
describe("elementsOverlappingBBox()", () => {
|
||||||
|
it("should return elements that overlap bbox", () => {
|
||||||
|
const bbox = makeBBox(0, 0, 100, 100);
|
||||||
|
|
||||||
|
const rectOutside = makeElement(110, 110, 100, 100);
|
||||||
|
const rectInside = makeElement(10, 10, 85, 85);
|
||||||
|
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
||||||
|
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
elementsOverlappingBBox({
|
||||||
|
bounds: bbox,
|
||||||
|
type: "overlap",
|
||||||
|
elements: [
|
||||||
|
rectOutside,
|
||||||
|
rectInside,
|
||||||
|
rectContainingBBox,
|
||||||
|
rectOverlappingTopLeft,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([rectInside, rectOverlappingTopLeft]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return elements inside/containing bbox", () => {
|
||||||
|
const bbox = makeBBox(0, 0, 100, 100);
|
||||||
|
|
||||||
|
const rectOutside = makeElement(110, 110, 100, 100);
|
||||||
|
const rectInside = makeElement(10, 10, 85, 85);
|
||||||
|
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
||||||
|
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
elementsOverlappingBBox({
|
||||||
|
bounds: bbox,
|
||||||
|
type: "contain",
|
||||||
|
elements: [
|
||||||
|
rectOutside,
|
||||||
|
rectInside,
|
||||||
|
rectContainingBBox,
|
||||||
|
rectOverlappingTopLeft,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([rectInside]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -398,11 +398,7 @@ export {
|
|||||||
convertToExcalidrawElements,
|
convertToExcalidrawElements,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
export {
|
export { elementsOverlappingBBox } from "@excalidraw/element";
|
||||||
elementsOverlappingBBox,
|
|
||||||
isElementInsideBBox,
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox,
|
|
||||||
} from "@excalidraw/utils/withinBounds";
|
|
||||||
|
|
||||||
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
||||||
export { getDataURL } from "./data/blob";
|
export { getDataURL } from "./data/blob";
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import {
|
|
||||||
vectorCross,
|
|
||||||
vectorFromPoint,
|
|
||||||
type GlobalPoint,
|
|
||||||
type LocalPoint,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type { Bounds } from "@excalidraw/common";
|
|
||||||
|
|
||||||
export type LineSegment<P extends LocalPoint | GlobalPoint> = [P, P];
|
|
||||||
|
|
||||||
export function getBBox<P extends LocalPoint | GlobalPoint>(
|
|
||||||
line: LineSegment<P>,
|
|
||||||
): Bounds {
|
|
||||||
return [
|
|
||||||
Math.min(line[0][0], line[1][0]),
|
|
||||||
Math.min(line[0][1], line[1][1]),
|
|
||||||
Math.max(line[0][0], line[1][0]),
|
|
||||||
Math.max(line[0][1], line[1][1]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function doBBoxesIntersect(a: Bounds, b: Bounds) {
|
|
||||||
return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
const EPSILON = 0.000001;
|
|
||||||
|
|
||||||
export function isPointOnLine<P extends GlobalPoint | LocalPoint>(
|
|
||||||
l: LineSegment<P>,
|
|
||||||
p: P,
|
|
||||||
) {
|
|
||||||
const p1 = vectorFromPoint(l[1], l[0]);
|
|
||||||
const p2 = vectorFromPoint(p, l[0]);
|
|
||||||
|
|
||||||
const r = vectorCross(p1, p2);
|
|
||||||
|
|
||||||
return Math.abs(r) < EPSILON;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPointRightOfLine<P extends GlobalPoint | LocalPoint>(
|
|
||||||
l: LineSegment<P>,
|
|
||||||
p: P,
|
|
||||||
) {
|
|
||||||
const p1 = vectorFromPoint(l[1], l[0]);
|
|
||||||
const p2 = vectorFromPoint(p, l[0]);
|
|
||||||
|
|
||||||
return vectorCross(p1, p2) < 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLineSegmentTouchingOrCrossingLine<
|
|
||||||
P extends GlobalPoint | LocalPoint,
|
|
||||||
>(a: LineSegment<P>, b: LineSegment<P>) {
|
|
||||||
return (
|
|
||||||
isPointOnLine(a, b[0]) ||
|
|
||||||
isPointOnLine(a, b[1]) ||
|
|
||||||
(isPointRightOfLine(a, b[0])
|
|
||||||
? !isPointRightOfLine(a, b[1])
|
|
||||||
: isPointRightOfLine(a, b[1]))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
|
|
||||||
export function doLineSegmentsIntersect<P extends GlobalPoint | LocalPoint>(
|
|
||||||
a: LineSegment<P>,
|
|
||||||
b: LineSegment<P>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
doBBoxesIntersect(getBBox(a), getBBox(b)) &&
|
|
||||||
isLineSegmentTouchingOrCrossingLine(a, b) &&
|
|
||||||
isLineSegmentTouchingOrCrossingLine(b, a)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export * from "./export";
|
export * from "./export";
|
||||||
export * from "./withinBounds";
|
export { elementsOverlappingBBox } from "@excalidraw/element";
|
||||||
export * from "./bbox";
|
|
||||||
export { getCommonBounds } from "@excalidraw/element";
|
export { getCommonBounds } from "@excalidraw/element";
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
import { arrayToMap, type Bounds } from "@excalidraw/common";
|
|
||||||
import { getElementBounds } from "@excalidraw/element";
|
|
||||||
import {
|
|
||||||
isArrowElement,
|
|
||||||
isExcalidrawElement,
|
|
||||||
isFreeDrawElement,
|
|
||||||
isLinearElement,
|
|
||||||
isTextElement,
|
|
||||||
} from "@excalidraw/element";
|
|
||||||
import {
|
|
||||||
rangeIncludesValue,
|
|
||||||
pointFrom,
|
|
||||||
pointRotateRads,
|
|
||||||
rangeInclusive,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawFreeDrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
} from "@excalidraw/element/types";
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
|
||||||
|
|
||||||
type Element = NonDeletedExcalidrawElement;
|
|
||||||
type Elements = readonly NonDeletedExcalidrawElement[];
|
|
||||||
|
|
||||||
type Points = readonly LocalPoint[];
|
|
||||||
|
|
||||||
/** @returns vertices relative to element's top-left [0,0] position */
|
|
||||||
const getNonLinearElementRelativePoints = (
|
|
||||||
element: Exclude<
|
|
||||||
Element,
|
|
||||||
ExcalidrawLinearElement | ExcalidrawFreeDrawElement
|
|
||||||
>,
|
|
||||||
): [
|
|
||||||
TopLeft: LocalPoint,
|
|
||||||
TopRight: LocalPoint,
|
|
||||||
BottomRight: LocalPoint,
|
|
||||||
BottomLeft: LocalPoint,
|
|
||||||
] => {
|
|
||||||
if (element.type === "diamond") {
|
|
||||||
return [
|
|
||||||
pointFrom(element.width / 2, 0),
|
|
||||||
pointFrom(element.width, element.height / 2),
|
|
||||||
pointFrom(element.width / 2, element.height),
|
|
||||||
pointFrom(0, element.height / 2),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
pointFrom(0, 0),
|
|
||||||
pointFrom(0 + element.width, 0),
|
|
||||||
pointFrom(0 + element.width, element.height),
|
|
||||||
pointFrom(0, element.height),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @returns vertices relative to element's top-left [0,0] position */
|
|
||||||
const getElementRelativePoints = (element: ExcalidrawElement): Points => {
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
|
||||||
return element.points;
|
|
||||||
}
|
|
||||||
return getNonLinearElementRelativePoints(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMinMaxPoints = (points: Points) => {
|
|
||||||
const ret = points.reduce(
|
|
||||||
(limits, [x, y]) => {
|
|
||||||
limits.minY = Math.min(limits.minY, y);
|
|
||||||
limits.minX = Math.min(limits.minX, x);
|
|
||||||
|
|
||||||
limits.maxX = Math.max(limits.maxX, x);
|
|
||||||
limits.maxY = Math.max(limits.maxY, y);
|
|
||||||
|
|
||||||
return limits;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
minX: Infinity,
|
|
||||||
minY: Infinity,
|
|
||||||
maxX: -Infinity,
|
|
||||||
maxY: -Infinity,
|
|
||||||
cx: 0,
|
|
||||||
cy: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ret.cx = (ret.maxX + ret.minX) / 2;
|
|
||||||
ret.cy = (ret.maxY + ret.minY) / 2;
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRotatedBBox = (element: Element): Bounds => {
|
|
||||||
const points = getElementRelativePoints(element);
|
|
||||||
|
|
||||||
const { cx, cy } = getMinMaxPoints(points);
|
|
||||||
const centerPoint = pointFrom<LocalPoint>(cx, cy);
|
|
||||||
|
|
||||||
const rotatedPoints = points.map((p) =>
|
|
||||||
pointRotateRads(p, centerPoint, element.angle),
|
|
||||||
);
|
|
||||||
const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
|
|
||||||
|
|
||||||
return [
|
|
||||||
minX + element.x,
|
|
||||||
minY + element.y,
|
|
||||||
maxX + element.x,
|
|
||||||
maxY + element.y,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isElementInsideBBox = (
|
|
||||||
element: Element,
|
|
||||||
bbox: Bounds,
|
|
||||||
eitherDirection = false,
|
|
||||||
): boolean => {
|
|
||||||
const elementBBox = getRotatedBBox(element);
|
|
||||||
|
|
||||||
const elementInsideBbox =
|
|
||||||
bbox[0] <= elementBBox[0] &&
|
|
||||||
bbox[2] >= elementBBox[2] &&
|
|
||||||
bbox[1] <= elementBBox[1] &&
|
|
||||||
bbox[3] >= elementBBox[3];
|
|
||||||
|
|
||||||
if (!eitherDirection) {
|
|
||||||
return elementInsideBbox;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elementInsideBbox) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
elementBBox[0] <= bbox[0] &&
|
|
||||||
elementBBox[2] >= bbox[2] &&
|
|
||||||
elementBBox[1] <= bbox[1] &&
|
|
||||||
elementBBox[3] >= bbox[3]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const elementPartiallyOverlapsWithOrContainsBBox = (
|
|
||||||
element: Element,
|
|
||||||
bbox: Bounds,
|
|
||||||
): boolean => {
|
|
||||||
const elementBBox = getRotatedBBox(element);
|
|
||||||
|
|
||||||
return (
|
|
||||||
(rangeIncludesValue(elementBBox[0], rangeInclusive(bbox[0], bbox[2])) ||
|
|
||||||
rangeIncludesValue(
|
|
||||||
bbox[0],
|
|
||||||
rangeInclusive(elementBBox[0], elementBBox[2]),
|
|
||||||
)) &&
|
|
||||||
(rangeIncludesValue(elementBBox[1], rangeInclusive(bbox[1], bbox[3])) ||
|
|
||||||
rangeIncludesValue(
|
|
||||||
bbox[1],
|
|
||||||
rangeInclusive(elementBBox[1], elementBBox[3]),
|
|
||||||
))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const elementsOverlappingBBox = ({
|
|
||||||
elements,
|
|
||||||
bounds,
|
|
||||||
type,
|
|
||||||
errorMargin = 0,
|
|
||||||
}: {
|
|
||||||
elements: Elements;
|
|
||||||
bounds: Bounds | ExcalidrawElement;
|
|
||||||
/** safety offset. Defaults to 0. */
|
|
||||||
errorMargin?: number;
|
|
||||||
/**
|
|
||||||
* - overlap: elements overlapping or inside bounds
|
|
||||||
* - contain: elements inside bounds or bounds inside elements
|
|
||||||
* - inside: elements inside bounds
|
|
||||||
**/
|
|
||||||
type: "overlap" | "contain" | "inside";
|
|
||||||
}) => {
|
|
||||||
if (isExcalidrawElement(bounds)) {
|
|
||||||
bounds = getElementBounds(bounds, arrayToMap(elements));
|
|
||||||
}
|
|
||||||
const adjustedBBox: Bounds = [
|
|
||||||
bounds[0] - errorMargin,
|
|
||||||
bounds[1] - errorMargin,
|
|
||||||
bounds[2] + errorMargin,
|
|
||||||
bounds[3] + errorMargin,
|
|
||||||
];
|
|
||||||
|
|
||||||
const includedElementSet = new Set<string>();
|
|
||||||
|
|
||||||
for (const element of elements) {
|
|
||||||
if (includedElementSet.has(element.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOverlaping =
|
|
||||||
type === "overlap"
|
|
||||||
? elementPartiallyOverlapsWithOrContainsBBox(element, adjustedBBox)
|
|
||||||
: type === "inside"
|
|
||||||
? isElementInsideBBox(element, adjustedBBox)
|
|
||||||
: isElementInsideBBox(element, adjustedBBox, true);
|
|
||||||
|
|
||||||
if (isOverlaping) {
|
|
||||||
includedElementSet.add(element.id);
|
|
||||||
|
|
||||||
if (element.boundElements) {
|
|
||||||
for (const boundElement of element.boundElements) {
|
|
||||||
includedElementSet.add(boundElement.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTextElement(element) && element.containerId) {
|
|
||||||
includedElementSet.add(element.containerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isArrowElement(element)) {
|
|
||||||
if (element.startBinding) {
|
|
||||||
includedElementSet.add(element.startBinding.elementId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.endBinding) {
|
|
||||||
includedElementSet.add(element.endBinding?.elementId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements.filter((element) => includedElementSet.has(element.id));
|
|
||||||
};
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
||||||
|
|
||||||
import type { Bounds } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import {
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox,
|
|
||||||
elementsOverlappingBBox,
|
|
||||||
isElementInsideBBox,
|
|
||||||
} from "../src/withinBounds";
|
|
||||||
|
|
||||||
const makeElement = (x: number, y: number, width: number, height: number) =>
|
|
||||||
API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeBBox = (
|
|
||||||
minX: number,
|
|
||||||
minY: number,
|
|
||||||
maxX: number,
|
|
||||||
maxY: number,
|
|
||||||
): Bounds => [minX, minY, maxX, maxY];
|
|
||||||
|
|
||||||
describe("isElementInsideBBox()", () => {
|
|
||||||
it("should return true if element is fully inside", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// bbox contains element
|
|
||||||
expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true);
|
|
||||||
expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false if element is only partially overlapping", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// element contains bbox
|
|
||||||
expect(isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// element overlaps bbox from top-left
|
|
||||||
expect(isElementInsideBBox(makeElement(-10, -10, 100, 100), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// element overlaps bbox from top-right
|
|
||||||
expect(isElementInsideBBox(makeElement(90, -10, 100, 100), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// element overlaps bbox from bottom-left
|
|
||||||
expect(isElementInsideBBox(makeElement(-10, 90, 100, 100), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// element overlaps bbox from bottom-right
|
|
||||||
expect(isElementInsideBBox(makeElement(90, 90, 100, 100), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false if element outside", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// outside diagonally
|
|
||||||
expect(isElementInsideBBox(makeElement(110, 110, 100, 100), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// outside on the left
|
|
||||||
expect(isElementInsideBBox(makeElement(-110, 10, 50, 50), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// outside on the right
|
|
||||||
expect(isElementInsideBBox(makeElement(110, 10, 50, 50), bbox)).toBe(false);
|
|
||||||
// outside on the top
|
|
||||||
expect(isElementInsideBBox(makeElement(10, -110, 50, 50), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// outside on the bottom
|
|
||||||
expect(isElementInsideBBox(makeElement(10, 110, 50, 50), bbox)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true if bbox contains element and flag enabled", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// element contains bbox
|
|
||||||
expect(
|
|
||||||
isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox, true),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// bbox contains element
|
|
||||||
expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true);
|
|
||||||
expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("elementPartiallyOverlapsWithOrContainsBBox()", () => {
|
|
||||||
it("should return true if element overlaps, is inside, or contains", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// bbox contains element
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(0, 0, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(10, 10, 90, 90),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// element contains bbox
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(-10, -10, 110, 110),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// element overlaps bbox from top-left
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(-10, -10, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
// element overlaps bbox from top-right
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(90, -10, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
// element overlaps bbox from bottom-left
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(-10, 90, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
// element overlaps bbox from bottom-right
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(90, 90, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false if element does not overlap", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// outside diagonally
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(110, 110, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
// outside on the left
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(-110, 10, 50, 50),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
// outside on the right
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(110, 10, 50, 50),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
// outside on the top
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(10, -110, 50, 50),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
// outside on the bottom
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(10, 110, 50, 50),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("elementsOverlappingBBox()", () => {
|
|
||||||
it("should return elements that overlap bbox", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
const rectOutside = makeElement(110, 110, 100, 100);
|
|
||||||
const rectInside = makeElement(10, 10, 90, 90);
|
|
||||||
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
|
||||||
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
elementsOverlappingBBox({
|
|
||||||
bounds: bbox,
|
|
||||||
type: "overlap",
|
|
||||||
elements: [
|
|
||||||
rectOutside,
|
|
||||||
rectInside,
|
|
||||||
rectContainingBBox,
|
|
||||||
rectOverlappingTopLeft,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toEqual([rectInside, rectContainingBBox, rectOverlappingTopLeft]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return elements inside/containing bbox", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
const rectOutside = makeElement(110, 110, 100, 100);
|
|
||||||
const rectInside = makeElement(10, 10, 90, 90);
|
|
||||||
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
|
||||||
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
elementsOverlappingBBox({
|
|
||||||
bounds: bbox,
|
|
||||||
type: "contain",
|
|
||||||
elements: [
|
|
||||||
rectOutside,
|
|
||||||
rectInside,
|
|
||||||
rectContainingBBox,
|
|
||||||
rectOverlappingTopLeft,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toEqual([rectInside, rectContainingBBox]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return elements inside bbox", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
const rectOutside = makeElement(110, 110, 100, 100);
|
|
||||||
const rectInside = makeElement(10, 10, 90, 90);
|
|
||||||
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
|
||||||
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
elementsOverlappingBBox({
|
|
||||||
bounds: bbox,
|
|
||||||
type: "inside",
|
|
||||||
elements: [
|
|
||||||
rectOutside,
|
|
||||||
rectInside,
|
|
||||||
rectContainingBBox,
|
|
||||||
rectOverlappingTopLeft,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toEqual([rectInside]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO test linear, freedraw, and diamond element types (+rotated)
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user