Compare commits

...

26 Commits

Author SHA1 Message Date
Mark Tolmacs
bca6b3fbe1
Timed binding mode change for simple arrows 2025-06-25 22:12:11 +02:00
Mark Tolmacs
3ccb7e3239
Fix binding disabled use-case triggering arrow editor 2025-06-25 19:59:57 +02:00
Mark Tolmacs
fbff9a3463
Fixed point binding for simple arrows when the arrow doesn't point to the element 2025-06-25 18:44:29 +02:00
Mark Tolmacs
133cc2b354
Arrow dragging gets a little drag to avoid accidental unbinding 2025-06-24 20:55:05 +02:00
Mark Tolmacs
5cf79b078e
Fix all point multipoint arrow highlight and binding 2025-06-24 20:21:29 +02:00
Mark Tolmacs
4980631cb1
Fix linear editor bug when both midpoint and endpoint is moved 2025-06-24 18:26:17 +02:00
Mark Tolmacs
8c2de8c3c4
Add test for mobing mid points for simple arrows when bound on the same element on both ends 2025-06-24 17:52:00 +02:00
Mark Tolmacs
06dc04ea5d
Do not move mid point for simple arrows bound on both ends 2025-06-24 17:27:42 +02:00
Mark Tolmacs
c4a52a982a
Fix drag unbind when the bound element is in the selection 2025-06-24 15:34:23 +02:00
Mark Tolmacs
d13f6477b9
Fix binding test 2025-06-23 17:02:46 +02:00
Mark Tolmacs
3ae89aba47
Unbind arrow if bound and moved at shaft of arrow key 2025-06-23 17:01:12 +02:00
Mark Tolmacs
b8fac37115
Make elbow arrows respect grids 2025-06-23 15:20:24 +02:00
Mark Tolmacs
71cfd1d82d
Fix multi-point arrow grid off 2025-06-23 15:20:13 +02:00
Mark Tolmacs
087353e06a
No confirm threshold when inside biding range 2025-06-21 22:32:19 +02:00
Mark Tolmacs
fdc6ea6d2e
Updating tests 2025-06-21 22:16:35 +02:00
Mark Tolmacs
1e99d28a6e
Refactored simple arrow creation 2025-06-21 20:56:04 +02:00
Mark Tolmacs
b04814cca7
Fix crash for elbow arrws in mutateElement() 2025-06-21 17:47:15 +02:00
Mark Tolmacs
b696b7bf98
Type updates to support fixed binding for simple arrows 2025-06-20 22:22:57 +02:00
Mark Tolmacs
331790e9af
Existing arrows now jump out 2025-06-20 22:22:32 +02:00
Mark Tolmacs
0dd76db5f0
Do not apply the jumping logic to elbow arrows for new elements 2025-06-20 21:17:48 +02:00
Mark Tolmacs
77d691e397
Fix newly created jumping arrow when gets outside 2025-06-20 20:12:59 +02:00
Mark Tolmacs
6efe4c6f38
Unfinished simple arrow avoidance 2025-06-19 22:37:53 +02:00
Mark Tolmacs
898499777f
Remove unneeded params 2025-06-19 18:06:53 +02:00
Mark Tolmacs
5d16a81327
Fix binding 2025-06-19 18:06:53 +02:00
Mark Tolmacs
c3d40c3781
Tests added 2025-06-19 18:06:53 +02:00
Mark Tolmacs
bc70f06edd
Fixed point binding for simple arrows 2025-06-19 18:06:53 +02:00
26 changed files with 1941 additions and 357 deletions

View File

@ -514,3 +514,5 @@ export enum UserIdleState {
* the start and end points) * the start and end points)
*/ */
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const BIND_MODE_TIMEOUT = 1000; // ms

View File

