diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index cea24d9d5..e43bef1d0 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -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>, coords) => { + const p = pointFrom(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( - 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( - 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, + hoveredElement: ExcalidrawBindableElement | null, + coords: GlobalPoint, + pointIndex: number, + elementsMap: ElementsMap, +): GlobalPoint => { + if (hoveredElement) { + const newPoints = Array.from(element.points); + newPoints[pointIndex] = pointFrom( + coords[0] - element.x, + coords[1] - element.y, + ); + + return bindPointToSnapToElementOutline( + { + ...element, + points: newPoints, + }, + hoveredElement, + pointIndex === 0 ? "start" : "end", + elementsMap, + ); + } + + return coords; }; export const avoidRectangularCorner = ( diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 3f666c412..e0c2cbdad 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -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( + element.x + newPointPosition[0], + element.y + newPointPosition[1], + ), + pointFrom(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, ); } } diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index dcc3fba11..871bb2788 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -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 = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 120e697db..85e1ac244 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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 { [scenePointer], this.scene, this.state.zoom, + this.scene.getNonDeletedElementsMap(), + //this.state.startBoundElement, ), }); } else { @@ -5897,9 +5901,7 @@ class App extends React.Component { 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 { elementType === "arrow" ? [currentItemStartArrowhead, currentItemEndArrowhead] : [null, null]; - const element = elementType === "arrow" ? newArrowElement({ @@ -7805,6 +7806,28 @@ class App extends React.Component { 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 { } 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( + 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 { this.scene.mutateElement( newElement, { + x: firstPointX, + y: firstPointY, points: [...points, pointFrom(dx, dy)], }, { informMutation: false, isDragging: false }, @@ -8698,6 +8782,8 @@ class App extends React.Component { this.scene.mutateElement( newElement, { + x: firstPointX, + y: firstPointY, points: [...points.slice(0, -1), pointFrom(dx, dy)], }, { isDragging: true, informMutation: false }, @@ -8716,6 +8802,8 @@ class App extends React.Component { [pointerCoords], this.scene, this.state.zoom, + elementsMap, + this.state.startBoundElement, ), }); } @@ -8953,6 +9041,7 @@ class App extends React.Component { this.setState({ selectedElementsAreBeingDragged: false, + arrowOriginalEndpoint: null, }); const elementsMap = this.scene.getNonDeletedElementsMap(); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 7981e7b7f..3b01a2c49 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -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 = {