feat: Overlap & distance based binding candidate selection
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -291,54 +291,6 @@ const borderDistance = (
|
||||
return distance > tolerance ? -Infinity : -distance;
|
||||
};
|
||||
|
||||
// const bindingBorderTest = (
|
||||
// element: NonDeleted<ExcalidrawBindableElement>,
|
||||
// [x, y]: Readonly<GlobalPoint>,
|
||||
// elementsMap: NonDeletedSceneElementsMap,
|
||||
// tolerance: number = 0,
|
||||
// ): boolean => {
|
||||
// const p = pointFrom<GlobalPoint>(x, y);
|
||||
// const shouldTestInside =
|
||||
// // disable fullshape snapping for frame elements so we
|
||||
// // can bind to frame children
|
||||
// !isFrameLikeElement(element);
|
||||
|
||||
// // PERF: Run a cheap test to see if the binding element
|
||||
// // is even close to the element
|
||||
// const t = Math.max(1, tolerance);
|
||||
// const bounds = [x - t, y - t, x + t, y + t] as Bounds;
|
||||
// const elementBounds = getElementBounds(element, elementsMap);
|
||||
// if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// // If the element is inside a frame, we should clip the element
|
||||
// if (element.frameId) {
|
||||
// const enclosingFrame = elementsMap.get(element.frameId);
|
||||
// if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
|
||||
// const enclosingFrameBounds = getElementBounds(
|
||||
// enclosingFrame,
|
||||
// elementsMap,
|
||||
// );
|
||||
// if (!pointInsideBounds(p, enclosingFrameBounds)) {
|
||||
// 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 <= tolerance
|
||||
// : intersections.length > 0 && distance <= t;
|
||||
// };
|
||||
|
||||
export const getAllHoveredElementAtPoint = (
|
||||
arrow: { elbowed: boolean },
|
||||
point: Readonly<GlobalPoint>,
|
||||
@@ -347,7 +299,7 @@ export const getAllHoveredElementAtPoint = (
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// We need to do hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
@@ -400,7 +352,12 @@ export const getHoveredElementForBinding = (
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = borderDistance(element, point, elementsMap, tolerance);
|
||||
const distance = borderDistance(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
tolerance ?? 0,
|
||||
);
|
||||
const bindingGap = getBindingGap(element, arrow);
|
||||
|
||||
if (distance > -(tolerance ?? bindingGap)) {
|
||||
@@ -416,9 +373,44 @@ export const getHoveredElementForBinding = (
|
||||
return candidateElements[0].element;
|
||||
}
|
||||
|
||||
return candidateElements
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.map((c) => c.element)[0];
|
||||
const furthestDistance = candidateElements.sort(
|
||||
(a, b) => a.distance - b.distance,
|
||||
);
|
||||
|
||||
const candidate = furthestDistance[furthestDistance.length - 1].element;
|
||||
const [cx1, cy1, cx2, cy2] = getElementBounds(candidate, elementsMap);
|
||||
const candidateArea = Math.max(
|
||||
0.00001,
|
||||
Math.abs(cx2 - cx1) * Math.abs(cy2 - cy1),
|
||||
);
|
||||
const overlaps = furthestDistance
|
||||
.map((c) => {
|
||||
if (c.element === candidate) {
|
||||
return { ...c, overlapPercent: 0, relativeArea: 1 };
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(c.element, elementsMap);
|
||||
const overlapX1 = x1 > cx1 && x1 < cx2 ? x1 : cx1;
|
||||
const overlapY1 = y1 > cy1 && y1 < cy2 ? y1 : cy1;
|
||||
const overlapX2 = x2 < cx2 && x2 > cx1 ? x2 : cx2;
|
||||
const overlapY2 = y2 < cy2 && y2 > cy1 ? y2 : cy2;
|
||||
const overlapWdith =
|
||||
overlapX1 !== cx1 || overlapX2 !== cx2 ? overlapX2 - overlapX1 : 0;
|
||||
const overlapHeight =
|
||||
overlapY1 !== cy1 || overlapY2 !== cy2 ? overlapY2 - overlapY1 : 0;
|
||||
const area = Math.max(0.00001, Math.abs(x2 - x1) * Math.abs(y2 - y1));
|
||||
const overlapPercent = Math.abs(overlapHeight * overlapWdith) / area;
|
||||
|
||||
return {
|
||||
...c,
|
||||
overlapPercent,
|
||||
relativeArea:
|
||||
overlapPercent === 0 ? 1 : Math.min(area / candidateArea, 1),
|
||||
};
|
||||
})
|
||||
.filter((c) => c.overlapPercent > 0.25 && c.relativeArea < 0.75);
|
||||
|
||||
return overlaps.length > 0 ? overlaps[0].element : candidate;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -344,7 +344,7 @@ export class LinearElementEditor {
|
||||
|
||||
// Apply the point movement if needed
|
||||
let suggestedBinding: AppState["suggestedBinding"] = null;
|
||||
const { positions, updates } = pointDraggingUpdates(
|
||||
const { positions, updates, hit } = pointDraggingUpdates(
|
||||
[idx],
|
||||
deltaX,
|
||||
deltaY,
|
||||
@@ -382,17 +382,20 @@ export class LinearElementEditor {
|
||||
|
||||
// Move the arrow over the bindable object in terms of z-index
|
||||
if (isBindingElement(element)) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
if (hit) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[element.points.length - 1],
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
element.points[element.points.length - 1],
|
||||
elements,
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
elements,
|
||||
elementsMap,
|
||||
app.scene,
|
||||
);
|
||||
app.scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PERF: Avoid state updates if not absolutely necessary
|
||||
@@ -539,7 +542,7 @@ export class LinearElementEditor {
|
||||
|
||||
// Apply the point movement if needed
|
||||
let suggestedBinding: AppState["suggestedBinding"] = null;
|
||||
const { positions, updates } = pointDraggingUpdates(
|
||||
const { positions, updates, hit } = pointDraggingUpdates(
|
||||
selectedPointsIndices,
|
||||
deltaX,
|
||||
deltaY,
|
||||
@@ -578,19 +581,22 @@ export class LinearElementEditor {
|
||||
|
||||
// Move the arrow over the bindable object in terms of z-index
|
||||
if (isBindingElement(element) && startIsSelected !== endIsSelected) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
if (hit) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
startIsSelected
|
||||
? element.points[0]
|
||||
: element.points[element.points.length - 1],
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
startIsSelected
|
||||
? element.points[0]
|
||||
: element.points[element.points.length - 1],
|
||||
elements,
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
elements,
|
||||
elementsMap,
|
||||
app.scene,
|
||||
);
|
||||
app.scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Attached text might need to update if arrow dimensions change
|
||||
@@ -2136,6 +2142,7 @@ const pointDraggingUpdates = (
|
||||
): {
|
||||
positions: PointsPositionUpdates;
|
||||
updates?: PointMoveOtherUpdates;
|
||||
hit?: ExcalidrawBindableElement | null;
|
||||
} => {
|
||||
const naiveDraggingPoints = new Map(
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
@@ -2497,6 +2504,7 @@ const pointDraggingUpdates = (
|
||||
];
|
||||
}),
|
||||
),
|
||||
hit: startIsDragged ? start.element : end.element,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getElementsInGroup } from "./groups";
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
import { getSelectedElements } from "./selection";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type {
|
||||
@@ -156,12 +155,8 @@ export const moveArrowAboveBindable = (
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
hit?: NonDeletedExcalidrawElement,
|
||||
hoveredElement: NonDeletedExcalidrawElement,
|
||||
): readonly OrderedExcalidrawElement[] => {
|
||||
const hoveredElement = hit
|
||||
? hit
|
||||
: getHoveredElementForBinding(arrow, point, elements, elementsMap);
|
||||
|
||||
if (!hoveredElement) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
@@ -9887,17 +9887,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) {
|
||||
const hoveredElement = getHoveredElementForBinding(
|
||||
element,
|
||||
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
|
||||
this.scene.getNonDeletedElements(),
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(this.state.zoom),
|
||||
);
|
||||
|
||||
if (getFeatureFlag("COMPLEX_BINDINGS")) {
|
||||
this.handleDelayedBindModeChange(element, hoveredElement);
|
||||
}
|
||||
this.handleDelayedBindModeChange(element, hoveredElement);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user