Compare commits

...

16 Commits

Author SHA1 Message Date
Mark Tolmacs 7d3f116e65 Merge branch 'master' into mtolmacs/transparent-bind-hit
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-04 15:27:07 +00:00
Mark Tolmacs 13d6f6cf1d fix: Lint
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-10 14:54:26 +00:00
Mark Tolmacs 73c940bcf6 chore: Refactor binding gap calc
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-10 14:51:05 +00:00
Mark Tolmacs 60f29dc188 fix: Common midpoint handling with render
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 14:53:35 +00:00
Mark Tolmacs 4a161c1764 fix: Centralized midpoint snap code
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 13:58:30 +00:00
Mark Tolmacs 4e94b02375 fix: Add back transparency check for bindables
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:42 +00:00
Mark Tolmacs d8e8d1aeda fix: Distance calc with absolute distance
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:41 +00:00
Mark Tolmacs d142190796 chore: Snapshots
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:41 +00:00
Mark Tolmacs 0598f945f6 feat: Overlap & distance based binding candidate selection
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:41 +00:00
Mark Tolmacs 47c2fa9a39 fix: Remove the rest of startBoundElement
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:40 +00:00
Mark Tolmacs b0339916ab fix: Remove startBoundElements
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:40 +00:00
Mark Tolmacs 5b7bcbec9c fix: Test snapshots
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:39 +00:00
Mark Tolmacs 3a2d147dbd fix: Binding hit test
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:39 +00:00
Mark Tolmacs 37603d8e0b fix: Test snapshots
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:39 +00:00
Mark Tolmacs 05e5b13466 fix: Clear up element dragging & binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:38 +00:00
Mark Tolmacs 281c99e2d1 fix: Binding hit test
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-05-08 10:59:33 +00:00
18 changed files with 505 additions and 579 deletions
+6 -6
View File
@@ -10,7 +10,6 @@ import {
getBindingGap, getBindingGap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
isBindingEnabled, isBindingEnabled,
maxBindingDistance_simple,
unbindBindingElement, unbindBindingElement,
updateBoundPoint, updateBoundPoint,
} from "../binding"; } from "../binding";
@@ -20,7 +19,7 @@ import {
isElbowArrow, isElbowArrow,
} from "../typeChecks"; } from "../typeChecks";
import { LinearElementEditor } from "../linearElementEditor"; import { LinearElementEditor } from "../linearElementEditor";
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision"; import { getHoveredElementForBinding, hitElementItself } from "../collision";
import { moveArrowAboveBindable } from "../zindex"; import { moveArrowAboveBindable } from "../zindex";
import type { import type {
@@ -92,7 +91,7 @@ export const isFocusPointVisible = (
element: bindableElement, element: bindableElement,
elementsMap, elementsMap,
point: focusPoint, point: focusPoint,
threshold: getBindingGap(bindableElement, arrow), threshold: getBindingGap(bindableElement),
overrideShouldTestInside: true, overrideShouldTestInside: true,
}) })
); );
@@ -234,12 +233,12 @@ export const handleFocusPointDrag = (
pointerCoords.y - offsetY, pointerCoords.y - offsetY,
); );
const bindingField = isStartBinding ? "startBinding" : "endBinding"; const bindingField = isStartBinding ? "startBinding" : "endBinding";
const hit = getHoveredElementForFocusPoint( const hit = getHoveredElementForBinding(
point,
arrow, arrow,
point,
scene.getNonDeletedElements(), scene.getNonDeletedElements(),
elementsMap, elementsMap,
maxBindingDistance_simple(appState.zoom), appState.zoom,
); );
// Hovering a bindable element // Hovering a bindable element
@@ -270,6 +269,7 @@ export const handleFocusPointDrag = (
newMode || "orbit", newMode || "orbit",
linearElementEditor.draggedFocusPointBinding, linearElementEditor.draggedFocusPointBinding,
scene, scene,
appState.zoom,
point, point,
); );
} }
+38 -134
View File
@@ -60,6 +60,7 @@ import { updateElbowArrowPoints } from "./elbowArrow";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
getSnapOutlineMidPoint,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
} from "./utils"; } from "./utils";
@@ -110,18 +111,13 @@ export type BindingStrategy =
* IMPORTANT: currently must be > 0 (this also applies to the computed gap) * IMPORTANT: currently must be > 0 (this also applies to the computed gap)
*/ */
export const BASE_BINDING_GAP = 5; export const BASE_BINDING_GAP = 5;
export const BASE_BINDING_GAP_ELBOW = 5;
export const BASE_ARROW_MIN_LENGTH = 10; export const BASE_ARROW_MIN_LENGTH = 10;
export const FOCUS_POINT_SIZE = 10 / 1.5; export const FOCUS_POINT_SIZE = 10 / 1.5;
export const getBindingGap = ( export const getBindingGap = (
bindTarget: ExcalidrawBindableElement, bindTarget: ExcalidrawBindableElement,
opts: Pick<ExcalidrawArrowElement, "elbowed">,
): number => { ): number => {
return ( return BASE_BINDING_GAP + bindTarget.strokeWidth / 2;
(opts.elbowed ? BASE_BINDING_GAP_ELBOW : BASE_BINDING_GAP) +
bindTarget.strokeWidth / 2
);
}; };
export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => { export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => {
@@ -175,6 +171,7 @@ export const bindOrUnbindBindingElement = (
start, start,
"start", "start",
scene, scene,
appState.zoom,
appState.isBindingEnabled, appState.isBindingEnabled,
); );
bindOrUnbindBindingElementEdge( bindOrUnbindBindingElementEdge(
@@ -182,6 +179,7 @@ export const bindOrUnbindBindingElement = (
end, end,
"end", "end",
scene, scene,
appState.zoom,
appState.isBindingEnabled, appState.isBindingEnabled,
); );
if (start.focusPoint || end.focusPoint) { if (start.focusPoint || end.focusPoint) {
@@ -226,6 +224,7 @@ const bindOrUnbindBindingElementEdge = (
{ mode, element, focusPoint }: BindingStrategy, { mode, element, focusPoint }: BindingStrategy,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
scene: Scene, scene: Scene,
zoom: AppState["zoom"],
shouldSnapToOutline = true, shouldSnapToOutline = true,
): void => { ): void => {
if (mode === null) { if (mode === null) {
@@ -238,6 +237,7 @@ const bindOrUnbindBindingElementEdge = (
mode, mode,
startOrEnd, startOrEnd,
scene, scene,
zoom,
focusPoint, focusPoint,
shouldSnapToOutline, shouldSnapToOutline,
); );
@@ -270,10 +270,11 @@ const bindingStrategyForElbowArrowEndpointDragging = (
elementsMap, elementsMap,
); );
const hit = getHoveredElementForBinding( const hit = getHoveredElementForBinding(
arrow,
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
maxBindingDistance_simple(zoom), zoom,
); );
const current = hit const current = hit
@@ -321,7 +322,7 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
draggingPoints.get(startDragged ? startIdx : endIdx)!.point, draggingPoints.get(startDragged ? startIdx : endIdx)!.point,
elementsMap, elementsMap,
); );
const hit = getHoveredElementForBinding(point, elements, elementsMap); const hit = getHoveredElementForBinding(arrow, point, elements, elementsMap);
// With new arrows this handles the binding at arrow creation // With new arrows this handles the binding at arrow creation
if (startDragged) { if (startDragged) {
@@ -366,7 +367,12 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
// Check and handle nested shapes // Check and handle nested shapes
if (hit && arrow.startBinding) { if (hit && arrow.startBinding) {
const startBinding = arrow.startBinding; const startBinding = arrow.startBinding;
const allHits = getAllHoveredElementAtPoint(point, elements, elementsMap); const allHits = getAllHoveredElementAtPoint(
arrow,
point,
elements,
elementsMap,
);
if (allHits.find((el) => el.id === startBinding.elementId)) { if (allHits.find((el) => el.id === startBinding.elementId)) {
const otherElement = elementsMap.get( const otherElement = elementsMap.get(
@@ -468,9 +474,9 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
let other: BindingStrategy = { mode: undefined }; let other: BindingStrategy = { mode: undefined };
const isMultiPoint = arrow.points.length > 2; const isMultiPoint = arrow.points.length > 2;
const hit = getHoveredElementForBinding(point, elements, elementsMap); const hit = getHoveredElementForBinding(arrow, point, elements, elementsMap);
const isOverlapping = oppositeBinding const isOverlapping = oppositeBinding
? getAllHoveredElementAtPoint(point, elements, elementsMap).some( ? getAllHoveredElementAtPoint(arrow, point, elements, elementsMap).some(
(el) => el.id === oppositeBinding.elementId, (el) => el.id === oppositeBinding.elementId,
) )
: false; : false;
@@ -695,10 +701,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
elementsMap, elementsMap,
); );
const hit = getHoveredElementForBinding( const hit = getHoveredElementForBinding(
arrow,
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
maxBindingDistance_simple(appState.zoom), appState.zoom,
); );
const pointInElement = const pointInElement =
hit && hit &&
@@ -1019,6 +1026,7 @@ export const bindBindingElement = (
mode: BindMode, mode: BindMode,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
scene: Scene, scene: Scene,
zoom: AppState["zoom"],
focusPoint?: GlobalPoint, focusPoint?: GlobalPoint,
shouldSnapToOutline = true, shouldSnapToOutline = true,
): void => { ): void => {
@@ -1035,6 +1043,7 @@ export const bindBindingElement = (
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap, elementsMap,
zoom,
shouldSnapToOutline, shouldSnapToOutline,
), ),
}; };
@@ -1259,6 +1268,7 @@ const updateArrowBindings = (
strategy[strategyName].mode, strategy[strategyName].mode,
strategyName, strategyName,
scene, scene,
appState.zoom,
strategy[strategyName].focusPoint, strategy[strategyName].focusPoint,
); );
} }
@@ -1368,6 +1378,7 @@ export const bindPointToSnapToElementOutline = (
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: AppState["zoom"],
customIntersector?: LineSegment<GlobalPoint>, customIntersector?: LineSegment<GlobalPoint>,
isMidpointSnappingEnabled = true, isMidpointSnappingEnabled = true,
): GlobalPoint => { ): GlobalPoint => {
@@ -1400,7 +1411,7 @@ export const bindPointToSnapToElementOutline = (
startOrEnd === "start" ? 1 : -2, startOrEnd === "start" ? 1 : -2,
elementsMap, elementsMap,
); );
const bindingGap = getBindingGap(bindableElement, arrowElement); const bindingGap = getBindingGap(bindableElement);
const aabb = aabbForElement(bindableElement, elementsMap); const aabb = aabbForElement(bindableElement, elementsMap);
const bindableCenter = getCenterForBounds(aabb); const bindableCenter = getCenterForBounds(aabb);
@@ -1410,7 +1421,13 @@ export const bindPointToSnapToElementOutline = (
headingForPointFromElement(bindableElement, aabb, point), headingForPointFromElement(bindableElement, aabb, point),
); );
const snapPoint = isMidpointSnappingEnabled const snapPoint = isMidpointSnappingEnabled
? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement) ? getSnapOutlineMidPoint(
edgePoint,
bindableElement,
elementsMap,
zoom,
arrowElement,
)
: undefined; : undefined;
const resolved = snapPoint || point; const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
@@ -1455,7 +1472,7 @@ export const bindPointToSnapToElementOutline = (
bindableElement, bindableElement,
elementsMap, elementsMap,
anotherIntersector, anotherIntersector,
BASE_BINDING_GAP_ELBOW, BASE_BINDING_GAP,
).sort(pointDistanceSq)[0]; ).sort(pointDistanceSq)[0];
} }
} else { } else {
@@ -1514,7 +1531,7 @@ export const avoidRectangularCorner = (
-bindTarget.angle as Radians, -bindTarget.angle as Radians,
); );
const bindingGap = getBindingGap(bindTarget, arrowElement); const bindingGap = getBindingGap(bindTarget);
if (nonRotatedPoint[0] < bindTarget.x && nonRotatedPoint[1] < bindTarget.y) { if (nonRotatedPoint[0] < bindTarget.x && nonRotatedPoint[1] < bindTarget.y) {
// Top left // Top left
@@ -1592,121 +1609,6 @@ export const avoidRectangularCorner = (
return p; return p;
}; };
export const snapToMid = (
bindTarget: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint,
tolerance: number = 0.05,
arrowElement?: ExcalidrawArrowElement,
): GlobalPoint | undefined => {
const { x, y, width, height, angle } = bindTarget;
const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0;
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThreshold = clamp(tolerance * width, 5, 80);
// Too close to the center makes it hard to resolve direction precisely
if (pointDistance(center, nonRotated) < bindingGap) {
return undefined;
}
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// LEFT
return pointRotateRads(
pointFrom<GlobalPoint>(x - bindingGap, center[1]),
center,
angle,
);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// TOP
return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y - bindingGap),
center,
angle,
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// RIGHT
return pointRotateRads(
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
center,
angle,
);
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// DOWN
return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
center,
angle,
);
} else if (bindTarget.type === "diamond") {
const distance = bindingGap;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
);
const topRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + height / 4 - distance,
);
const bottomLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + (3 * height) / 4 + distance,
);
const bottomRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topLeft, center, angle);
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topRight, center, angle);
}
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomLeft, center, angle);
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomRight, center, angle);
}
}
return undefined;
};
const extractBinding = ( const extractBinding = (
arrow: ExcalidrawArrowElement, arrow: ExcalidrawArrowElement,
startOrEnd: "startBinding" | "endBinding", startOrEnd: "startBinding" | "endBinding",
@@ -1807,7 +1709,7 @@ export const updateBoundPoint = (
otherBindable, otherBindable,
elementsMap, elementsMap,
intersector, intersector,
getBindingGap(otherBindable, arrow), getBindingGap(otherBindable),
).sort( ).sort(
(a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint), (a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
)[0]; )[0];
@@ -1817,7 +1719,7 @@ export const updateBoundPoint = (
bindableElement, bindableElement,
elementsMap, elementsMap,
intersector, intersector,
getBindingGap(bindableElement, arrow), getBindingGap(bindableElement),
).sort( ).sort(
(a, b) => (a, b) =>
pointDistanceSq(a, otherFocusPointOrArrowPoint) - pointDistanceSq(a, otherFocusPointOrArrowPoint) -
@@ -1845,7 +1747,7 @@ export const updateBoundPoint = (
element: otherBindable, element: otherBindable,
point: outlinePoint, point: outlinePoint,
elementsMap, elementsMap,
threshold: getBindingGap(otherBindable, arrow), threshold: getBindingGap(otherBindable),
overrideShouldTestInside: true, overrideShouldTestInside: true,
}) })
) { ) {
@@ -1906,6 +1808,7 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: AppState["zoom"],
shouldSnapToOutline = true, shouldSnapToOutline = true,
isMidpointSnappingEnabled = true, isMidpointSnappingEnabled = true,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
@@ -1921,6 +1824,7 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap, elementsMap,
zoom,
undefined, undefined,
isMidpointSnappingEnabled, isMidpointSnappingEnabled,
) )
+90 -84
View File
@@ -25,7 +25,7 @@ import type {
Radians, Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import type { AppState, FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils"; import { isPathALoop } from "./utils";
import { import {
@@ -59,13 +59,12 @@ import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding"; import { getBindingGap, maxBindingDistance_simple } from "./binding";
import { hasBackground } from "./comparisons"; import { hasBackground } from "./comparisons";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
@@ -254,25 +253,20 @@ export const hitElementBoundText = (
return isPointInElement(point, boundTextElement, elementsMap); return isPointInElement(point, boundTextElement, elementsMap);
}; };
const bindingBorderTest = ( const bindableElementBorderDistanceIfClose = (
element: NonDeleted<ExcalidrawBindableElement>, element: NonDeleted<ExcalidrawBindableElement>,
[x, y]: Readonly<GlobalPoint>, point: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap, elementsMap: ElementsMap,
tolerance: number = 0, 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 // PERF: Run a cheap test to see if the binding element
// is even close to the element // is even close to the element
const [x, y] = point;
const t = Math.max(1, tolerance); const t = Math.max(1, tolerance);
const bounds = [x - t, y - t, x + t, y + t] as Bounds; const bounds = [x - t, y - t, x + t, y + t] as Bounds;
const elementBounds = getElementBounds(element, elementsMap); const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) { if (!doBoundsIntersect(bounds, elementBounds)) {
return false; return -Infinity;
} }
// If the element is inside a frame, we should clip the element // If the element is inside a frame, we should clip the element
@@ -283,33 +277,29 @@ const bindingBorderTest = (
enclosingFrame, enclosingFrame,
elementsMap, elementsMap,
); );
if (!pointInsideBounds(p, enclosingFrameBounds)) { if (!pointInsideBounds(point, enclosingFrameBounds)) {
return false; return -Infinity;
} }
} }
} }
// Do the intersection test against the element since it's close enough const distance = distanceToElement(element, elementsMap, point);
const intersections = intersectElementWithLineSegment( if (isPointInElement(point, element, elementsMap)) {
element, return distance;
elementsMap, }
lineSegment(elementCenterPoint(element, elementsMap), p),
);
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside return distance > tolerance ? -Infinity : -distance;
? intersections.length === 0 || distance <= tolerance
: intersections.length > 0 && distance <= t;
}; };
export const getAllHoveredElementAtPoint = ( export const getAllHoveredElementAtPoint = (
arrow: { elbowed: boolean },
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
tolerance?: number, tolerance?: number,
): NonDeleted<ExcalidrawBindableElement>[] => { ): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: 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 // because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index // with higher z-index
for (let index = elements.length - 1; index >= 0; --index) { for (let index = elements.length - 1; index >= 0; --index) {
@@ -322,7 +312,13 @@ export const getAllHoveredElementAtPoint = (
if ( if (
isBindableElement(element, false) && isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance) hitElementItself({
element,
point,
elementsMap,
threshold: tolerance ?? getBindingGap(element),
overrideShouldTestInside: true,
})
) { ) {
candidateElements.push(element); candidateElements.push(element);
@@ -339,82 +335,92 @@ export const getAllHoveredElementAtPoint = (
}; };
export const getHoveredElementForBinding = ( export const getHoveredElementForBinding = (
arrow: { elbowed: boolean },
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
tolerance?: number, zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint( type Candidate = {
point, element: NonDeleted<ExcalidrawBindableElement>;
elements, distance: number;
elementsMap, overlapPercent?: number;
tolerance, relativeArea?: number;
); };
if (!candidateElements || candidateElements.length === 0) { const candidates: Candidate[] = [];
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
export const getHoveredElementForFocusPoint = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): ExcalidrawBindableElement | null => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to 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) { for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index]; const element = elements[index];
invariant( if (!isBindableElement(element, false)) {
!element.isDeleted, continue;
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements", }
const maxDistance = maxBindingDistance_simple(zoom);
const distance = bindableElementBorderDistanceIfClose(
element,
point,
elementsMap,
maxDistance,
); );
if ( if (distance > -maxDistance) {
isBindableElement(element, false) && candidates.push({ element, distance });
bindingBorderTest(element, point, elementsMap, tolerance)
) { if (!isTransparent(element.backgroundColor) && distance >= 0) {
candidateElements.push(element); break;
}
} }
} }
if (!candidateElements || candidateElements.length === 0) { if (candidates.length === 0) {
return null; return null;
} }
if (candidateElements.length === 1) { if (candidates.length === 1) {
return candidateElements[0]; return candidates[0].element;
} }
const distanceFilteredCandidateElements = candidateElements const closestElements = candidates.sort(
// Resolve by distance (a, b) => Math.abs(a.distance) - Math.abs(b.distance),
.filter( );
(el) =>
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
isPointInElement(point, el, elementsMap),
);
if (distanceFilteredCandidateElements.length === 0) { const candidate = closestElements[0];
return null; const [cx1, cy1, cx2, cy2] = getElementBounds(candidate.element, elementsMap);
} const candidateArea = Math.max(
0.00001,
Math.abs(cx2 - cx1) * Math.abs(cy2 - cy1),
);
const overlaps = closestElements
.map((c) => {
if (c.element === candidate.element) {
return { ...c, overlapPercent: 0, relativeArea: 1 };
}
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>; 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 candidate.distance >= 0 && overlaps.length > 0
? overlaps[0].element
: candidate.element;
}; };
/** /**
+8 -18
View File
@@ -21,7 +21,7 @@ import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { getMinTextElementWidth } from "./textMeasurements"; import { getMinTextElementWidth } from "./textMeasurements";
import { import {
isArrowElement, isArrowElement as isBindingElement,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isImageElement, isImageElement,
@@ -108,19 +108,7 @@ export const dragSelectedElements = (
); );
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
const isArrow = !isArrowElement(element); if (!isBindingElement(element)) {
const isStartBoundElementSelected =
isArrow ||
(element.startBinding
? elementsToUpdateIds.has(element.startBinding.elementId)
: false);
const isEndBoundElementSelected =
isArrow ||
(element.endBinding
? elementsToUpdateIds.has(element.endBinding.elementId)
: false);
if (!isArrowElement(element)) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset); updateElementCoords(pointerDownState, element, scene, adjustedOffset);
// skip arrow labels since we calculate its position during render // skip arrow labels since we calculate its position during render
@@ -143,7 +131,6 @@ export const dragSelectedElements = (
// NOTE: Add a little initial drag to the arrow dragging when the arrow // NOTE: Add a little initial drag to the arrow dragging when the arrow
// is the single element being dragged to avoid accidentally unbinding // is the single element being dragged to avoid accidentally unbinding
// the arrow when the user just wants to select it. // the arrow when the user just wants to select it.
elementsToUpdate.size > 1 || elementsToUpdate.size > 1 ||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) > Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
DRAGGING_THRESHOLD || DRAGGING_THRESHOLD ||
@@ -151,9 +138,12 @@ export const dragSelectedElements = (
) { ) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset); updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const shouldUnbindStart = const shouldUnbindStart = element.startBinding
element.startBinding && !isStartBoundElementSelected; ? !elementsToUpdateIds.has(element.startBinding.elementId)
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected; : true;
const shouldUnbindEnd = element.endBinding
? !elementsToUpdateIds.has(element.endBinding.elementId)
: true;
if (shouldUnbindStart || shouldUnbindEnd) { if (shouldUnbindStart || shouldUnbindEnd) {
// NOTE: Moving the bound arrow should unbind it, otherwise we would // NOTE: Moving the bound arrow should unbind it, otherwise we would
// have weird situations, like 0 lenght arrow when the user moves // have weird situations, like 0 lenght arrow when the user moves
+21 -20
View File
@@ -30,8 +30,7 @@ import {
getHeadingForElbowArrowSnap, getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
getBindingGap, getBindingGap,
maxBindingDistance_simple, BASE_BINDING_GAP,
BASE_BINDING_GAP_ELBOW,
} from "./binding"; } from "./binding";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
@@ -318,6 +317,7 @@ const handleSegmentRelease = (
...rest ...rest
} = getElbowArrowData( } = getElbowArrowData(
{ {
...arrow,
x, x,
y, y,
startBinding, startBinding,
@@ -1041,6 +1041,7 @@ export const updateElbowArrowPoints = (
...rest ...rest
} = getElbowArrowData( } = getElbowArrowData(
{ {
...arrow,
x: arrow.x, x: arrow.x,
y: arrow.y, y: arrow.y,
startBinding, startBinding,
@@ -1190,15 +1191,7 @@ export const updateElbowArrowPoints = (
* - hoveredEndElement: The element being hovered over at the end point. * - hoveredEndElement: The element being hovered over at the end point.
*/ */
const getElbowArrowData = ( const getElbowArrowData = (
arrow: { arrow: ExcalidrawElbowArrowElement,
x: number;
y: number;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
points: readonly LocalPoint[];
},
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
nextPoints: readonly LocalPoint[], nextPoints: readonly LocalPoint[],
options?: { options?: {
@@ -1223,6 +1216,7 @@ const getElbowArrowData = (
const elements = Array.from(elementsMap.values()); const elements = Array.from(elementsMap.values());
hoveredStartElement = hoveredStartElement =
getHoveredElement( getHoveredElement(
arrow,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap, elementsMap,
elements, elements,
@@ -1230,6 +1224,7 @@ const getElbowArrowData = (
) || null; ) || null;
hoveredEndElement = hoveredEndElement =
getHoveredElement( getHoveredElement(
arrow,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap, elementsMap,
elements, elements,
@@ -1256,6 +1251,7 @@ const getElbowArrowData = (
"start", "start",
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
options?.zoom || ({ value: 1 } as AppState["zoom"]),
hoveredStartElement, hoveredStartElement,
elementsMap, elementsMap,
options?.isDragging, options?.isDragging,
@@ -1273,6 +1269,7 @@ const getElbowArrowData = (
"end", "end",
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
options?.zoom || ({ value: 1 } as AppState["zoom"]),
hoveredEndElement, hoveredEndElement,
elementsMap, elementsMap,
options?.isDragging, options?.isDragging,
@@ -1314,8 +1311,8 @@ const getElbowArrowData = (
offsetFromHeading( offsetFromHeading(
startHeading, startHeading,
arrow.startArrowhead arrow.startArrowhead
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6 ? getBindingGap(hoveredStartElement) * 6
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2, : getBindingGap(hoveredStartElement) * 2,
1, 1,
), ),
) )
@@ -1327,8 +1324,8 @@ const getElbowArrowData = (
offsetFromHeading( offsetFromHeading(
endHeading, endHeading,
arrow.endArrowhead arrow.endArrowhead
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6 ? getBindingGap(hoveredEndElement) * 6
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2, : getBindingGap(hoveredEndElement) * 2,
1, 1,
), ),
) )
@@ -1375,8 +1372,8 @@ const getElbowArrowData = (
? 0 ? 0
: BASE_PADDING - : BASE_PADDING -
(arrow.startArrowhead (arrow.startArrowhead
? BASE_BINDING_GAP_ELBOW * 6 ? BASE_BINDING_GAP * 6
: BASE_BINDING_GAP_ELBOW * 2), : BASE_BINDING_GAP * 2),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap boundsOverlap
@@ -1391,8 +1388,8 @@ const getElbowArrowData = (
? 0 ? 0
: BASE_PADDING - : BASE_PADDING -
(arrow.endArrowhead (arrow.endArrowhead
? BASE_BINDING_GAP_ELBOW * 6 ? BASE_BINDING_GAP * 6
: BASE_BINDING_GAP_ELBOW * 2), : BASE_BINDING_GAP * 2),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
@@ -2218,6 +2215,7 @@ const getGlobalPoint = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint, initialPoint: GlobalPoint,
zoom: AppState["zoom"],
element?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap, elementsMap?: ElementsMap,
isDragging?: boolean, isDragging?: boolean,
@@ -2231,6 +2229,7 @@ const getGlobalPoint = (
element, element,
startOrEnd, startOrEnd,
elementsMap, elementsMap,
zoom,
undefined, undefined,
isMidpointSnappingEnabled, isMidpointSnappingEnabled,
); );
@@ -2279,16 +2278,18 @@ const getBindPointHeading = (
); );
const getHoveredElement = ( const getHoveredElement = (
arrow: ExcalidrawElbowArrowElement,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
) => { ) => {
return getHoveredElementForBinding( return getHoveredElementForBinding(
arrow,
origPoint, origPoint,
elements, elements,
elementsMap, elementsMap,
maxBindingDistance_simple(zoom), zoom,
); );
}; };
+9 -1
View File
@@ -452,8 +452,16 @@ const createBindingArrow = (
"orbit", "orbit",
"start", "start",
scene, scene,
appState.zoom,
);
bindBindingElement(
bindingArrow,
endBindingElement,
"orbit",
"end",
scene,
appState.zoom,
); );
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>(); const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set( changedElements.set(
+37 -26
View File
@@ -48,7 +48,6 @@ import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints, getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled, isBindingEnabled,
snapToMid,
updateBoundPoint, updateBoundPoint,
} from "./binding"; } from "./binding";
import { import {
@@ -344,7 +343,7 @@ export class LinearElementEditor {
// Apply the point movement if needed // Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null; let suggestedBinding: AppState["suggestedBinding"] = null;
const { positions, updates } = pointDraggingUpdates( const { positions, updates, hit } = pointDraggingUpdates(
[idx], [idx],
deltaX, deltaX,
deltaY, deltaY,
@@ -382,17 +381,20 @@ export class LinearElementEditor {
// Move the arrow over the bindable object in terms of z-index // Move the arrow over the bindable object in terms of z-index
if (isBindingElement(element)) { if (isBindingElement(element)) {
moveArrowAboveBindable( if (hit) {
LinearElementEditor.getPointGlobalCoordinates( moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[element.points.length - 1],
elementsMap,
),
element, element,
element.points[element.points.length - 1], elements,
elementsMap, elementsMap,
), app.scene,
element, hit,
elements, );
elementsMap, }
app.scene,
);
} }
// PERF: Avoid state updates if not absolutely necessary // PERF: Avoid state updates if not absolutely necessary
@@ -539,7 +541,7 @@ export class LinearElementEditor {
// Apply the point movement if needed // Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null; let suggestedBinding: AppState["suggestedBinding"] = null;
const { positions, updates } = pointDraggingUpdates( const { positions, updates, hit } = pointDraggingUpdates(
selectedPointsIndices, selectedPointsIndices,
deltaX, deltaX,
deltaY, deltaY,
@@ -578,19 +580,22 @@ export class LinearElementEditor {
// Move the arrow over the bindable object in terms of z-index // Move the arrow over the bindable object in terms of z-index
if (isBindingElement(element) && startIsSelected !== endIsSelected) { if (isBindingElement(element) && startIsSelected !== endIsSelected) {
moveArrowAboveBindable( if (hit) {
LinearElementEditor.getPointGlobalCoordinates( moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
element,
startIsSelected
? element.points[0]
: element.points[element.points.length - 1],
elementsMap,
),
element, element,
startIsSelected elements,
? element.points[0]
: element.points[element.points.length - 1],
elementsMap, elementsMap,
), app.scene,
element, hit,
elements, );
elementsMap, }
app.scene,
);
} }
// Attached text might need to update if arrow dimensions change // Attached text might need to update if arrow dimensions change
@@ -2136,6 +2141,7 @@ const pointDraggingUpdates = (
): { ): {
positions: PointsPositionUpdates; positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates; updates?: PointMoveOtherUpdates;
hit?: ExcalidrawBindableElement | null;
} => { } => {
const naiveDraggingPoints = new Map( const naiveDraggingPoints = new Map(
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
@@ -2193,13 +2199,15 @@ const pointDraggingUpdates = (
? { ? {
element: suggestedBindingElement, element: suggestedBindingElement,
midPoint: app.state.isMidpointSnappingEnabled midPoint: app.state.isMidpointSnappingEnabled
? snapToMid( ? getSnapOutlineMidPoint(
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x, scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
), ),
suggestedBindingElement,
elementsMap,
app.state.zoom,
element,
) )
: undefined, : undefined,
} }
@@ -2306,6 +2314,7 @@ const pointDraggingUpdates = (
start.element, start.element,
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
element,
), ),
} }
: null; : null;
@@ -2345,6 +2354,7 @@ const pointDraggingUpdates = (
end.element, end.element,
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
element,
), ),
} }
: null; : null;
@@ -2497,6 +2507,7 @@ const pointDraggingUpdates = (
]; ];
}), }),
), ),
hit: startIsDragged ? start.element : end.element,
}; };
}; };
+6
View File
@@ -17,6 +17,7 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { MarkOptional } from "@excalidraw/common/utility-types"; import type { MarkOptional } from "@excalidraw/common/utility-types";
import type { Zoom } from "@excalidraw/excalidraw/types";
import { bindBindingElement } from "./binding"; import { bindBindingElement } from "./binding";
import { import {
@@ -248,6 +249,7 @@ const bindLinearElementToElement = (
end: ValidLinearElement["end"], end: ValidLinearElement["end"],
elementStore: ElementStore, elementStore: ElementStore,
scene: Scene, scene: Scene,
zoom: Zoom,
): { ): {
linearElement: ExcalidrawLinearElement; linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement; startBoundElement?: ExcalidrawElement;
@@ -335,6 +337,7 @@ const bindLinearElementToElement = (
"orbit", "orbit",
"start", "start",
scene, scene,
zoom,
); );
} }
} }
@@ -411,6 +414,7 @@ const bindLinearElementToElement = (
"orbit", "orbit",
"end", "end",
scene, scene,
zoom,
); );
} }
} }
@@ -696,6 +700,7 @@ export const convertToExcalidrawElements = (
originalEnd, originalEnd,
elementStore, elementStore,
scene, scene,
{ value: 1 } as Zoom,
); );
container = linearElement; container = linearElement;
elementStore.add(linearElement); elementStore.add(linearElement);
@@ -721,6 +726,7 @@ export const convertToExcalidrawElements = (
end, end,
elementStore, elementStore,
scene, scene,
{ value: 1 } as Zoom,
); );
elementStore.add(linearElement); elementStore.add(linearElement);
+181 -56
View File
@@ -8,6 +8,7 @@ import {
import { import {
bezierEquation, bezierEquation,
clamp,
curve, curve,
curveCatmullRomCubicApproxPoints, curveCatmullRomCubicApproxPoints,
curveOffsetPoints, curveOffsetPoints,
@@ -26,7 +27,7 @@ import {
type GlobalPoint, type GlobalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; import type { Curve, LineSegment, LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
AppState, AppState,
@@ -41,7 +42,7 @@ import { generateLinearCollisionShape } from "./shape";
import { hitElementItself, isPointInElement } from "./collision"; import { hitElementItself, isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks"; import { isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding"; import { getBindingGap, maxBindingDistance_simple } from "./binding";
import { import {
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
@@ -587,67 +588,193 @@ const getDiagonalsForBindableElement = (
return [diagonalOne, diagonalTwo]; return [diagonalOne, diagonalTwo];
}; };
export const getSnapOutlineMidPoint = ( const getSnappedMidpointIndexForElbowArrow = (
point: GlobalPoint,
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap, point: GlobalPoint,
zoom: AppState["zoom"], center: GlobalPoint,
horizontalThreshold: number,
verticalThreshold: number,
) => { ) => {
const center = elementCenterPoint(element, elementsMap); const { x, y, width, height, angle } = element;
const sideMidpoints = const nonRotated = pointRotateRads(point, center, -angle as Radians);
element.type === "diamond"
? getDiamondBaseCorners(element).map((curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(point, center, element.angle);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]); const bindingGap = getBindingGap(element);
})
: [ if (pointDistance(center, nonRotated) < bindingGap) {
// RIGHT midpoint return -1;
pointRotateRads( }
pointFrom<GlobalPoint>(
element.x + element.width, if (
element.y + element.height / 2, nonRotated[0] <= x + width / 2 &&
), nonRotated[1] > center[1] - verticalThreshold &&
center, nonRotated[1] < center[1] + verticalThreshold
element.angle, ) {
), return 2;
// BOTTOM midpoint } else if (
pointRotateRads( nonRotated[1] <= y + height / 2 &&
pointFrom<GlobalPoint>( nonRotated[0] > center[0] - horizontalThreshold &&
element.x + element.width / 2, nonRotated[0] < center[0] + horizontalThreshold
element.y + element.height, ) {
), return 3;
center, } else if (
element.angle, nonRotated[0] >= x + width / 2 &&
), nonRotated[1] > center[1] - verticalThreshold &&
// LEFT midpoint nonRotated[1] < center[1] + verticalThreshold
pointRotateRads( ) {
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2), return 0;
center, } else if (
element.angle, nonRotated[1] >= y + height / 2 &&
), nonRotated[0] > center[0] - horizontalThreshold &&
// TOP midpoint nonRotated[0] < center[0] + horizontalThreshold
pointRotateRads( ) {
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y), return 1;
center, } else if (element.type === "diamond") {
element.angle, const distance = bindingGap;
), const topLeft = pointFrom<GlobalPoint>(
]; x + width / 4 - distance,
const candidate = sideMidpoints.find( y + height / 4 - distance,
(midpoint) => );
pointDistance(point, midpoint) <= const topRight = pointFrom<GlobalPoint>(
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 && x + (3 * width) / 4 + distance,
y + height / 4 - distance,
);
const bottomLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + (3 * height) / 4 + distance,
);
const bottomRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 1;
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 0;
}
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 2;
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 3;
}
}
return -1;
};
const getSnappedMidpointIndexForSimpleArrow = (
element: ExcalidrawBindableElement,
point: GlobalPoint,
elementsMap: ElementsMap,
horizontalThreshold: number,
verticalThreshold: number,
) => {
const baseMidpoints = getAllMidpoints(element, elementsMap);
for (let i = 0; i < baseMidpoints.length; i++) {
const threshold = i % 2 === 0 ? horizontalThreshold : verticalThreshold;
if (
pointDistance(baseMidpoints[i], point) <= threshold &&
!hitElementItself({ !hitElementItself({
point, point,
element, element,
threshold: 0, threshold: 0,
elementsMap, elementsMap,
overrideShouldTestInside: true, overrideShouldTestInside: true,
}), })
); ) {
return i;
}
}
return candidate; return -1;
};
export const getAllMidpoints = (
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
if (element.type === "diamond") {
return getDiamondBaseCorners(element).map((curve) =>
pointRotateRads(bezierEquation(curve, 0.5), center, element.angle),
);
}
return [
pointFrom(element.width, element.height / 2),
pointFrom(element.width / 2, element.height),
pointFrom(0, element.height / 2),
pointFrom(element.width / 2, 0),
].map(([x, y]) =>
pointRotateRads(
pointFrom<GlobalPoint>(element.x + x, element.y + y),
center,
element.angle,
),
);
};
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
arrow: { elbowed: boolean },
): GlobalPoint | undefined => {
const center = elementCenterPoint(element, elementsMap);
const baseMidpoints = getAllMidpoints(element, elementsMap);
const sideMidpoints =
element.type === "diamond"
? baseMidpoints.map((midpoint) => {
return pointFrom<GlobalPoint>(
midpoint[0] + (midpoint[0] - center[0]) * 0.1,
midpoint[1] + (midpoint[1] - center[1]) * 0.1,
);
})
: baseMidpoints;
const TOLERANCE = 0.05;
const maxDistance = maxBindingDistance_simple(zoom) + element.strokeWidth / 2;
const verticalThreshold = clamp(TOLERANCE * element.height, 5, maxDistance);
const horizontalThreshold = clamp(TOLERANCE * element.width, 5, maxDistance);
const idx = arrow.elbowed
? getSnappedMidpointIndexForElbowArrow(
element,
point,
center,
horizontalThreshold,
verticalThreshold,
)
: getSnappedMidpointIndexForSimpleArrow(
element,
point,
elementsMap,
horizontalThreshold,
verticalThreshold,
);
if (idx === -1) {
return undefined;
}
return sideMidpoints[idx];
}; };
export const projectFixedPointOntoDiagonal = ( export const projectFixedPointOntoDiagonal = (
@@ -660,9 +787,6 @@ export const projectFixedPointOntoDiagonal = (
isMidpointSnappingEnabled: boolean = true, isMidpointSnappingEnabled: boolean = true,
): GlobalPoint | null => { ): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points"); invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
if (isMidpointSnappingEnabled) { if (isMidpointSnappingEnabled) {
const sideMidPoint = getSnapOutlineMidPoint( const sideMidPoint = getSnapOutlineMidPoint(
@@ -670,6 +794,7 @@ export const projectFixedPointOntoDiagonal = (
element, element,
elementsMap, elementsMap,
zoom, zoom,
arrow,
); );
if (sideMidPoint) { if (sideMidPoint) {
return sideMidPoint; return sideMidPoint;
+1 -6
View File
@@ -8,7 +8,6 @@ import { getElementsInGroup } from "./groups";
import { syncMovedIndices } from "./fractionalIndex"; import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { getHoveredElementForBinding } from "./collision";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { import type {
@@ -156,12 +155,8 @@ export const moveArrowAboveBindable = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
scene: Scene, scene: Scene,
hit?: NonDeletedExcalidrawElement, hoveredElement: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => { ): readonly OrderedExcalidrawElement[] => {
const hoveredElement = hit
? hit
: getHoveredElementForBinding(point, elements, elementsMap);
if (!hoveredElement) { if (!hoveredElement) {
return elements; return elements;
} }
+7 -2
View File
@@ -16,6 +16,7 @@ import "@excalidraw/utils/test-utils";
import { bindBindingElement } from "@excalidraw/element"; import { bindBindingElement } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import type { Zoom } from "@excalidraw/excalidraw/types";
import { Scene } from "../src/Scene"; import { Scene } from "../src/Scene";
@@ -187,8 +188,12 @@ describe("elbow arrow routing", () => {
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
API.setElements([rectangle1, rectangle2, arrow]); API.setElements([rectangle1, rectangle2, arrow]);
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene); bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene, {
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene); value: 1,
} as Zoom);
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene, {
value: 1,
} as Zoom);
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
@@ -1897,6 +1897,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
startElement, startElement,
"start", "start",
elementsMap, elementsMap,
appState.zoom,
appState.isBindingEnabled, appState.isBindingEnabled,
), ),
} }
@@ -1911,6 +1912,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
endElement, endElement,
"end", "end",
elementsMap, elementsMap,
appState.zoom,
appState.isBindingEnabled, appState.isBindingEnabled,
), ),
} }
@@ -1943,6 +1945,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
appState.bindMode === "inside" ? "inside" : "orbit", appState.bindMode === "inside" ? "inside" : "orbit",
"start", "start",
app.scene, app.scene,
appState.zoom,
); );
} }
} }
@@ -1957,6 +1960,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
appState.bindMode === "inside" ? "inside" : "orbit", appState.bindMode === "inside" ? "inside" : "orbit",
"end", "end",
app.scene, app.scene,
appState.zoom,
); );
} }
} }
+41 -24
View File
@@ -248,7 +248,6 @@ import {
getElementBounds, getElementBounds,
doBoundsIntersect, doBoundsIntersect,
isPointInElement, isPointInElement,
maxBindingDistance_simple,
convertToExcalidrawElements, convertToExcalidrawElements,
type ExcalidrawElementSkeleton, type ExcalidrawElementSkeleton,
getSnapOutlineMidPoint, getSnapOutlineMidPoint,
@@ -934,14 +933,20 @@ class App extends React.Component<AppProps, AppState> {
"Missing last pointer move coords when changing bind skip mode for arrow start", "Missing last pointer move coords when changing bind skip mode for arrow start",
); );
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
const hoveredElement = getHoveredElementForBinding( const arrow = elementsMap.get(
pointFrom<GlobalPoint>( this.state.selectedLinearElement.elementId,
this.lastPointerMoveCoords.x, ) as ExcalidrawArrowElement | undefined;
this.lastPointerMoveCoords.y, const hoveredElement =
), arrow &&
this.scene.getNonDeletedElements(), getHoveredElementForBinding(
elementsMap, arrow,
); pointFrom<GlobalPoint>(
this.lastPointerMoveCoords.x,
this.lastPointerMoveCoords.y,
),
this.scene.getNonDeletedElements(),
elementsMap,
);
const element = LinearElementEditor.getElement( const element = LinearElementEditor.getElement(
this.state.selectedLinearElement.elementId, this.state.selectedLinearElement.elementId,
elementsMap, elementsMap,
@@ -1071,6 +1076,7 @@ class App extends React.Component<AppProps, AppState> {
const { x, y } = this.lastPointerMoveCoords; const { x, y } = this.lastPointerMoveCoords;
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
arrow,
pointFrom<GlobalPoint>(x, y), pointFrom<GlobalPoint>(x, y),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
@@ -5377,12 +5383,6 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
); );
const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
);
if (this.state.selectedLinearElement) { if (this.state.selectedLinearElement) {
const element = LinearElementEditor.getElement( const element = LinearElementEditor.getElement(
this.state.selectedLinearElement.elementId, this.state.selectedLinearElement.elementId,
@@ -5390,6 +5390,13 @@ class App extends React.Component<AppProps, AppState> {
); );
if (isBindingElement(element)) { if (isBindingElement(element)) {
const hoveredElement = getHoveredElementForBinding(
element,
pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
);
this.handleDelayedBindModeChange(element, hoveredElement); this.handleDelayedBindModeChange(element, hoveredElement);
} }
} }
@@ -7076,10 +7083,11 @@ class App extends React.Component<AppProps, AppState> {
); );
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
globalPoint, { elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow },
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
elementsMap, elementsMap,
maxBindingDistance_simple(this.state.zoom), this.state.zoom,
); );
if (hoveredElement) { if (hoveredElement) {
this.setState({ this.setState({
@@ -7090,6 +7098,9 @@ class App extends React.Component<AppProps, AppState> {
hoveredElement, hoveredElement,
elementsMap, elementsMap,
this.state.zoom, this.state.zoom,
{
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
},
), ),
}, },
}); });
@@ -7115,10 +7126,11 @@ class App extends React.Component<AppProps, AppState> {
isArrowElement(this.state.newElement) && isArrowElement(this.state.newElement) &&
isBindingEnabled(this.state) && isBindingEnabled(this.state) &&
getHoveredElementForBinding( getHoveredElementForBinding(
this.state.newElement,
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom), this.state.zoom,
); );
if (hoveredElement) { if (hoveredElement) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
@@ -7233,6 +7245,7 @@ class App extends React.Component<AppProps, AppState> {
if (isSimpleArrow(multiElement)) { if (isSimpleArrow(multiElement)) {
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
multiElement,
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
elementsMap, elementsMap,
@@ -7265,10 +7278,11 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.activeTool.type === "arrow") { if (this.state.activeTool.type === "arrow") {
const hit = getHoveredElementForBinding( const hit = getHoveredElementForBinding(
{ elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow },
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom), this.state.zoom,
); );
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY); const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
@@ -7281,6 +7295,7 @@ class App extends React.Component<AppProps, AppState> {
hit, hit,
elementsMap, elementsMap,
this.state.zoom, this.state.zoom,
{ elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow },
), ),
}, },
}); });
@@ -9267,6 +9282,7 @@ class App extends React.Component<AppProps, AppState> {
const hoveredElementForBinding = const hoveredElementForBinding =
isBindingEnabled(this.state) && isBindingEnabled(this.state) &&
getHoveredElementForBinding( getHoveredElementForBinding(
{ elbowed: isElbowArrow(multiElement) },
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(
this.lastPointerMoveCoords?.x ?? this.lastPointerMoveCoords?.x ??
rx + multiElement.points[multiElement.points.length - 1][0], rx + multiElement.points[multiElement.points.length - 1][0],
@@ -9389,6 +9405,7 @@ class App extends React.Component<AppProps, AppState> {
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
const boundElement = isBindingEnabled(this.state) const boundElement = isBindingEnabled(this.state)
? getHoveredElementForBinding( ? getHoveredElementForBinding(
{ elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow },
point, point,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
elementsMap, elementsMap,
@@ -9464,7 +9481,6 @@ class App extends React.Component<AppProps, AppState> {
...prevState, ...prevState,
bindMode: "orbit", bindMode: "orbit",
newElement: element, newElement: element,
startBoundElement: boundElement,
suggestedBinding: suggestedBinding:
boundElement && isBindingElement(element) boundElement && isBindingElement(element)
? { ? {
@@ -9474,6 +9490,7 @@ class App extends React.Component<AppProps, AppState> {
boundElement, boundElement,
elementsMap, elementsMap,
this.state.zoom, this.state.zoom,
element,
), ),
} }
: null, : null,
@@ -9870,16 +9887,16 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (isBindingElement(element)) { if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) {
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
element,
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y), pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
elementsMap, elementsMap,
this.state.zoom,
); );
if (getFeatureFlag("COMPLEX_BINDINGS")) { this.handleDelayedBindModeChange(element, hoveredElement);
this.handleDelayedBindModeChange(element, hoveredElement);
}
} }
if ( if (
+29 -135
View File
@@ -7,7 +7,6 @@ import {
type Radians, type Radians,
bezierEquation, bezierEquation,
pointRotateRads, pointRotateRads,
pointDistance,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
@@ -24,13 +23,12 @@ import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
elementCenterPoint, elementCenterPoint,
getDiamondBaseCorners, getAllMidpoints,
FOCUS_POINT_SIZE, FOCUS_POINT_SIZE,
getOmitSidesForEditorInterface, getOmitSidesForEditorInterface,
getTransformHandles, getTransformHandles,
getTransformHandlesFromCoords, getTransformHandlesFromCoords,
hasBoundingBox, hasBoundingBox,
hitElementItself,
isArrowElement, isArrowElement,
isBindableElement, isBindableElement,
isElbowArrow, isElbowArrow,
@@ -38,7 +36,6 @@ import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
maxBindingDistance_simple,
isTextElement, isTextElement,
LinearElementEditor, LinearElementEditor,
getActiveTextElement, getActiveTextElement,
@@ -413,145 +410,42 @@ const renderBindingHighlightForBindableElement_simple = (
break; break;
} }
// Draw midpoint indicators
if ( if (
appState.isMidpointSnappingEnabled && appState.isMidpointSnappingEnabled &&
(isFrameLikeElement(suggestedBinding.element) || (isFrameLikeElement(suggestedBinding.element) ||
isBindableElement(suggestedBinding.element)) isBindableElement(suggestedBinding.element))
) { ) {
// Draw midpoint indicators context.save();
const linearElement = appState.selectedLinearElement;
const arrow =
linearElement?.elementId &&
LinearElementEditor.getElement(linearElement?.elementId, elementsMap);
const cursorIsInsideBindable =
pointerCoords &&
hitElementItself({
point: pointerCoords,
element: suggestedBinding.element,
elementsMap,
threshold: 0,
overrideShouldTestInside: true,
});
const isElbow = const midpointRadius = 4 / appState.zoom.value;
(arrow && isElbowArrow(arrow)) ||
(appState.activeTool.type === "arrow" &&
appState.currentItemArrowType === "elbow");
if (!cursorIsInsideBindable || isElbow) { // Render base midpoints
context.save(); const midpoints = getAllMidpoints(suggestedBinding.element, elementsMap);
for (const midpoint of midpoints) {
const center = elementCenterPoint(suggestedBinding.element, elementsMap); context.fillStyle =
appState.theme === THEME.DARK
let midpoints: GlobalPoint[]; ? `rgba(0, 0, 0, 0.8)`
if (suggestedBinding.element.type === "diamond") { : `rgba(65, 65, 65, 0.5)`;
const center = elementCenterPoint( context.beginPath();
suggestedBinding.element, context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
elementsMap, context.fill();
);
midpoints = getDiamondBaseCorners(suggestedBinding.element).map(
(curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(
point,
center,
suggestedBinding.element.angle,
);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
},
);
} else {
const basePoints = [
{
x: suggestedBinding.element.width,
y: suggestedBinding.element.height / 2,
}, // RIGHT
{
x: suggestedBinding.element.width / 2,
y: suggestedBinding.element.height,
}, // BOTTOM
{ x: 0, y: suggestedBinding.element.height / 2 }, // LEFT
{ x: suggestedBinding.element.width / 2, y: 0 }, // TOP
];
midpoints = basePoints.map((point) => {
const globalPoint = pointFrom<GlobalPoint>(
point.x + suggestedBinding.element.x,
point.y + suggestedBinding.element.y,
);
const rotatedPoint = pointRotateRads(
globalPoint,
center,
suggestedBinding.element.angle,
);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
});
}
const hoveredMidpoint =
pointerCoords &&
midpoints.reduce(
(
closestIdx: {
idx: number;
distance: number;
},
point,
idx,
) => {
const distance = pointDistance(point, pointerCoords);
if (idx === -1 || distance < closestIdx.distance) {
return { idx, distance };
}
return closestIdx;
},
{
idx: -1,
distance: Infinity,
},
);
const midpointRadius = 4 / appState.zoom.value;
const highlightThreshold =
maxBindingDistance_simple(appState.zoom) +
suggestedBinding.element.strokeWidth / 2;
midpoints.forEach((midpoint, idx) => {
const isHighlighted =
(!cursorIsInsideBindable || isElbow) &&
hoveredMidpoint?.idx === idx &&
hoveredMidpoint.distance <= highlightThreshold;
// also render midpoint if cursor close but not highlighted
// (for elbows, always show all points)
const isShown =
!isHighlighted &&
(isElbow ||
(idx === hoveredMidpoint?.idx &&
hoveredMidpoint.distance <= highlightThreshold * 2));
if (isHighlighted) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, 1)`
: `rgba(106, 189, 252, 1)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
} else if (isShown) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(0, 0, 0, 0.8)`
: `rgba(65, 65, 65, 0.5)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
}
});
context.restore();
} }
// Render the highlighted midpoint if any
const midpoint = appState.suggestedBinding?.midPoint;
if (midpoint) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, 1)`
: `rgba(106, 189, 252, 1)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
}
context.restore();
} }
}; };
@@ -98,7 +98,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -732,7 +731,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2277,40 +2275,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id4",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"id": "id0",
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 493213705,
"width": 100,
"x": -100,
"y": -50,
},
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2410,7 +2374,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endBinding": { "endBinding": {
"elementId": "id1", "elementId": "id1",
"fixedPoint": [ "fixedPoint": [
0, "0.50010",
"0.50010", "0.50010",
], ],
"mode": "orbit", "mode": "orbit",
@@ -2418,7 +2382,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "439.20000", "height": "399.26547",
"id": "id4", "id": "id4",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -2432,8 +2396,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
488, "488.00000",
"-439.20000", "-399.26547",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -2455,9 +2419,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"width": 488, "width": "488.00000",
"x": 6, "x": 6,
"y": "-5.39000", "y": "-4.89900",
} }
`; `;
@@ -2578,7 +2542,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endBinding": { "endBinding": {
"elementId": "id1", "elementId": "id1",
"fixedPoint": [ "fixedPoint": [
0, "0.50010",
"0.50010", "0.50010",
], ],
"mode": "orbit", "mode": "orbit",
@@ -2586,7 +2550,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "439.20000", "height": "399.26547",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -2598,8 +2562,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
488, "488.00000",
"-439.20000", "-399.26547",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -2620,9 +2584,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 11, "version": 11,
"width": 488, "width": "488.00000",
"x": 6, "x": 6,
"y": "-5.39000", "y": "-4.89900",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -7388,7 +7352,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10360,7 +10323,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -16591,7 +16553,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -17340,7 +17301,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -17987,7 +17947,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -18632,7 +18591,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -19385,7 +19343,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -21603,7 +21560,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -6243,7 +6243,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8692,7 +8691,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8925,7 +8923,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9348,7 +9345,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9761,7 +9757,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14519,7 +14514,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
+1 -1
View File
@@ -5132,7 +5132,7 @@ describe("history", () => {
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]), fixedPoint: expect.arrayContaining([0.5001, 0.5001]),
}), }),
isDeleted: true, isDeleted: true,
}), }),
+14 -4
View File
@@ -16,6 +16,8 @@ import * as StaticScene from "../renderer/staticScene";
import { UI, Pointer, Keyboard } from "./helpers/ui"; import { UI, Pointer, Keyboard } from "./helpers/ui";
import { render, fireEvent, act, unmountComponent } from "./test-utils"; import { render, fireEvent, act, unmountComponent } from "./test-utils";
import type { Zoom } from "../types";
unmountComponent(); unmountComponent();
const renderInteractiveScene = vi.spyOn( const renderInteractiveScene = vi.spyOn(
@@ -88,6 +90,7 @@ describe("move element", () => {
"orbit", "orbit",
"start", "start",
h.app.scene, h.app.scene,
{ value: 1 } as Zoom,
); );
bindBindingElement( bindBindingElement(
arrow.get() as NonDeleted<ExcalidrawArrowElement>, arrow.get() as NonDeleted<ExcalidrawArrowElement>,
@@ -95,6 +98,7 @@ describe("move element", () => {
"orbit", "orbit",
"end", "end",
h.app.scene, h.app.scene,
{ value: 1 } as Zoom,
); );
}); });
@@ -111,10 +115,13 @@ describe("move element", () => {
expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([200, 0]); expect([rectB.x, rectB.y]).toEqual([200, 0]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints( expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
[[106.00000000000001, 55.6867741935484]], [[106, 56.011199999998695]],
0,
);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints(
[[88, 88.01760000000121]],
0, 0,
); );
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[88, 88]], 0);
renderInteractiveScene.mockClear(); renderInteractiveScene.mockClear();
renderStaticScene.mockClear(); renderStaticScene.mockClear();
@@ -133,10 +140,13 @@ describe("move element", () => {
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( expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
[[106, 55.6867741935484]], [[106, 56.011199999998695]],
0,
);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints(
[[89, 90.01760000000121]],
0, 0,
); );
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[89, 90]], 0);
h.elements.forEach((element) => expect(element).toMatchSnapshot()); h.elements.forEach((element) => expect(element).toMatchSnapshot());
}); });