diff --git a/packages/element/src/arrows/focus.ts b/packages/element/src/arrows/focus.ts new file mode 100644 index 0000000000..960524465c --- /dev/null +++ b/packages/element/src/arrows/focus.ts @@ -0,0 +1,541 @@ +import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math"; +import { invariant } from "@excalidraw/common"; + +import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types"; + +import { + bindBindingElement, + calculateFixedPointForNonElbowArrowBinding, + FOCUS_POINT_SIZE, + getBindingGap, + getGlobalFixedPointForBindableElement, + isBindingEnabled, + maxBindingDistance_simple, + unbindBindingElement, + updateBoundPoint, +} from "../binding"; +import { + isBindableElement, + isBindingElement, + isElbowArrow, +} from "../typeChecks"; +import { LinearElementEditor } from "../linearElementEditor"; +import { getHoveredElementForFocusPoint, hitElementItself } from "../collision"; +import { moveArrowAboveBindable } from "../zindex"; + +import type { + ElementsMap, + ExcalidrawArrowElement, + ExcalidrawBindableElement, + NonDeletedSceneElementsMap, + PointsPositionUpdates, +} from "../types"; + +import type { Scene } from "../Scene"; + +export const isFocusPointVisible = ( + focusPoint: GlobalPoint, + arrow: ExcalidrawArrowElement, + bindableElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, + appState: { + isBindingEnabled: AppState["isBindingEnabled"]; + zoom: AppState["zoom"]; + }, + ignoreOverlap = false, +): boolean => { + // No focus point management for elbow arrows, because elbow arrows + // always have their focus point at the arrow point itself + if ( + isElbowArrow(arrow) || + !isBindingEnabled(appState) || + arrow.points.length !== 2 + ) { + return false; + } + + // Avoid showing the focus point indicator if the focus point is essentially + // on top of the arrow point it belongs to itself, if not ignoring specifically + if (!ignoreOverlap) { + const associatedPointIdx = + arrow.startBinding?.elementId === bindableElement.id + ? 0 + : arrow.points.length - 1; + const associatedArrowPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + associatedPointIdx, + elementsMap, + ); + + if ( + pointDistance(focusPoint, associatedArrowPoint) < + (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value + ) { + return false; + } + } + + // Check if the focus point is within the element's shape bounds + return hitElementItself({ + element: bindableElement, + elementsMap, + point: focusPoint, + threshold: getBindingGap(bindableElement, arrow), + overrideShouldTestInside: true, + }); +}; + +// Updates the arrow endpoints in "orbit" configuration +const focusPointUpdate = ( + arrow: ExcalidrawArrowElement, + bindableElement: ExcalidrawBindableElement | null, + isStartBinding: boolean, + elementsMap: NonDeletedSceneElementsMap, + scene: Scene, + appState: AppState, + switchToInsideBinding: boolean, +) => { + const pointUpdates = new Map(); + + const bindingField = isStartBinding ? "startBinding" : "endBinding"; + const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding"; + let currentBinding = arrow[bindingField]; + let adjacentBinding = arrow[adjacentBindingField]; + + // Update the dragged focus point related end + if (currentBinding && bindableElement) { + // Update the targeted bindings + const boundToSameElement = + bindableElement && + adjacentBinding && + currentBinding.elementId === adjacentBinding.elementId; + if (switchToInsideBinding || boundToSameElement) { + currentBinding = { + ...currentBinding, + mode: "inside", + }; + } else { + currentBinding = { + ...currentBinding, + mode: "orbit", + }; + } + + const pointIndex = isStartBinding ? 0 : arrow.points.length - 1; + const newPoint = updateBoundPoint( + arrow, + bindingField as "startBinding" | "endBinding", + currentBinding, + bindableElement, + elementsMap, + ); + + if (newPoint) { + pointUpdates.set(pointIndex, { point: newPoint }); + } + } + + // Also update the adjacent end if it has a binding + if (adjacentBinding) { + const adjacentBindableElement = elementsMap.get( + adjacentBinding.elementId, + ) as ExcalidrawBindableElement; + + if ( + adjacentBindableElement && + isBindableElement(adjacentBindableElement) && + isBindingEnabled(appState) + ) { + // Same shape bound on both ends + const boundToSameElementAfterUpdate = + bindableElement && adjacentBinding.elementId === bindableElement.id; + if (switchToInsideBinding || boundToSameElementAfterUpdate) { + adjacentBinding = { + ...adjacentBinding, + mode: "inside", + }; + } else { + adjacentBinding = { + ...adjacentBinding, + mode: "orbit", + }; + } + + const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0; + const adjacentNewPoint = updateBoundPoint( + arrow, + adjacentBindingField, + adjacentBinding, + adjacentBindableElement, + elementsMap, + ); + + if (adjacentNewPoint) { + pointUpdates.set(adjacentPointIndex, { + point: adjacentNewPoint, + }); + } + } + } + + if (pointUpdates.size > 0) { + LinearElementEditor.movePoints(arrow, scene, pointUpdates, { + [bindingField]: currentBinding, + [adjacentBindingField]: adjacentBinding, + }); + } +}; + +export const handleFocusPointDrag = ( + linearElementEditor: LinearElementEditor, + elementsMap: NonDeletedSceneElementsMap, + pointerCoords: { x: number; y: number }, + scene: Scene, + appState: AppState, + gridSize: NullableGridSize, + switchToInsideBinding: boolean, +) => { + const arrow = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ) as any; + + // Sanity checks + if ( + !arrow || + !isBindingElement(arrow) || + isElbowArrow(arrow) || + !linearElementEditor.hoveredFocusPointBinding || + !linearElementEditor.draggedFocusPointBinding + ) { + return; + } + + const isStartBinding = + linearElementEditor.draggedFocusPointBinding === "start"; + const binding = isStartBinding ? arrow.startBinding : arrow.endBinding; + const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset; + const point = pointFrom( + pointerCoords.x - offsetX, + pointerCoords.y - offsetY, + ); + const bindingField = isStartBinding ? "startBinding" : "endBinding"; + const hit = getHoveredElementForFocusPoint( + point, + arrow, + scene.getNonDeletedElements(), + elementsMap, + maxBindingDistance_simple(appState.zoom), + ); + + // Hovering a bindable element + if (hit && isBindingEnabled(appState)) { + // Break existing binding if bound to another shape or if binding is disabled + if (arrow[bindingField] && hit.id !== binding?.elementId) { + unbindBindingElement( + arrow, + linearElementEditor.draggedFocusPointBinding, + scene, + ); + } + + // Handle binding mode switch + const newMode = + switchToInsideBinding && arrow[bindingField]?.mode === "orbit" + ? "inside" + : !switchToInsideBinding && arrow[bindingField]?.mode === "inside" + ? "orbit" + : null; + + // If no existing binding, create it + if (!arrow[bindingField] || newMode) { + // Create a new binding if none exists + bindBindingElement( + arrow, + hit, + newMode || "orbit", + linearElementEditor.draggedFocusPointBinding, + scene, + point, + ); + } + + // Update the binding's fixed point + scene.mutateElement(arrow, { + [bindingField]: { + ...arrow[bindingField], + elementId: hit.id, + mode: newMode || arrow[bindingField]?.mode || "orbit", + ...calculateFixedPointForNonElbowArrowBinding( + arrow, + hit, + linearElementEditor.draggedFocusPointBinding, + elementsMap, + point, + ), + }, + }); + } else { + // Not hovering any bindable element, move the arrow endpoint + const pointUpdates: PointsPositionUpdates = new Map(); + const pointIndex = isStartBinding ? 0 : arrow.points.length - 1; + pointUpdates.set(pointIndex, { + point: LinearElementEditor.createPointAt( + arrow, + elementsMap, + point[0], + point[1], + gridSize, + ), + }); + LinearElementEditor.movePoints(arrow, scene, pointUpdates); + if (arrow[bindingField]) { + unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene); + } + } + + // Update the arrow endpoints + focusPointUpdate( + arrow, + hit, + isStartBinding, + elementsMap, + scene, + appState, + switchToInsideBinding, + ); + + if (hit && isBindingEnabled(appState)) { + moveArrowAboveBindable( + point, + arrow, + scene.getElementsIncludingDeleted(), + elementsMap, + scene, + hit, + ); + } +}; + +export const handleFocusPointPointerDown = ( + arrow: ExcalidrawArrowElement, + pointerDownState: { origin: { x: number; y: number } }, + elementsMap: NonDeletedSceneElementsMap, + appState: AppState, +): { + hitFocusPoint: "start" | "end" | null; + pointerOffset: { x: number; y: number }; +} => { + const pointerPos = pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ); + const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value; + + // Check start binding focus point + if (arrow.startBinding?.elementId) { + const bindableElement = elementsMap.get(arrow.startBinding.elementId); + if ( + bindableElement && + isBindableElement(bindableElement) && + !bindableElement.isDeleted + ) { + const focusPoint = getGlobalFixedPointForBindableElement( + arrow.startBinding.fixedPoint, + bindableElement, + elementsMap, + ); + if ( + isFocusPointVisible( + focusPoint, + arrow, + bindableElement, + elementsMap, + appState, + ) && + pointDistance(pointerPos, focusPoint) <= hitThreshold + ) { + return { + hitFocusPoint: "start", + pointerOffset: { + x: pointerPos[0] - focusPoint[0], + y: pointerPos[1] - focusPoint[1], + }, + }; + } + } + } + + // Check end binding focus point (only if start not already hit) + if (arrow.endBinding?.elementId) { + const bindableElement = elementsMap.get(arrow.endBinding.elementId); + if ( + bindableElement && + isBindableElement(bindableElement) && + !bindableElement.isDeleted + ) { + const focusPoint = getGlobalFixedPointForBindableElement( + arrow.endBinding.fixedPoint, + bindableElement, + elementsMap, + ); + if ( + isFocusPointVisible( + focusPoint, + arrow, + bindableElement, + elementsMap, + appState, + ) && + pointDistance(pointerPos, focusPoint) <= hitThreshold + ) { + return { + hitFocusPoint: "end", + pointerOffset: { + x: pointerPos[0] - focusPoint[0], + y: pointerPos[1] - focusPoint[1], + }, + }; + } + } + } + + return { + hitFocusPoint: null, + pointerOffset: { x: 0, y: 0 }, + }; +}; + +export const handleFocusPointPointerUp = ( + linearElementEditor: LinearElementEditor, + scene: Scene, +) => { + invariant( + linearElementEditor.draggedFocusPointBinding, + "Must have a dragged focus point at pointer release", + ); + + const arrow = LinearElementEditor.getElement( + linearElementEditor.elementId, + scene.getNonDeletedElementsMap(), + ); + invariant(arrow, "Arrow must be in the scene"); + + // Clean up + const bindingKey = + linearElementEditor.draggedFocusPointBinding === "start" + ? "startBinding" + : "endBinding"; + const otherBindingKey = + linearElementEditor.draggedFocusPointBinding === "start" + ? "endBinding" + : "startBinding"; + const boundElementId = arrow[bindingKey]?.elementId; + const otherBoundElementId = arrow[otherBindingKey]?.elementId; + const oldBoundElement = + boundElementId && + scene + .getNonDeletedElements() + .find( + (element) => + element.id !== boundElementId && + element.id !== otherBoundElementId && + isBindableElement(element) && + element.boundElements?.find(({ id }) => id === arrow.id), + ); + if (oldBoundElement) { + scene.mutateElement(oldBoundElement, { + boundElements: oldBoundElement.boundElements?.filter( + ({ id }) => id !== arrow.id, + ), + }); + } + + // Record the new bound element + const boundElement = + boundElementId && scene.getNonDeletedElementsMap().get(boundElementId); + if (boundElement) { + scene.mutateElement(boundElement, { + boundElements: [ + ...(boundElement.boundElements || [])?.filter( + ({ id }) => id !== arrow.id, + ), + { + id: arrow.id, + type: "arrow", + }, + ], + }); + } +}; + +export const handleFocusPointHover = ( + arrow: ExcalidrawArrowElement, + scenePointerX: number, + scenePointerY: number, + scene: Scene, + appState: AppState, +): "start" | "end" | null => { + const elementsMap = scene.getNonDeletedElementsMap(); + const pointerPos = pointFrom(scenePointerX, scenePointerY); + const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value; + + // Check start binding focus point + if (arrow.startBinding?.elementId) { + const bindableElement = elementsMap.get(arrow.startBinding.elementId); + if ( + bindableElement && + isBindableElement(bindableElement) && + !bindableElement.isDeleted + ) { + const focusPoint = getGlobalFixedPointForBindableElement( + arrow.startBinding.fixedPoint, + bindableElement, + elementsMap, + ); + if ( + isFocusPointVisible( + focusPoint, + arrow, + bindableElement, + elementsMap, + appState, + ) && + pointDistance(pointerPos, focusPoint) <= hitThreshold + ) { + return "start"; + } + } + } + + // Check end binding focus point (only if start not already hovered) + if (arrow.endBinding?.elementId) { + const bindableElement = elementsMap.get(arrow.endBinding.elementId); + if ( + bindableElement && + isBindableElement(bindableElement) && + !bindableElement.isDeleted + ) { + const focusPoint = getGlobalFixedPointForBindableElement( + arrow.endBinding.fixedPoint, + bindableElement, + elementsMap, + ); + if ( + isFocusPointVisible( + focusPoint, + arrow, + bindableElement, + elementsMap, + appState, + ) && + pointDistance(pointerPos, focusPoint) <= hitThreshold + ) { + return "end"; + } + } + } + + return null; +}; diff --git a/packages/element/src/arrows/helpers.ts b/packages/element/src/arrows/helpers.ts new file mode 100644 index 0000000000..9f7c4ae9b3 --- /dev/null +++ b/packages/element/src/arrows/helpers.ts @@ -0,0 +1,45 @@ +import type { App } from "@excalidraw/excalidraw/types"; + +import { LinearElementEditor } from "../linearElementEditor"; + +import { handleFocusPointDrag } from "./focus"; + +export const maybeHandleArrowPointlikeDrag = ({ + app, + event, +}: { + app: App; + event: KeyboardEvent | React.KeyboardEvent | PointerEvent; +}): boolean => { + const appState = app.state; + if (appState.selectedLinearElement && app.lastPointerMoveCoords) { + // Update focus point status if the binding mode is changing + if (appState.selectedLinearElement.draggedFocusPointBinding) { + handleFocusPointDrag( + appState.selectedLinearElement, + app.scene.getNonDeletedElementsMap(), + app.lastPointerMoveCoords, + app.scene, + appState, + app.getEffectiveGridSize(), + event.altKey, + ); + return true; + } else if ( + appState.selectedLinearElement.hoverPointIndex !== null && + app.lastPointerMoveEvent && + appState.selectedLinearElement.initialState.lastClickedPoint >= 0 && + appState.selectedLinearElement.isDragging + ) { + LinearElementEditor.handlePointDragging( + app.lastPointerMoveEvent, + app, + app.lastPointerMoveCoords.x, + app.lastPointerMoveCoords.y, + appState.selectedLinearElement, + ); + return true; + } + } + return false; +}; diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index b1e36ff13f..074fe2a21f 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -116,6 +116,7 @@ export type BindingStrategy = */ export const BASE_BINDING_GAP = 10; export const BASE_BINDING_GAP_ELBOW = 5; +export const FOCUS_POINT_SIZE = 10 / 1.5; export const getBindingGap = ( bindTarget: ExcalidrawBindableElement, @@ -145,7 +146,9 @@ export const shouldEnableBindingForPointerEvent = ( return !event[KEYS.CTRL_OR_CMD]; }; -export const isBindingEnabled = (appState: AppState): boolean => { +export const isBindingEnabled = (appState: { + isBindingEnabled: AppState["isBindingEnabled"]; +}): boolean => { return appState.isBindingEnabled; }; @@ -259,7 +262,7 @@ const bindingStrategyForElbowArrowEndpointDragging = ( globalPoint, elements, elementsMap, - (element) => maxBindingDistance_simple(zoom), + maxBindingDistance_simple(zoom), ); const current = hit @@ -684,7 +687,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( globalPoint, elements, elementsMap, - (e) => maxBindingDistance_simple(appState.zoom), + maxBindingDistance_simple(appState.zoom), ); const pointInElement = hit && @@ -711,7 +714,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( const otherFocusPointIsInElement = otherBindableElement && otherFocusPoint && - isPointInElement(otherFocusPoint, otherBindableElement, elementsMap); + hitElementItself({ + point: otherFocusPoint, + element: otherBindableElement, + elementsMap, + threshold: 0, + overrideShouldTestInside: true, + }); // Handle outside-outside binding to the same element if (otherBinding && otherBinding.elementId === hit?.id) { @@ -1674,7 +1683,9 @@ export const updateBoundPoint = ( binding: FixedPointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, - customIntersector?: LineSegment, + opts?: { + customIntersector?: LineSegment; + }, ): LocalPoint | null => { if ( binding == null || @@ -1761,16 +1772,14 @@ export const updateBoundPoint = ( const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther; - let _customIntersector = customIntersector; + let _customIntersector = opts?.customIntersector; if (!elbowed && !_customIntersector) { const [x1, y1, x2, y2] = LinearElementEditor.getElementAbsoluteCoords( arrow, elementsMap, ); const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); - const edgePoint = isRectanguloidElement(bindableElement) - ? avoidRectangularCorner(arrow, bindableElement, elementsMap, global) - : global; + const edgePoint = global; const adjacentPoint = pointRotateRads( pointFrom( arrow.x + @@ -1884,7 +1893,7 @@ export const calculateFixedPointForNonElbowArrowBinding = ( elementsMap: ElementsMap, focusPoint?: GlobalPoint, ): { fixedPoint: FixedPoint } => { - const edgePoint = focusPoint + const edgePoint: GlobalPoint = focusPoint ? focusPoint : LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, @@ -1892,11 +1901,7 @@ export const calculateFixedPointForNonElbowArrowBinding = ( elementsMap, ); - // Convert the global point to element-local coordinates - const elementCenter = pointFrom( - hoveredElement.x + hoveredElement.width / 2, - hoveredElement.y + hoveredElement.height / 2, - ); + const elementCenter = elementCenterPoint(hoveredElement, elementsMap); // Rotate the point to account for element rotation const nonRotatedPoint = pointRotateRads( diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index d93363a4d8..46d2dbd46a 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -59,8 +59,11 @@ import { LinearElementEditor } from "./linearElementEditor"; import { distanceToElement } from "./distance"; +import { getBindingGap } from "./binding"; + import type { ElementsMap, + ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, @@ -290,7 +293,7 @@ export const getAllHoveredElementAtPoint = ( point: Readonly, elements: readonly Ordered[], elementsMap: NonDeletedSceneElementsMap, - toleranceFn?: (element: ExcalidrawBindableElement) => number, + tolerance?: number, ): NonDeleted[] => { const candidateElements: NonDeleted[] = []; // We need to to hit testing from front (end of the array) to back (beginning of the array) @@ -306,7 +309,7 @@ export const getAllHoveredElementAtPoint = ( if ( isBindableElement(element, false) && - bindingBorderTest(element, point, elementsMap, toleranceFn?.(element)) + bindingBorderTest(element, point, elementsMap, tolerance) ) { candidateElements.push(element); @@ -323,13 +326,13 @@ export const getHoveredElementForBinding = ( point: Readonly, elements: readonly Ordered[], elementsMap: NonDeletedSceneElementsMap, - toleranceFn?: (element: ExcalidrawBindableElement) => number, + tolerance?: number, ): NonDeleted | null => { const candidateElements = getAllHoveredElementAtPoint( point, elements, elementsMap, - toleranceFn, + tolerance, ); if (!candidateElements || candidateElements.length === 0) { @@ -348,6 +351,56 @@ export const getHoveredElementForBinding = ( .pop() as NonDeleted; }; +export const getHoveredElementForFocusPoint = ( + point: GlobalPoint, + arrow: ExcalidrawArrowElement, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + tolerance?: number, +): ExcalidrawBindableElement | null => { + const candidateElements: NonDeleted[] = []; + // 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) { + const element = elements[index]; + + invariant( + !element.isDeleted, + "Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements", + ); + + if ( + isBindableElement(element, false) && + bindingBorderTest(element, point, elementsMap, tolerance) + ) { + candidateElements.push(element); + } + } + + if (!candidateElements || candidateElements.length === 0) { + return null; + } + + if (candidateElements.length === 1) { + return candidateElements[0]; + } + + const distanceFilteredCandidateElements = candidateElements + // Resolve by distance + .filter( + (el) => + distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) || + isPointInElement(point, el, elementsMap), + ); + + if (distanceFilteredCandidateElements.length === 0) { + return null; + } + + return distanceFilteredCandidateElements[0] as NonDeleted; +}; + /** * Intersect a line with an element for binding test * diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index b0a5e935f5..63b3b7926d 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -2276,7 +2276,7 @@ const getHoveredElement = ( origPoint, elements, elementsMap, - (element) => maxBindingDistance_simple(zoom), + maxBindingDistance_simple(zoom), ); }; diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 381cc733bf..1ca1c1a289 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -70,6 +70,7 @@ export * from "./elbowArrow"; export * from "./elementLink"; export * from "./embeddable"; export * from "./flowchart"; +export * from "./arrows/focus"; export * from "./fractionalIndex"; export * from "./frame"; export * from "./groups"; @@ -97,3 +98,4 @@ export * from "./transformHandles"; export * from "./typeChecks"; export * from "./utils"; export * from "./zindex"; +export * from "./arrows/helpers"; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 00bf516350..125ef05025 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -149,6 +149,8 @@ export class LinearElementEditor { public readonly pointerOffset: Readonly<{ x: number; y: number }>; public readonly hoverPointIndex: number; public readonly segmentMidPointHoveredCoords: GlobalPoint | null; + public readonly hoveredFocusPointBinding: "start" | "end" | null; + public readonly draggedFocusPointBinding: "start" | "end" | null; public readonly elbowed: boolean; public readonly customLineAngle: number | null; public readonly isEditing: boolean; @@ -194,6 +196,8 @@ export class LinearElementEditor { }; this.hoverPointIndex = -1; this.segmentMidPointHoveredCoords = null; + this.hoveredFocusPointBinding = null; + this.draggedFocusPointBinding = null; this.elbowed = isElbowArrow(element) && element.elbowed; this.customLineAngle = null; this.isEditing = isEditing; @@ -2135,6 +2139,63 @@ const pointDraggingUpdates = ( }; } + // Handle the case where neither endpoint is being dragged + // but we need to update bound endpoints + if (!startIsDragged && !endIsDragged) { + const nextArrow = { + ...element, + points: element.points.map((p, idx) => { + return naiveDraggingPoints.get(idx)?.point ?? p; + }), + }; + const positions = new Map(naiveDraggingPoints); + + if (element.startBinding) { + const startBindable = elementsMap.get(element.startBinding.elementId) as + | ExcalidrawBindableElement + | undefined; + if (startBindable) { + const startPoint = + updateBoundPoint( + nextArrow, + "startBinding", + element.startBinding, + startBindable, + elementsMap, + ) ?? null; + if (startPoint) { + positions.set(0, { point: startPoint, isDragging: true }); + } + } + } + + if (element.endBinding) { + const endBindable = elementsMap.get(element.endBinding.elementId) as + | ExcalidrawBindableElement + | undefined; + if (endBindable) { + const endPoint = + updateBoundPoint( + nextArrow, + "endBinding", + element.endBinding, + endBindable, + elementsMap, + ) ?? null; + if (endPoint) { + positions.set(element.points.length - 1, { + point: endPoint, + isDragging: true, + }); + } + } + } + + return { + positions, + }; + } + if (startIsDragged === endIsDragged) { return { positions: naiveDraggingPoints, @@ -2279,7 +2340,9 @@ const pointDraggingUpdates = ( nextArrow.endBinding, endBindable, elementsMap, - endCustomIntersector, + { + customIntersector: endCustomIntersector, + }, ) || nextArrow.points[nextArrow.points.length - 1] : nextArrow.points[nextArrow.points.length - 1]; @@ -2302,7 +2365,7 @@ const pointDraggingUpdates = ( : startIsDraggingOverEndElement && app.state.bindMode !== "inside" && getFeatureFlag("COMPLEX_BINDINGS") - ? nextArrow.points[nextArrow.points.length - 1] + ? endLocalPoint : startBindable ? updateBoundPoint( element, @@ -2310,15 +2373,18 @@ const pointDraggingUpdates = ( nextArrow.startBinding, startBindable, elementsMap, - startCustomIntersector, + { customIntersector: startCustomIntersector }, ) || nextArrow.points[0] : nextArrow.points[0]; const endChanged = - pointDistance( - endLocalPoint, - nextArrow.points[nextArrow.points.length - 1], - ) !== 0; + !startIsDraggingOverEndElement && + !( + endIsDraggingOverStartElement && + app.state.bindMode !== "inside" && + getFeatureFlag("COMPLEX_BINDINGS") + ) && + !!endBindable; const startChanged = pointDistance(startLocalPoint, nextArrow.points[0]) !== 0; @@ -2332,13 +2398,7 @@ const pointDraggingUpdates = ( const indices = Array.from(indicesSet); return { - updates: - updates.startBinding || updates.suggestedBinding - ? { - startBinding: updates.startBinding, - suggestedBinding: updates.suggestedBinding, - } - : undefined, + updates, positions: new Map( indices.map((idx) => { return [ diff --git a/packages/element/src/zindex.ts b/packages/element/src/zindex.ts index 0bb0cda9c2..c4171c6bbd 100644 --- a/packages/element/src/zindex.ts +++ b/packages/element/src/zindex.ts @@ -156,12 +156,11 @@ export const moveArrowAboveBindable = ( elements: readonly Ordered[], elementsMap: NonDeletedSceneElementsMap, scene: Scene, + hit?: NonDeletedExcalidrawElement, ): readonly OrderedExcalidrawElement[] => { - const hoveredElement = getHoveredElementForBinding( - point, - elements, - elementsMap, - ); + const hoveredElement = hit + ? hit + : getHoveredElementForBinding(point, elements, elementsMap); if (!hoveredElement) { return elements; diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 5b31892b4d..4c9ab3825d 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -378,7 +378,7 @@ describe("Test Linear Elements", () => { // drag line from midpoint drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `11`, + `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -419,7 +419,7 @@ describe("Test Linear Elements", () => { fireEvent.click(screen.getByTitle("Round")); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `9`, + `10`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); @@ -480,7 +480,7 @@ describe("Test Linear Elements", () => { drag(startPoint, endPoint); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `11`, + `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -548,7 +548,7 @@ describe("Test Linear Elements", () => { ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `14`, + `15`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); @@ -599,7 +599,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `11`, + `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -640,7 +640,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `11`, + `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -688,7 +688,7 @@ describe("Test Linear Elements", () => { deletePoint(points[2]); expect(line.points.length).toEqual(3); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `17`, + `18`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); @@ -746,7 +746,7 @@ describe("Test Linear Elements", () => { ), ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `14`, + `15`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); @@ -844,7 +844,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `11`, + `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 2c9f2f7308..e224ca97db 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -250,6 +250,11 @@ import { maxBindingDistance_simple, convertToExcalidrawElements, type ExcalidrawElementSkeleton, + handleFocusPointDrag, + handleFocusPointHover, + handleFocusPointPointerDown, + handleFocusPointPointerUp, + maybeHandleArrowPointlikeDrag, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -4734,8 +4739,12 @@ class App extends React.Component { } // Handle Alt key for bind mode - if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) { - this.handleSkipBindMode(); + if (event.key === KEYS.ALT) { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleSkipBindMode(); + } else { + maybeHandleArrowPointlikeDrag({ app: this, event }); + } } if (this.actionManager.handleKeyDown(event)) { @@ -4751,7 +4760,11 @@ class App extends React.Component { this.resetDelayedBindMode(); } - this.setState({ isBindingEnabled: false }); + flushSync(() => { + this.setState({ isBindingEnabled: false }); + }); + + maybeHandleArrowPointlikeDrag({ app: this, event }); } if (isArrowKey(event.key)) { @@ -5024,6 +5037,11 @@ class App extends React.Component { } isHoldingSpace = false; } + + if (event.key === KEYS.ALT) { + maybeHandleArrowPointlikeDrag({ app: this, event }); + } + if ( (event.key === KEYS.ALT && this.state.bindMode === "skip") || (!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state)) @@ -5034,7 +5052,7 @@ class App extends React.Component { }); // Restart the timer if we're creating/editing a linear element and hovering over an element - if (this.lastPointerMoveEvent) { + if (this.lastPointerMoveEvent && getFeatureFlag("COMPLEX_BINDINGS")) { const scenePointer = viewportCoordsToSceneCoords( { clientX: this.lastPointerMoveEvent.clientX, @@ -5055,14 +5073,18 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); - if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) { + if (isBindingElement(element)) { this.handleDelayedBindModeChange(element, hoveredElement); } } } } if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) { - this.setState({ isBindingEnabled: true }); + flushSync(() => { + this.setState({ isBindingEnabled: true }); + }); + + maybeHandleArrowPointlikeDrag({ app: this, event }); } if (isArrowKey(event.key)) { bindOrUnbindBindingElements( @@ -6379,7 +6401,7 @@ class App extends React.Component { pointFrom(scenePointerX, scenePointerY), this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), - (el) => maxBindingDistance_simple(this.state.zoom), + maxBindingDistance_simple(this.state.zoom), ); if (hoveredElement) { this.setState({ @@ -6410,7 +6432,7 @@ class App extends React.Component { pointFrom(scenePointerX, scenePointerY), this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), - (el) => maxBindingDistance_simple(this.state.zoom), + maxBindingDistance_simple(this.state.zoom), ); if (hoveredElement) { this.actionManager.executeAction(actionFinalize, "ui", { @@ -6564,7 +6586,7 @@ class App extends React.Component { pointFrom(scenePointerX, scenePointerY), this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), - (el) => maxBindingDistance_simple(this.state.zoom), + maxBindingDistance_simple(this.state.zoom), ); if ( hit && @@ -6923,6 +6945,37 @@ class App extends React.Component { }, }); } + + // Check for focus point hover + let hoveredFocusPointBinding: "start" | "end" | null = null; + const arrow = element as any; + if (arrow.startBinding || arrow.endBinding) { + hoveredFocusPointBinding = handleFocusPointHover( + element as ExcalidrawArrowElement, + scenePointerX, + scenePointerY, + this.scene, + this.state, + ); + } + + if ( + this.state.selectedLinearElement.hoveredFocusPointBinding !== + hoveredFocusPointBinding + ) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + isDragging: false, + hoveredFocusPointBinding, + }, + }); + } + + // Set cursor to pointer when hovering over a focus point + if (hoveredFocusPointBinding) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + } } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } @@ -7844,6 +7897,37 @@ class App extends React.Component { if (ret.didAddPoint) { return true; } + + // Also check at current pointer position if focus point is being hovered + // (in case we're clicking directly without a prior move event) + const elementsMap = this.scene.getNonDeletedElementsMap(); + const arrow = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ) as any; + + if (arrow && isBindingElement(arrow)) { + const { hitFocusPoint, pointerOffset } = + handleFocusPointPointerDown( + arrow, + pointerDownState, + elementsMap, + this.state, + ); + + // If focus point is hit, update state and prevent element selection + if (hitFocusPoint) { + this.setState({ + selectedLinearElement: { + ...linearElementEditor, + hoveredFocusPointBinding: hitFocusPoint, + draggedFocusPointBinding: hitFocusPoint, + pointerOffset, + }, + }); + return false; + } + } } const allHitElements = this.getElementsAtPosition( @@ -8991,6 +9075,31 @@ class App extends React.Component { if (this.state.selectedLinearElement) { const linearElementEditor = this.state.selectedLinearElement; + // Handle focus point dragging if needed + if (linearElementEditor.draggedFocusPointBinding) { + handleFocusPointDrag( + linearElementEditor, + elementsMap, + pointerCoords, + this.scene, + this.state, + this.getEffectiveGridSize(), + event.altKey, + ); + this.setState({ + selectedLinearElement: { + ...linearElementEditor, + isDragging: false, + selectedPointsIndices: [], + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: -1, + }, + }, + }); + return; + } + if ( LinearElementEditor.shouldAddMidpoint( this.state.selectedLinearElement, @@ -9859,12 +9968,14 @@ class App extends React.Component { // and sets binding element if ( this.state.selectedLinearElement?.isEditing && - !this.state.newElement + !this.state.newElement && + this.state.selectedLinearElement.draggedFocusPointBinding === null ) { if ( !pointerDownState.boxSelection.hasOccurred && pointerDownState.hit?.element?.id !== - this.state.selectedLinearElement.elementId + this.state.selectedLinearElement.elementId && + this.state.selectedLinearElement.draggedFocusPointBinding === null ) { this.actionManager.executeAction(actionFinalize); } else { @@ -9900,7 +10011,18 @@ class App extends React.Component { } } - if ( + if (this.state.selectedLinearElement.draggedFocusPointBinding) { + handleFocusPointPointerUp( + this.state.selectedLinearElement, + this.scene, + ); + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + draggedFocusPointBinding: null, + }, + }); + } else if ( pointerDownState.hit?.element?.id !== this.state.selectedLinearElement.elementId ) { @@ -9910,6 +10032,12 @@ class App extends React.Component { this.setState({ selectedLinearElement: null }); } } else if (this.state.selectedLinearElement.isDragging) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + isDragging: false, + }, + }); this.actionManager.executeAction(actionFinalize, "ui", { event: childEvent, sceneCoords, diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 548a16ffa4..9ea376580b 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -135,7 +135,8 @@ describe("binding with linear elements", () => { ) as HTMLInputElement; expect(linear.startBinding).not.toBe(null); expect(inputX).not.toBeNull(); - UI.updateInput(inputX, String("186")); + + UI.updateInput(inputX, String("184")); expect(linear.startBinding).not.toBe(null); }); diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 5f314c2557..0c55dfaac4 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -21,10 +21,13 @@ import { deconstructDiamondElement, deconstructRectanguloidElement, elementCenterPoint, + FOCUS_POINT_SIZE, getOmitSidesForEditorInterface, getTransformHandles, getTransformHandlesFromCoords, hasBoundingBox, + isArrowElement, + isBindableElement, isElbowArrow, isFrameLikeElement, isImageElement, @@ -44,6 +47,10 @@ import { } from "@excalidraw/element"; import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element"; +import { + getGlobalFixedPointForBindableElement, + isFocusPointVisible, +} from "@excalidraw/element"; import type { TransformHandles, @@ -52,6 +59,7 @@ import type { import type { ElementsMap, + ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFrameLikeElement, @@ -71,11 +79,6 @@ import { SCROLLBAR_WIDTH, } from "../scene/scrollbars"; -import { - type AppClassProperties, - type InteractiveCanvasAppState, -} from "../types"; - import { getClientColor, renderRemoteCursors } from "../clients"; import { @@ -85,6 +88,8 @@ import { strokeRectWithRotation_simple, } from "./helpers"; +import type { AppClassProperties, InteractiveCanvasAppState } from "../types"; + import type { InteractiveCanvasRenderConfig, InteractiveSceneRenderConfig, @@ -123,6 +128,9 @@ const renderLinearElementPointHighlight = ( ) { return; } + if (appState.selectedLinearElement?.isDragging) { + return; + } const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { @@ -156,6 +164,19 @@ const highlightPoint = ( ); }; +const renderFocusPointHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + focusPoint: GlobalPoint, +) => { + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + highlightPoint(focusPoint, context, appState); + + context.restore(); +}; + const renderSingleLinearPoint = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -920,6 +941,145 @@ const renderLinearPointHandles = ( context.restore(); }; +const renderFocusPointConnectionLine = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + fromPoint: GlobalPoint, + toPoint: GlobalPoint, +) => { + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + context.strokeStyle = "rgba(134, 131, 226, 0.6)"; + context.lineWidth = 1 / appState.zoom.value; + context.setLineDash([4 / appState.zoom.value, 4 / appState.zoom.value]); + + context.beginPath(); + context.moveTo(fromPoint[0], fromPoint[1]); + context.lineTo(toPoint[0], toPoint[1]); + context.stroke(); + + context.restore(); +}; + +const renderFocusPointCicle = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + point: GlobalPoint, + radius: number, + isHovered: boolean, +) => { + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.strokeStyle = "rgba(134, 131, 226, 0.6)"; + context.lineWidth = 1 / appState.zoom.value; + context.setLineDash([]); + context.fillStyle = isHovered + ? "rgba(134, 131, 226, 0.9)" + : "rgba(255, 255, 255, 0.9)"; + + fillCircle( + context, + point[0], + point[1], + radius / appState.zoom.value, + true, + true, + ); + context.restore(); +}; + +const renderFocusPointIndicator = ({ + arrow, + appState, + type, + context, + elementsMap, +}: { + arrow: NonDeleted; + appState: InteractiveCanvasAppState; + context: CanvasRenderingContext2D; + elementsMap: NonDeletedSceneElementsMap; + type: "start" | "end"; +}) => { + const binding = type === "start" ? arrow.startBinding : arrow.endBinding; + const bindableElement = + binding?.elementId && elementsMap.get(binding.elementId); + + if ( + !bindableElement || + !isBindableElement(bindableElement) || + bindableElement.isDeleted + ) { + return; + } + + const focusPoint = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + bindableElement, + elementsMap, + ); + + // Only render if focus point is within the bindable element + if ( + !isFocusPointVisible( + focusPoint, + arrow, + bindableElement, + elementsMap, + appState, + ) + ) { + return; + } + + const linearState = appState.selectedLinearElement; + const isDragging = !!linearState?.isDragging; + const pointIndex = type === "start" ? 0 : arrow.points.length - 1; + const pointSelected = + !!linearState?.selectedPointsIndices?.includes(pointIndex); + + // render focus point highlight + // ---------------------------- + + if ( + linearState?.hoveredFocusPointBinding === type && + !linearState.draggedFocusPointBinding + ) { + renderFocusPointHighlight(context, appState, focusPoint); + } + + // render focus point + // ---------------------------- + + if (!(pointSelected && isDragging)) { + const focusPoint = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + bindableElement, + elementsMap, + ); + + const isHovered = linearState?.hoveredFocusPointBinding === type; + + // Render dashed line from arrow start point to focus point + const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + pointIndex, + elementsMap, + ); + + renderFocusPointConnectionLine(context, appState, arrowPoint, focusPoint); + + renderFocusPointCicle( + context, + appState, + focusPoint, + FOCUS_POINT_SIZE / 1.5, + isHovered, + ); + } +}; + const renderTransformHandles = ( context: CanvasRenderingContext2D, renderConfig: InteractiveCanvasRenderConfig, @@ -1251,26 +1411,44 @@ const _renderInteractiveScene = ({ ); } + const linearState = appState.selectedLinearElement; + const selectedLinearElement = + linearState && + LinearElementEditor.getElement(linearState.elementId, allElementsMap); // Arrows have a different highlight behavior when // they are the only selected element - if (appState.selectedLinearElement) { - const editor = appState.selectedLinearElement; - const firstSelectedLinear = selectedElements.find( - (el) => el.id === editor.elementId, // Don't forget bound text elements! - ); - + if (selectedLinearElement) { if (!appState.selectedLinearElement.isDragging) { - if (editor.segmentMidPointHoveredCoords) { + if (linearState.segmentMidPointHoveredCoords) { renderElbowArrowMidPointHighlight(context, appState); } else if ( - isElbowArrow(firstSelectedLinear) - ? editor.hoverPointIndex === 0 || - editor.hoverPointIndex === firstSelectedLinear.points.length - 1 - : editor.hoverPointIndex >= 0 + isElbowArrow(selectedLinearElement) + ? linearState.hoverPointIndex === 0 || + linearState.hoverPointIndex === + selectedLinearElement.points.length - 1 + : linearState.hoverPointIndex >= 0 ) { renderLinearElementPointHighlight(context, appState, elementsMap); } } + + if (isArrowElement(selectedLinearElement)) { + renderFocusPointIndicator({ + arrow: selectedLinearElement, + elementsMap: allElementsMap, + appState, + context, + type: "start", + }); + + renderFocusPointIndicator({ + arrow: selectedLinearElement, + elementsMap: allElementsMap, + appState, + context, + type: "end", + }); + } } // Paint selected elements diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index d87b67f8d7..4e9d7bb568 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -2398,7 +2398,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "353.93938", + "height": "390.11932", "id": "id4", "index": "a2", "isDeleted": false, @@ -2412,8 +2412,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "478.03877", - "-353.93938", + "478.03878", + "-390.11932", ], ], "roughness": 1, @@ -2424,7 +2424,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -2435,9 +2435,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 12, - "width": "478.03877", + "width": "478.03878", "x": 11, - "y": "-45.14705", + "y": "-8.96692", } `; @@ -2566,7 +2566,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "353.93938", + "height": "390.11932", "index": "a2", "isDeleted": false, "link": null, @@ -2578,8 +2578,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "478.03877", - "-353.93938", + "478.03878", + "-390.11932", ], ], "roughness": 1, @@ -2590,7 +2590,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "startBinding": { "elementId": "id0", "fixedPoint": [ - "0.50010", + 1, "0.50010", ], "mode": "orbit", @@ -2600,9 +2600,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "version": 12, - "width": "478.03877", + "width": "478.03878", "x": 11, - "y": "-45.14705", + "y": "-8.96692", }, "inserted": { "isDeleted": true, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index f5ab13c34f..d9f469988c 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -8743,9 +8743,11 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { "customLineAngle": null, + "draggedFocusPointBinding": null, "elbowed": false, "elementId": "id0", "hoverPointIndex": -1, + "hoveredFocusPointBinding": null, "initialState": { "altFocusPoint": null, "arrowStartIsInside": false, @@ -8976,9 +8978,11 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { "customLineAngle": null, + "draggedFocusPointBinding": null, "elbowed": false, "elementId": "id0", "hoverPointIndex": -1, + "hoveredFocusPointBinding": null, "initialState": { "altFocusPoint": null, "arrowStartIsInside": false, @@ -9402,9 +9406,11 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { "customLineAngle": null, + "draggedFocusPointBinding": null, "elbowed": false, "elementId": "id0", "hoverPointIndex": -1, + "hoveredFocusPointBinding": null, "initialState": { "altFocusPoint": null, "arrowStartIsInside": false, @@ -9818,9 +9824,11 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { "customLineAngle": null, + "draggedFocusPointBinding": null, "elbowed": false, "elementId": "id0", "hoverPointIndex": -1, + "hoveredFocusPointBinding": null, "initialState": { "altFocusPoint": null, "arrowStartIsInside": false, diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index d3b0cf48f3..014d8608ed 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -5051,7 +5051,7 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: expect.arrayContaining([0.5001, 0.5001]), + fixedPoint: expect.arrayContaining([1, 0.5001]), }), endBinding: expect.objectContaining({ elementId: rect2.id, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 2f464d8683..eeb8aa8056 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -63,6 +63,8 @@ import type { isOverScrollBars } from "./scene/scrollbars"; import type React from "react"; import type { JSX } from "react"; +export type { App }; + export type SocketId = string & { _brand: "SocketId" }; export type Collaborator = Readonly<{