refactor: update collision from ga to vector geometry (#7636)

* new collision api

* isPointOnShape

* removed redundant code

* new collision methods in app

* curve shape takes starting point

* clean up geometry

* curve rotation

* freedraw

* inside curve

* improve ellipse inside check

* ellipse distance func

* curve inside

* include frame name bounds

* replace previous private methods for getting elements at x,y

* arrow bound text hit detection

* keep iframes on top

* remove dependence on old collision methods from app

* remove old collision functions

* move some hit functions outside of app

* code refactor

* type

* text collision from inside

* fix context menu test

* highest z-index collision

* fix 1px away binding test

* strictly less

* remove unused imports

* lint

* 'ignore' resize flipping test

* more lint fix

* skip 'flips while resizing' test

* more test

* fix merge errors

* fix selection in resize test

* added a bit more comment

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di 2024-04-04 16:31:23 +08:00 committed by GitHub
parent 3e334a67ed
commit bbdcd30a73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2721 additions and 1627 deletions

View file

@ -107,8 +107,6 @@ import {
getResizeOffsetXY,
getLockedLinearCursorAlignSize,
getTransformHandleTypeFromCoords,
hitTest,
isHittingElementBoundingBoxWithoutHittingElement,
isInvisiblySmallElement,
isNonDeletedElement,
isTextElement,
@ -119,6 +117,7 @@ import {
transformElements,
updateTextElement,
redrawTextBoundingBox,
getElementAbsoluteCoords,
} from "../element";
import {
bindOrUnbindLinearElement,
@ -162,6 +161,7 @@ import {
isIframeElement,
isIframeLikeElement,
isMagicFrameElement,
isTextBindableContainer,
} from "../element/typeChecks";
import {
ExcalidrawBindableElement,
@ -212,7 +212,6 @@ import {
} from "../math";
import {
calculateScrollCenter,
getElementsAtPosition,
getElementsWithinSelection,
getNormalizedZoom,
getSelectedElements,
@ -223,6 +222,15 @@ import Scene from "../scene/Scene";
import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes";
import {
GeometricShape,
getClosedCurveShape,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
} from "../../utils/geometry/shape";
import { isPointInShape } from "../../utils/collision";
import {
AppClassProperties,
AppProps,
@ -318,11 +326,9 @@ import {
getContainerElement,
getDefaultLineHeight,
getLineHeightInPx,
getTextBindableContainerAtPosition,
isMeasureTextSupported,
isValidTextContainer,
} from "../element/textElement";
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
import {
showHyperlinkTooltip,
hideHyperlinkToolip,
@ -407,6 +413,13 @@ import { AnimatedTrail } from "../animated-trail";
import { LaserTrails } from "../laser-trails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { getRenderOpacity } from "../renderer/renderElement";
import {
hitElementBoundText,
hitElementBoundingBox,
hitElementBoundingBoxOnly,
hitElementItself,
shouldTestInside,
} from "../element/collision";
import { textWysiwyg } from "../element/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars";
import {
@ -2757,7 +2770,6 @@ class App extends React.Component<AppProps, AppState> {
maybeBindLinearElement(
multiElement,
this.state,
this.scene,
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiElement,
@ -2765,7 +2777,7 @@ class App extends React.Component<AppProps, AppState> {
elementsMap,
),
),
elementsMap,
this,
);
}
this.history.record(this.state, elements);
@ -4048,11 +4060,7 @@ class App extends React.Component<AppProps, AppState> {
const selectedElements = this.scene.getSelectedElements(this.state);
const elementsMap = this.scene.getNonDeletedElementsMap();
isBindingEnabled(this.state)
? bindOrUnbindSelectedElements(
selectedElements,
this.scene.getNonDeletedElements(),
elementsMap,
)
? bindOrUnbindSelectedElements(selectedElements, this)
: unbindLinearElements(selectedElements, elementsMap);
this.setState({ suggestedBindings: [] });
}
@ -4355,12 +4363,87 @@ class App extends React.Component<AppProps, AppState> {
return null;
}
/**
* get the pure geometric shape of an excalidraw element
* which is then used for hit detection
*/
public getElementShape(element: ExcalidrawElement): GeometricShape {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
this.scene.getNonDeletedElementsMap(),
);
return shouldTestInside(element)
? getClosedCurveShape(
roughShape,
[element.x, element.y],
element.angle,
[cx, cy],
)
: getCurveShape(roughShape, [element.x, element.y], element.angle, [
cx,
cy,
]);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
this.scene.getNonDeletedElementsMap(),
);
return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
}
}
}
private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null {
const boundTextElement = getBoundTextElement(
element,
this.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
if (element.type === "arrow") {
return this.getElementShape({
...boundTextElement,
// 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,
boundTextElement,
this.scene.getNonDeletedElementsMap(),
),
});
}
return this.getElementShape(boundTextElement);
}
return null;
}
private getElementAtPosition(
x: number,
y: number,
opts?: {
/** if true, returns the first selected element (with highest z-index)
of all hit elements */
preferSelected?: boolean;
includeBoundTextElement?: boolean;
includeLockedElements?: boolean;
@ -4372,6 +4455,7 @@ class App extends React.Component<AppProps, AppState> {
opts?.includeBoundTextElement,
opts?.includeLockedElements,
);
if (allHitElements.length > 1) {
if (opts?.preferSelected) {
for (let index = allHitElements.length - 1; index > -1; index--) {
@ -4382,22 +4466,20 @@ class App extends React.Component<AppProps, AppState> {
}
const elementWithHighestZIndex =
allHitElements[allHitElements.length - 1];
// 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.
return isHittingElementBoundingBoxWithoutHittingElement(
elementWithHighestZIndex,
this.state,
this.frameNameBoundsCache,
x,
y,
this.scene.getNonDeletedElementsMap(),
return isPointInShape(
[x, y],
this.getElementShape(elementWithHighestZIndex),
)
? allHitElements[allHitElements.length - 2]
: elementWithHighestZIndex;
? elementWithHighestZIndex
: allHitElements[allHitElements.length - 2];
}
if (allHitElements.length === 1) {
return allHitElements[0];
}
return null;
}
@ -4407,7 +4489,11 @@ class App extends React.Component<AppProps, AppState> {
includeBoundTextElement: boolean = false,
includeLockedElements: boolean = false,
): NonDeleted<ExcalidrawElement>[] {
const elements =
const iframeLikes: ExcalidrawIframeElement[] = [];
const elementsMap = this.scene.getNonDeletedElementsMap();
const elements = (
includeBoundTextElement && includeLockedElements
? this.scene.getNonDeletedElements()
: this.scene
@ -4417,29 +4503,120 @@ class App extends React.Component<AppProps, AppState> {
(includeLockedElements || !element.locked) &&
(includeBoundTextElement ||
!(isTextElement(element) && element.containerId)),
);
)
)
.filter((el) => this.hitElement(x, y, el))
.filter((element) => {
// hitting a frame's element from outside the frame is not considered a hit
const containingFrame = getContainingFrame(element, elementsMap);
return containingFrame &&
this.state.frameRendering.enabled &&
this.state.frameRendering.clip
? isCursorInFrame({ x, y }, containingFrame, elementsMap)
: true;
})
.filter((el) => {
// The parameter elements comes ordered from lower z-index to higher.
// We want to preserve that order on the returned array.
// Exception being embeddables which should be on top of everything else in
// terms of hit testing.
if (isIframeElement(el)) {
iframeLikes.push(el);
return false;
}
return true;
})
.concat(iframeLikes) as NonDeleted<ExcalidrawElement>[];
const elementsMap = this.scene.getNonDeletedElementsMap();
return getElementsAtPosition(elements, (element) =>
hitTest(
element,
this.state,
this.frameNameBoundsCache,
return elements;
}
private getHitThreshold() {
return 10 / this.state.zoom.value;
}
private hitElement(
x: number,
y: number,
element: ExcalidrawElement,
considerBoundingBox = true,
) {
// if the element is selected, then hit test is done against its bounding box
if (
considerBoundingBox &&
this.state.selectedElementIds[element.id] &&
shouldShowBoundingBox([element], this.state)
) {
return hitElementBoundingBox(
x,
y,
elementsMap,
),
).filter((element) => {
// hitting a frame's element from outside the frame is not considered a hit
const containingFrame = getContainingFrame(element, elementsMap);
return containingFrame &&
this.state.frameRendering.enabled &&
this.state.frameRendering.clip
? isCursorInFrame({ x, y }, containingFrame, elementsMap)
: true;
element,
this.scene.getNonDeletedElementsMap(),
this.getHitThreshold(),
);
}
// take bound text element into consideration for hit collision as well
const hitBoundTextOfElement = hitElementBoundText(
x,
y,
this.getBoundTextShape(element),
);
if (hitBoundTextOfElement) {
return true;
}
return hitElementItself({
x,
y,
element,
shape: this.getElementShape(element),
threshold: this.getHitThreshold(),
frameNameBound: isFrameLikeElement(element)
? this.frameNameBoundsCache.get(element)
: null,
});
}
private getTextBindableContainerAtPosition(x: number, y: number) {
const elements = this.scene.getNonDeletedElements();
const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.length === 1) {
return isTextBindableContainer(selectedElements[0], false)
? selectedElements[0]
: null;
}
let hitElement = null;
// We need to do hit testing from front (end of the array) to back (beginning of the array)
for (let index = elements.length - 1; index >= 0; --index) {
if (elements[index].isDeleted) {
continue;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(
elements[index],
this.scene.getNonDeletedElementsMap(),
);
if (
isArrowElement(elements[index]) &&
hitElementItself({
x,
y,
element: elements[index],
shape: this.getElementShape(elements[index]),
threshold: this.getHitThreshold(),
})
) {
hitElement = elements[index];
break;
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
hitElement = elements[index];
break;
}
}
return isTextBindableContainer(hitElement, false) ? hitElement : null;
}
private startTextEditing = ({
sceneX,
sceneY,
@ -4667,25 +4844,19 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const container = getTextBindableContainerAtPosition(
this.scene.getNonDeletedElements(),
this.state,
sceneX,
sceneY,
this.scene.getNonDeletedElementsMap(),
);
const container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
if (container) {
if (
hasBoundTextElement(container) ||
!isTransparent(container.backgroundColor) ||
isHittingElementNotConsideringBoundingBox(
container,
this.state,
this.frameNameBoundsCache,
[sceneX, sceneY],
this.scene.getNonDeletedElementsMap(),
)
hitElementItself({
x: sceneX,
y: sceneY,
element: container,
shape: this.getElementShape(container),
threshold: this.getHitThreshold(),
})
) {
const midPoint = getContainerCenter(
container,
@ -5281,7 +5452,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointer.x,
scenePointer.y,
);
const threshold = 10 / this.state.zoom.value;
const threshold = this.getHitThreshold();
const point = { ...pointerDownState.lastCoords };
let samplingInterval = 0;
while (samplingInterval <= distance) {
@ -5346,7 +5517,6 @@ class App extends React.Component<AppProps, AppState> {
linearElementEditor.elementId,
elementsMap,
);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (!element) {
return;
@ -5355,13 +5525,12 @@ class App extends React.Component<AppProps, AppState> {
let hoverPointIndex = -1;
let segmentMidPointHoveredCoords = null;
if (
isHittingElementNotConsideringBoundingBox(
hitElementItself({
x: scenePointerX,
y: scenePointerY,
element,
this.state,
this.frameNameBoundsCache,
[scenePointerX, scenePointerY],
elementsMap,
)
shape: this.getElementShape(element),
})
) {
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
element,
@ -5383,29 +5552,7 @@ class App extends React.Component<AppProps, AppState> {
} else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
} else if (
shouldShowBoundingBox([element], this.state) &&
isHittingElementBoundingBoxWithoutHittingElement(
element,
this.state,
this.frameNameBoundsCache,
scenePointerX,
scenePointerY,
elementsMap,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} else if (
boundTextElement &&
hitTest(
boundTextElement,
this.state,
this.frameNameBoundsCache,
scenePointerX,
scenePointerY,
this.scene.getNonDeletedElementsMap(),
)
) {
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
@ -6159,8 +6306,7 @@ class App extends React.Component<AppProps, AppState> {
this.history,
pointerDownState.origin,
linearElementEditor,
this.scene.getNonDeletedElements(),
elementsMap,
this,
);
if (ret.hitElement) {
pointerDownState.hit.element = ret.hitElement;
@ -6383,7 +6529,7 @@ class App extends React.Component<AppProps, AppState> {
}
// How many pixels off the shape boundary we still consider a hit
const threshold = 10 / this.state.zoom.value;
const threshold = this.getHitThreshold();
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
return (
point.x > x1 - threshold &&
@ -6411,13 +6557,7 @@ class App extends React.Component<AppProps, AppState> {
});
// FIXME
let container = getTextBindableContainerAtPosition(
this.scene.getNonDeletedElements(),
this.state,
sceneX,
sceneY,
this.scene.getNonDeletedElementsMap(),
);
let container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
if (hasBoundTextElement(element)) {
container = element as ExcalidrawTextContainer;
@ -6497,8 +6637,7 @@ class App extends React.Component<AppProps, AppState> {
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this,
);
this.scene.addNewElement(element);
this.setState({
@ -6766,8 +6905,7 @@ class App extends React.Component<AppProps, AppState> {
});
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this,
);
this.scene.addNewElement(element);
@ -7551,7 +7689,6 @@ class App extends React.Component<AppProps, AppState> {
? this.state.editingElement
: null,
snapLines: updateStable(prevState.snapLines, []),
originSnapOffset: null,
}));
@ -7578,8 +7715,7 @@ class App extends React.Component<AppProps, AppState> {
childEvent,
this.state.editingLinearElement,
this.state,
this.scene.getNonDeletedElements(),
elementsMap,
this,
);
if (editingLinearElement !== this.state.editingLinearElement) {
this.setState({
@ -7603,8 +7739,7 @@ class App extends React.Component<AppProps, AppState> {
childEvent,
this.state.selectedLinearElement,
this.state,
this.scene.getNonDeletedElements(),
elementsMap,
this,
);
const { startBindingElement, endBindingElement } =
@ -7753,9 +7888,8 @@ class App extends React.Component<AppProps, AppState> {
maybeBindLinearElement(
draggingElement,
this.state,
this.scene,
pointerCoords,
elementsMap,
this,
);
}
this.setState({ suggestedBindings: [], startBoundElement: null });
@ -8207,16 +8341,24 @@ class App extends React.Component<AppProps, AppState> {
}
if (
// not dragged
!pointerDownState.drag.hasOccurred &&
// not resized
!this.state.isResizing &&
// only hitting the bounding box of the previous hit element
((hitElement &&
isHittingElementBoundingBoxWithoutHittingElement(
hitElement,
this.state,
this.frameNameBoundsCache,
pointerDownState.origin.x,
pointerDownState.origin.y,
this.scene.getNonDeletedElementsMap(),
hitElementBoundingBoxOnly(
{
x: pointerDownState.origin.x,
y: pointerDownState.origin.y,
element: hitElement,
shape: this.getElementShape(hitElement),
threshold: this.getHitThreshold(),
frameNameBound: isFrameLikeElement(hitElement)
? this.frameNameBoundsCache.get(hitElement)
: null,
},
elementsMap,
)) ||
(!hitElement &&
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
@ -8232,6 +8374,8 @@ class App extends React.Component<AppProps, AppState> {
activeEmbeddable: null,
});
}
// reset cursor
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
return;
}
@ -8267,11 +8411,10 @@ class App extends React.Component<AppProps, AppState> {
isBindingEnabled(this.state)
? bindOrUnbindSelectedElements(
this.scene.getSelectedElements(this.state),
this.scene.getNonDeletedElements(),
elementsMap,
this,
)
: unbindLinearElements(
this.scene.getSelectedElements(this.state),
this.scene.getNonDeletedElements(),
elementsMap,
);
}
@ -8758,8 +8901,7 @@ class App extends React.Component<AppProps, AppState> {
}): void => {
const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this,
);
this.setState({
suggestedBindings:
@ -8786,8 +8928,7 @@ class App extends React.Component<AppProps, AppState> {
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this,
);
if (
hoveredBindableElement != null &&
@ -8815,8 +8956,7 @@ class App extends React.Component<AppProps, AppState> {
}
const suggestedBindings = getEligibleElementsForBinding(
selectedElements,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this,
);
this.setState({ suggestedBindings });
}

View file

@ -26,9 +26,9 @@ import clsx from "clsx";
import { KEYS } from "../../keys";
import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
import { getElementAbsoluteCoords } from "../../element/bounds";
import { getTooltipDiv, updateTooltipPosition } from "../Tooltip";
import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
import { getSelectedElements } from "../../scene";
import { isPointHittingElementBoundingBox } from "../../element/collision";
import { hitElementBoundingBox } from "../../element/collision";
import { isLocalLink, normalizeLink } from "../../data/url";
import "./Hyperlink.scss";
@ -425,15 +425,7 @@ const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box
if (
isPointHittingElementBoundingBox(
element,
elementsMap,
[sceneX, sceneY],
threshold,
null,
)
) {
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) {
return false;
}
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);

View file

@ -1,6 +1,6 @@
import { MIME_TYPES } from "../../constants";
import { Bounds, getElementAbsoluteCoords } from "../../element/bounds";
import { isPointHittingElementBoundingBox } from "../../element/collision";
import { hitElementBoundingBox } from "../../element/collision";
import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types";
import { rotate } from "../../math";
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
@ -75,17 +75,10 @@ export const isPointHittingLink = (
if (!element.link || appState.selectedElementIds[element.id]) {
return false;
}
const threshold = 4 / appState.zoom.value;
if (
!isMobile &&
appState.viewModeEnabled &&
isPointHittingElementBoundingBox(
element,
elementsMap,
[x, y],
threshold,
null,
)
hitElementBoundingBox(x, y, element, elementsMap)
) {
return true;
}