diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 209b833389..acd3e5ffcf 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -11,11 +11,14 @@ import { type AppState } from "@excalidraw/excalidraw/types"; import { arrayToMap, invariant, throttleRAF } from "@excalidraw/common"; import { useCallback, useImperativeHandle, useRef } from "react"; -import { isArrowElement, isBindableElement } from "@excalidraw/element"; +import { + getGlobalFixedPointForBindableElement, + isArrowElement, + isBindableElement, +} from "@excalidraw/element"; import { isLineSegment, - pointFrom, type GlobalPoint, type LineSegment, } from "@excalidraw/math"; @@ -94,9 +97,10 @@ const _renderBinding = ( const bindable = elementsMap.get( binding.elementId, ) as ExcalidrawBindableElement; - const [x, y] = pointFrom( - bindable.x + bindable.width * binding.fixedPoint[0], - bindable.y + bindable.height * binding.fixedPoint[1], + const [x, y] = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + bindable, + elementsMap, ); context.save(); @@ -128,9 +132,10 @@ const _renderBindableBinding = ( const bindable = elementsMap.get( binding.elementId, ) as ExcalidrawBindableElement; - const [x, y] = pointFrom( - bindable.x + bindable.width * binding.fixedPoint[0], - bindable.y + bindable.height * binding.fixedPoint[1], + const [x, y] = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + bindable, + elementsMap, ); context.save(); diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index d60dffe899..f968219012 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -125,6 +125,9 @@ export const bindOrUnbindBindingElement = ( draggingPoints: PointsPositionUpdates, scene: Scene, appState: AppState, + opts?: { + newArrow: boolean; + }, ) => { const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( arrow, @@ -132,9 +135,43 @@ export const bindOrUnbindBindingElement = ( scene.getNonDeletedElementsMap(), scene.getNonDeletedElements(), appState, + opts, ); bindOrUnbindBindingElementEdge(arrow, start, "start", scene); bindOrUnbindBindingElementEdge(arrow, end, "end", scene); + if (start.focusPoint || end.focusPoint) { + // If the strategy dictates a focus point override, then + // update the arrow points to point to the focus point. + const updates: PointsPositionUpdates = new Map(); + + if (start.focusPoint) { + updates.set(0, { + point: + updateBoundPoint( + arrow, + "startBinding", + arrow.startBinding, + start.element, + scene.getNonDeletedElementsMap(), + ) || arrow.points[0], + }); + } + + if (end.focusPoint) { + updates.set(arrow.points.length - 1, { + point: + updateBoundPoint( + arrow, + "endBinding", + arrow.endBinding, + end.element, + scene.getNonDeletedElementsMap(), + ) || arrow.points[arrow.points.length - 1], + }); + } + + LinearElementEditor.movePoints(arrow, scene, updates); + } }; const bindOrUnbindBindingElementEdge = ( @@ -193,6 +230,9 @@ const bindingStrategyForEndpointDragging = ( elements: readonly Ordered[], zoom: AppState["zoom"], globalBindMode?: AppState["bindMode"], + opts?: { + newArrow: boolean; + }, ): { current: BindingStrategy; other: BindingStrategy } => { let current: BindingStrategy = { mode: undefined }; let other: BindingStrategy = { mode: undefined }; @@ -233,10 +273,12 @@ const bindingStrategyForEndpointDragging = ( current = { element: hovered, mode: "orbit", - focusPoint: pointFrom( - hovered.x + hovered.width / 2, - hovered.y + hovered.height / 2, - ), + focusPoint: opts?.newArrow + ? pointFrom( + hovered.x + hovered.width / 2, + hovered.y + hovered.height / 2, + ) + : undefined, }; return { current, other }; @@ -273,10 +315,12 @@ const bindingStrategyForEndpointDragging = ( current = { element: hovered, mode: "orbit", - focusPoint: pointFrom( - hovered.x + hovered.width / 2, - hovered.y + hovered.height / 2, - ), + focusPoint: opts?.newArrow + ? pointFrom( + hovered.x + hovered.width / 2, + hovered.y + hovered.height / 2, + ) + : undefined, }; return { current, other }; @@ -302,6 +346,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints = ( elementsMap: NonDeletedSceneElementsMap, elements: readonly Ordered[], appState: AppState, + opts?: { + newArrow: boolean; + }, ): { start: BindingStrategy; end: BindingStrategy } => { const globalBindMode = appState.bindMode || "focus"; const startIdx = 0; @@ -330,7 +377,18 @@ const getBindingStrategyForDraggingBindingElementEndpoints = ( return { start: hovered - ? { element: hovered, mode: hit ? "inside" : "outside" } + ? hit + ? { element: hovered, mode: "inside" } + : opts?.newArrow + ? { + element: hovered, + mode: "orbit", + focusPoint: pointFrom( + hovered.x + hovered.width / 2, + hovered.y + hovered.height / 2, + ), + } + : { element: hovered, mode: "inside" } : { mode: undefined }, end: { mode: undefined }, }; @@ -372,6 +430,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints = ( elements, appState.zoom, globalBindMode, + opts, ); return { start: current, end: other }; @@ -393,6 +452,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints = ( elements, appState.zoom, globalBindMode, + opts, ); return { start: other, end: current }; @@ -560,21 +620,6 @@ export const bindBindingElement = ( } }; -export const isBindingElementSimpleAndAlreadyBound = ( - linearElement: NonDeleted, - alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined, - bindableElement: ExcalidrawBindableElement, -): boolean => { - return ( - alreadyBoundToId === bindableElement.id && - isBindingElementSimple(linearElement) - ); -}; - -const isBindingElementSimple = ( - linearElement: NonDeleted, -): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement); - export const unbindBindingElement = ( arrow: NonDeleted, startOrEnd: "start" | "end", @@ -1115,7 +1160,7 @@ export const snapToMid = ( }; export const updateBoundPoint = ( - linearElement: NonDeleted, + arrow: NonDeleted, startOrEnd: "startBinding" | "endBinding", binding: FixedPointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, @@ -1124,36 +1169,37 @@ export const updateBoundPoint = ( if ( binding == null || // We only need to update the other end if this is a 2 point line element - (binding.elementId !== bindableElement.id && - linearElement.points.length > 2) + (binding.elementId !== bindableElement.id && arrow.points.length > 2) ) { return null; } const fixedPoint = normalizeFixedPoint(binding.fixedPoint); - const globalMidPoint = elementCenterPoint(bindableElement, elementsMap); - const global = pointFrom( - bindableElement.x + fixedPoint[0] * bindableElement.width, - bindableElement.y + fixedPoint[1] * bindableElement.height, - ); - const rotatedGlobal = pointRotateRads( - global, - globalMidPoint, - bindableElement.angle, + const global = getGlobalFixedPointForBindableElement( + fixedPoint, + bindableElement, + elementsMap, ); + const element = + arrow.points.length === 1 + ? { + ...arrow, + points: [arrow.points[0], arrow.points[0]], + } + : arrow; const maybeOutlineGlobal = binding.mode === "orbit" ? getOutlineAvoidingPoint( - linearElement, + element, bindableElement, - rotatedGlobal, - startOrEnd === "startBinding" ? 0 : linearElement.points.length - 1, + global, + startOrEnd === "startBinding" ? 0 : arrow.points.length - 1, elementsMap, ) - : rotatedGlobal; + : global; return LinearElementEditor.pointFromAbsoluteCoords( - linearElement, + arrow, maybeOutlineGlobal, elementsMap, ); diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 71b8b0ab6d..7a6129d35c 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -23,9 +23,9 @@ import { } from "@excalidraw/common"; import { + bindingBorderTest, deconstructLinearOrFreeDrawElement, getHoveredElementForBinding, - hitElementItself, isPathALoop, type Store, } from "@excalidraw/element"; @@ -42,6 +42,7 @@ import type { } from "@excalidraw/excalidraw/types"; import { + getGlobalFixedPointForBindableElement, getOutlineAvoidingPoint, isBindingEnabled, maybeSuggestBindingsForBindingElementAtCoords, @@ -55,7 +56,12 @@ import { import { headingIsHorizontal, vectorToHeading } from "./heading"; import { mutateElement } from "./mutateElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; -import { isArrowElement, isBindingElement, isElbowArrow } from "./typeChecks"; +import { + isArrowElement, + isBindingElement, + isElbowArrow, + isSimpleArrow, +} from "./typeChecks"; import { ShapeCache, toggleLinePolygonState } from "./shape"; @@ -1936,12 +1942,13 @@ const pointDraggingUpdates = ( elements: readonly Ordered[], appState: AppState, ): PointsPositionUpdates => { + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap, true); const hasMidPoints = selectedPointsIndices.filter( (_, idx) => idx > 0 && idx < element.points.length - 1, ).length > 0; - return new Map( + const updates = new Map( selectedPointsIndices.map((pointIndex) => { let newPointPosition: LocalPoint = pointIndex === lastClickedPoint @@ -1958,14 +1965,10 @@ const pointDraggingUpdates = ( ); if ( + isSimpleArrow(element) && !hasMidPoints && (pointIndex === 0 || pointIndex === element.points.length - 1) ) { - const [, , , , cx, cy] = getElementAbsoluteCoords( - element, - elementsMap, - true, - ); let newGlobalPointPosition = pointRotateRads( pointFrom( element.x + newPointPosition[0], @@ -1988,12 +1991,12 @@ const pointDraggingUpdates = ( ); const otherPointInsideElement = !!hoveredElement && - hitElementItself({ - element: hoveredElement, - point: otherGlobalPoint, + !!bindingBorderTest( + hoveredElement, + otherGlobalPoint, elementsMap, - threshold: 0, - }); + appState.zoom, + ); if ( isBindingEnabled(appState) && @@ -2029,4 +2032,60 @@ const pointDraggingUpdates = ( ]; }), ); + + if (isSimpleArrow(element)) { + const adjacentPointIndices = + element.points.length === 2 + ? [0, 1] + : element.points.length === 3 + ? [1] + : [1, element.points.length - 2]; + + adjacentPointIndices + .filter((adjacentPointIndex) => + selectedPointsIndices.includes(adjacentPointIndex), + ) + .flatMap((adjacentPointIndex) => + element.points.length === 3 + ? [0, 2] + : adjacentPointIndex === 1 + ? 0 + : element.points.length - 1, + ) + .forEach((pointIndex) => { + const binding = + element[pointIndex === 0 ? "startBinding" : "endBinding"]; + const bindingIsOrbiting = binding?.mode === "orbit"; + if (bindingIsOrbiting) { + const hoveredElement = elementsMap.get( + binding.elementId, + ) as ExcalidrawBindableElement; + const focusGlobalPoint = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + hoveredElement, + elementsMap, + ); + const newGlobalPointPosition = getOutlineAvoidingPoint( + element, + hoveredElement, + focusGlobalPoint, + pointIndex, + elementsMap, + ); + const newPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + newGlobalPointPosition[0] - linearElementEditor.pointerOffset.x, + newGlobalPointPosition[1] - linearElementEditor.pointerOffset.y, + null, + ); + updates.set(pointIndex, { + point: newPointPosition, + isDragging: false, + }); + } + }); + } + + return updates; }; diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 36e1c2248c..5fa597dc9c 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -276,6 +276,7 @@ export const actionFinalize = register({ ]), scene, appState, + { newArrow: true }, ); } } diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0a5ffb648b..4032afb378 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -239,6 +239,7 @@ import { calculateFixedPointForNonElbowArrowBinding, normalizeFixedPoint, bindOrUnbindBindingElement, + updateBoundPoint, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -6236,40 +6237,31 @@ class App extends React.Component { const startElement = this.scene.getElement( multiElement.startBinding.elementId, ) as ExcalidrawBindableElement; - const avoidancePoint = getOutlineAvoidingPoint( + const localPoint = updateBoundPoint( multiElement, + "startBinding", + multiElement.startBinding, startElement, - startPoint, - 0, elementsMap, ); - if (!pointsEqual(startPoint, avoidancePoint)) { + const avoidancePoint = localPoint + ? LinearElementEditor.getPointGlobalCoordinates( + multiElement, + localPoint, + elementsMap, + ) + : null; + if (avoidancePoint && !pointsEqual(startPoint, avoidancePoint)) { + const point = LinearElementEditor.pointFromAbsoluteCoords( + multiElement, + avoidancePoint, + elementsMap, + ); + LinearElementEditor.movePoints( multiElement, this.scene, - new Map([ - [ - 0, - { - point: LinearElementEditor.pointFromAbsoluteCoords( - multiElement, - avoidancePoint, - elementsMap, - ), - }, - ], - ]), - { - startBinding: { - ...multiElement.startBinding, - ...calculateFixedPointForNonElbowArrowBinding( - multiElement, - startElement, - "start", - elementsMap, - ), - }, - }, + new Map([[0, { point }]]), ); } } @@ -8168,6 +8160,7 @@ class App extends React.Component { ]), this.scene, this.state, + { newArrow: true }, ); } this.setState((prevState) => {