Unfinished simple arrow avoidance

This commit is contained in:
Mark Tolmacs 2025-06-19 22:37:53 +02:00
parent 898499777f
commit 6efe4c6f38
No known key found for this signature in database
5 changed files with 233 additions and 27 deletions

View File

@ -3,9 +3,6 @@ import {
arrayToMap,
isBindingFallthroughEnabled,
tupleToCoors,
invariant,
isDevEnv,
isTestEnv,
} from "@excalidraw/common";
import {
@ -399,10 +396,15 @@ export const maybeSuggestBindingsForLinearElementAtCoords = (
}[],
scene: Scene,
zoom: AppState["zoom"],
elementsMap: ElementsMap,
// During line creation the start binding hasn't been written yet
// into `linearElement`
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
): ExcalidrawBindableElement[] =>
Array.from(
pointerCoords.reduce(
(acc: Set<NonDeleted<ExcalidrawBindableElement>>, coords) => {
const p = pointFrom<GlobalPoint>(coords.x, coords.y);
const hoveredBindableElement = getHoveredElementForBinding(
coords,
scene.getNonDeletedElements(),
@ -411,8 +413,20 @@ export const maybeSuggestBindingsForLinearElementAtCoords = (
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
const pointIsInside =
hoveredBindableElement != null &&
isPointInElement(p, hoveredBindableElement, elementsMap);
if (hoveredBindableElement != null) {
if (
hoveredBindableElement != null &&
((pointIsInside &&
oppositeBindingBoundElement?.id === hoveredBindableElement.id) ||
!isLinearElementSimpleAndAlreadyBound(
linearElement,
oppositeBindingBoundElement?.id,
hoveredBindableElement,
))
) {
acc.add(hoveredBindableElement);
}
@ -996,35 +1010,40 @@ const getDistanceForBinding = (
};
export const bindPointToSnapToElementOutline = (
arrow: ExcalidrawElbowArrowElement,
linearElement: ExcalidrawLinearElement,
bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): GlobalPoint => {
if (isDevEnv() || isTestEnv()) {
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
}
const aabb = aabbForElement(bindableElement, elementsMap);
const localP =
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
linearElement.points[
startOrEnd === "start" ? 0 : linearElement.points.length - 1
];
const globalP = pointFrom<GlobalPoint>(
arrow.x + localP[0],
arrow.y + localP[1],
linearElement.x + localP[0],
linearElement.y + localP[1],
);
if (linearElement.points.length < 2) {
// New arrow creation, so no snapping
return globalP;
}
const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, elementsMap, globalP)
: globalP;
const elbowed = isElbowArrow(arrow);
const elbowed = isElbowArrow(linearElement);
const center = getCenterForBounds(aabb);
const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2;
const adjacentPointIdx =
startOrEnd === "start" ? 1 : linearElement.points.length - 2;
const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>(
arrow.x + arrow.points[adjacentPointIdx][0],
arrow.y + arrow.points[adjacentPointIdx][1],
linearElement.x + linearElement.points[adjacentPointIdx][0],
linearElement.y + linearElement.points[adjacentPointIdx][1],
),
center,
arrow.angle ?? 0,
linearElement.angle ?? 0,
);
let intersection: GlobalPoint | null = null;
@ -1083,7 +1102,35 @@ export const bindPointToSnapToElementOutline = (
return edgePoint;
}
return elbowed ? intersection : edgePoint;
return intersection;
};
export const getOutlineAvoidingPoint = (
element: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement | null,
coords: GlobalPoint,
pointIndex: number,
elementsMap: ElementsMap,
): GlobalPoint => {
if (hoveredElement) {
const newPoints = Array.from(element.points);
newPoints[pointIndex] = pointFrom<LocalPoint>(
coords[0] - element.x,
coords[1] - element.y,
);
return bindPointToSnapToElementOutline(
{
...element,
points: newPoints,
},
hoveredElement,
pointIndex === 0 ? "start" : "end",
elementsMap,
);
}
return coords;
};
export const avoidRectangularCorner = (

View File

@ -45,6 +45,7 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import {
bindOrUnbindLinearElement,
getHoveredElementForBinding,
getOutlineAvoidingPoint,
isBindingEnabled,
maybeSuggestBindingsForLinearElementAtCoords,
} from "./binding";
@ -58,6 +59,7 @@ import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isArrowElement,
isBindingElement,
isElbowArrow,
isFixedPointBinding,
@ -290,15 +292,17 @@ export class LinearElementEditor {
return null;
}
const elbowed = isElbowArrow(element);
if (
isElbowArrow(element) &&
elbowed &&
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
linearElementEditor.pointerDownState.lastClickedPoint !== 0
) {
return null;
}
const selectedPointsIndices = isElbowArrow(element)
const selectedPointsIndices = elbowed
? [
!!linearElementEditor.selectedPointsIndices?.includes(0)
? 0
@ -308,7 +312,7 @@ export class LinearElementEditor {
: undefined,
].filter((idx): idx is number => idx !== undefined)
: linearElementEditor.selectedPointsIndices;
const lastClickedPoint = isElbowArrow(element)
const lastClickedPoint = elbowed
? linearElementEditor.pointerDownState.lastClickedPoint > 0
? element.points.length - 1
: 0
@ -375,7 +379,7 @@ export class LinearElementEditor {
app.scene,
new Map(
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
let newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
@ -390,6 +394,67 @@ export class LinearElementEditor {
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
if (
pointIndex === 0 ||
pointIndex === element.points.length - 1
) {
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
let newGlobalPointPosition = pointRotateRads(
pointFrom<GlobalPoint>(
element.x + newPointPosition[0],
element.y + newPointPosition[1],
),
pointFrom<GlobalPoint>(cx, cy),
element.angle,
);
const hoveredElement = getHoveredElementForBinding(
{
x: newGlobalPointPosition[0],
y: newGlobalPointPosition[1],
},
app.scene.getNonDeletedElements(),
elementsMap,
app.state.zoom,
true,
isElbowArrow(element),
);
const otherBinding =
element[pointIndex === 0 ? "endBinding" : "startBinding"];
// Allow binding inside the element if both ends are inside
if (
isArrowElement(element) &&
!(
hoveredElement?.id === otherBinding?.elementId &&
hoveredElement != null
)
) {
newGlobalPointPosition = getOutlineAvoidingPoint(
element,
hoveredElement,
newGlobalPointPosition,
pointIndex,
elementsMap,
);
}
newPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
newGlobalPointPosition[0] -
linearElementEditor.pointerOffset.x,
newGlobalPointPosition[1] -
linearElementEditor.pointerOffset.y,
null,
);
}
return [
pointIndex,
{
@ -452,6 +517,7 @@ export class LinearElementEditor {
coords,
app.scene,
app.state.zoom,
elementsMap,
);
}
}

View File

@ -124,6 +124,7 @@ export const getDefaultAppState = (): Omit<
searchMatches: null,
lockedMultiSelections: {},
activeLockedId: null,
arrowOriginalEndpoint: null,
};
};
@ -249,6 +250,7 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false },
arrowOriginalEndpoint: { browser: false, export: false, server: false },
});
const _clearAppStateForStorage = <

View File

@ -232,9 +232,11 @@ import {
hitElementBoundingBox,
isLineElement,
isSimpleArrow,
getOutlineAvoidingPoint,
mutateElement,
} from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
import type {
ExcalidrawElement,
@ -5888,6 +5890,8 @@ class App extends React.Component<AppProps, AppState> {
[scenePointer],
this.scene,
this.state.zoom,
this.scene.getNonDeletedElementsMap(),
//this.state.startBoundElement,
),
});
} else {
@ -5897,9 +5901,7 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.multiElement) {
const { multiElement } = this.state;
const { x: rx, y: ry } = multiElement;
const { points, lastCommittedPoint } = multiElement;
const { x: rx, y: ry, points, lastCommittedPoint } = multiElement;
const lastPoint = points[points.length - 1];
setCursorForShape(this.interactiveCanvas, this.state);
@ -7757,7 +7759,6 @@ class App extends React.Component<AppProps, AppState> {
elementType === "arrow"
? [currentItemStartArrowhead, currentItemEndArrowhead]
: [null, null];
const element =
elementType === "arrow"
? newArrowElement({
@ -7805,6 +7806,28 @@ class App extends React.Component<AppProps, AppState> {
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
const hoveredElement = getHoveredElementForBinding(
{
x: gridX,
y: gridY,
},
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
true,
true,
);
if (hoveredElement) {
mutateElement(element, this.scene.getNonDeletedElementsMap(), {
startBinding: {
elementId: hoveredElement.id,
focus: 0,
gap: 0,
},
});
}
this.setState((prevState) => {
const nextSelectedElementIds = {
...prevState.selectedElementIds,
@ -8671,8 +8694,67 @@ class App extends React.Component<AppProps, AppState> {
} else if (isLinearElement(newElement)) {
pointerDownState.drag.hasOccurred = true;
const points = newElement.points;
const hoveredElement = getHoveredElementForBinding(
{ x: gridX, y: gridY },
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(newElement),
isElbowArrow(newElement),
);
const arrowIsInsideTheSameElement =
newElement.startBinding &&
hoveredElement?.id === newElement.startBinding.elementId;
let dx = gridX - newElement.x;
let dy = gridY - newElement.y;
let firstPointX = newElement.x + newElement.points[0][0];
let firstPointY = newElement.y + newElement.points[0][1];
if (isBindingElement(newElement, false)) {
if (!arrowIsInsideTheSameElement) {
const [outlinePointX, outlinePointY] = getOutlineAvoidingPoint(
newElement,
hoveredElement,
pointFrom(gridX, gridY),
newElement.points.length - 1,
elementsMap,
);
dx = outlinePointX - newElement.x;
dy = outlinePointY - newElement.y;
if (!this.state.arrowOriginalEndpoint) {
this.setState({
arrowOriginalEndpoint: pointFrom<GlobalPoint>(
firstPointX,
firstPointY,
),
});
}
const otherHoveredElement = getHoveredElementForBinding(
{ x: firstPointX, y: firstPointY },
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(newElement),
isElbowArrow(newElement),
);
[firstPointX, firstPointY] = getOutlineAvoidingPoint(
newElement,
otherHoveredElement,
pointFrom(firstPointX, firstPointY),
0,
elementsMap,
);
} else {
firstPointX =
this.state.arrowOriginalEndpoint?.[0] ?? firstPointX;
firstPointY =
this.state.arrowOriginalEndpoint?.[1] ?? firstPointY;
}
}
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
@ -8687,6 +8769,8 @@ class App extends React.Component<AppProps, AppState> {
this.scene.mutateElement(
newElement,
{
x: firstPointX,
y: firstPointY,
points: [...points, pointFrom<LocalPoint>(dx, dy)],
},
{ informMutation: false, isDragging: false },
@ -8698,6 +8782,8 @@ class App extends React.Component<AppProps, AppState> {
this.scene.mutateElement(
newElement,
{
x: firstPointX,
y: firstPointY,
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
},
{ isDragging: true, informMutation: false },
@ -8716,6 +8802,8 @@ class App extends React.Component<AppProps, AppState> {
[pointerCoords],
this.scene,
this.state.zoom,
elementsMap,
this.state.startBoundElement,
),
});
}
@ -8953,6 +9041,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
selectedElementsAreBeingDragged: false,
arrowOriginalEndpoint: null,
});
const elementsMap = this.scene.getNonDeletedElementsMap();

View File

@ -47,6 +47,7 @@ import type {
DurableIncrement,
EphemeralIncrement,
} from "@excalidraw/element";
import type { GlobalPoint } from "@excalidraw/math";
import type { Action } from "./actions/types";
import type { Spreadsheet } from "./charts";
@ -444,6 +445,7 @@ export interface AppState {
// as elements are unlocked, we remove the groupId from the elements
// and also remove groupId from this map
lockedMultiSelections: { [groupId: string]: true };
arrowOriginalEndpoint: GlobalPoint | null;
}
export type SearchMatch = {