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

View File

@ -45,6 +45,7 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
getHoveredElementForBinding, getHoveredElementForBinding,
getOutlineAvoidingPoint,
isBindingEnabled, isBindingEnabled,
maybeSuggestBindingsForLinearElementAtCoords, maybeSuggestBindingsForLinearElementAtCoords,
} from "./binding"; } from "./binding";
@ -58,6 +59,7 @@ import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { import {
isArrowElement,
isBindingElement, isBindingElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
@ -290,15 +292,17 @@ export class LinearElementEditor {
return null; return null;
} }
const elbowed = isElbowArrow(element);
if ( if (
isElbowArrow(element) && elbowed &&
!linearElementEditor.pointerDownState.lastClickedIsEndPoint && !linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
linearElementEditor.pointerDownState.lastClickedPoint !== 0 linearElementEditor.pointerDownState.lastClickedPoint !== 0
) { ) {
return null; return null;
} }
const selectedPointsIndices = isElbowArrow(element) const selectedPointsIndices = elbowed
? [ ? [
!!linearElementEditor.selectedPointsIndices?.includes(0) !!linearElementEditor.selectedPointsIndices?.includes(0)
? 0 ? 0
@ -308,7 +312,7 @@ export class LinearElementEditor {
: undefined, : undefined,
].filter((idx): idx is number => idx !== undefined) ].filter((idx): idx is number => idx !== undefined)
: linearElementEditor.selectedPointsIndices; : linearElementEditor.selectedPointsIndices;
const lastClickedPoint = isElbowArrow(element) const lastClickedPoint = elbowed
? linearElementEditor.pointerDownState.lastClickedPoint > 0 ? linearElementEditor.pointerDownState.lastClickedPoint > 0
? element.points.length - 1 ? element.points.length - 1
: 0 : 0
@ -375,7 +379,7 @@ export class LinearElementEditor {
app.scene, app.scene,
new Map( new Map(
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint = let newPointPosition: LocalPoint =
pointIndex === lastClickedPoint pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt( ? LinearElementEditor.createPointAt(
element, element,
@ -390,6 +394,67 @@ export class LinearElementEditor {
element.points[pointIndex][0] + deltaX, element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY, 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 [ return [
pointIndex, pointIndex,
{ {
@ -452,6 +517,7 @@ export class LinearElementEditor {
coords, coords,
app.scene, app.scene,
app.state.zoom, app.state.zoom,
elementsMap,
); );
} }
} }

View File

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

View File

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

View File

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