@ -3,9 +3,6 @@ import {
arrayToMap, arrayToMap,
isBindingFallthroughEnabled, isBindingFallthroughEnabled,
tupleToCoors, tupleToCoors,
invariant,
isDevEnv,
isTestEnv,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -37,7 +34,7 @@ import {
getCenterForBounds, getCenterForBounds,
getElementBounds, getElementBounds,
} from "./bounds"; } from "./bounds";
import { intersectElementWithLineSegment } from "./collision"; import { hitElementItself, intersectElementWithLineSegment } from "./collision";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
headingForPointFromElement, headingForPointFromElement,
@ -127,6 +124,9 @@ export const bindOrUnbindLinearElement = (
endBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep",
scene: Scene, scene: Scene,
): void => { ): void => {
const bothEndBoundToTheSameElement =
linearElement.startBinding?.elementId ===
linearElement.endBinding?.elementId && !!linearElement.startBinding;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
@ -151,6 +151,7 @@ export const bindOrUnbindLinearElement = (
elementsMap, elementsMap,
); );
if (!bothEndBoundToTheSameElement) {
const onlyUnbound = Array.from(unboundFromElementIds).filter( const onlyUnbound = Array.from(unboundFromElementIds).filter(
(id) => !boundToElementIds.has(id), (id) => !boundToElementIds.has(id),
); );
@ -163,6 +164,7 @@ export const bindOrUnbindLinearElement = (
), ),
}); });
}); });
}
}; };
const bindOrUnbindLinearElementEdge = ( const bindOrUnbindLinearElementEdge = (
@ -203,6 +205,7 @@ const bindOrUnbindLinearElementEdge = (
linearElement, linearElement,
bindableElement, bindableElement,
startOrEnd, startOrEnd,
elementsMap,
) )
: startOrEnd === "start" || : startOrEnd === "start" ||
otherEdgeBindableElement.id !== bindableElement.id) otherEdgeBindableElement.id !== bindableElement.id)
@ -393,6 +396,7 @@ 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 // During line creation the start binding hasn't been written yet
// into `linearElement` // into `linearElement`
oppositeBindingBoundElement?: ExcalidrawBindableElement | null, oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
@ -411,11 +415,12 @@ export const maybeSuggestBindingsForLinearElementAtCoords = (
if ( if (
hoveredBindableElement != null && hoveredBindableElement != null &&
(oppositeBindingBoundElement?.id === hoveredBindableElement.id ||
!isLinearElementSimpleAndAlreadyBound( !isLinearElementSimpleAndAlreadyBound(
linearElement, linearElement,
oppositeBindingBoundElement?.id, oppositeBindingBoundElement?.id,
hoveredBindableElement, hoveredBindableElement,
) ))
) { ) {
acc.add(hoveredBindableElement); acc.add(hoveredBindableElement);
} }
@ -459,6 +464,7 @@ export const maybeBindLinearElement = (
linearElement, linearElement,
hoveredElement, hoveredElement,
"end", "end",
elementsMap,
) )
) { ) {
bindLinearElement(linearElement, hoveredElement, "end", scene); bindLinearElement(linearElement, hoveredElement, "end", scene);
@ -487,29 +493,120 @@ export const bindLinearElement = (
return; return;
} }
let binding: PointBinding | FixedPointBinding = { const elementsMap = scene.getNonDeletedElementsMap();
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
let binding: PointBinding | FixedPointBinding;
if (isElbowArrow(linearElement)) {
binding = {
elementId: hoveredElement.id, elementId: hoveredElement.id,
...normalizePointBinding( ...normalizePointBinding(
calculateFocusAndGap( calculateFocusAndGap(
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
scene.getNonDeletedElementsMap(), elementsMap,
), ),
hoveredElement, hoveredElement,
), ),
};
if (isElbowArrow(linearElement)) {
binding = {
...binding,
...calculateFixedPointForElbowArrowBinding( ...calculateFixedPointForElbowArrowBinding(
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
scene.getNonDeletedElementsMap(), elementsMap,
), ),
}; };
} else if (
hitElementItself({
point: edgePoint,
element: hoveredElement,
elementsMap,
threshold: 0, // TODO: Not ideal, should be calculated from the same source
})
) {
// Use FixedPoint binding when the arrow endpoint is inside the shape
binding = {
elementId: hoveredElement.id,
focus: 0,
gap: 0,
...calculateFixedPointForNonElbowArrowBinding(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
};
} else {
// For non-elbow arrows, extend the last segment and check intersection
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd === "start" ? 1 : -2,
elementsMap,
);
const extendedDirection = vectorScale(
vectorNormalize(
vectorFromPoint(
pointFrom(
edgePoint[0] - adjacentPoint[0],
edgePoint[1] - adjacentPoint[1],
),
),
),
Math.max(hoveredElement.width, hoveredElement.height) * 2,
);
const intersector = lineSegment(
edgePoint,
pointFromVector(
vectorFromPoint(
pointFrom(
edgePoint[0] + extendedDirection[0],
edgePoint[1] + extendedDirection[1],
),
),
),
);
// Check if this extended segment intersects the bindable element
const intersections = intersectElementWithLineSegment(
hoveredElement,
elementsMap,
intersector,
);
const intersectsElement = intersections.length > 0;
if (intersectsElement) {
// Use traditional focus/gap binding when the extended segment intersects
binding = {
elementId: hoveredElement.id,
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement,
),
};
} else {
// Use FixedPoint binding when the extended segment doesn't intersect
binding = {
elementId: hoveredElement.id,
focus: 0,
gap: 0,
...calculateFixedPointForNonElbowArrowBinding(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
};
}
} }
scene.mutateElement(linearElement, { scene.mutateElement(linearElement, {
@ -532,14 +629,43 @@ const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): boolean => { ): boolean => {
const otherBinding = const otherBinding =
linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"]; linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"];
return isLinearElementSimpleAndAlreadyBound(
// Only prevent binding if opposite end is bound to the same element
if (
otherBinding?.elementId !== bindableElement.id ||
!isLinearElementSimple(linearElement)
) {
return false;
}
// For non-elbow arrows, allow FixedPoint binding even when both ends bind to the same element
if (!isElbowArrow(linearElement)) {
const currentEndPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement, linearElement,
otherBinding?.elementId, startOrEnd === "start" ? 0 : -1,
bindableElement, elementsMap,
); );
// If current end would use FixedPoint binding, allow it
if (
hitElementItself({
point: currentEndPoint,
element: bindableElement,
elementsMap,
threshold: 0, // TODO: Not ideal, should be calculated from the same source
})
) {
return false;
}
}
// Prevent traditional focus/gap binding when both ends would bind to the same element
return true;
}; };
export const isLinearElementSimpleAndAlreadyBound = ( export const isLinearElementSimpleAndAlreadyBound = (
@ -776,7 +902,10 @@ export const updateBoundElements = (
? elementsMap.get(element.startBinding.elementId) ? elementsMap.get(element.startBinding.elementId)
: null; : null;
const endBindingElement = element.endBinding const endBindingElement = element.endBinding
? elementsMap.get(element.endBinding.elementId) ? // PERF: If the arrow is bound to the same element on both ends.
startBindingElement?.id === element.endBinding.elementId
? startBindingElement
: elementsMap.get(element.endBinding.elementId)
: null; : null;
let startBounds: Bounds | null = null; let startBounds: Bounds | null = null;
@ -849,6 +978,9 @@ export const updateBoundElements = (
...(changedElement.id === element.endBinding?.elementId ...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding } ? { endBinding: bindings.endBinding }
: {}), : {}),
moveMidPointsWithElement:
!!startBindingElement &&
startBindingElement?.id === endBindingElement?.id,
}); });
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
@ -942,35 +1074,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;
@ -1029,7 +1166,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 = (
@ -1254,15 +1419,22 @@ const updateBoundPoint = (
const direction = startOrEnd === "startBinding" ? -1 : 1; const direction = startOrEnd === "startBinding" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) { if (isFixedPointBinding(binding)) {
const fixedPoint = const fixedPoint =
normalizeFixedPoint(binding.fixedPoint) ?? normalizeFixedPoint(binding.fixedPoint) ??
calculateFixedPointForElbowArrowBinding( (isElbowArrow(linearElement)
? calculateFixedPointForElbowArrowBinding(
linearElement, linearElement,
bindableElement, bindableElement,
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap, elementsMap,
).fixedPoint; ).fixedPoint
: calculateFixedPointForNonElbowArrowBinding(
linearElement,
bindableElement,
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint);
const globalMidPoint = elementCenterPoint(bindableElement, elementsMap); const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
const global = pointFrom<GlobalPoint>( const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
@ -1401,6 +1573,42 @@ export const calculateFixedPointForElbowArrowBinding = (
}; };
}; };
export const calculateFixedPointForNonElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => {
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
// Convert the global point to element-local coordinates
const elementCenter = pointFrom(
hoveredElement.x + hoveredElement.width / 2,
hoveredElement.y + hoveredElement.height / 2,
);
// Rotate the point to account for element rotation
const nonRotatedPoint = pointRotateRads(
edgePoint,
elementCenter,
-hoveredElement.angle as Radians,
);
// Calculate the ratio relative to the element's bounds
const fixedPointX =
(nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
const fixedPointY =
(nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
};
};
const maybeCalculateNewGapWhenScaling = ( const maybeCalculateNewGapWhenScaling = (
changedElement: ExcalidrawBindableElement, changedElement: ExcalidrawBindableElement,
currentBinding: PointBinding | null | undefined, currentBinding: PointBinding | null | undefined,
@ -2212,16 +2420,18 @@ export const getGlobalFixedPointForBindableElement = (
}; };
export const getGlobalFixedPoints = ( export const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawArrowElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): [GlobalPoint, GlobalPoint] => { ): [GlobalPoint, GlobalPoint] => {
const startElement = const startElement =
arrow.startBinding && arrow.startBinding &&
isFixedPointBinding(arrow.startBinding) &&
(elementsMap.get(arrow.startBinding.elementId) as (elementsMap.get(arrow.startBinding.elementId) as
| ExcalidrawBindableElement | ExcalidrawBindableElement
| undefined); | undefined);
const endElement = const endElement =
arrow.endBinding && arrow.endBinding &&
isFixedPointBinding(arrow.endBinding) &&
(elementsMap.get(arrow.endBinding.elementId) as (elementsMap.get(arrow.endBinding.elementId) as
| ExcalidrawBindableElement | ExcalidrawBindableElement
| undefined); | undefined);

View File

@ -13,7 +13,7 @@ import type {
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { updateBoundElements } from "./binding"; import { bindOrUnbindLinearElement, updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds"; import { getCommonBounds } from "./bounds";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
@ -102,9 +102,26 @@ export const dragSelectedElements = (
gridSize, gridSize,
); );
const elementsToUpdateIds = new Set(
Array.from(elementsToUpdate, (el) => el.id),
);
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, scene, adjustedOffset); const isArrow = !isArrowElement(element);
const isStartBoundElementSelected =
isArrow ||
(element.startBinding
? elementsToUpdateIds.has(element.startBinding.elementId)
: false);
const isEndBoundElementSelected =
isArrow ||
(element.endBinding
? elementsToUpdateIds.has(element.endBinding.elementId)
: false);
if (!isArrowElement(element)) { if (!isArrowElement(element)) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
// skip arrow labels since we calculate its position during render // skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement( const textElement = getBoundTextElement(
element, element,
@ -118,9 +135,33 @@ export const dragSelectedElements = (
adjustedOffset, adjustedOffset,
); );
} }
updateBoundElements(element, scene, { updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate), simultaneouslyUpdated: Array.from(elementsToUpdate),
}); });
} else if (
// NOTE: Add a little initial drag to the arrow dragging to avoid
// accidentally unbinding the arrow when the user just wants to select it.
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) > 1
) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const shouldUnbindStart =
element.startBinding && !isStartBoundElementSelected;
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
if (shouldUnbindStart || shouldUnbindEnd) {
// NOTE: Moving the bound arrow should unbind it, otherwise we would
// have weird situations, like 0 lenght arrow when the user moves
// the arrow outside a filled shape suddenly forcing the arrow start
// and end point to jump "outside" the shape.
bindOrUnbindLinearElement(
element,
shouldUnbindStart ? null : "keep",
shouldUnbindEnd ? null : "keep",
scene,
);
}
} }
}); });
}; };

View File

@ -25,7 +25,9 @@ import {
import { import {
deconstructLinearOrFreeDrawElement, deconstructLinearOrFreeDrawElement,
hitElementItself,
isPathALoop, isPathALoop,
shouldTestInside,
type Store, type Store,
} from "@excalidraw/element"; } from "@excalidraw/element";
@ -43,8 +45,10 @@ import type {
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { import {
bindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
getHoveredElementForBinding, getHoveredElementForBinding,
getOutlineAvoidingPoint,
isBindingEnabled, isBindingEnabled,
maybeSuggestBindingsForLinearElementAtCoords, maybeSuggestBindingsForLinearElementAtCoords,
} from "./binding"; } from "./binding";
@ -58,6 +62,8 @@ 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,
isBindableElement,
isBindingElement, isBindingElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
@ -85,6 +91,8 @@ import type {
FixedSegment, FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
PointsPositionUpdates, PointsPositionUpdates,
NonDeletedExcalidrawElement,
Ordered,
} from "./types"; } from "./types";
/** /**
@ -134,6 +142,7 @@ export class LinearElementEditor {
index: number | null; index: number | null;
added: boolean; added: boolean;
}; };
arrowOtherPoint?: GlobalPoint;
}>; }>;
/** whether you're dragging a point */ /** whether you're dragging a point */
@ -278,6 +287,7 @@ export class LinearElementEditor {
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
thresholdCallback: (element: ExcalidrawElement) => number,
): Pick<AppState, keyof AppState> | null { ): Pick<AppState, keyof AppState> | null {
if (!linearElementEditor) { if (!linearElementEditor) {
return null; return null;
@ -286,19 +296,23 @@ export class LinearElementEditor {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
let customLineAngle = linearElementEditor.customLineAngle; let customLineAngle = linearElementEditor.customLineAngle;
let arrowOtherPoint: GlobalPoint | undefined =
linearElementEditor.pointerDownState.arrowOtherPoint;
if (!element) { if (!element) {
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 +322,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
@ -366,38 +380,38 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
const elements = app.scene.getNonDeletedElements();
arrowOtherPoint = pointDraggingOtherEndpoint(
element,
elementsMap,
selectedPointsIndices,
scenePointerX,
scenePointerY,
linearElementEditor,
app.scene,
thresholdCallback,
);
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
app.scene, app.scene,
new Map( pointDraggingUpdates(
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices,
const newPointPosition: LocalPoint = deltaX,
pointIndex === lastClickedPoint deltaY,
? LinearElementEditor.createPointAt(
element,
elementsMap, elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x, lastClickedPoint,
scenePointerY - linearElementEditor.pointerOffset.y, element,
event[KEYS.CTRL_OR_CMD] scenePointerX,
? null scenePointerY,
: app.getEffectiveGridSize(), linearElementEditor,
) event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
: pointFrom( elements,
element.points[pointIndex][0] + deltaX, app.state.zoom,
element.points[pointIndex][1] + deltaY, app.state.bindMode,
);
return [
pointIndex,
{
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
},
];
}),
), ),
); );
} }
@ -410,16 +424,14 @@ export class LinearElementEditor {
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
let suggestedBindings: ExcalidrawBindableElement[] = []; let suggestedBindings: ExcalidrawBindableElement[] = [];
if (isBindingElement(element, false)) { if (isBindingElement(element, false)) {
const firstSelectedIndex = selectedPointsIndices[0] === 0; const firstIndexIsSelected = selectedPointsIndices[0] === 0;
const lastSelectedIndex = const lastIndexIsSelected =
selectedPointsIndices[selectedPointsIndices.length - 1] === selectedPointsIndices[selectedPointsIndices.length - 1] ===
element.points.length - 1; element.points.length - 1;
const coords: { x: number; y: number }[] = []; const coords: { x: number; y: number }[] = [];
if (!firstSelectedIndex !== !lastSelectedIndex) { if (firstIndexIsSelected !== lastIndexIsSelected) {
coords.push({ x: scenePointerX, y: scenePointerY }); if (firstIndexIsSelected) {
} else {
if (firstSelectedIndex) {
coords.push( coords.push(
tupleToCoors( tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates( LinearElementEditor.getPointGlobalCoordinates(
@ -431,7 +443,7 @@ export class LinearElementEditor {
); );
} }
if (lastSelectedIndex) { if (lastIndexIsSelected) {
coords.push( coords.push(
tupleToCoors( tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates( LinearElementEditor.getPointGlobalCoordinates(
@ -452,6 +464,7 @@ export class LinearElementEditor {
coords, coords,
app.scene, app.scene,
app.state.zoom, app.state.zoom,
elementsMap,
); );
} }
} }
@ -475,6 +488,10 @@ export class LinearElementEditor {
: -1, : -1,
isDragging: true, isDragging: true,
customLineAngle, customLineAngle,
pointerDownState: {
...linearElementEditor.pointerDownState,
arrowOtherPoint,
},
}; };
return { return {
@ -605,6 +622,10 @@ export class LinearElementEditor {
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 }, pointerOffset: { x: 0, y: 0 },
customLineAngle: null, customLineAngle: null,
pointerDownState: {
...editingLinearElement.pointerDownState,
arrowOtherPoint: undefined,
},
}; };
} }
@ -943,8 +964,15 @@ export class LinearElementEditor {
// from the end points of the `linearElement` - this is to allow disabling // from the end points of the `linearElement` - this is to allow disabling
// binding (which needs to happen at the point the user finishes moving // binding (which needs to happen at the point the user finishes moving
// the point). // the point).
const allPointSelected =
linearElementEditor.pointerDownState.prevSelectedPointsIndices
?.length === element.points.length;
const { startBindingElement, endBindingElement } = linearElementEditor; const { startBindingElement, endBindingElement } = linearElementEditor;
if (isBindingEnabled(appState) && isBindingElement(element)) { if (
!allPointSelected &&
isBindingEnabled(appState) &&
isBindingElement(element)
) {
bindOrUnbindLinearElement( bindOrUnbindLinearElement(
element, element,
startBindingElement, startBindingElement,
@ -1404,6 +1432,7 @@ export class LinearElementEditor {
otherUpdates?: { otherUpdates?: {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
endBinding?: PointBinding | null; endBinding?: PointBinding | null;
moveMidPointsWithElement?: boolean | null;
}, },
) { ) {
const { points } = element; const { points } = element;
@ -1449,6 +1478,15 @@ export class LinearElementEditor {
: points.map((p, idx) => { : points.map((p, idx) => {
const current = pointUpdates.get(idx)?.point ?? p; const current = pointUpdates.get(idx)?.point ?? p;
if (
otherUpdates?.moveMidPointsWithElement &&
idx !== 0 &&
idx !== points.length - 1 &&
!pointUpdates.has(idx)
) {
return pointFrom<LocalPoint>(current[0], current[1]);
}
return pointFrom<LocalPoint>( return pointFrom<LocalPoint>(
current[0] - offsetX, current[0] - offsetX,
current[1] - offsetY, current[1] - offsetY,
@ -1977,3 +2015,242 @@ const normalizeSelectedPoints = (
nextPoints = nextPoints.sort((a, b) => a - b); nextPoints = nextPoints.sort((a, b) => a - b);
return nextPoints.length ? nextPoints : null; return nextPoints.length ? nextPoints : null;
}; };
const pointDraggingUpdates = (
selectedPointsIndices: readonly number[],
deltaX: number,
deltaY: number,
elementsMap: NonDeletedSceneElementsMap,
lastClickedPoint: number,
element: NonDeleted<ExcalidrawLinearElement>,
scenePointerX: number,
scenePointerY: number,
linearElementEditor: LinearElementEditor,
gridSize: NullableGridSize,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom: AppState["zoom"],
bindMode: AppState["bindMode"],
): PointsPositionUpdates => {
const hasMidPoints =
selectedPointsIndices.filter(
(_, idx) => idx > 0 && idx < element.points.length - 1,
).length > 0;
return new Map(
selectedPointsIndices.map((pointIndex) => {
let newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
gridSize,
)
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
if (
!hasMidPoints &&
(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],
},
elements,
elementsMap,
zoom,
shouldTestInside(element),
isElbowArrow(element),
);
const otherGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
pointIndex === 0 ? element.points.length - 1 : 0,
elementsMap,
);
const otherHoveredElement = getHoveredElementForBinding(
{
x: otherGlobalPoint[0],
y: otherGlobalPoint[1],
},
elements,
elementsMap,
zoom,
shouldTestInside(element),
isElbowArrow(element),
);
// Allow binding inside the element if both ends are inside
if (
isArrowElement(element) &&
!(
hoveredElement?.id === otherHoveredElement?.id &&
hoveredElement != null
) &&
bindMode === "focus"
) {
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,
{
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
},
];
}),
);
};
const pointDraggingOtherEndpoint = (
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap,
selectedPointsIndices: readonly number[],
scenePointerX: number,
scenePointerY: number,
linearElementEditor: LinearElementEditor,
scene: Scene,
thresholdCallback: (element: ExcalidrawElement) => number,
) => {
let arrowOtherPoint = linearElementEditor.pointerDownState.arrowOtherPoint;
if (isArrowElement(element) && !isElbowArrow(element)) {
const startPointIsIncluded = selectedPointsIndices.includes(0);
const endPointIsIncluded = selectedPointsIndices.includes(
element.points.length - 1,
);
if (
// Make sure that not both of the endpoints are selected
(startPointIsIncluded || endPointIsIncluded) &&
startPointIsIncluded !== endPointIsIncluded
) {
const otherBinding =
element[startPointIsIncluded ? "endBinding" : "startBinding"];
if (
// The other end is bound
otherBinding
) {
const otherElement = elementsMap.get(otherBinding.elementId);
invariant(
isBindableElement(otherElement),
"Other element should exist in elementsMap at all times and be a bindable element",
);
let newOtherPointPosition;
// Only avoid shape if the start and end point is not inside
// the same element
if (
!hitElementItself({
point: pointFrom(scenePointerX, scenePointerY),
element: otherElement,
elementsMap,
threshold: thresholdCallback(otherElement),
})
) {
// If we don't have a restore point, that means we need to jump out
// of the element but first, create the restore point
if (!arrowOtherPoint) {
arrowOtherPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[
startPointIsIncluded ? element.points.length - 1 : 0
],
elementsMap,
);
}
// Find a snap point outside the element
const newOtherGlobalPoint = getOutlineAvoidingPoint(
element,
otherElement,
arrowOtherPoint,
startPointIsIncluded ? element.points.length - 1 : 0,
elementsMap,
);
newOtherPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
newOtherGlobalPoint[0] - linearElementEditor.pointerOffset.x,
newOtherGlobalPoint[1] - linearElementEditor.pointerOffset.y,
null,
);
}
// Restore the saved point if we are back inside the element
else if (arrowOtherPoint) {
newOtherPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
arrowOtherPoint[0] - linearElementEditor.pointerOffset.x,
arrowOtherPoint[1] - linearElementEditor.pointerOffset.y,
null,
);
arrowOtherPoint = undefined;
}
// Finally, move the other endpoint if needed
if (newOtherPointPosition) {
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
startPointIsIncluded ? element.points.length - 1 : 0,
{
point: newOtherPointPosition,
},
],
]),
);
bindLinearElement(
element,
otherElement,
startPointIsIncluded ? "end" : "start",
scene,
);
}
}
}
}
return arrowOtherPoint;
};

View File

@ -12,7 +12,7 @@ import { ShapeCache } from "./shape";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import { isElbowArrow } from "./typeChecks"; import { isElbowArrow, isFixedPointBinding } from "./typeChecks";
import type { import type {
ElementsMap, ElementsMap,
@ -54,8 +54,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
(Object.keys(updates).length === 0 || // normalization case (Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined" || // segment fixing typeof fixedSegments !== "undefined" || // segment fixing
typeof startBinding !== "undefined" || isFixedPointBinding(startBinding) ||
typeof endBinding !== "undefined") // manual binding to element isFixedPointBinding(endBinding)) // manual binding to element
) { ) {
updates = { updates = {
...updates, ...updates,

View File

@ -362,6 +362,7 @@ export const isFixedPointBinding = (
binding: PointBinding | FixedPointBinding, binding: PointBinding | FixedPointBinding,
): binding is FixedPointBinding => { ): binding is FixedPointBinding => {
return ( return (
binding != null &&
Object.hasOwn(binding, "fixedPoint") && Object.hasOwn(binding, "fixedPoint") &&
(binding as FixedPointBinding).fixedPoint != null (binding as FixedPointBinding).fixedPoint != null
); );

View File

@ -323,8 +323,8 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
type: "line" | "arrow"; type: "line" | "arrow";
points: readonly LocalPoint[]; points: readonly LocalPoint[];
lastCommittedPoint: LocalPoint | null; lastCommittedPoint: LocalPoint | null;
startBinding: PointBinding | null; startBinding: FixedPointBinding | PointBinding | null;
endBinding: PointBinding | null; endBinding: FixedPointBinding | PointBinding | null;
startArrowhead: Arrowhead | null; startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null;
}>; }>;
@ -351,9 +351,9 @@ export type ExcalidrawElbowArrowElement = Merge<
ExcalidrawArrowElement, ExcalidrawArrowElement,
{ {
elbowed: true; elbowed: true;
fixedSegments: readonly FixedSegment[] | null;
startBinding: FixedPointBinding | null; startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null;
fixedSegments: readonly FixedSegment[] | null;
/** /**
* Marks that the 3rd point should be used as the 2nd point of the arrow in * Marks that the 3rd point should be used as the 2nd point of the arrow in
* order to temporarily hide the first segment of the arrow without losing * order to temporarily hide the first segment of the arrow without losing

View File

@ -8,7 +8,13 @@ import { Excalidraw, isLinearElement } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui"; import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; import {
act,
fireEvent,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
import { getTransformHandles } from "../src/transformHandles"; import { getTransformHandles } from "../src/transformHandles";
import { import {
@ -16,6 +22,8 @@ import {
TEXT_EDITOR_SELECTOR, TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom"; } from "../../excalidraw/tests/queries/dom";
import type { ExcalidrawLinearElement, FixedPointBinding } from "../src/types";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@ -71,8 +79,9 @@ describe("element binding", () => {
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect.id, elementId: rect.id,
focus: expect.toBeNonNaNNumber(), focus: 0,
gap: expect.toBeNonNaNNumber(), gap: 0,
fixedPoint: expect.arrayContaining([1.1, 0]),
}); });
// Move the end point to the overlapping binding position // Move the end point to the overlapping binding position
@ -83,13 +92,15 @@ describe("element binding", () => {
// Both the start and the end points should be bound // Both the start and the end points should be bound
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect.id, elementId: rect.id,
focus: expect.toBeNonNaNNumber(), focus: 0,
gap: expect.toBeNonNaNNumber(), gap: 0,
fixedPoint: expect.arrayContaining([1.1, 0]),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect.id, elementId: rect.id,
focus: expect.toBeNonNaNNumber(), focus: 0,
gap: expect.toBeNonNaNNumber(), gap: 0,
fixedPoint: expect.arrayContaining([1.1, 0]),
}); });
}); });
@ -188,9 +199,9 @@ describe("element binding", () => {
// Test sticky connection // Test sticky connection
expect(API.getSelectedElement().type).toBe("arrow"); expect(API.getSelectedElement().type).toBe("arrow");
Keyboard.keyPress(KEYS.ARROW_RIGHT); Keyboard.keyPress(KEYS.ARROW_RIGHT);
expect(arrow.endBinding?.elementId).toBe(rectangle.id); expect(arrow.endBinding?.elementId).not.toBe(rectangle.id);
Keyboard.keyPress(KEYS.ARROW_LEFT); Keyboard.keyPress(KEYS.ARROW_LEFT);
expect(arrow.endBinding?.elementId).toBe(rectangle.id); expect(arrow.endBinding?.elementId).not.toBe(rectangle.id);
// Sever connection // Sever connection
expect(API.getSelectedElement().type).toBe("arrow"); expect(API.getSelectedElement().type).toBe("arrow");
@ -476,3 +487,354 @@ describe("element binding", () => {
}); });
}); });
}); });
describe("Fixed-point arrow binding", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should create fixed-point binding when both arrow endpoint is inside rectangle", () => {
// Create a filled solid rectangle
UI.clickTool("rectangle");
mouse.downAt(100, 100);
mouse.moveTo(200, 200);
mouse.up();
const rect = API.getSelectedElement();
API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" });
// Draw arrow with endpoint inside the filled rectangle, since only
// filled bindables bind inside the shape
UI.clickTool("arrow");
mouse.downAt(110, 110);
mouse.moveTo(160, 160);
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
expect(arrow.x).toBe(110);
expect(arrow.y).toBe(110);
// Should bind to the rectangle since endpoint is inside
expect(arrow.startBinding?.elementId).toBe(rect.id);
expect(arrow.endBinding?.elementId).toBe(rect.id);
const startBinding = arrow.startBinding as FixedPointBinding;
expect(startBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
expect(startBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(startBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(startBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
const endBinding = arrow.endBinding as FixedPointBinding;
expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
mouse.reset();
// Move the bindable
mouse.downAt(130, 110);
mouse.moveTo(280, 110);
mouse.up();
// Check if the arrow moved
expect(arrow.x).toBe(260);
expect(arrow.y).toBe(110);
});
it("should create fixed-point binding when one of the arrow endpoint is inside rectangle", () => {
// Create a filled solid rectangle
UI.clickTool("rectangle");
mouse.downAt(100, 100);
mouse.moveTo(200, 200);
mouse.up();
const rect = API.getSelectedElement();
API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" });
// Draw arrow with endpoint inside the filled rectangle, since only
// filled bindables bind inside the shape
UI.clickTool("arrow");
mouse.downAt(10, 10);
mouse.moveTo(160, 160);
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
expect(arrow.x).toBe(10);
expect(arrow.y).toBe(10);
expect(arrow.width).toBe(150);
expect(arrow.height).toBe(150);
// Should bind to the rectangle since endpoint is inside
expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding?.elementId).toBe(rect.id);
const endBinding = arrow.endBinding as FixedPointBinding;
expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
mouse.reset();
// Move the bindable
mouse.downAt(130, 110);
mouse.moveTo(280, 110);
mouse.up();
// Check if the arrow moved
expect(arrow.x).toBe(10);
expect(arrow.y).toBe(10);
expect(arrow.width).toBe(300);
expect(arrow.height).toBe(150);
});
it("should maintain relative position when arrow start point is dragged outside and rectangle is moved", () => {
// Create a filled solid rectangle
UI.clickTool("rectangle");
mouse.downAt(100, 100);
mouse.moveTo(200, 200);
mouse.up();
const rect = API.getSelectedElement();
API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" });
// Draw arrow with both endpoints inside the filled rectangle, creating same-element binding
UI.clickTool("arrow");
mouse.downAt(120, 120);
mouse.moveTo(180, 180);
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
// Both ends should be bound to the same rectangle
expect(arrow.startBinding?.elementId).toBe(rect.id);
expect(arrow.endBinding?.elementId).toBe(rect.id);
mouse.reset();
// Select the arrow and drag the start point outside the rectangle
mouse.downAt(120, 120);
mouse.moveTo(50, 50); // Move start point outside rectangle
mouse.up();
mouse.reset();
// Move the rectangle by dragging it
mouse.downAt(150, 110);
mouse.moveTo(300, 300);
mouse.up();
// The end point should be a normal point binding
const endBinding = arrow.endBinding as FixedPointBinding;
expect(endBinding.focus).toBeCloseTo(0);
expect(endBinding.gap).toBeCloseTo(0);
expect(arrow.x).toBe(50);
expect(arrow.y).toBe(50);
expect(arrow.width).toBeCloseTo(304, 0);
expect(arrow.height).toBeCloseTo(344, 0);
});
it("should move inner points when arrow is bound to same element on both ends", () => {
// Create one rectangle as binding target
const rect = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 200,
height: 100,
fillStyle: "solid",
backgroundColor: "#a5d8ff",
});
// Create a non-elbowed arrow with inner points bound to the same element on both ends
const arrow = API.createElement({
type: "arrow",
x: 100,
y: 75,
width: 100,
height: 50,
points: [
pointFrom(0, 0), // start point
pointFrom(25, -25), // first inner point
pointFrom(75, 25), // second inner point
pointFrom(100, 0), // end point
],
startBinding: {
elementId: rect.id,
focus: 0,
gap: 0,
fixedPoint: [0.25, 0.5],
},
endBinding: {
elementId: rect.id,
focus: 0,
gap: 0,
fixedPoint: [0.75, 0.5],
},
});
API.setElements([rect, arrow]);
// Store original inner point positions (local coordinates)
const originalInnerPoint1 = [...arrow.points[1]];
const originalInnerPoint2 = [...arrow.points[2]];
// Move the rectangle
mouse.reset();
mouse.downAt(150, 100); // Click on the rectangle
mouse.moveTo(300, 200); // Move it down and to the right
mouse.up();
// Verify that inner points moved with the arrow (same local coordinates)
// When both ends are bound to the same element, inner points should maintain
// their local coordinates relative to the arrow's origin
expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]);
expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]);
expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]);
expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]);
});
it("should NOT move inner points when arrow is bound to different elements", () => {
// Create two rectangles as binding targets
const rectLeft = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
});
const rectRight = API.createElement({
type: "rectangle",
x: 300,
y: 0,
width: 100,
height: 100,
});
// Create a non-elbowed arrow with inner points bound to different elements
const arrow = API.createElement({
type: "arrow",
x: 100,
y: 50,
width: 200,
height: 0,
points: [
pointFrom(0, 0), // start point
pointFrom(50, -20), // first inner point
pointFrom(150, 20), // second inner point
pointFrom(200, 0), // end point
],
startBinding: {
elementId: rectLeft.id,
focus: 0.5,
gap: 5,
},
endBinding: {
elementId: rectRight.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rectLeft, rectRight, arrow]);
// Store original inner point positions
const originalInnerPoint1 = [...arrow.points[1]];
const originalInnerPoint2 = [...arrow.points[2]];
// Move the right rectangle down by 50 pixels
mouse.reset();
mouse.downAt(350, 50); // Click on the right rectangle
mouse.moveTo(350, 100); // Move it down
mouse.up();
// Verify that inner points did NOT move when bound to different elements
// The arrow should NOT translate inner points proportionally when only one end moves
expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]);
expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]);
expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]);
expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]);
});
});
describe("line segment extension binding", () => {
beforeEach(async () => {
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should use point binding when extended segment intersects element", () => {
// Create a rectangle that will be intersected by the extended arrow segment
const rect = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 100,
height: 100,
});
API.setElements([rect]);
// Draw an arrow that points at the rectangle (extended segment will intersect)
UI.clickTool("arrow");
mouse.downAt(0, 0); // Start point
mouse.moveTo(120, 95); // End point - arrow direction points toward rectangle
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
// Should create a normal point binding since the extended line segment
// from the last arrow segment intersects the rectangle
expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.endBinding).toHaveProperty("focus");
expect(arrow.endBinding).toHaveProperty("gap");
expect(arrow.endBinding).not.toHaveProperty("fixedPoint");
});
it("should use fixed point binding when extended segment misses element", () => {
// Create a rectangle positioned so the extended arrow segment will miss it
const rect = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 100,
height: 100,
});
API.setElements([rect]);
// Draw an arrow that doesn't point at the rectangle (extended segment will miss)
UI.clickTool("arrow");
mouse.reset();
mouse.downAt(125, 93); // Start point
mouse.moveTo(175, 93); // End point - arrow direction is horizontal, misses rectangle
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
// Should create a fixed point binding since the extended line segment
// from the last arrow segment misses the rectangle
expect(arrow.startBinding?.elementId).toBe(rect.id);
expect(arrow.startBinding).toHaveProperty("fixedPoint");
expect(
(arrow.startBinding as FixedPointBinding).fixedPoint[0],
).toBeGreaterThanOrEqual(0);
expect(
(arrow.startBinding as FixedPointBinding).fixedPoint[0],
).toBeLessThanOrEqual(1);
expect(
(arrow.startBinding as FixedPointBinding).fixedPoint[1],
).toBeLessThanOrEqual(0);
expect(
(arrow.startBinding as FixedPointBinding).fixedPoint[1],
).toBeLessThanOrEqual(1);
expect(arrow.endBinding).toBe(null);
});
});

View File

@ -27,6 +27,7 @@ import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
PointBinding,
} from "../src/types"; } from "../src/types";
unmountComponent(); unmountComponent();
@ -1023,8 +1024,20 @@ describe("multiple selection", () => {
1 - move[0] / selectionWidth, 1 - move[0] / selectionWidth,
1 - move[1] / selectionHeight, 1 - move[1] / selectionHeight,
); );
const leftArrowBinding = { ...leftBoundArrow.endBinding }; const leftArrowBinding: {
const rightArrowBinding = { ...rightBoundArrow.endBinding }; elementId: string;
gap?: number;
focus?: number;
} = {
...leftBoundArrow.endBinding,
} as PointBinding;
const rightArrowBinding: {
elementId: string;
gap?: number;
focus?: number;
} = {
...rightBoundArrow.endBinding,
} as PointBinding;
delete rightArrowBinding.gap; delete rightArrowBinding.gap;
UI.resize([rectangle, rightBoundArrow], "nw", move, { UI.resize([rectangle, rightBoundArrow], "nw", move, {

View File

@ -4,8 +4,14 @@ import {
maybeBindLinearElement, maybeBindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
isBindingEnabled, isBindingEnabled,
getHoveredElementForBinding,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element"; import {
isElbowArrow,
isValidPolygon,
LinearElementEditor,
shouldTestInside,
} from "@excalidraw/element";
import { import {
isBindingElement, isBindingElement,
@ -26,7 +32,7 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -94,13 +100,22 @@ export const actionFinalize = register({
} }
} }
if (appState.editingLinearElement) { if (appState.editingLinearElement && !appState.newElement) {
const { elementId, startBindingElement, endBindingElement } = const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement; appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (element) { if (element) {
if (isBindingElement(element)) { // NOTE: Dragging the entire arrow doesn't allow binding.
const allPointsSelected =
appState.editingLinearElement?.pointerDownState
.prevSelectedPointsIndices?.length === element.points.length;
if (
!allPointsSelected &&
isBindingEnabled(appState) &&
isBindingElement(element)
) {
bindOrUnbindLinearElement( bindOrUnbindLinearElement(
element, element,
startBindingElement, startBindingElement,
@ -108,6 +123,7 @@ export const actionFinalize = register({
scene, scene,
); );
} }
if (isLineElement(element) && !isValidPolygon(element.points)) { if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, { scene.mutateElement(element, {
polygon: false, polygon: false,
@ -159,10 +175,26 @@ export const actionFinalize = register({
element.type !== "freedraw" && element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch" appState.lastPointerDownWith !== "touch"
) { ) {
const { points, lastCommittedPoint } = element; const { x: rx, y: ry, points, lastCommittedPoint } = element;
const lastGlobalPoint = pointFrom<GlobalPoint>(
rx + points[points.length - 1][0],
ry + points[points.length - 1][1],
);
const hoveredElementForBinding = getHoveredElementForBinding(
{
x: lastGlobalPoint[0],
y: lastGlobalPoint[1],
},
elements,
elementsMap,
app.state.zoom,
shouldTestInside(element),
isElbowArrow(element),
);
if ( if (
!lastCommittedPoint || !hoveredElementForBinding &&
points[points.length - 1] !== lastCommittedPoint (!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint)
) { ) {
scene.mutateElement(element, { scene.mutateElement(element, {
points: element.points.slice(0, -1), points: element.points.slice(0, -1),
@ -282,6 +314,17 @@ export const actionFinalize = register({
element && isLinearElement(element) element && isLinearElement(element)
? new LinearElementEditor(element, arrayToMap(newElements)) ? new LinearElementEditor(element, arrayToMap(newElements))
: appState.selectedLinearElement, : appState.selectedLinearElement,
editingLinearElement: appState.newElement
? null
: appState.editingLinearElement
? {
...appState.editingLinearElement,
pointerDownState: {
...appState.editingLinearElement.pointerDownState,
arrowOtherPoint: undefined,
},
}
: null,
}, },
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,

View File

@ -124,6 +124,7 @@ export const getDefaultAppState = (): Omit<
searchMatches: null, searchMatches: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
bindMode: "focus",
}; };
}; };
@ -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 },
bindMode: { browser: true, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <

View File

@ -100,6 +100,7 @@ import {
randomInteger, randomInteger,
CLASSES, CLASSES,
Emitter, Emitter,
BIND_MODE_TIMEOUT,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -232,9 +233,11 @@ import {
hitElementBoundingBox, hitElementBoundingBox,
isLineElement, isLineElement,
isSimpleArrow, isSimpleArrow,
getOutlineAvoidingPoint,
bindOrUnbindLinearElement,
} 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,
@ -568,7 +571,6 @@ class App extends React.Component<AppProps, AppState> {
public renderer: Renderer; public renderer: Renderer;
public visibleElements: readonly NonDeletedExcalidrawElement[]; public visibleElements: readonly NonDeletedExcalidrawElement[];
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined; public libraryItemsFromStorage: LibraryItems | undefined;
public id: string; public id: string;
@ -598,6 +600,8 @@ class App extends React.Component<AppProps, AppState> {
public flowChartCreator: FlowChartCreator = new FlowChartCreator(); public flowChartCreator: FlowChartCreator = new FlowChartCreator();
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(); private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
private bindModeHandler: ReturnType<typeof setTimeout> | null = null;
hitLinkElement?: NonDeletedExcalidrawElement; hitLinkElement?: NonDeletedExcalidrawElement;
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null; lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null = lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
@ -4387,6 +4391,14 @@ class App extends React.Component<AppProps, AppState> {
{ informMutation: false, isDragging: false }, { informMutation: false, isDragging: false },
); );
if (isSimpleArrow(element)) {
// NOTE: Moving the bound arrow should unbind it, otherwise we would
// have weird situations, like 0 lenght arrow when the user moves
// the arrow outside a filled shape suddenly forcing the arrow start
// and end point to jump "outside" the shape.
bindOrUnbindLinearElement(element, null, null, this.scene);
}
updateBoundElements(element, this.scene, { updateBoundElements(element, this.scene, {
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: selectedElements,
}); });
@ -5864,10 +5876,13 @@ class App extends React.Component<AppProps, AppState> {
}); });
}); });
} }
if (editingLinearElement?.lastUncommittedPoint != null) { if (
editingLinearElement?.lastUncommittedPoint != null ||
this.state.newElement
) {
this.maybeSuggestBindingAtCursor( this.maybeSuggestBindingAtCursor(
scenePointer, scenePointer,
editingLinearElement.elbowed, editingLinearElement?.elbowed || false,
); );
} else { } else {
// causes stack overflow if not sync // causes stack overflow if not sync
@ -5888,7 +5903,7 @@ class App extends React.Component<AppProps, AppState> {
[scenePointer], [scenePointer],
this.scene, this.scene,
this.state.zoom, this.state.zoom,
this.state.startBoundElement, this.scene.getNonDeletedElementsMap(),
), ),
}); });
} else { } else {
@ -5898,9 +5913,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);
@ -5946,19 +5959,42 @@ class App extends React.Component<AppProps, AppState> {
{ informMutation: false, isDragging: false }, { informMutation: false, isDragging: false },
); );
} else { } else {
const hoveredElement = getHoveredElementForBinding(
{
x: scenePointerX,
y: scenePointerY,
},
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
false,
false,
);
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
scenePointerX, scenePointerX,
scenePointerY, scenePointerY,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement) event[KEYS.CTRL_OR_CMD] || hoveredElement
? null ? null
: this.getEffectiveGridSize(), : this.getEffectiveGridSize(),
); );
const avoidancePoint =
hoveredElement &&
getOutlineAvoidingPoint(
multiElement,
hoveredElement,
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
multiElement.points.length - 1,
this.scene.getNonDeletedElementsMap(),
);
const [lastCommittedX, lastCommittedY] = const [lastCommittedX, lastCommittedY] =
multiElement?.lastCommittedPoint ?? [0, 0]; multiElement?.lastCommittedPoint ?? [0, 0];
let dxFromLastCommitted = gridX - rx - lastCommittedX; let dxFromLastCommitted =
let dyFromLastCommitted = gridY - ry - lastCommittedY; (avoidancePoint ? avoidancePoint[0] : gridX) - rx - lastCommittedX;
let dyFromLastCommitted =
(avoidancePoint ? avoidancePoint[1] : gridY) - ry - lastCommittedY;
if (shouldRotateWithDiscreteAngle(event)) { if (shouldRotateWithDiscreteAngle(event)) {
({ width: dxFromLastCommitted, height: dyFromLastCommitted } = ({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
@ -7704,10 +7740,26 @@ class App extends React.Component<AppProps, AppState> {
} }
const { x: rx, y: ry, lastCommittedPoint } = multiElement; const { x: rx, y: ry, lastCommittedPoint } = multiElement;
const lastGlobalPoint = pointFrom<GlobalPoint>(
rx + multiElement.points[multiElement.points.length - 1][0],
ry + multiElement.points[multiElement.points.length - 1][1],
);
const hoveredElementForBinding = getHoveredElementForBinding(
{
x: lastGlobalPoint[0],
y: lastGlobalPoint[1],
},
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
true,
isElbowArrow(multiElement),
);
// clicking inside commit zone → finalize arrow // clicking inside commit zone → finalize arrow
if ( if (
multiElement.points.length > 1 && hoveredElementForBinding ||
(multiElement.points.length > 1 &&
lastCommittedPoint && lastCommittedPoint &&
pointDistance( pointDistance(
pointFrom( pointFrom(
@ -7715,7 +7767,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin.y - ry, pointerDownState.origin.y - ry,
), ),
lastCommittedPoint, lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD ) < LINE_CONFIRM_THRESHOLD)
) { ) {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
return; return;
@ -7758,7 +7810,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({
@ -7806,21 +7857,7 @@ class App extends React.Component<AppProps, AppState> {
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
this.setState((prevState) => {
const nextSelectedElementIds = {
...prevState.selectedElementIds,
};
delete nextSelectedElementIds[element.id];
return {
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
prevState,
),
};
});
this.scene.mutateElement(element, {
points: [...element.points, pointFrom<LocalPoint>(0, 0)],
});
const boundElement = getHoveredElementForBinding( const boundElement = getHoveredElementForBinding(
pointerDownState.origin, pointerDownState.origin,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
@ -7830,11 +7867,72 @@ class App extends React.Component<AppProps, AppState> {
isElbowArrow(element), isElbowArrow(element),
); );
if (isSimpleArrow(element)) {
this.setState((prevState) => {
const linearElement = new LinearElementEditor(
element,
this.scene.getNonDeletedElementsMap(),
);
const linearElementEditor = {
...linearElement,
startBindingElement: boundElement,
pointerDownState: {
...linearElement.pointerDownState,
arrowOtherPoint: pointFrom<GlobalPoint>(
pointerDownState.origin.x,
pointerDownState.origin.y,
),
},
};
const nextSelectedElementIds = makeNextSelectedElementIds(
{ [element.id]: true },
prevState,
);
return {
selectedElementIds: nextSelectedElementIds,
editingLinearElement: linearElementEditor,
};
});
}
this.scene.mutateElement(element, {
points: [...element.points, pointFrom<LocalPoint>(0, 0)],
});
this.scene.insertElement(element); this.scene.insertElement(element);
this.setState({ this.setState((prevState) => {
let linearElementEditor = null;
let nextSelectedElementIds = prevState.selectedElementIds;
if (isSimpleArrow(element)) {
const linearElement = new LinearElementEditor(
element,
this.scene.getNonDeletedElementsMap(),
);
linearElementEditor = {
...linearElement,
startBindingElement: boundElement,
pointerDownState: {
...linearElement.pointerDownState,
arrowOtherPoint: pointFrom<GlobalPoint>(
pointerDownState.origin.x,
pointerDownState.origin.y,
),
},
};
nextSelectedElementIds = makeNextSelectedElementIds(
{ [element.id]: true },
prevState,
);
}
return {
...prevState,
newElement: element, newElement: element,
startBoundElement: boundElement, startBoundElement: boundElement,
suggestedBindings: [], suggestedBindings: [],
selectedElementIds: nextSelectedElementIds,
editingLinearElement: linearElementEditor,
};
}); });
} }
}; };
@ -8220,12 +8318,39 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
// Timed bind mode handler for arrow elements
if (this.state.bindMode === "focus") {
const pointerMovementDistance = Math.hypot(
(this.lastPointerMoveCoords?.x ?? Infinity) - pointerCoords.x,
);
if (this.bindModeHandler && pointerMovementDistance < 1) {
clearTimeout(this.bindModeHandler);
}
this.bindModeHandler = setTimeout(() => {
const hoveredElement = getHoveredElementForBinding(
pointerCoords,
this.scene.getNonDeletedElements(),
elementsMap,
this.state.zoom,
);
if (hoveredElement) {
this.setState({
bindMode: "fixed",
});
} else {
this.bindModeHandler = null;
}
}, BIND_MODE_TIMEOUT);
}
const newState = LinearElementEditor.handlePointDragging( const newState = LinearElementEditor.handlePointDragging(
event, event,
this, this,
pointerCoords.x, pointerCoords.x,
pointerCoords.y, pointerCoords.y,
linearElementEditor, linearElementEditor,
(element) => this.getElementHitThreshold(element),
); );
if (newState) { if (newState) {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
@ -8672,9 +8797,76 @@ 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 startBindingElement =
this.state.editingLinearElement?.startBindingElement;
let [firstPointX, firstPointY] =
LinearElementEditor.getPointGlobalCoordinates(
newElement,
newElement.points[0],
elementsMap,
);
let dx = gridX - newElement.x; let dx = gridX - newElement.x;
let dy = gridY - newElement.y; let dy = gridY - newElement.y;
if (
!isElbowArrow(newElement) &&
this.state.editingLinearElement &&
isBindingElement(newElement, false)
) {
// Handles the case where we need to "jump out" the simple arrow
// start point as we drag-create it.
const hoveredElement = getHoveredElementForBinding(
{ x: gridX, y: gridY },
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(newElement),
isElbowArrow(newElement),
);
const arrowIsInsideTheSameElement =
startBindingElement &&
startBindingElement !== "keep" &&
hoveredElement?.id === startBindingElement.id;
if (!arrowIsInsideTheSameElement) {
const [outlinePointX, outlinePointY] = getOutlineAvoidingPoint(
newElement,
hoveredElement,
hoveredElement
? pointFrom(pointerCoords.x, pointerCoords.y)
: pointFrom(gridX, gridY),
newElement.points.length - 1,
elementsMap,
);
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,
);
dx = outlinePointX - firstPointX;
dy = outlinePointY - firstPointY;
} else {
firstPointX =
this.state.editingLinearElement?.pointerDownState
.arrowOtherPoint?.[0] ?? firstPointX;
firstPointY =
this.state.editingLinearElement?.pointerDownState
.arrowOtherPoint?.[1] ?? firstPointY;
}
}
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) { if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
({ width: dx, height: dy } = getLockedLinearCursorAlignSize( ({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
newElement.x, newElement.x,
@ -8688,6 +8880,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 },
@ -8699,6 +8893,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 },
@ -8717,6 +8913,7 @@ class App extends React.Component<AppProps, AppState> {
[pointerCoords], [pointerCoords],
this.scene, this.scene,
this.state.zoom, this.state.zoom,
elementsMap,
this.state.startBoundElement, this.state.startBoundElement,
), ),
}); });
@ -8953,8 +9150,14 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
if (this.bindModeHandler) {
clearTimeout(this.bindModeHandler);
this.bindModeHandler = null;
}
this.setState({ this.setState({
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,
bindMode: "focus",
}); });
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
@ -8977,7 +9180,7 @@ class App extends React.Component<AppProps, AppState> {
// Handle end of dragging a point of a linear element, might close a loop // Handle end of dragging a point of a linear element, might close a loop
// and sets binding element // and sets binding element
if (this.state.editingLinearElement) { if (this.state.editingLinearElement && !this.state.newElement) {
if ( if (
!pointerDownState.boxSelection.hasOccurred && !pointerDownState.boxSelection.hasOccurred &&
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
@ -9116,10 +9319,7 @@ class App extends React.Component<AppProps, AppState> {
newElement, newElement,
}); });
} else if (pointerDownState.drag.hasOccurred && !multiElement) { } else if (pointerDownState.drag.hasOccurred && !multiElement) {
if ( if (isBindingElement(newElement, false)) {
isBindingEnabled(this.state) &&
isBindingElement(newElement, false)
) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent, event: childEvent,
sceneCoords, sceneCoords,

View File

@ -88,8 +88,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "ellipse-1", "elementId": "ellipse-1",
"focus": -0.007519379844961235, "fixedPoint": [
"gap": 11.562288374879595, 0.04,
0.4633333333333333,
],
"focus": 0,
"gap": 0,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -118,8 +122,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id49", "elementId": "id49",
"focus": -0.0813953488372095, "fixedPoint": [
"gap": 1, 1,
0.5001,
],
"focus": 0,
"gap": 0,
}, },
"strokeColor": "#1864ab", "strokeColor": "#1864ab",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -334,8 +342,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"fixedPoint": [
-2.05,
0.5001,
],
"focus": 0, "focus": 0,
"gap": 16, "gap": 0,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -364,8 +376,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "text-1", "elementId": "text-1",
"fixedPoint": [
1,
0.5001,
],
"focus": 0, "focus": 0,
"gap": 1, "gap": 0,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -436,8 +452,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id42", "elementId": "id42",
"focus": -0, "fixedPoint": [
"gap": 1, 0,
0.5001,
],
"focus": 0,
"gap": 0,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -466,8 +486,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id41", "elementId": "id41",
"fixedPoint": [
1,
0.5001,
],
"focus": 0, "focus": 0,
"gap": 1, "gap": 0,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -612,8 +636,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id46", "elementId": "id46",
"focus": -0, "fixedPoint": [
"gap": 1, 0,
0.5001,
],
"focus": 0,
"gap": 0,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -642,8 +670,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id45", "elementId": "id45",
"fixedPoint": [
1,
0.5001,
],
"focus": 0, "focus": 0,
"gap": 1, "gap": 0,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1539,8 +1571,12 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "B", "elementId": "B",
"fixedPoint": [
0.46387050630528887,
0.48466257668711654,
],
"focus": 0, "focus": 0,
"gap": 32, "gap": 0,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -1567,8 +1603,12 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "Bob", "elementId": "Bob",
"fixedPoint": [
0.39381496335223337,
1,
],
"focus": 0, "focus": 0,
"gap": 1, "gap": 0,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",

View File

@ -433,11 +433,11 @@ describe("Test Transform", () => {
startBinding: { startBinding: {
elementId: rectangle.id, elementId: rectangle.id,
focus: 0, focus: 0,
gap: 1, gap: 0,
}, },
endBinding: { endBinding: {
elementId: ellipse.id, elementId: ellipse.id,
focus: -0, focus: 0,
}, },
}); });
@ -518,11 +518,11 @@ describe("Test Transform", () => {
startBinding: { startBinding: {
elementId: text2.id, elementId: text2.id,
focus: 0, focus: 0,
gap: 1, gap: 0,
}, },
endBinding: { endBinding: {
elementId: text3.id, elementId: text3.id,
focus: -0, focus: 0,
}, },
}); });

View File

@ -11,6 +11,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -1083,6 +1084,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -1296,6 +1298,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -1626,6 +1629,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -1956,6 +1960,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2169,6 +2174,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2409,6 +2415,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2706,6 +2713,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -3077,6 +3085,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -3569,6 +3578,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -3891,6 +3901,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -4213,6 +4224,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -4623,6 +4635,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -5839,6 +5852,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -7106,6 +7120,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -7772,6 +7787,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -8762,6 +8778,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [

File diff suppressed because it is too large Load Diff

View File

@ -193,6 +193,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"moveMidPointsWithElement": false,
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [

View File

@ -11,6 +11,7 @@ exports[`given element A and group of elements B and given both are selected whe
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -436,6 +437,7 @@ exports[`given element A and group of elements B and given both are selected whe
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -851,6 +853,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -1416,6 +1419,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -1622,6 +1626,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2005,6 +2010,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2249,6 +2255,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2428,6 +2435,7 @@ exports[`regression tests > can drag element that covers another element, while
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2752,6 +2760,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -3006,6 +3015,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -3246,6 +3256,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -3481,6 +3492,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -3738,6 +3750,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -4051,6 +4064,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -4486,6 +4500,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -4768,6 +4783,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -5043,6 +5059,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -5250,6 +5267,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -5449,6 +5467,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -5841,6 +5860,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -6137,6 +6157,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"locked": false, "locked": false,
"type": "freedraw", "type": "freedraw",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -6558,12 +6579,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"appState": AppStateDelta { "appState": AppStateDelta {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"editingLinearElementId": "id15",
"selectedElementIds": { "selectedElementIds": {
"id15": true, "id15": true,
}, },
"selectedLinearElementId": null, "selectedLinearElementId": null,
}, },
"inserted": { "inserted": {
"editingLinearElementId": null,
"selectedElementIds": { "selectedElementIds": {
"id12": true, "id12": true,
}, },
@ -6694,9 +6717,11 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"appState": AppStateDelta { "appState": AppStateDelta {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"editingLinearElementId": null,
"selectedLinearElementId": "id15", "selectedLinearElementId": "id15",
}, },
"inserted": { "inserted": {
"editingLinearElementId": "id15",
"selectedLinearElementId": null, "selectedLinearElementId": null,
}, },
}, },
@ -6968,6 +6993,7 @@ exports[`regression tests > given a group of selected elements with an element t
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -7301,6 +7327,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -7579,6 +7606,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -7813,6 +7841,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -8052,6 +8081,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -8231,6 +8261,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -8410,6 +8441,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -8589,6 +8621,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -8814,6 +8847,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -9037,6 +9071,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"locked": false, "locked": false,
"type": "freedraw", "type": "freedraw",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -9232,6 +9267,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -9457,6 +9493,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -9636,6 +9673,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -9859,6 +9897,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -10038,6 +10077,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"locked": false, "locked": false,
"type": "freedraw", "type": "freedraw",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -10233,6 +10273,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -10412,6 +10453,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -10942,6 +10984,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -11221,6 +11264,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -11343,6 +11387,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -11542,6 +11587,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -11860,6 +11906,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -12288,6 +12335,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -12927,6 +12975,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -13052,6 +13101,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -13682,6 +13732,7 @@ exports[`regression tests > switches from group of selected elements to another
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -14020,6 +14071,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -14283,6 +14335,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -14405,6 +14458,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -14520,9 +14574,11 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
"appState": AppStateDelta { "appState": AppStateDelta {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"editingLinearElementId": "id6",
"selectedLinearElementId": null, "selectedLinearElementId": null,
}, },
"inserted": { "inserted": {
"editingLinearElementId": null,
"selectedLinearElementId": "id6", "selectedLinearElementId": "id6",
}, },
}, },
@ -14597,11 +14653,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
"appState": AppStateDelta { "appState": AppStateDelta {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"editingLinearElementId": null,
"selectedElementIds": { "selectedElementIds": {
"id3": true, "id3": true,
}, },
}, },
"inserted": { "inserted": {
"editingLinearElementId": "id6",
"selectedElementIds": { "selectedElementIds": {
"id6": true, "id6": true,
}, },
@ -14793,6 +14851,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"locked": false, "locked": false,
"type": "text", "type": "text",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -14915,6 +14974,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,

View File

@ -157,7 +157,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`, `6`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();

View File

@ -1148,7 +1148,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(4);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.editingLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1165,7 +1165,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(5);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.editingLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1197,7 +1197,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(5);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.editingLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1213,7 +1213,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(4);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.editingLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1638,13 +1638,15 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(5); expect(API.getUndoStack().length).toBe(5);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
focus: expect.toBeNonNaNNumber(), fixedPoint: expect.arrayContaining([1, 0.5001]),
gap: expect.toBeNonNaNNumber(), focus: 0,
gap: 0,
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
focus: expect.toBeNonNaNNumber(), fixedPoint: expect.arrayContaining([0, 0.5001]),
gap: expect.toBeNonNaNNumber(), focus: 0,
gap: 0,
}); });
expect(rect1.boundElements).toStrictEqual([ expect(rect1.boundElements).toStrictEqual([
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
@ -1661,13 +1663,15 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
focus: expect.toBeNonNaNNumber(), fixedPoint: expect.arrayContaining([1, 0.5001]),
gap: expect.toBeNonNaNNumber(), focus: 0,
gap: 0,
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
focus: expect.toBeNonNaNNumber(), fixedPoint: expect.arrayContaining([0, 0.5001]),
gap: expect.toBeNonNaNNumber(), focus: 0,
gap: 0,
}); });
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1684,13 +1688,15 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
focus: expect.toBeNonNaNNumber(), fixedPoint: expect.arrayContaining([1, 0.5001]),
gap: expect.toBeNonNaNNumber(), focus: 0,
gap: 0,
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
focus: expect.toBeNonNaNNumber(), fixedPoint: expect.arrayContaining([0, 0.5001]),
gap: expect.toBeNonNaNNumber(), focus: 0,
gap: 0,
}); });
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1715,13 +1721,15 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
focus: expect.toBeNonNaNNumber(), fixedPoint: expect.arrayContaining([1, 0.5001]),
gap: expect.toBeNonNaNNumber(), focus: 0,
gap: 0,
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
focus: expect.toBeNonNaNNumber(), fixedPoint: expect.arrayContaining([0, 0.5001]),
gap: expect.toBeNonNaNNumber(), focus: 0,
gap: 0,
}); });
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1738,13 +1746,15 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
focus: expect.toBeNonNaNNumber(), fixedPoint: expect.arrayContaining([1, 0.5001]),
gap: expect.toBeNonNaNNumber(), focus: 0,
gap: 0,
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
focus: expect.toBeNonNaNNumber(), fixedPoint: expect.arrayContaining([0, 0.5001]),
gap: expect.toBeNonNaNNumber(), focus: 0,
gap: 0,
}); });
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -5079,13 +5089,11 @@ describe("history", () => {
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
focus: 0, fixedPoint: expect.arrayContaining([1, 0.5001]),
gap: 1,
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
focus: -0, fixedPoint: expect.arrayContaining([0, 0.5001]),
gap: 1,
}), }),
isDeleted: true, isDeleted: true,
}), }),

View File

@ -97,7 +97,7 @@ describe("move element", () => {
new Pointer("mouse").clickOn(rectB); new Pointer("mouse").clickOn(rectB);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`17`, `18`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`13`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`13`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();

View File

@ -118,7 +118,7 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);

View File

@ -425,7 +425,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@ -487,7 +487,12 @@ describe("tool locking & selection", () => {
expect(h.state.activeTool.locked).toBe(true); expect(h.state.activeTool.locked).toBe(true);
for (const { value } of Object.values(SHAPES)) { for (const { value } of Object.values(SHAPES)) {
if (value !== "image" && value !== "selection" && value !== "eraser") { if (
value !== "image" &&
value !== "selection" &&
value !== "eraser" &&
value !== "arrow"
) {
const element = UI.createElement(value); const element = UI.createElement(value);
expect(h.state.selectedElementIds[element.id]).not.toBe(true); expect(h.state.selectedElementIds[element.id]).not.toBe(true);
} }

View File

@ -444,6 +444,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 };
bindMode: "focus" | "fixed";
} }
export type SearchMatch = { export type SearchMatch = {

View File

@ -2,3 +2,4 @@ export * from "./export";
export * from "./withinBounds"; export * from "./withinBounds";
export * from "./bbox"; export * from "./bbox";
export { getCommonBounds } from "@excalidraw/element"; export { getCommonBounds } from "@excalidraw/element";
export * from "./visualdebug";

View File

@ -11,6 +11,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "focus",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,