Compare commits

...

53 commits

Author SHA1 Message Date
Márk Tolmács
b9320ed2a8
Merge a5a74be45d into 6fc85022ae 2025-04-09 12:20:33 +00:00
Mark Tolmacs
a5a74be45d
Refactor
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-09 14:20:21 +02:00
Mark Tolmacs
3f9c6299a0
Fix the grid and angle lock
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-09 12:15:04 +02:00
Mark Tolmacs
3068787ac4
Move linear element handling out of App.tsx
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-08 13:44:54 +02:00
Mark Tolmacs
c2b78346c1 Fix snapshot 2025-04-07 13:10:10 +02:00
Mark Tolmacs
44df764a88 [skip ci] Remove temporary size hacks 2025-04-07 13:09:06 +02:00
Mark Tolmacs
cc01e16e52 Attempt at moving the initial point when inside shape
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:09:06 +02:00
Mark Tolmacs
6b9fa5bcc5 Do not snap linear elements
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:09:06 +02:00
Mark Tolmacs
06b3750a2f Fix microjump on drag binding, no keyboard move if bound arrow
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:09:06 +02:00
Mark Tolmacs
c3924a8f8c Fix tests for stats
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
eecabccf8d Stats unbonds arrows when used 2025-04-07 13:08:36 +02:00
Mark Tolmacs
ccda36a0e3 [skip ci] Remove debug
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
37653484a1 Rotated arrow drag fixes
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
b2e19055bf [skip ci] Refactor visual debug
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
3d40221dc1 Restore input value and fix history
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
5cc5c626df Position revert 2025-04-07 13:08:36 +02:00
Mark Tolmacs
9a599cfc05 Fix drag rotation
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:36 +02:00
Mark Tolmacs
88d4c4fe8d Stats: Angle setting now works properly and resets on unconnected bindings 2025-04-07 13:08:36 +02:00
Mark Tolmacs
4efa6f69e5 Inline binding logic fix 2025-04-07 13:08:36 +02:00
Mark Tolmacs
a79eb06939 [skip ci] Test updates 2025-04-07 13:08:36 +02:00
Mark Tolmacs
bcbd418154 [skip ci] Change flipping 2025-04-07 13:08:35 +02:00
Mark Tolmacs
db9e501d35 [skip ci] Stats binding behavior changes and test updates 2025-04-07 13:08:35 +02:00
Mark Tolmacs
ce10087edc [skip ci] Binding refactor 2025-04-07 13:08:35 +02:00
Mark Tolmacs
76a782bd52 [skip ci] No binding for properties panel movement and angle
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:35 +02:00
Mark Tolmacs
d6d4d00f60 Inside detection for outline binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:35 +02:00
Mark Tolmacs
4ea534a732 [skip ci] Tests for inner binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:08:35 +02:00
Mark Tolmacs
e90350b7d1 Fix tests 2025-04-07 13:08:35 +02:00
Mark Tolmacs
ea5ad1412c [skip ci] No jumping at the beginning 2025-04-07 13:08:09 +02:00
Mark Tolmacs
c8ade51b53 [skip ci] Remove unneeded code segments 2025-04-07 13:08:09 +02:00
Mark Tolmacs
6e520fdbb9 Restore collision optimization 2025-04-07 13:08:09 +02:00
Mark Tolmacs
fdd7420e65 Type fixes 2025-04-07 13:08:09 +02:00
Mark Tolmacs
f4abdc751e [skip ci] Small updates to tests 2025-04-07 13:07:30 +02:00
Mark Tolmacs
946d3ddf87 Fine-tuning diamon intersections 2025-04-07 13:07:30 +02:00
Mark Tolmacs
fbde68c849 [skip ci] First iteration of bringing over previous changes 2025-04-07 13:07:30 +02:00
Mark Tolmacs
4ee99de2fb Get three solutions for curve-line intersections to avoid issue with high inclination intersectors 2025-04-07 13:07:30 +02:00
Mark Tolmacs
4d1e2c2bbb Revert to master 2025-04-07 13:07:30 +02:00
Mark Tolmacs
1a87aa8e55 Start grid point arrow align 2025-04-07 13:07:30 +02:00
Mark Tolmacs
528e6aa2df Fix tests
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:07:30 +02:00
Mark Tolmacs
8c9666b8ab Multipoint arrows now have single point commit in binding zones 2025-04-07 13:07:30 +02:00
Mark Tolmacs
8ac508af11 Fix missing parameter 2025-04-07 13:07:30 +02:00
Mark Tolmacs
cbe6705c98 10% inside shape still tracks outline 2025-04-07 13:07:30 +02:00
Mark Tolmacs
1819661828 Tune what's considered a duplicate intersection point 2025-04-07 13:07:30 +02:00
Mark Tolmacs
373b940e75 New simple arrows stick to outline as well 2025-04-07 13:07:30 +02:00
Mark Tolmacs
2f02d72741 Refactors 2025-04-07 13:07:30 +02:00
Mark Tolmacs
a54322a34f Fix unbind by move test 2025-04-07 13:07:30 +02:00
Mark Tolmacs
5c1fc2f4fb FIx tests 2025-04-07 13:07:30 +02:00
Mark Tolmacs
63d53fc242 Fix freshly created elbow arrow and bindable interaction 2025-04-07 13:07:29 +02:00
Mark Tolmacs
e1812c4c91 Need all intersection points for curved corners 2025-04-07 13:07:29 +02:00
Mark Tolmacs
e459ea0cc7 Apply outline tracking to simple arrows as well
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 13:07:29 +02:00
Mark Tolmacs
f354285d69 Linear element compatible snap binding 2025-04-07 13:07:29 +02:00
Mark Tolmacs
03b91deb4a Adjusted elbow in-shape binding strategy 2025-04-07 13:07:29 +02:00
Mark Tolmacs
dca9fbe306 Fixed gap binding 2025-04-07 13:07:29 +02:00
Mark Tolmacs
f363fcabd8 Common center point util 2025-04-07 13:07:29 +02:00
46 changed files with 1776 additions and 1410 deletions

View file

@ -18,7 +18,7 @@ import {
} from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
import type { DebugElement } from "@excalidraw/common";
import type { Curve } from "@excalidraw/math";

View file

@ -9,3 +9,4 @@ export * from "./promise-pool";
export * from "./random";
export * from "./url";
export * from "./utils";
export * from "./visualdebug";

View file

@ -437,26 +437,12 @@ export const _generateElementShape = (
: [pointFrom<LocalPoint>(0, 0)];
if (isElbowArrow(element)) {
// NOTE (mtolmacs): Temporary fix for extremely big arrow shapes
if (
!points.every(
(point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6,
)
) {
console.error(
`Elbow arrow with extreme point positions detected. Arrow not rendered.`,
element.id,
JSON.stringify(points),
);
shape = [];
} else {
shape = [
generator.path(
generateElbowArrowShape(points, 16),
generateRoughOptions(element, true),
),
];
}
shape = [
generator.path(
generateElbowArrowShape(points, 16),
generateRoughOptions(element, true),
),
];
} else if (!element.roundness) {
// curve is always the first element
// this simplifies finding the curve for an element

View file

@ -26,7 +26,7 @@ import {
PRECISION,
} from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math";
@ -81,11 +81,10 @@ import type {
NonDeletedSceneElementsMap,
ExcalidrawTextElement,
ExcalidrawArrowElement,
OrderedExcalidrawElement,
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
FixedPointBinding,
ExcalidrawElbowArrowElement,
} from "./types";
export type SuggestedBinding =
@ -108,6 +107,7 @@ export const isBindingEnabled = (appState: AppState): boolean => {
return appState.isBindingEnabled;
};
export const INSIDE_BINDING_BAND_PERCENT = 0.1;
export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
@ -223,43 +223,33 @@ const bindOrUnbindLinearElementEdge = (
}
};
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
edge === "start"
? linearElement.startBinding?.elementId
: linearElement.endBinding?.elementId;
if (elementId) {
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
};
const getOriginalBindingsIfStillCloseToArrowEnds = (
export const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge(
["start", "end"].map((edge) => {
const coors = getLinearElementEdgeCoors(
linearElement,
edge as "start" | "end",
elementsMap,
zoom,
),
);
);
const elementId =
edge === "start"
? linearElement.startBinding?.elementId
: linearElement.endBinding?.elementId;
if (elementId) {
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
});
const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>,
@ -275,7 +265,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
const start = startDragged
? isBindingEnabled
? getElligibleElementForBindingElement(
? getEligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
@ -285,7 +275,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
: null // If binding is disabled and start is dragged, break all binds
: !isElbowArrow(selectedElement)
? // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(
getEligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
@ -295,7 +285,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
: "keep";
const end = endDragged
? isBindingEnabled
? getElligibleElementForBindingElement(
? getEligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
@ -305,7 +295,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
: null // If binding is disabled and end is dragged, break all binds
: !isElbowArrow(selectedElement)
? // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(
getEligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
@ -336,7 +326,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
);
const start = startIsClose
? isBindingEnabled
? getElligibleElementForBindingElement(
? getEligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
@ -347,7 +337,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
: null;
const end = endIsClose
? isBindingEnabled
? getElligibleElementForBindingElement(
? getEligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
@ -428,10 +418,47 @@ export const getSuggestedBindingsForArrows = (
export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
pointerCoords: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
): void => {
const start = tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
0,
elementsMap,
),
);
const end = tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
-1,
elementsMap,
),
);
const otherHoveredElement = getHoveredElementForBinding(
start,
elements,
elementsMap,
appState.zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
const hoveredElement = getHoveredElementForBinding(
end,
elements,
elementsMap,
appState.zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
// Inside the same element there is no binding to the shape
if (hoveredElement === otherHoveredElement) {
return;
}
if (appState.startBoundElement != null) {
bindLinearElement(
linearElement,
@ -441,15 +468,6 @@ export const maybeBindLinearElement = (
);
}
const hoveredElement = getHoveredElementForBinding(
pointerCoords,
elements,
elementsMap,
appState.zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
if (hoveredElement !== null) {
if (
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
@ -463,26 +481,6 @@ export const maybeBindLinearElement = (
}
};
const normalizePointBinding = (
binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement,
) => {
let gap = binding.gap;
const maxGap = maxBindingGap(
hoveredElement,
hoveredElement.width,
hoveredElement.height,
);
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
@ -493,17 +491,25 @@ export const bindLinearElement = (
return;
}
const direction = startOrEnd === "start" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
const adjacentPointIndex = edgePointIndex - direction;
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edgePointIndex,
elementsMap,
);
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
adjacentPointIndex,
elementsMap,
);
let binding: PointBinding | FixedPointBinding = {
elementId: hoveredElement.id,
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement,
),
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
gap: FIXED_BINDING_DISTANCE,
};
if (isElbowArrow(linearElement)) {
@ -517,8 +523,26 @@ export const bindLinearElement = (
),
};
}
const points = Array.from(linearElement.points);
if (isArrowElement(linearElement)) {
const [x, y] = bindPointToSnapToElementOutline(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
);
points[edgePointIndex] = LinearElementEditor.createPointAt(
linearElement,
elementsMap,
x,
y,
null,
);
}
mutateElement(linearElement, {
points,
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
@ -706,33 +730,6 @@ const getAllElementsAtPositionForBinding = (
return elementsAtPosition;
};
const calculateFocusAndGap = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
): { focus: number; gap: number } => {
const direction = startOrEnd === "start" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
const adjacentPointIndex = edgePointIndex - direction;
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edgePointIndex,
elementsMap,
);
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
adjacentPointIndex,
elementsMap,
);
return {
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
};
};
// Supports translating, rotating and scaling `changedElement` with bound
// linear elements.
// Because scaling involves moving the focus points as well, it is
@ -743,11 +740,9 @@ export const updateBoundElements = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>;
},
) => {
const { newSize, simultaneouslyUpdated } = options ?? {};
const { simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
@ -781,22 +776,13 @@ export const updateBoundElements = (
endBounds = getElementBounds(endBindingElement, elementsMap);
}
const bindings = {
startBinding: maybeCalculateNewGapWhenScaling(
changedElement,
element.startBinding,
newSize,
),
endBinding: maybeCalculateNewGapWhenScaling(
changedElement,
element.endBinding,
newSize,
),
};
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, bindings, true);
mutateElement(
element,
{ startBinding: element.startBinding, endBinding: element.endBinding },
true,
);
return;
}
@ -818,7 +804,9 @@ export const updateBoundElements = (
const point = updateBoundPoint(
element,
bindingProp,
bindings[bindingProp],
bindingProp === "startBinding"
? element.startBinding
: element.endBinding,
bindableElement,
elementsMap,
);
@ -848,10 +836,10 @@ export const updateBoundElements = (
updates,
{
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
? { startBinding: element.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
? { endBinding: element.endBinding }
: {}),
},
elementsMap as NonDeletedSceneElementsMap,
@ -885,7 +873,6 @@ export const getHeadingForElbowArrowSnap = (
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: GlobalPoint,
zoom?: AppState["zoom"],
): Heading => {
@ -895,12 +882,7 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading;
}
const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
zoom,
);
const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
if (!distance) {
return vectorToHeading(
@ -920,7 +902,6 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = (
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(bindableElement, point);
@ -935,40 +916,54 @@ const getDistanceForBinding = (
};
export const bindPointToSnapToElementOutline = (
arrow: ExcalidrawElbowArrowElement,
linearElement: ExcalidrawLinearElement,
bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): GlobalPoint => {
if (isDevEnv() || isTestEnv()) {
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
invariant(
linearElement.points.length > 0,
"Arrow should have at least 1 point",
);
}
const elbowed = isElbowArrow(linearElement);
const aabb = aabbForElement(bindableElement);
const localP =
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
const globalP = pointFrom<GlobalPoint>(
arrow.x + localP[0],
arrow.y + localP[1],
);
const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, globalP)
: globalP;
const elbowed = isElbowArrow(arrow);
const center = getCenterForBounds(aabb);
const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2;
const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>(
arrow.x + arrow.points[adjacentPointIdx][0],
arrow.y + arrow.points[adjacentPointIdx][1],
),
center,
arrow.angle ?? 0,
const pointIdx = startOrEnd === "start" ? 0 : linearElement.points.length - 1;
const p = pointFrom<GlobalPoint>(
linearElement.x + linearElement.points[pointIdx][0],
linearElement.y + linearElement.points[pointIdx][1],
);
const edgePoint = avoidRectangularCorner(bindableElement, p);
const otherPointIdx =
startOrEnd === "start" ? linearElement.points.length - 1 : 0;
const otherPoint = pointFrom<GlobalPoint>(
linearElement.x + linearElement.points[otherPointIdx][0],
linearElement.y + linearElement.points[otherPointIdx][1],
);
const adjacentPointIdx =
startOrEnd === "start" ? 1 : linearElement.points.length - 2;
const adjacentPoint =
linearElement.points.length === 1
? center
: pointRotateRads(
pointFrom<GlobalPoint>(
linearElement.x + linearElement.points[adjacentPointIdx][0],
linearElement.y + linearElement.points[adjacentPointIdx][1],
),
center,
linearElement.angle ?? 0,
);
let intersection: GlobalPoint | null = null;
if (elbowed) {
const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, globalP),
headingForPointFromElement(bindableElement, aabb, p),
);
const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : edgePoint[0],
@ -1016,6 +1011,14 @@ export const bindPointToSnapToElementOutline = (
return edgePoint;
}
const shape = getElementShape(bindableElement, elementsMap);
const pointInShape = isPointInShape(edgePoint, shape);
const otherPointInShape = isPointInShape(otherPoint, shape);
if (pointInShape && otherPointInShape) {
return edgePoint;
}
if (elbowed) {
const scalar =
pointDistanceSq(edgePoint, center) -
@ -1033,6 +1036,28 @@ export const bindPointToSnapToElementOutline = (
);
}
const isInside = isPointInShape(
edgePoint,
getElementShape(
{
...bindableElement,
x:
bindableElement.x +
bindableElement.width * INSIDE_BINDING_BAND_PERCENT,
y:
bindableElement.y +
bindableElement.height * INSIDE_BINDING_BAND_PERCENT,
width: bindableElement.width * (1 - INSIDE_BINDING_BAND_PERCENT * 2),
height: bindableElement.height * (1 - INSIDE_BINDING_BAND_PERCENT * 2),
} as ExcalidrawBindableElement,
elementsMap,
),
);
if (!isInside) {
return intersection;
}
return edgePoint;
};
@ -1040,6 +1065,10 @@ export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
p: GlobalPoint,
): GlobalPoint => {
if (!isRectanguloidElement(element)) {
return p;
}
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
@ -1200,6 +1229,45 @@ export const snapToMid = (
return p;
};
export const getOutlineAvoidingPoint = (
element: NonDeleted<ExcalidrawLinearElement>,
coords: GlobalPoint,
pointIndex: number,
scene: Scene,
zoom: AppState["zoom"],
fallback?: GlobalPoint,
): GlobalPoint => {
const elementsMap = scene.getNonDeletedElementsMap();
const hoveredElement = getHoveredElementForBinding(
{ x: coords[0], y: coords[1] },
scene.getNonDeletedElements(),
elementsMap,
zoom,
true,
isElbowArrow(element),
);
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 fallback ?? coords;
};
const updateBoundPoint = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "startBinding" | "endBinding",
@ -1263,66 +1331,58 @@ const updateBoundPoint = (
let newEdgePoint: GlobalPoint;
// The linear element was not originally pointing inside the bound shape,
// we can point directly at the focus point
if (binding.gap === 0) {
const edgePointAbsolute =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edgePointIndex,
elementsMap,
);
const center = pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
);
const interceptorLength =
pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) +
Math.max(bindableElement.width, bindableElement.height) * 2;
const intersections = [
...intersectElementWithLineSegment(
bindableElement,
lineSegment<GlobalPoint>(
adjacentPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
interceptorLength,
),
adjacentPoint,
),
),
FIXED_BINDING_DISTANCE,
).sort(
(g, h) =>
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
),
// Fallback when arrow doesn't point to the shape
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
pointDistance(adjacentPoint, edgePointAbsolute),
),
adjacentPoint,
),
];
if (intersections.length > 1) {
// The adjacent point is outside the shape (+ gap)
newEdgePoint = intersections[0];
} else if (intersections.length === 1) {
// The adjacent point is inside the shape (+ gap)
newEdgePoint = focusPointAbsolute;
} else {
const edgePointAbsolute =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edgePointIndex,
elementsMap,
);
const center = pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
);
const interceptorLength =
pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) +
Math.max(bindableElement.width, bindableElement.height) * 2;
const intersections = [
...intersectElementWithLineSegment(
bindableElement,
lineSegment<GlobalPoint>(
adjacentPoint,
pointFromVector(
vectorScale(
vectorNormalize(
vectorFromPoint(focusPointAbsolute, adjacentPoint),
),
interceptorLength,
),
adjacentPoint,
),
),
binding.gap,
).sort(
(g, h) =>
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
),
// Fallback when arrow doesn't point to the shape
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
pointDistance(adjacentPoint, edgePointAbsolute),
),
adjacentPoint,
),
];
if (intersections.length > 1) {
// The adjacent point is outside the shape (+ gap)
newEdgePoint = intersections[0];
} else if (intersections.length === 1) {
// The adjacent point is inside the shape (+ gap)
newEdgePoint = focusPointAbsolute;
} else {
// Shouldn't happend, but just in case
newEdgePoint = edgePointAbsolute;
}
// Shouldn't happend, but just in case
newEdgePoint = edgePointAbsolute;
}
return LinearElementEditor.pointFromAbsoluteCoords(
@ -1333,7 +1393,7 @@ const updateBoundPoint = (
};
export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
linearElement: NonDeleted<ExcalidrawArrowElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
@ -1348,6 +1408,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
);
const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2,
@ -1369,29 +1430,7 @@ export const calculateFixedPointForElbowArrowBinding = (
};
};
const maybeCalculateNewGapWhenScaling = (
changedElement: ExcalidrawBindableElement,
currentBinding: PointBinding | null | undefined,
newSize: { width: number; height: number } | undefined,
): PointBinding | null | undefined => {
if (currentBinding == null || newSize == null) {
return currentBinding;
}
const { width: newWidth, height: newHeight } = newSize;
const { width, height } = changedElement;
const newGap = Math.max(
1,
Math.min(
maxBindingGap(changedElement, newWidth, newHeight),
currentBinding.gap *
(newWidth < newHeight ? newWidth / width : newHeight / height),
),
);
return { ...currentBinding, gap: newGap };
};
const getElligibleElementForBindingElement = (
const getEligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,

View file

@ -20,6 +20,7 @@ import {
tupleToCoors,
getSizeFromPoints,
isDevEnv,
isTestEnv,
} from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
@ -50,7 +51,6 @@ import { isBindableElement } from "./typeChecks";
import {
type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap,
type SceneElementsMap,
} from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes";
@ -63,7 +63,6 @@ import type {
ExcalidrawBindableElement,
FixedPointBinding,
FixedSegment,
NonDeletedExcalidrawElement,
} from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" };
@ -877,8 +876,6 @@ const handleEndpointDrag = (
);
};
const MAX_POS = 1e6;
/**
*
*/
@ -899,51 +896,7 @@ export const updateElbowArrowPoints = (
return { points: updates.points ?? arrow.points };
}
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
// arrow size is valid. This check will be removed once the issue is identified
if (
arrow.x < -MAX_POS ||
arrow.x > MAX_POS ||
arrow.y < -MAX_POS ||
arrow.y > MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
) {
console.error(
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
{
arrow,
updates,
},
);
}
// @ts-ignore See above note
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
// @ts-ignore See above note
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
if (updates.points) {
updates.points = updates.points.map(([x, y]) =>
pointFrom<LocalPoint>(
clamp(x, -MAX_POS, MAX_POS),
clamp(y, -MAX_POS, MAX_POS),
),
);
}
if (!import.meta.env.PROD) {
if (isDevEnv() || isTestEnv()) {
invariant(
!updates.points || updates.points.length >= 2,
"Updated point array length must match the arrow point length, contain " +
@ -1221,19 +1174,31 @@ const getElbowArrowData = (
if (options?.isDragging) {
const elements = Array.from(elementsMap.values());
hoveredStartElement =
getHoveredElement(
origStartGlobalPoint,
elementsMap,
getHoveredElementForBinding(
tupleToCoors(origStartGlobalPoint),
elements,
elementsMap,
options?.zoom,
true,
true,
) || null;
hoveredEndElement =
getHoveredElement(
origEndGlobalPoint,
elementsMap,
getHoveredElementForBinding(
tupleToCoors(origEndGlobalPoint),
elements,
elementsMap,
options?.zoom,
true,
true,
) || null;
// Inside the same element there is no binding to the shape
if (hoveredStartElement === hoveredEndElement) {
hoveredStartElement = null;
hoveredEndElement = null;
arrow.startBinding = null;
arrow.endBinding = null;
}
} else {
hoveredStartElement = arrow.startBinding
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
@ -1254,6 +1219,7 @@ const getElbowArrowData = (
"start",
arrow.startBinding?.fixedPoint,
origStartGlobalPoint,
elementsMap,
hoveredStartElement,
options?.isDragging,
);
@ -1267,20 +1233,19 @@ const getElbowArrowData = (
"end",
arrow.endBinding?.fixedPoint,
origEndGlobalPoint,
elementsMap,
hoveredEndElement,
options?.isDragging,
);
const startHeading = getBindPointHeading(
startGlobalPoint,
endGlobalPoint,
elementsMap,
hoveredStartElement,
origStartGlobalPoint,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
startGlobalPoint,
elementsMap,
hoveredEndElement,
origEndGlobalPoint,
);
@ -2110,29 +2075,6 @@ const normalizeArrowElementUpdate = (
),
);
// NOTE (mtolmacs): This is a temporary check to see if the normalization
// creates an overly large arrow. This should be removed once we have an answer.
if (
offsetX < -MAX_POS ||
offsetX > MAX_POS ||
offsetY < -MAX_POS ||
offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetY + points[points.length - 1][0] > MAX_POS ||
offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS
) {
console.error(
"Elbow arrow normalization is outside reasonable bounds (> 1e6)",
{
x: offsetX,
y: offsetY,
points,
...getSizeFromPoints(points),
},
);
}
points = points.map(([x, y]) =>
pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)),
);
@ -2212,6 +2154,7 @@ const getGlobalPoint = (
startOrEnd: "start" | "end",
fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap,
element?: ExcalidrawBindableElement | null,
isDragging?: boolean,
): GlobalPoint => {
@ -2221,6 +2164,7 @@ const getGlobalPoint = (
arrow,
element,
startOrEnd,
elementsMap,
);
return snapToMid(element, snapPoint);
@ -2240,7 +2184,7 @@ const getGlobalPoint = (
distanceToBindableElement(element, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE,
) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
: fixedGlobalPoint;
}
@ -2250,7 +2194,6 @@ const getGlobalPoint = (
const getBindPointHeading = (
p: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint,
): Heading =>
@ -2268,26 +2211,9 @@ const getBindPointHeading = (
number,
],
),
elementsMap,
origPoint,
);
const getHoveredElement = (
origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
) => {
return getHoveredElementForBinding(
tupleToCoors(origPoint),
elements,
elementsMap,
zoom,
true,
true,
);
};
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
a[0] === b[0] && a[1] === b[1];

View file

@ -42,6 +42,7 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import {
bindOrUnbindLinearElement,
getHoveredElementForBinding,
getOutlineAvoidingPoint,
isBindingEnabled,
} from "./binding";
import {
@ -56,6 +57,7 @@ import { headingIsHorizontal, vectorToHeading } from "./heading";
import { bumpVersion, mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isArrowElement,
isBindingElement,
isElbowArrow,
isFixedPointBinding,
@ -252,27 +254,28 @@ export class LinearElementEditor {
pointSceneCoords: { x: number; y: number }[],
) => void,
linearElementEditor: LinearElementEditor,
scene: Scene,
): LinearElementEditor | null {
if (!linearElementEditor) {
return null;
}
const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap();
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return null;
}
const elbowed = isElbowArrow(element);
if (
isElbowArrow(element) &&
elbowed &&
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
linearElementEditor.pointerDownState.lastClickedPoint !== 0
) {
return null;
}
const selectedPointsIndices = isElbowArrow(element)
const selectedPointsIndices = elbowed
? [
!!linearElementEditor.selectedPointsIndices?.includes(0)
? 0
@ -282,7 +285,7 @@ export class LinearElementEditor {
: undefined,
].filter((idx): idx is number => idx !== undefined)
: linearElementEditor.selectedPointsIndices;
const lastClickedPoint = isElbowArrow(element)
const lastClickedPoint = elbowed
? linearElementEditor.pointerDownState.lastClickedPoint > 0
? element.points.length - 1
: 0
@ -334,7 +337,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
let newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
@ -347,6 +350,46 @@ export class LinearElementEditor {
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
if (pointIndex === 0 || pointIndex === element.points.length - 1) {
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
const newGlobalPointPosition = pointRotateRads(
pointFrom<GlobalPoint>(
element.x + newPointPosition[0],
element.y + newPointPosition[1],
),
pointFrom<GlobalPoint>(cx, cy),
element.angle,
);
const avoidancePoint = getOutlineAvoidingPoint(
element,
newGlobalPointPosition,
pointIndex,
app.scene,
app.state.zoom,
);
newPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
!isArrowElement(element) ||
avoidancePoint[0] === newGlobalPointPosition[0]
? newGlobalPointPosition[0] -
linearElementEditor.pointerOffset.x
: avoidancePoint[0],
!isArrowElement(element) ||
avoidancePoint[1] === newGlobalPointPosition[1]
? newGlobalPointPosition[1] -
linearElementEditor.pointerOffset.y
: avoidancePoint[1],
null,
);
}
return {
index: pointIndex,
point: newPointPosition,
@ -426,6 +469,7 @@ export class LinearElementEditor {
editingLinearElement: LinearElementEditor,
appState: AppState,
scene: Scene,
shouldBind?: boolean,
): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
@ -488,6 +532,19 @@ export class LinearElementEditor {
}
}
if (shouldBind) {
const element = scene.getElement(editingLinearElement.elementId);
if (isBindingElement(element) && isBindingEnabled(appState)) {
bindOrUnbindLinearElement(
element,
bindings.startBindingElement || "keep",
bindings.endBindingElement || "keep",
elementsMap,
scene,
);
}
}
return {
...editingLinearElement,
...bindings,

View file

@ -98,28 +98,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
x < -1e6 ||
x > 1e6 ||
y < -1e6 ||
y > 1e6 ||
width < -1e6 ||
width > 1e6 ||
height < -1e6 ||
height > 1e6
) {
console.error("New element size or position is too large", {
x,
y,
width,
height,
// @ts-ignore
points: rest.points,
});
}
// assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
id: rest.id || randomId(),

View file

@ -969,10 +969,7 @@ export const resizeSingleElement = (
mutateElement(latestElement, updates, shouldInformMutation);
updateBoundElements(latestElement, elementsMap as SceneElementsMap, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
updateBoundElements(latestElement, elementsMap as SceneElementsMap);
if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, {
@ -1525,7 +1522,7 @@ export const resizeMultipleElements = (
element,
update: { boundTextFontSize, ...update },
} of elementsAndUpdates) {
const { width, height, angle } = update;
const { angle } = update;
mutateElement(element, update, false, {
// needed for the fixed binding point udpate to take effect
@ -1534,7 +1531,6 @@ export const resizeMultipleElements = (
updateBoundElements(element, elementsMap as SceneElementsMap, {
simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height },
});
const boundTextElement = getBoundTextElement(element, elementsMap);

View file

@ -130,7 +130,7 @@ export const isLinearElementType = (
export const isBindingElement = (
element?: ExcalidrawElement | null,
includeLocked = true,
): element is ExcalidrawLinearElement => {
): element is ExcalidrawArrowElement => {
return (
element != null &&
(!element.locked || includeLocked === true) &&

View file

@ -10,6 +10,8 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import "@excalidraw/utils/test-utils";
import { getTransformHandles } from "../src/transformHandles";
const { h } = window;
@ -18,7 +20,9 @@ const mouse = new Pointer("mouse");
describe("element binding", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
mouse.reset();
});
it("should create valid binding if duplicate start/end points", async () => {
@ -89,46 +93,55 @@ describe("element binding", () => {
});
});
//@TODO fix the test with rotation
it.skip("rotation of arrow should rebind both ends", () => {
const rectLeft = UI.createElement("rectangle", {
x: 0,
width: 200,
height: 500,
});
const rectRight = UI.createElement("rectangle", {
x: 400,
width: 200,
height: 500,
});
const arrow = UI.createElement("arrow", {
x: 210,
y: 250,
width: 180,
height: 1,
});
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
// UX RATIONALE: We are not aware of any use-case where the user would want to
// have the arrow rebind after rotation but not when the arrow shaft is
// dragged so either the start or the end point is in the binding range of a
// bindable element. So to remain consistent, we only "rebind" if at the end
// of the rotation the original binding would remain the same (i.e. like we
// would've evaluated binding only at the end of the operation).
it(
"rotation of arrow should not rebind on both ends if rotated enough to" +
" not be in the binding range of the original elements",
() => {
const rectLeft = UI.createElement("rectangle", {
x: 0,
width: 200,
height: 500,
});
const rectRight = UI.createElement("rectangle", {
x: 400,
width: 200,
height: 500,
});
const arrow = UI.createElement("arrow", {
x: 210,
y: 250,
width: 180,
height: 1,
});
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
const rotation = getTransformHandles(
arrow,
h.state.zoom,
arrayToMap(h.elements),
"mouse",
).rotation!;
const rotationHandleX = rotation[0] + rotation[2] / 2;
const rotationHandleY = rotation[1] + rotation[3] / 2;
mouse.down(rotationHandleX, rotationHandleY);
mouse.move(300, 400);
mouse.up();
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
expect(arrow.startBinding?.elementId).toBe(rectRight.id);
expect(arrow.endBinding?.elementId).toBe(rectLeft.id);
});
const rotation = getTransformHandles(
arrow,
h.state.zoom,
arrayToMap(h.elements),
"mouse",
).rotation!;
const rotationHandleX = rotation[0] + rotation[2] / 2;
const rotationHandleY = rotation[1] + rotation[3] / 2;
mouse.down(rotationHandleX, rotationHandleY);
mouse.move(300, 400);
mouse.up();
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding).toBe(null);
},
);
// TODO fix & reenable once we rewrite tests to work with concurrency
it.skip(
it(
"editing arrow and moving its head to bind it to element A, finalizing the" +
"editing by clicking on element A should end up selecting A",
async () => {
@ -142,7 +155,10 @@ describe("element binding", () => {
mouse.up(0, 80);
// Edit arrow with multi-point
mouse.doubleClick();
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
// move arrow head
mouse.down();
mouse.up(0, 10);
@ -152,16 +168,12 @@ describe("element binding", () => {
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
mouse.reset();
expect(h.state.editingLinearElement).not.toBe(null);
mouse.down(0, 0);
await new Promise((r) => setTimeout(r, 100));
expect(h.state.editingLinearElement).toBe(null);
expect(API.getSelectedElement().type).toBe("rectangle");
mouse.up();
mouse.click();
expect(API.getSelectedElement().type).toBe("rectangle");
},
);
it("should unbind arrow when moving it with keyboard", () => {
it("should not move bound arrows when moving it with keyboard", () => {
const rectangle = UI.createElement("rectangle", {
x: 75,
y: 0,
@ -187,13 +199,19 @@ describe("element binding", () => {
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
Keyboard.keyPress(KEYS.ARROW_LEFT);
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
expect(API.getSelectedElement().type).toBe("arrow");
// Sever connection
expect(API.getSelectedElement().type).toBe("arrow");
Keyboard.keyPress(KEYS.ARROW_LEFT);
expect(arrow.endBinding).toBe(null);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
expect(arrow.endBinding).toBe(null);
Keyboard.withModifierKeys({ shift: true }, () => {
// We have to move a significant distance to get out of the binding zone
Array.from({ length: 10 }).forEach(() => {
Keyboard.keyPress(KEYS.ARROW_LEFT);
});
});
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
expect(arrow.x).toBe(0);
expect(arrow.y).toBe(0);
});
it("should unbind on bound element deletion", () => {
@ -481,4 +499,86 @@ describe("element binding", () => {
});
});
});
// UX RATIONALE: The arrow might be outside of the shape at high zoom and you
// won't see what's going on.
it(
"allow non-binding simple (complex) arrow creation while start and end" +
" points are in the same shape",
() => {
const rect = UI.createElement("rectangle", {
x: 0,
y: 0,
width: 100,
height: 100,
});
const arrow = UI.createElement("arrow", {
x: 5,
y: 5,
height: 95,
width: 95,
});
expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding).toBe(null);
expect(rect.boundElements).toEqual(null);
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[92.2855, 92.2855],
]);
const rect2 = API.createElement({
type: "rectangle",
x: 300,
y: 300,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect2]);
const arrow2 = UI.createElement("arrow", {
x: 305,
y: 305,
height: 95,
width: 95,
});
expect(arrow2.startBinding).toBe(null);
expect(arrow2.endBinding).toBe(null);
expect(rect2.boundElements).toEqual(null);
expect(arrow2.points).toCloselyEqualPoints([
[0, 0],
[92.2855, 92.2855],
]);
const rect3 = UI.createElement("rectangle", {
x: 0,
y: 300,
width: 100,
height: 100,
});
const arrow3 = UI.createElement("arrow", {
x: 10,
y: 310,
height: 85,
width: 84,
elbowed: true,
});
expect(arrow3.startBinding).toBe(null);
expect(arrow3.endBinding).toBe(null);
expect(rect3.boundElements).toEqual(null);
expect(arrow3.points).toCloselyEqualPoints([
[0, 0],
[0, 42.5],
[84, 42.5],
[84, 85],
]);
},
);
});

View file

@ -10,11 +10,14 @@ import {
import { Excalidraw } from "@excalidraw/excalidraw";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
import {
actionDuplicateSelection,
actionSelectAll,
} from "@excalidraw/excalidraw/actions";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
@ -28,7 +31,10 @@ import type { LocalPoint } from "@excalidraw/math";
import { mutateElement } from "../src/mutateElement";
import { duplicateElement, duplicateElements } from "../src/duplicate";
import type { ExcalidrawLinearElement } from "../src/types";
import type {
ExcalidrawArrowElement,
ExcalidrawLinearElement,
} from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
@ -408,6 +414,122 @@ describe("duplicating multiple elements", () => {
});
});
describe("elbow arrow duplication", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
const originalArrowId = arrow.id;
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
act(() => {
h.app.actionManager.executeAction(actionSelectAll);
});
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
expect(h.elements.length).toEqual(6);
const duplicatedArrow = h.scene.getSelectedElements(
h.state,
)[2] as ExcalidrawArrowElement;
expect(duplicatedArrow.id).not.toBe(originalArrowId);
expect(duplicatedArrow.type).toBe("arrow");
expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toEqual([
[0, 0],
[45, 0],
[45, 200],
[90, 200],
]);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
});
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
const originalArrowId = arrow.id;
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
expect(h.elements.length).toEqual(4);
const duplicatedArrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(duplicatedArrow.id).not.toBe(originalArrowId);
expect(duplicatedArrow.type).toBe("arrow");
expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toEqual([
[0, 0],
[0, 100],
[90, 100],
[90, 200],
]);
});
});
describe("duplication z-order", () => {
beforeEach(async () => {
await render(<Excalidraw />);

View file

@ -3,14 +3,11 @@ import { pointFrom } from "@excalidraw/math";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import Scene from "@excalidraw/excalidraw/scene/Scene";
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
fireEvent,
GlobalTestState,
queryByTestId,
@ -301,114 +298,4 @@ describe("elbow arrow ui", () => {
[103, 165],
]);
});
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
const originalArrowId = arrow.id;
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
act(() => {
h.app.actionManager.executeAction(actionSelectAll);
});
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
expect(h.elements.length).toEqual(6);
const duplicatedArrow = h.scene.getSelectedElements(
h.state,
)[2] as ExcalidrawArrowElement;
expect(duplicatedArrow.id).not.toBe(originalArrowId);
expect(duplicatedArrow.type).toBe("arrow");
expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toEqual([
[0, 0],
[45, 0],
[45, 200],
[90, 200],
]);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
});
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
const originalArrowId = arrow.id;
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
expect(h.elements.length).toEqual(4);
const duplicatedArrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(duplicatedArrow.id).not.toBe(originalArrowId);
expect(duplicatedArrow.type).toBe("arrow");
expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toEqual([
[0, 0],
[0, 100],
[90, 100],
[90, 200],
]);
});
});

View file

@ -15,6 +15,8 @@ import {
unmountComponent,
} from "@excalidraw/excalidraw/tests/test-utils";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
import type { LocalPoint } from "@excalidraw/math";
import { isLinearElement } from "../src/typeChecks";
@ -195,7 +197,7 @@ describe("generic element", () => {
UI.resize(rectangle, "w", [50, 0]);
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(81, 0);
});
it("resizes with a label", async () => {
@ -826,8 +828,9 @@ describe("image element", () => {
UI.resize(image, "nw", [50, 20]);
expect(arrow.endBinding?.elementId).toEqual(image.id);
expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
30 + imageWidth * scale,
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(
30 + imageWidth * scale + 1,
0,
);
});
@ -1003,14 +1006,14 @@ describe("multiple selection", () => {
size: 100,
});
const leftBoundArrow = UI.createElement("arrow", {
x: -110,
x: -100 - FIXED_BINDING_DISTANCE,
y: 50,
width: 100,
height: 0,
});
const rightBoundArrow = UI.createElement("arrow", {
x: 210,
x: 200 + FIXED_BINDING_DISTANCE,
y: 50,
width: -100,
height: 0,
@ -1031,27 +1034,29 @@ describe("multiple selection", () => {
shift: true,
});
expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.x).toBeCloseTo(-100 - FIXED_BINDING_DISTANCE);
expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
expect(leftBoundArrow.width).toBeCloseTo(146 - FIXED_BINDING_DISTANCE, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull();
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(5);
expect(leftBoundArrow.endBinding?.elementId).toBe(
leftArrowBinding.elementId,
);
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
expect(rightBoundArrow.x).toBeCloseTo(210);
expect(rightBoundArrow.x).toBeCloseTo(210 - FIXED_BINDING_DISTANCE);
expect(rightBoundArrow.y).toBeCloseTo(
(selectionHeight - 50) * (1 - scale) + 50,
0,
);
expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
//console.log(JSON.stringify(h.elements));
expect(rightBoundArrow.width).toBeCloseTo(100 * scale, 0);
expect(rightBoundArrow.height).toBeCloseTo(0);
expect(rightBoundArrow.angle).toEqual(0);
expect(rightBoundArrow.startBinding).toBeNull();
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(FIXED_BINDING_DISTANCE);
expect(rightBoundArrow.endBinding?.elementId).toBe(
rightArrowBinding.elementId,
);
@ -1338,8 +1343,8 @@ describe("multiple selection", () => {
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX);
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY);
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX - 2, 0);
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY + 2, 0);
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
boundArrow.x + boundArrow.points[1][0] / 2,

View file

@ -1,17 +1,19 @@
import { pointFrom } from "@excalidraw/math";
import { type GlobalPoint, pointFrom } from "@excalidraw/math";
import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
getHoveredElementForBinding,
} from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { mutateElement } from "@excalidraw/element/mutateElement";
import {
isBindingElement,
isElbowArrow,
isLinearElement,
} from "@excalidraw/element/typeChecks";
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
import { KEYS, updateActiveTool } from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element/shapes";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
@ -91,10 +93,26 @@ export const actionFinalize = register({
multiPointElement.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points, lastCommittedPoint } = multiPointElement;
const { x: rx, y: ry, points, lastCommittedPoint } = multiPointElement;
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,
true,
isElbowArrow(multiPointElement),
);
if (
!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint
!hoveredElementForBinding &&
(!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint)
) {
mutateElement(multiPointElement, {
points: multiPointElement.points.slice(0, -1),
@ -135,15 +153,9 @@ export const actionFinalize = register({
!isLoop &&
multiPointElement.points.length > 1
) {
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiPointElement,
-1,
arrayToMap(elements),
);
maybeBindLinearElement(
multiPointElement,
appState,
{ x, y },
elementsMap,
elements,
);

View file

@ -73,12 +73,12 @@ describe("flipping re-centers selection", () => {
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1.x).toBeCloseTo(100, 0);
expect(rec1.y).toBeCloseTo(100, 0);
expect(Math.floor(rec1.x)).toBeCloseTo(100, 0);
expect(Math.floor(rec1.y)).toBeCloseTo(100, 0);
const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0);
expect(rec2.y).toBeCloseTo(250, 0);
expect(Math.floor(rec2.x)).toBeCloseTo(220, 0);
expect(Math.floor(rec2.y)).toBeCloseTo(250, 0);
});
});
@ -87,6 +87,16 @@ describe("flipping arrowheads", () => {
await render(<Excalidraw />);
});
// UX RATIONALE: If we flip bound arrows by the center axes then there could
// be a case where the bindable objects are offset and the arrow would lay
// outside both bindable objects binding range, yet remain bound to then,
// resulting in a jump on movement.
//
// We are aware that 2+ point simple arrows behave incorrectly when flipped
// this way but it was decided that there is no known use case for this so
// left as it is.
//
// Demo: https://excalidraw.com/#json=isE-S8LqNlD1u-LsS8Ezz,iZZ09PPasp6OWbGtJwOUGQ
it("flipping bound arrow should flip arrowheads only", () => {
const rect = API.createElement({
type: "rectangle",
@ -123,6 +133,7 @@ describe("flipping arrowheads", () => {
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
});
// UX RATIONALE: See above for the reasoning.
it("flipping bound arrow should flip arrowheads only 2", () => {
const rect = API.createElement({
type: "rectangle",
@ -164,7 +175,9 @@ describe("flipping arrowheads", () => {
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping unbound arrow shouldn't flip arrowheads", () => {
// UX RATIONALE: Unbound arrows are not constrained by other elements and
// should behave like any other element when flipped for consisency.
it("flipping unbound arrow should mirror on horizontal or vertical axis", () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow1",

View file

@ -1,6 +1,6 @@
import { getNonDeletedElements } from "@excalidraw/element";
import {
bindOrUnbindLinearElements,
bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding";
import { getCommonBoundingBox } from "@excalidraw/element/bounds";
@ -12,15 +12,15 @@ import { deepCopyElement } from "@excalidraw/element/duplicate";
import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
import {
isArrowElement,
isElbowArrow,
isLinearElement,
isBindableElement,
isBindingElement,
} from "@excalidraw/element/typeChecks";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawBindableElement,
ExcalidrawElement,
NonDeleted,
NonDeletedSceneElementsMap,
@ -160,52 +160,54 @@ const flipElements = (
},
);
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState),
[],
appState.zoom,
const selectedBindables = selectedElements.filter(
(e): e is ExcalidrawBindableElement => isBindableElement(e),
);
// ---------------------------------------------------------------------------
// flipping arrow elements (and potentially other) makes the selection group
// "move" across the canvas because of how arrows can bump against the "wall"
// of the selection, so we need to center the group back to the original
// position so that repeated flips don't accumulate the offset
const { elbowArrows, otherElements } = selectedElements.reduce(
(
acc: {
elbowArrows: ExcalidrawElbowArrowElement[];
otherElements: ExcalidrawElement[];
},
element,
) =>
isElbowArrow(element)
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
: { ...acc, otherElements: acc.otherElements.concat(element) },
{ elbowArrows: [], otherElements: [] },
);
const { midX: newMidX, midY: newMidY } =
getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) =>
selectedElements.forEach((element) => {
fixBindings(element, selectedBindables, app, elementsMap);
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
elbowArrows.forEach((element) =>
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
// ---------------------------------------------------------------------------
});
});
return selectedElements;
};
// BEHAVIOR: If you flip a binding element along with its bound elements,
// the binding should be preserved. If your selected elements doesn't contain
// the bound element(s), then remove the binding. Also do not "magically"
// re-bind a binable just because the arrow endpoint is flipped into the
// binding range. Rationale being the consistency with the fact that arrows
// don't bind when the arrow is moved into the binding range by its shaft.
const fixBindings = (
element: ExcalidrawElement,
selectedBindables: ExcalidrawBindableElement[],
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap,
) => {
if (isBindingElement(element)) {
let start = null;
let end = null;
if (isBindingEnabled(app.state)) {
start = element.startBinding
? selectedBindables.find(
(e) => element.startBinding!.elementId === e.id,
) ?? null
: null;
end = element.endBinding
? selectedBindables.find(
(e) => element.endBinding!.elementId === e.id,
) ?? null
: null;
}
bindOrUnbindLinearElement(element, start, end, elementsMap, app.scene);
}
};

View file

@ -1655,6 +1655,7 @@ export const actionChangeArrowType = register({
newElement,
startHoveredElement,
"start",
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
@ -1662,6 +1663,7 @@ export const actionChangeArrowType = register({
newElement,
endHoveredElement,
"end",
elementsMap,
)
: endGlobalPoint;

View file

@ -1508,9 +1508,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
) {
for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements, {
changedElements: changed,
});
updateBoundElements(element, elements);
}
}
}

View file

@ -113,11 +113,11 @@ import {
fixBindingsAfterDeletion,
getHoveredElementForBinding,
isBindingEnabled,
isLinearElementSimpleAndAlreadyBound,
maybeBindLinearElement,
shouldEnableBindingForPointerEvent,
updateBoundElements,
getSuggestedBindingsForArrows,
getOutlineAvoidingPoint,
} from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
@ -170,7 +170,6 @@ import {
} from "@excalidraw/element/typeChecks";
import {
getLockedLinearCursorAlignSize,
getNormalizedDimensions,
isElementCompletelyInViewport,
isElementInViewport,
@ -302,10 +301,9 @@ import {
import { isNonDeletedElement } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
import type {
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawGenericElement,
@ -329,7 +327,7 @@ import type {
ExcalidrawArrowElement,
} from "@excalidraw/element/types";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
import {
actionAddToLibrary,
@ -464,6 +462,14 @@ import { isMaybeMermaidDefinition } from "../mermaid";
import { LassoTrail } from "../lasso";
import {
handleCanvasPointerMoveForLinearElement,
handleDoubleClickForLinearElement,
maybeSuggestBindingsForLinearElementAtCoords,
onPointerMoveFromPointerDownOnLinearElement,
onPointerUpFromPointerDownOnLinearElementHandler,
} from "../linear";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
@ -2768,7 +2774,6 @@ class App extends React.Component<AppProps, AppState> {
this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted();
const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap();
if (!this.state.showWelcomeScreen && !elements.length) {
this.setState({ showWelcomeScreen: true });
@ -2925,13 +2930,6 @@ class App extends React.Component<AppProps, AppState> {
maybeBindLinearElement(
multiElement,
this.state,
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiElement,
-1,
nonDeletedElementsMap,
),
),
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
);
@ -4381,7 +4379,7 @@ class App extends React.Component<AppProps, AppState> {
const arrowIdsToRemove = new Set<string>();
selectedElements
.filter(isElbowArrow)
.filter(isArrowElement)
.filter((arrow) => {
const startElementNotInSelection =
arrow.startBinding &&
@ -5453,75 +5451,16 @@ class App extends React.Component<AppProps, AppState> {
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (
event[KEYS.CTRL_OR_CMD] &&
(!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !==
selectedElements[0].id) &&
!isElbowArrow(selectedElements[0])
handleDoubleClickForLinearElement(
this,
this.store,
selectedElements[0],
event,
sceneX,
sceneY,
)
) {
this.store.shouldCaptureIncrement();
this.setState({
editingLinearElement: new LinearElementEditor(selectedElements[0]),
});
return;
} else if (
this.state.selectedLinearElement &&
isElbowArrow(selectedElements[0])
) {
const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
this.state.selectedLinearElement,
{ x: sceneX, y: sceneY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
const midPoint = hitCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
hitCoords,
this.scene.getNonDeletedElementsMap(),
)
: -1;
if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement();
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: null,
},
{ x: sceneX, y: sceneY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
const nextIndex = nextCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
nextCoords,
this.scene.getNonDeletedElementsMap(),
)
: null;
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
pointerDownState: {
...this.state.selectedLinearElement.pointerDownState,
segmentMidpoint: {
index: nextIndex,
value: hitCoords,
added: false,
},
},
segmentMidPointHoveredCoords: nextCoords,
},
});
return;
}
}
}
@ -5902,9 +5841,10 @@ class App extends React.Component<AppProps, AppState> {
// and point
const { newElement } = this.state;
if (isBindingElement(newElement, false)) {
this.maybeSuggestBindingsForLinearElementAtCoords(
maybeSuggestBindingsForLinearElementAtCoords(
newElement,
[scenePointer],
this,
this.state.startBoundElement,
);
} else {
@ -5914,106 +5854,14 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.multiElement) {
const { multiElement } = this.state;
const { x: rx, y: ry } = multiElement;
const { points, lastCommittedPoint } = multiElement;
const lastPoint = points[points.length - 1];
setCursorForShape(this.interactiveCanvas, this.state);
if (lastPoint === lastCommittedPoint) {
// if we haven't yet created a temp point and we're beyond commit-zone
// threshold, add a point
if (
pointDistance(
pointFrom(scenePointerX - rx, scenePointerY - ry),
lastPoint,
) >= LINE_CONFIRM_THRESHOLD
) {
mutateElement(
multiElement,
{
points: [
...points,
pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
],
},
false,
);
} else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
// in this branch, we're inside the commit zone, and no uncommitted
// point exists. Thus do nothing (don't add/remove points).
}
} else if (
points.length > 2 &&
lastCommittedPoint &&
pointDistance(
pointFrom(scenePointerX - rx, scenePointerY - ry),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
mutateElement(
multiElement,
{
points: points.slice(0, -1),
},
false,
);
} else {
const [gridX, gridY] = getGridPoint(
scenePointerX,
scenePointerY,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement)
? null
: this.getEffectiveGridSize(),
);
const [lastCommittedX, lastCommittedY] =
multiElement?.lastCommittedPoint ?? [0, 0];
let dxFromLastCommitted = gridX - rx - lastCommittedX;
let dyFromLastCommitted = gridY - ry - lastCommittedY;
if (shouldRotateWithDiscreteAngle(event)) {
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
getLockedLinearCursorAlignSize(
// actual coordinate of the last committed point
lastCommittedX + rx,
lastCommittedY + ry,
// cursor-grid coordinate
gridX,
gridY,
));
}
if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
// update last uncommitted point
mutateElement(
multiElement,
{
points: [
...points.slice(0, -1),
pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
),
],
},
false,
{
isDragging: true,
},
);
// in this path, we're mutating multiElement to reflect
// how it will be after adding pointer position as the next point
// trigger update here so that new element canvas renders again to reflect this
this.triggerRender(false);
}
handleCanvasPointerMoveForLinearElement(
multiElement,
this,
scenePointerX,
scenePointerY,
event,
this.triggerRender,
);
return;
}
@ -7751,18 +7599,34 @@ class App extends React.Component<AppProps, AppState> {
}
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
if (
multiElement.points.length > 1 &&
lastCommittedPoint &&
pointDistance(
pointFrom(
pointerDownState.origin.x - rx,
pointerDownState.origin.y - ry,
),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD
hoveredElementForBinding ||
(multiElement.points.length > 1 &&
lastCommittedPoint &&
pointDistance(
pointFrom(
pointerDownState.origin.x - rx,
pointerDownState.origin.y - ry,
),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD)
) {
this.actionManager.executeAction(actionFinalize);
return;
@ -7806,53 +7670,68 @@ class App extends React.Component<AppProps, AppState> {
? [currentItemStartArrowhead, currentItemEndArrowhead]
: [null, null];
const element =
elementType === "arrow"
? newArrowElement({
type: elementType,
x: gridX,
y: gridY,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness:
this.state.currentItemArrowType === ARROW_TYPE.round
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: // note, roundness doesn't have any effect for elbow arrows,
// but it's best to set it to null as well
null,
startArrowhead,
endArrowhead,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
fixedSegments:
this.state.currentItemArrowType === ARROW_TYPE.elbow
? []
: null,
})
: newLinearElement({
type: elementType,
x: gridX,
y: gridY,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness:
this.state.currentItemRoundness === "round"
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: null,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
let element: NonDeleted<ExcalidrawLinearElement>;
if (elementType === "arrow") {
const arrow: Mutable<NonDeleted<ExcalidrawArrowElement>> =
newArrowElement({
type: "arrow",
x: gridX,
y: gridY,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness:
this.state.currentItemArrowType === ARROW_TYPE.round
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: // note, roundness doesn't have any effect for elbow arrows,
// but it's best to set it to null as well
null,
startArrowhead,
endArrowhead,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
fixedSegments:
this.state.currentItemArrowType === ARROW_TYPE.elbow ? [] : null,
});
const [x, y] = getOutlineAvoidingPoint(
arrow,
pointFrom<GlobalPoint>(gridX, gridY),
0,
this.scene,
this.state.zoom,
);
element = {
...arrow,
x,
y,
};
} else {
element = newLinearElement({
type: elementType,
x: gridX,
y: gridY,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness:
this.state.currentItemRoundness === "round"
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: null,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
}
this.setState((prevState) => {
const nextSelectedElementIds = {
...prevState.selectedElementIds,
@ -8167,12 +8046,6 @@ class App extends React.Component<AppProps, AppState> {
this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y);
}
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
// for arrows/lines, don't start dragging until a given threshold
// to ensure we don't create a 2-point arrow by mistake when
// user clicks mouse in a way that it moves a tiny bit (thus
@ -8267,13 +8140,13 @@ class App extends React.Component<AppProps, AppState> {
pointerCoords.x,
pointerCoords.y,
(element, pointsSceneCoords) => {
this.maybeSuggestBindingsForLinearElementAtCoords(
maybeSuggestBindingsForLinearElementAtCoords(
element,
pointsSceneCoords,
this,
);
},
linearElementEditor,
this.scene,
);
if (newLinearElementEditor) {
pointerDownState.lastCoords.x = pointerCoords.x;
@ -8658,54 +8531,14 @@ class App extends React.Component<AppProps, AppState> {
});
}
} else if (isLinearElement(newElement)) {
pointerDownState.drag.hasOccurred = true;
const points = newElement.points;
let dx = gridX - newElement.x;
let dy = gridY - newElement.y;
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
newElement.x,
newElement.y,
pointerCoords.x,
pointerCoords.y,
));
}
if (points.length === 1) {
mutateElement(
newElement,
{
points: [...points, pointFrom<LocalPoint>(dx, dy)],
},
false,
);
} else if (
points.length === 2 ||
(points.length > 1 && isElbowArrow(newElement))
) {
mutateElement(
newElement,
{
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
},
false,
{ isDragging: true },
);
}
this.setState({
onPointerMoveFromPointerDownOnLinearElement(
newElement,
});
if (isBindingElement(newElement, false)) {
// When creating a linear element by dragging
this.maybeSuggestBindingsForLinearElementAtCoords(
newElement,
[pointerCoords],
this.state.startBoundElement,
);
}
this,
pointerDownState,
pointerCoords,
event,
elementsMap,
);
} else {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
@ -8956,21 +8789,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedLinearElement,
this.state,
this.scene,
true,
);
const { startBindingElement, endBindingElement } =
linearElementEditor;
const element = this.scene.getElement(linearElementEditor.elementId);
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
elementsMap,
this.scene,
);
}
if (linearElementEditor !== this.state.selectedLinearElement) {
this.setState({
selectedLinearElement: {
@ -9071,66 +8892,15 @@ class App extends React.Component<AppProps, AppState> {
}
if (isLinearElement(newElement)) {
if (newElement!.points.length > 1) {
this.store.shouldCaptureIncrement();
}
const pointerCoords = viewportCoordsToSceneCoords(
onPointerUpFromPointerDownOnLinearElementHandler(
newElement,
multiElement,
this,
this.store,
pointerDownState,
childEvent,
this.state,
activeTool,
);
if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) {
mutateElement(newElement, {
points: [
...newElement.points,
pointFrom<LocalPoint>(
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
],
});
this.setState({
multiElement: newElement,
newElement,
});
} else if (pointerDownState.drag.hasOccurred && !multiElement) {
if (
isBindingEnabled(this.state) &&
isBindingElement(newElement, false)
) {
maybeBindLinearElement(
newElement,
this.state,
pointerCoords,
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
);
}
this.setState({ suggestedBindings: [], startBoundElement: null });
if (!activeTool.locked) {
resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({
newElement: null,
activeTool: updateActiveTool(this.state, {
type: "selection",
}),
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[newElement.id]: true,
},
prevState,
),
selectedLinearElement: new LinearElementEditor(newElement),
}));
} else {
this.setState((prevState) => ({
newElement: null,
}));
}
// so that the scene gets rendered again to display the newly drawn linear as well
this.scene.triggerUpdate();
}
return;
}
@ -10196,49 +9966,6 @@ class App extends React.Component<AppProps, AppState> {
});
};
private maybeSuggestBindingsForLinearElementAtCoords = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
/** scene coords */
pointerCoords: {
x: number;
y: number;
}[],
// During line creation the start binding hasn't been written yet
// into `linearElement`
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
): void => {
if (!pointerCoords.length) {
return;
}
const suggestedBindings = pointerCoords.reduce(
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
if (
hoveredBindableElement != null &&
!isLinearElementSimpleAndAlreadyBound(
linearElement,
oppositeBindingBoundElement?.id,
hoveredBindableElement,
)
) {
acc.push(hoveredBindableElement);
}
return acc;
},
[],
);
this.setState({ suggestedBindings });
};
private clearSelection(hitElement: ExcalidrawElement | null): void {
this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds({}, prevState),
@ -10725,12 +10452,6 @@ class App extends React.Component<AppProps, AppState> {
updateBoundElements(
croppingElement,
this.scene.getNonDeletedElementsMap(),
{
newSize: {
width: croppingElement.width,
height: croppingElement.height,
},
},
);
this.setState({

View file

@ -5,6 +5,10 @@ import { mutateElement } from "@excalidraw/element/mutateElement";
import { getBoundTextElement } from "@excalidraw/element/textElement";
import { isArrowElement, isElbowArrow } from "@excalidraw/element/typeChecks";
import { getSuggestedBindingsForArrows } from "@excalidraw/element/binding";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Degrees } from "@excalidraw/math";
import type { ExcalidrawElement } from "@excalidraw/element/types";
@ -14,9 +18,11 @@ import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type {
DragFinishedCallbackType,
DragInputCallbackType,
} from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface AngleProps {
element: ExcalidrawElement;
@ -33,9 +39,10 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
shouldChangeByStepSize,
nextValue,
scene,
setAppState,
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement && !isElbowArrow(origElement)) {
const latestElement = elementsMap.get(origElement.id);
@ -48,7 +55,8 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap, elements, scene);
updateBindings(latestElement, elementsMap, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
@ -74,15 +82,32 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap, elements, scene);
updateBindings(latestElement, elementsMap, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
setAppState({
suggestedBindings: getSuggestedBindingsForArrows(
[latestElement],
elementsMap,
originalAppState.zoom,
),
});
}
};
const handleFinished: DragFinishedCallbackType<AngleProps["property"]> = ({
setAppState,
}) => {
setAppState({
suggestedBindings: [],
});
};
const Angle = ({ element, scene, appState, property }: AngleProps) => {
return (
<DragInput
@ -91,6 +116,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
elements={[element]}
dragInputCallback={handleDegreeChange}
dragFinishedCallback={handleFinished}
editable={isPropertyEditable(element, "angle")}
scene={scene}
appState={appState}

View file

@ -12,7 +12,7 @@ import { isImageElement } from "@excalidraw/element/typeChecks";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
@ -118,6 +118,9 @@ const handleDimensionChange: DragInputCallbackType<
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
});
updateBindings(element, elementsMap, scene);
return;
}
@ -150,6 +153,8 @@ const handleDimensionChange: DragInputCallbackType<
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
});
updateBindings(element, elementsMap, scene);
return;
}
@ -184,6 +189,8 @@ const handleDimensionChange: DragInputCallbackType<
},
);
updateBindings(origElement, elementsMap, scene);
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
@ -230,6 +237,8 @@ const handleDimensionChange: DragInputCallbackType<
shouldMaintainAspectRatio: keepAspectRatio,
},
);
updateBindings(origElement, elementsMap, scene);
}
};

View file

@ -8,7 +8,7 @@ import { deepCopyElement } from "@excalidraw/element/duplicate";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { CaptureUpdateAction } from "../../store";
import { useApp } from "../App";
import { useApp, useExcalidrawSetAppState } from "../App";
import { InlineIcon } from "../InlineIcon";
import { SMALLEST_DELTA } from "./utils";
@ -34,6 +34,21 @@ export type DragInputCallbackType<
property: P;
originalAppState: AppState;
setInputValue: (value: number) => void;
setAppState: React.Component<any, AppState>["setState"];
}) => void;
export type DragFinishedCallbackType<
P extends StatsInputProperty,
E = ExcalidrawElement,
> = (props: {
originalElements: readonly E[];
originalElementsMap: ElementsMap;
scene: Scene;
property: P;
originalAppState: AppState;
accumulatedChange: number;
setAppState: React.Component<any, AppState>["setState"];
setInputValue: (value: number) => void;
}) => void;
interface StatsDragInputProps<
@ -47,6 +62,7 @@ interface StatsDragInputProps<
editable?: boolean;
shouldKeepAspectRatio?: boolean;
dragInputCallback: DragInputCallbackType<T, E>;
dragFinishedCallback?: DragFinishedCallbackType<T, E>;
property: T;
scene: Scene;
appState: AppState;
@ -61,6 +77,7 @@ const StatsDragInput = <
label,
icon,
dragInputCallback,
dragFinishedCallback,
value,
elements,
editable = true,
@ -71,6 +88,7 @@ const StatsDragInput = <
sensitivity = 1,
}: StatsDragInputProps<T, E>) => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
@ -123,6 +141,7 @@ const StatsDragInput = <
// reason: idempotent to avoid unnecessary
if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
stateRef.current.lastUpdatedValue = updatedValue;
const originalElementsMap = app.scene.getNonDeletedElementsMap();
dragInputCallback({
accumulatedChange: 0,
instantChange: 0,
@ -135,6 +154,17 @@ const StatsDragInput = <
property,
originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)),
setAppState,
});
dragFinishedCallback?.({
originalElements: elements,
originalElementsMap,
scene,
originalAppState: appState,
accumulatedChange: rounded,
property,
setAppState,
setInputValue: (value) => setInputValue(String(value)),
});
app.syncActionResult({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -262,6 +292,7 @@ const StatsDragInput = <
scene,
originalAppState,
setInputValue: (value) => setInputValue(String(value)),
setAppState,
});
stepChange = 0;
@ -282,6 +313,19 @@ const StatsDragInput = <
false,
);
if (originalElements !== null && originalElementsMap !== null) {
dragFinishedCallback?.({
originalElements,
originalElementsMap,
scene,
originalAppState,
property,
accumulatedChange,
setAppState,
setInputValue: (value) => setInputValue(String(value)),
});
}
app.syncActionResult({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});

View file

@ -24,7 +24,13 @@ import type {
} from "@excalidraw/element/types";
import DragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import {
getAtomicUnits,
getStepSizedValue,
isPropertyEditable,
updateBindings,
updateSelectionBindings,
} from "./utils";
import { getElementsInAtomicUnit } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
@ -87,9 +93,7 @@ const resizeElementInGroup = (
);
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, {
newSize: { width: updates.width, height: updates.height },
});
updateBoundElements(latestElement, elementsMap);
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
mutateElement(
@ -120,6 +124,7 @@ const resizeGroup = (
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
// keep aspect ratio for groups
if (property === "width") {
@ -145,6 +150,8 @@ const resizeGroup = (
originalElementsMap,
);
}
updateSelectionBindings(originalElements, elementsMap, scene);
};
const handleDimensionChange: DragInputCallbackType<
@ -196,6 +203,7 @@ const handleDimensionChange: DragInputCallbackType<
originalElements,
elementsMap,
originalElementsMap,
scene,
);
} else {
const [el] = elementsInUnit;
@ -244,6 +252,8 @@ const handleDimensionChange: DragInputCallbackType<
shouldInformMutation: false,
},
);
updateBindings(latestElement, elementsMap, scene);
}
}
}
@ -303,6 +313,7 @@ const handleDimensionChange: DragInputCallbackType<
originalElements,
elementsMap,
originalElementsMap,
scene,
);
} else {
const [el] = elementsInUnit;

View file

@ -8,12 +8,16 @@ import { getCommonBounds } from "@excalidraw/element/bounds";
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import StatsDragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import {
getAtomicUnits,
getStepSizedValue,
isPropertyEditable,
updateSelectionBindings,
} from "./utils";
import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
@ -66,12 +70,12 @@ const moveElements = (
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
}
updateSelectionBindings(elements, elementsMap, scene);
};
const moveGroupTo = (
@ -79,7 +83,6 @@ const moveGroupTo = (
nextY: number,
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
originalElementsMap: ElementsMap,
scene: Scene,
) => {
@ -113,13 +116,13 @@ const moveGroupTo = (
topLeftY + offsetY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
}
}
updateSelectionBindings(originalElements, elementsMap, scene);
};
const handlePositionChange: DragInputCallbackType<
@ -135,7 +138,6 @@ const handlePositionChange: DragInputCallbackType<
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits(
@ -160,7 +162,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY,
elementsInUnit.map((el) => el.original),
elementsMap,
elements,
originalElementsMap,
scene,
);
@ -189,8 +190,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);

View file

@ -10,9 +10,12 @@ import { isImageElement } from "@excalidraw/element/typeChecks";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import StatsDragInput from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import { getStepSizedValue, moveElement, updateBindings } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type {
DragFinishedCallbackType,
DragInputCallbackType,
} from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
@ -36,9 +39,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
property,
scene,
originalAppState,
setAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
const [cx, cy] = [
origElement.x + origElement.width / 2,
@ -105,6 +108,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
crop: nextCrop,
});
updateBindings(element, elementsMap, scene);
return;
}
@ -123,6 +128,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
crop: nextCrop,
});
updateBindings(element, elementsMap, scene);
return;
}
@ -134,10 +141,11 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
updateBindings(origElement, elementsMap, scene);
return;
}
@ -167,19 +175,21 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
updateBindings(origElement, elementsMap, scene);
};
const Position = ({
property,
element,
elementsMap,
scene,
appState,
}: PositionProps) => {
const handleFinished: DragFinishedCallbackType<"x" | "y"> = ({
setAppState,
}) => {
setAppState({
suggestedBindings: [],
});
};
const Position = ({ property, element, scene, appState }: PositionProps) => {
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(element.x, element.y),
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
@ -207,6 +217,7 @@ const Position = ({
label={property === "x" ? "X" : "Y"}
elements={[element]}
dragInputCallback={handlePositionChange}
dragFinishedCallback={handleFinished}
scene={scene}
value={value}
property={property}

View file

@ -128,7 +128,7 @@ describe("binding with linear elements", () => {
restoreOriginalGetBoundingClientRect();
});
it("should remain bound to linear element on small position change", async () => {
it("should not remain bound to linear element even on small position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
@ -137,10 +137,10 @@ describe("binding with linear elements", () => {
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("204"));
expect(linear.startBinding).not.toBe(null);
expect(linear.startBinding).toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
it("should not remain bound to linear element on any angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
@ -148,7 +148,7 @@ describe("binding with linear elements", () => {
expect(linear.startBinding).not.toBe(null);
UI.updateInput(inputAngle, String("1"));
expect(linear.startBinding).not.toBe(null);
expect(linear.startBinding).toBe(null);
});
it("should unbind linear element on large position change", async () => {

View file

@ -1,14 +1,11 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math";
import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "@excalidraw/element/binding";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { getBoundTextElement } from "@excalidraw/element/textElement";
import {
isBindableElement,
isBindingElement,
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "@excalidraw/element/typeChecks";
@ -18,6 +15,11 @@ import {
isInGroup,
} from "@excalidraw/element/groups";
import {
bindOrUnbindLinearElement,
updateBoundElements,
} from "@excalidraw/element/binding";
import type { Radians } from "@excalidraw/math";
import type {
@ -27,7 +29,8 @@ import type {
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import type Scene from "../../scene/Scene";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { AppState } from "../../types";
export type StatsInputProperty =
@ -120,8 +123,6 @@ export const moveElement = (
newTopLeftY: number,
originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
originalElementsMap: ElementsMap,
shouldInformMutation = true,
) => {
@ -156,7 +157,10 @@ export const moveElement = (
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene);
if (isBindableElement(latestElement)) {
updateBoundElements(latestElement, elementsMap);
}
const boundTextElement = getBoundTextElement(
originalElement,
@ -200,25 +204,34 @@ export const getAtomicUnits = (
export const updateBindings = (
latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements(
[latestElement],
elementsMap,
elements,
scene,
true,
[],
options?.zoom,
);
} else {
updateBoundElements(latestElement, elementsMap, options);
if (isBindingElement(latestElement)) {
if (latestElement.startBinding || latestElement.endBinding) {
bindOrUnbindLinearElement(latestElement, null, null, elementsMap, scene);
}
} else if (isBindableElement(latestElement)) {
updateBoundElements(latestElement, elementsMap);
}
};
export const updateSelectionBindings = (
elements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
) => {
for (const element of elements) {
// Only preserve bindings if the bound element is in the selection
if (isBindingElement(element)) {
if (elements.find((el) => el.id !== element.startBinding?.elementId)) {
bindOrUnbindLinearElement(element, null, "keep", elementsMap, scene);
}
if (elements.find((el) => el.id !== element.endBinding?.elementId)) {
bindOrUnbindLinearElement(element, "keep", null, elementsMap, scene);
}
} else if (isBindableElement(element)) {
updateBoundElements(element, elementsMap);
}
}
};

View file

@ -89,12 +89,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endBinding": {
"elementId": "ellipse-1",
"focus": -0.007519379844961235,
"gap": 11.562288374879595,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 35,
"height": 33.53813187180941,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
@ -108,8 +108,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0,
],
[
394,
34,
377.5017739818493,
32.53813187180941,
],
],
"roughness": 1,
@ -119,7 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startBinding": {
"elementId": "id49",
"focus": -0.0813953488372095,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1864ab",
"strokeStyle": "solid",
@ -128,7 +128,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 395,
"width": 378.5017739818493,
"x": 247.5,
"y": 420.5,
}
@ -145,7 +145,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endBinding": {
"elementId": "ellipse-1",
"focus": 0.10666666666666667,
"gap": 3.8343264684446097,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -164,7 +164,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0,
],
[
399,
397.82801810964474,
0,
],
],
@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startBinding": {
"elementId": "diamond-1",
"focus": 0,
"gap": 4.545343408287929,
"gap": 5,
},
"strokeColor": "#e67700",
"strokeStyle": "solid",
@ -184,7 +184,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 400,
"width": 398.82801810964474,
"x": 227.5,
"y": 450,
}
@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": {
"elementId": "text-2",
"focus": 0,
"gap": 14,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -354,7 +354,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
0,
],
[
99,
299,
0,
],
],
@ -365,7 +365,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startBinding": {
"elementId": "text-1",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -374,7 +374,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 100,
"width": 300,
"x": 255.5,
"y": 239,
}
@ -437,7 +437,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"endBinding": {
"elementId": "id42",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -456,7 +456,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
0,
],
[
99,
89,
0,
],
],
@ -467,7 +467,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startBinding": {
"elementId": "id41",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -476,7 +476,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 100,
"width": 90,
"x": 255.5,
"y": 239,
}
@ -592,7 +592,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"version": 3,
"versionNonce": Any<Number>,
"width": 100,
"x": 355,
"x": 350,
"y": 189,
}
`;
@ -613,7 +613,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"endBinding": {
"elementId": "id46",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -632,7 +632,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
0,
],
[
99,
89,
0,
],
],
@ -643,7 +643,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startBinding": {
"elementId": "id45",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -652,7 +652,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 100,
"width": 90,
"x": 255.5,
"y": 239,
}
@ -786,7 +786,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 100,
"x": 355,
"x": 350,
"y": 226.5,
}
`;
@ -1475,12 +1475,12 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endBinding": {
"elementId": "Alice",
"focus": -0,
"gap": 5.299874999999986,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"height": 7.105427357601002e-15,
"id": Any<String>,
"index": "a4",
"isDeleted": false,
@ -1494,8 +1494,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
0,
],
[
271.985,
0,
272.28487500000006,
-0.9999999999999929,
],
],
"roughness": 1,
@ -1507,7 +1507,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startBinding": {
"elementId": "Bob",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -1516,9 +1516,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 272.985,
"width": 273.28487500000006,
"x": 111.762,
"y": 57,
"y": 57.5,
}
`;
@ -1538,7 +1538,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endBinding": {
"elementId": "B",
"focus": 0,
"gap": 14,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -1566,7 +1566,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startBinding": {
"elementId": "Bob",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",

View file

@ -20,10 +20,6 @@ import {
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { normalizeFixedPoint } from "@excalidraw/element/binding";
import {
updateElbowArrowPoints,
validateElbowPoints,
} from "@excalidraw/element/elbowArrow";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { bumpVersion } from "@excalidraw/element/mutateElement";
import { getContainerElement } from "@excalidraw/element/textElement";
@ -57,7 +53,6 @@ import type {
ExcalidrawTextElement,
FixedPointBinding,
FontFamilyValues,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
PointBinding,
StrokeRoundness,
@ -585,73 +580,7 @@ export const restoreElements = (
}
}
// NOTE (mtolmacs): Temporary fix for extremely large arrows
// Need to iterate again so we have attached text nodes in elementsMap
return restoredElements.map((element) => {
if (
isElbowArrow(element) &&
element.startBinding == null &&
element.endBinding == null &&
!validateElbowPoints(element.points)
) {
return {
...element,
...updateElbowArrowPoints(
element,
restoredElementsMap as NonDeletedSceneElementsMap,
{
points: [
pointFrom<LocalPoint>(0, 0),
element.points[element.points.length - 1],
],
},
),
index: element.index,
};
}
if (
isElbowArrow(element) &&
element.startBinding &&
element.endBinding &&
element.startBinding.elementId === element.endBinding.elementId &&
element.points.length > 1 &&
element.points.some(
([rx, ry]) => Math.abs(rx) > 1e6 || Math.abs(ry) > 1e6,
)
) {
console.error("Fixing self-bound elbow arrow", element.id);
const boundElement = restoredElementsMap.get(
element.startBinding.elementId,
);
if (!boundElement) {
console.error(
"Bound element not found",
element.startBinding.elementId,
);
return element;
}
return {
...element,
x: boundElement.x + boundElement.width / 2,
y: boundElement.y - 5,
width: boundElement.width,
height: boundElement.height,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(0, -10),
pointFrom<LocalPoint>(boundElement.width / 2 + 5, -10),
pointFrom<LocalPoint>(
boundElement.width / 2 + 5,
boundElement.height / 2 + 5,
),
],
};
}
return element;
});
return restoredElements;
};
const coalesceAppStateValue = <

View file

@ -1,6 +1,8 @@
import { pointFrom } from "@excalidraw/math";
import { vi } from "vitest";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
import type { ExcalidrawArrowElement } from "@excalidraw/element/types";
import { convertToExcalidrawElements } from "./transform";
@ -433,7 +435,7 @@ describe("Test Transform", () => {
startBinding: {
elementId: rectangle.id,
focus: 0,
gap: 1,
gap: FIXED_BINDING_DISTANCE,
},
endBinding: {
elementId: ellipse.id,
@ -462,7 +464,7 @@ describe("Test Transform", () => {
});
expect(ellipse).toMatchObject({
x: 355,
x: 350,
y: 189,
type: "ellipse",
boundElements: [
@ -518,7 +520,7 @@ describe("Test Transform", () => {
startBinding: {
elementId: text2.id,
focus: 0,
gap: 1,
gap: FIXED_BINDING_DISTANCE,
},
endBinding: {
elementId: text3.id,
@ -547,7 +549,7 @@ describe("Test Transform", () => {
});
expect(text3).toMatchObject({
x: 355,
x: 350,
y: 226.5,
type: "text",
boundElements: [
@ -781,7 +783,7 @@ describe("Test Transform", () => {
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
focus: -0,
gap: 14,
gap: FIXED_BINDING_DISTANCE,
});
expect(rect.boundElements).toStrictEqual([
{

View file

@ -0,0 +1,457 @@
import {
CURSOR_TYPE,
getGridPoint,
KEYS,
LINE_CONFIRM_THRESHOLD,
shouldRotateWithDiscreteAngle,
updateActiveTool,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import { getLockedLinearCursorAlignSize } from "@excalidraw/element/sizeHelpers";
import {
isArrowElement,
isBindingElement,
isElbowArrow,
} from "@excalidraw/element/typeChecks";
import {
getHoveredElementForBinding,
getOutlineAvoidingPoint,
isBindingEnabled,
isLinearElementSimpleAndAlreadyBound,
maybeBindLinearElement,
} from "@excalidraw/element/binding";
import { pointDistance, pointFrom } from "@excalidraw/math";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isPathALoop } from "@excalidraw/element/shapes";
import { makeNextSelectedElementIds } from "@excalidraw/element/selection";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawBindableElement,
ExcalidrawLinearElement,
NonDeleted,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import { resetCursor, setCursor, setCursorForShape } from "./cursor";
import type App from "./components/App";
import type { ActiveTool, PointerDownState } from "./types";
/**
* This function is called when the user drags the pointer to create a new linear element.
*/
export function onPointerMoveFromPointerDownOnLinearElement(
newElement: ExcalidrawLinearElement,
app: App,
pointerDownState: PointerDownState,
pointerCoords: { x: number; y: number },
event: PointerEvent,
elementsMap: NonDeletedSceneElementsMap,
) {
pointerDownState.drag.hasOccurred = true;
const points = newElement.points;
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
let dx = gridX - newElement.x;
let dy = gridY - newElement.y;
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
newElement.x,
newElement.y,
pointerCoords.x,
pointerCoords.y,
));
}
if (points.length === 1) {
let x = newElement.x + dx;
let y = newElement.y + dy;
if (isArrowElement(newElement)) {
[x, y] = getOutlineAvoidingPoint(
newElement,
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
newElement.points.length - 1,
app.scene,
app.state.zoom,
pointFrom<GlobalPoint>(newElement.x + dx, newElement.y + dy),
);
}
mutateElement(
newElement,
{
points: [
...points,
pointFrom<LocalPoint>(x - newElement.x, y - newElement.y),
],
},
false,
);
} else if (
points.length === 2 ||
(points.length > 1 && isElbowArrow(newElement))
) {
const targets = [];
if (isArrowElement(newElement)) {
const [endX, endY] = getOutlineAvoidingPoint(
newElement,
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
points.length - 1,
app.scene,
app.state.zoom,
pointFrom<GlobalPoint>(newElement.x + dx, newElement.y + dy),
);
targets.push({
index: points.length - 1,
isDragging: true,
point: pointFrom<LocalPoint>(endX - newElement.x, endY - newElement.y),
});
} else {
targets.push({
index: points.length - 1,
isDragging: true,
point: pointFrom<LocalPoint>(dx, dy),
});
}
LinearElementEditor.movePoints(newElement, targets);
}
app.setState({
newElement,
});
if (isBindingElement(newElement, false)) {
// When creating a linear element by dragging
maybeSuggestBindingsForLinearElementAtCoords(
newElement,
[pointerCoords],
app,
app.state.startBoundElement,
);
}
}
/**
*
*/
export function handleCanvasPointerMoveForLinearElement(
multiElement: NonDeleted<ExcalidrawLinearElement>,
app: App,
scenePointerX: number,
scenePointerY: number,
event: React.PointerEvent<HTMLCanvasElement>,
triggerRender: (forceUpdate?: boolean) => void,
) {
const { x: rx, y: ry } = multiElement;
const { points, lastCommittedPoint } = multiElement;
const lastPoint = points[points.length - 1];
setCursorForShape(app.interactiveCanvas, app.state);
if (lastPoint === lastCommittedPoint) {
// if we haven't yet created a temp point and we're beyond commit-zone
// threshold, add a point
if (
pointDistance(
pointFrom(scenePointerX - rx, scenePointerY - ry),
lastPoint,
) >= LINE_CONFIRM_THRESHOLD
) {
mutateElement(
multiElement,
{
points: [
...points,
pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
],
},
false,
);
} else {
setCursor(app.interactiveCanvas, CURSOR_TYPE.POINTER);
// in this branch, we're inside the commit zone, and no uncommitted
// point exists. Thus do nothing (don't add/remove points).
}
} else if (
points.length > 2 &&
lastCommittedPoint &&
pointDistance(
pointFrom(scenePointerX - rx, scenePointerY - ry),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD
) {
setCursor(app.interactiveCanvas, CURSOR_TYPE.POINTER);
mutateElement(
multiElement,
{
points: points.slice(0, -1),
},
false,
);
} else {
const [gridX, gridY] = getGridPoint(
scenePointerX,
scenePointerY,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
const [lastCommittedX, lastCommittedY] =
multiElement?.lastCommittedPoint ?? [0, 0];
let dxFromLastCommitted = gridX - rx - lastCommittedX;
let dyFromLastCommitted = gridY - ry - lastCommittedY;
if (shouldRotateWithDiscreteAngle(event)) {
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
getLockedLinearCursorAlignSize(
// actual coordinate of the last committed point
lastCommittedX + rx,
lastCommittedY + ry,
// cursor-grid coordinate
gridX,
gridY,
));
}
if (isPathALoop(points, app.state.zoom.value)) {
setCursor(app.interactiveCanvas, CURSOR_TYPE.POINTER);
}
let x = multiElement.x + lastCommittedX + dxFromLastCommitted;
let y = multiElement.y + lastCommittedY + dyFromLastCommitted;
if (isArrowElement(multiElement)) {
[x, y] = getOutlineAvoidingPoint(
multiElement,
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
multiElement.points.length - 1,
app.scene,
app.state.zoom,
pointFrom<GlobalPoint>(x, y),
);
}
// update last uncommitted point
LinearElementEditor.movePoints(multiElement, [
{
index: points.length - 1,
point: pointFrom<LocalPoint>(x - multiElement.x, y - multiElement.y),
isDragging: true,
},
]);
// in this path, we're mutating multiElement to reflect
// how it will be after adding pointer position as the next point
// trigger update here so that new element canvas renders again to reflect this
triggerRender(false);
}
}
export function onPointerUpFromPointerDownOnLinearElementHandler(
newElement: ExcalidrawLinearElement,
multiElement: NonDeleted<ExcalidrawLinearElement> | null,
app: App,
store: App["store"],
pointerDownState: PointerDownState,
childEvent: PointerEvent,
activeTool: {
lastActiveTool: ActiveTool | null;
locked: boolean;
fromSelection: boolean;
} & ActiveTool,
) {
if (newElement!.points.length > 1) {
store.shouldCaptureIncrement();
}
const pointerCoords = viewportCoordsToSceneCoords(childEvent, app.state);
if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) {
mutateElement(newElement, {
points: [
...newElement.points,
pointFrom<LocalPoint>(
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
],
});
app.setState({
multiElement: newElement,
newElement,
});
} else if (pointerDownState.drag.hasOccurred && !multiElement) {
if (isBindingEnabled(app.state) && isBindingElement(newElement, false)) {
maybeBindLinearElement(
newElement,
app.state,
app.scene.getNonDeletedElementsMap(),
app.scene.getNonDeletedElements(),
);
}
app.setState({ suggestedBindings: [], startBoundElement: null });
if (!activeTool.locked) {
resetCursor(app.interactiveCanvas);
app.setState((prevState) => ({
newElement: null,
activeTool: updateActiveTool(app.state, {
type: "selection",
}),
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[newElement.id]: true,
},
prevState,
),
selectedLinearElement: new LinearElementEditor(newElement),
}));
} else {
app.setState((prevState) => ({
newElement: null,
}));
}
// so that the scene gets rendered again to display the newly drawn linear as well
app.scene.triggerUpdate();
}
}
/**
* Handles double click on a linear element to edit it or delete a segment
*/
export function handleDoubleClickForLinearElement(
app: App,
store: App["store"],
selectedElement: NonDeleted<ExcalidrawLinearElement>,
event: React.MouseEvent<HTMLCanvasElement>,
sceneX: number,
sceneY: number,
) {
if (
event[KEYS.CTRL_OR_CMD] &&
(!app.state.editingLinearElement ||
app.state.editingLinearElement.elementId !== selectedElement.id) &&
!isElbowArrow(selectedElement)
) {
store.shouldCaptureIncrement();
app.setState({
editingLinearElement: new LinearElementEditor(selectedElement),
});
} else if (app.state.selectedLinearElement && isElbowArrow(selectedElement)) {
const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
app.state.selectedLinearElement,
{ x: sceneX, y: sceneY },
app.state,
app.scene.getNonDeletedElementsMap(),
);
const midPoint = hitCoords
? LinearElementEditor.getSegmentMidPointIndex(
app.state.selectedLinearElement,
app.state,
hitCoords,
app.scene.getNonDeletedElementsMap(),
)
: -1;
if (midPoint && midPoint > -1) {
store.shouldCaptureIncrement();
LinearElementEditor.deleteFixedSegment(selectedElement, midPoint);
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
...app.state.selectedLinearElement,
segmentMidPointHoveredCoords: null,
},
{ x: sceneX, y: sceneY },
app.state,
app.scene.getNonDeletedElementsMap(),
);
const nextIndex = nextCoords
? LinearElementEditor.getSegmentMidPointIndex(
app.state.selectedLinearElement,
app.state,
nextCoords,
app.scene.getNonDeletedElementsMap(),
)
: null;
app.setState({
selectedLinearElement: {
...app.state.selectedLinearElement,
pointerDownState: {
...app.state.selectedLinearElement.pointerDownState,
segmentMidpoint: {
index: nextIndex,
value: hitCoords,
added: false,
},
},
segmentMidPointHoveredCoords: nextCoords,
},
});
return true;
}
}
}
export function maybeSuggestBindingsForLinearElementAtCoords(
linearElement: NonDeleted<ExcalidrawLinearElement>,
/** scene coords */
pointerCoords: {
x: number;
y: number;
}[],
app: App,
// During line creation the start binding hasn't been written yet
// into `linearElement`
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
) {
if (!pointerCoords.length) {
return;
}
const suggestedBindings = pointerCoords.reduce(
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
app.scene.getNonDeletedElements(),
app.scene.getNonDeletedElementsMap(),
app.state.zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
if (
hoveredBindableElement != null &&
!isLinearElementSimpleAndAlreadyBound(
linearElement,
oppositeBindingBoundElement?.id,
hoveredBindableElement,
)
) {
acc.push(hoveredBindableElement);
}
return acc;
},
[],
);
app.setState({ suggestedBindings });
}

View file

@ -198,7 +198,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "102.35417",
"height": "99.23572",
"id": "id172",
"index": "a2",
"isDeleted": false,
@ -212,8 +212,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"101.77517",
"102.35417",
"96.42891",
"99.23572",
],
],
"roughness": 1,
@ -228,8 +228,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 40,
"width": "101.77517",
"x": "0.70711",
"width": "96.42891",
"x": "3.53553",
"y": 0,
}
`;
@ -295,47 +295,47 @@ History {
"deleted": {
"endBinding": {
"elementId": "id171",
"focus": "0.00990",
"gap": 1,
"focus": "0.01065",
"gap": 5,
},
"height": "0.98586",
"height": "1.00000",
"points": [
[
0,
0,
],
[
"98.58579",
"-0.98586",
"92.92893",
"-1.00000",
],
],
"startBinding": {
"elementId": "id170",
"focus": "0.02970",
"gap": 1,
"focus": "0.03194",
"gap": 5,
},
},
"inserted": {
"endBinding": {
"elementId": "id171",
"focus": "-0.02000",
"gap": 1,
"focus": "-0.02251",
"gap": 5,
},
"height": "0.00000",
"height": "0.08238",
"points": [
[
0,
0,
],
[
"98.58579",
"0.00000",
"92.92893",
"0.08238",
],
],
"startBinding": {
"elementId": "id170",
"focus": "0.02000",
"gap": 1,
"focus": "0.01897",
"gap": 5,
},
},
},
@ -390,43 +390,47 @@ History {
"focus": 0,
"gap": 1,
},
"height": "102.35417",
"height": "99.23572",
"points": [
[
0,
0,
],
[
"101.77517",
"102.35417",
"96.42891",
"99.23572",
],
],
"startBinding": null,
"width": "96.42891",
"x": "3.53553",
"y": 0,
},
"inserted": {
"endBinding": {
"elementId": "id171",
"focus": "0.00990",
"gap": 1,
"focus": "0.01065",
"gap": 5,
},
"height": "0.98586",
"height": "1.00000",
"points": [
[
0,
0,
],
[
"98.58579",
"-0.98586",
"92.92893",
"-1.00000",
],
],
"startBinding": {
"elementId": "id170",
"focus": "0.02970",
"gap": 1,
"focus": "0.03194",
"gap": 5,
},
"y": "0.99364",
"width": "92.92893",
"x": "3.53553",
"y": "1.03339",
},
},
"id175" => Delta {
@ -566,7 +570,7 @@ History {
0,
],
[
100,
"96.46447",
0,
],
],
@ -580,8 +584,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "96.46447",
"x": "3.53553",
"y": 0,
},
"inserted": {
@ -804,7 +808,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
100,
"96.46447",
0,
],
],
@ -820,8 +824,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 30,
"width": 0,
"x": "149.29289",
"width": "96.46447",
"x": 150,
"y": 0,
}
`;
@ -854,10 +858,11 @@ History {
0,
],
[
0,
"0.00000",
0,
],
],
"width": "0.00000",
},
"inserted": {
"points": [
@ -866,10 +871,11 @@ History {
0,
],
[
100,
"89.39340",
0,
],
],
"width": "89.39340",
},
},
},
@ -921,17 +927,19 @@ History {
0,
],
[
100,
"96.46447",
0,
],
],
"startBinding": null,
"width": "96.46447",
"x": 150,
},
"inserted": {
"endBinding": {
"elementId": "id166",
"focus": -0,
"gap": 1,
"gap": 5,
},
"points": [
[
@ -939,15 +947,17 @@ History {
0,
],
[
0,
"0.00000",
0,
],
],
"startBinding": {
"elementId": "id165",
"focus": 0,
"gap": 1,
"gap": 5,
},
"width": "0.00000",
"x": "146.46447",
},
},
},
@ -1074,7 +1084,7 @@ History {
0,
],
[
100,
"96.46447",
0,
],
],
@ -1088,8 +1098,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "96.46447",
"x": "3.53553",
"y": 0,
},
"inserted": {
@ -1241,7 +1251,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "1.30038",
"height": "1.71911",
"id": "id178",
"index": "Zz",
"isDeleted": false,
@ -1255,8 +1265,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"98.58579",
"1.30038",
"92.92893",
"1.71911",
],
],
"roughness": 1,
@ -1279,8 +1289,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 11,
"width": "98.58579",
"x": "0.70711",
"width": "92.92893",
"x": "3.53553",
"y": 0,
}
`;
@ -1613,7 +1623,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "1.30038",
"height": "1.71911",
"id": "id181",
"index": "a0",
"isDeleted": false,
@ -1627,8 +1637,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"98.58579",
"1.30038",
"92.92893",
"1.71911",
],
],
"roughness": 1,
@ -1651,8 +1661,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 11,
"width": "98.58579",
"x": "0.70711",
"width": "92.92893",
"x": "3.53553",
"y": 0,
}
`;
@ -1771,7 +1781,7 @@ History {
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "11.27227",
"height": "12.86717",
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
@ -1784,8 +1794,8 @@ History {
0,
],
[
"98.58579",
"11.27227",
"92.92893",
"12.86717",
],
],
"roughness": 1,
@ -1806,8 +1816,8 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": "98.58579",
"x": "0.70711",
"width": "92.92893",
"x": "3.53553",
"y": 0,
},
"inserted": {
@ -2321,12 +2331,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endBinding": {
"elementId": "id185",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "374.05754",
"height": "369.23631",
"id": "id186",
"index": "a2",
"isDeleted": false,
@ -2340,8 +2350,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"502.78936",
"-374.05754",
"496.83418",
"-369.23631",
],
],
"roughness": 1,
@ -2352,7 +2362,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"startBinding": {
"elementId": "id184",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -2360,9 +2370,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 10,
"width": "502.78936",
"x": "-0.83465",
"y": "-36.58211",
"width": "496.83418",
"x": "2.19080",
"y": "-38.78706",
}
`;
@ -2481,7 +2491,7 @@ History {
"endBinding": {
"elementId": "id185",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -2499,7 +2509,7 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
@ -2511,14 +2521,14 @@ History {
"startBinding": {
"elementId": "id184",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "92.92893",
"x": "3.53553",
"y": 0,
},
"inserted": {
@ -7490,7 +7500,7 @@ History {
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of elements 1`] = `1`;
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `10`;
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `11`;
exports[`history > multiplayer undo/redo > should iterate through the history when when element change relates to remotely deleted element > [end of test] appState 1`] = `
{
@ -10561,7 +10571,7 @@ History {
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`;
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `15`;
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `16`;
exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] appState 1`] = `
{
@ -15161,7 +15171,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id58",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -15180,7 +15190,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.58579",
"92.92893",
0,
],
],
@ -15192,7 +15202,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id56",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -15200,8 +15210,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.58579",
"x": "0.70711",
"width": "92.92893",
"x": "3.53553",
"y": 0,
}
`;
@ -15242,7 +15252,7 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
@ -15255,7 +15265,7 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
@ -15532,7 +15542,7 @@ History {
"endBinding": {
"elementId": "id58",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -15550,7 +15560,7 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
@ -15562,14 +15572,14 @@ History {
"startBinding": {
"elementId": "id56",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "92.92893",
"x": "3.53553",
"y": 0,
},
"inserted": {
@ -15859,7 +15869,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id52",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -15878,7 +15888,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.58579",
"92.92893",
0,
],
],
@ -15890,7 +15900,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id50",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -15898,8 +15908,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.58579",
"x": "0.70711",
"width": "92.92893",
"x": "3.53553",
"y": 0,
}
`;
@ -16152,7 +16162,7 @@ History {
"endBinding": {
"elementId": "id52",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -16170,7 +16180,7 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
@ -16182,14 +16192,14 @@ History {
"startBinding": {
"elementId": "id50",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "92.92893",
"x": "3.53553",
"y": 0,
},
"inserted": {
@ -16479,7 +16489,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id64",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -16498,7 +16508,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.58579",
"92.92893",
0,
],
],
@ -16510,7 +16520,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id62",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -16518,8 +16528,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.58579",
"x": "0.70711",
"width": "92.92893",
"x": "3.53553",
"y": 0,
}
`;
@ -16772,7 +16782,7 @@ History {
"endBinding": {
"elementId": "id64",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -16790,7 +16800,7 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
@ -16802,14 +16812,14 @@ History {
"startBinding": {
"elementId": "id62",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "92.92893",
"x": "3.53553",
"y": 0,
},
"inserted": {
@ -17097,7 +17107,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id70",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -17116,7 +17126,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.58579",
"92.92893",
0,
],
],
@ -17128,7 +17138,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id68",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -17136,8 +17146,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.58579",
"x": "0.70711",
"width": "92.92893",
"x": "3.53553",
"y": 0,
}
`;
@ -17193,14 +17203,14 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
"startBinding": {
"elementId": "id68",
"focus": 0,
"gap": 1,
"gap": 5,
},
},
"inserted": {
@ -17210,7 +17220,7 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
@ -17460,7 +17470,7 @@ History {
"endBinding": {
"elementId": "id70",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -17478,7 +17488,7 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
@ -17490,14 +17500,14 @@ History {
"startBinding": {
"elementId": "id68",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "92.92893",
"x": "3.53553",
"y": 0,
},
"inserted": {
@ -17811,7 +17821,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": {
"elementId": "id77",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -17830,7 +17840,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.58579",
"92.92893",
0,
],
],
@ -17842,7 +17852,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": {
"elementId": "id75",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -17850,8 +17860,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 11,
"width": "98.58579",
"x": "0.70711",
"width": "92.92893",
"x": "3.53553",
"y": 0,
}
`;
@ -17913,7 +17923,7 @@ History {
"endBinding": {
"elementId": "id77",
"focus": -0,
"gap": 1,
"gap": 5,
},
"points": [
[
@ -17921,14 +17931,14 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
"startBinding": {
"elementId": "id75",
"focus": 0,
"gap": 1,
"gap": 5,
},
},
"inserted": {
@ -17939,7 +17949,7 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
@ -18189,7 +18199,7 @@ History {
"endBinding": {
"elementId": "id77",
"focus": -0,
"gap": 1,
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
@ -18207,7 +18217,7 @@ History {
0,
],
[
100,
"92.92893",
0,
],
],
@ -18219,14 +18229,14 @@ History {
"startBinding": {
"elementId": "id75",
"focus": 0,
"gap": 1,
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": 100,
"x": 0,
"width": "92.92893",
"x": "3.53553",
"y": 0,
},
"inserted": {
@ -20188,4 +20198,4 @@ History {
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of elements 1`] = `1`;
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `21`;
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `22`;

View file

@ -190,13 +190,13 @@ exports[`move element > rectangles with binding arrow 7`] = `
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id1",
"focus": "-0.46667",
"gap": 10,
"focus": "-0.40764",
"gap": 5,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "87.29887",
"height": "82.18136",
"id": "id2",
"index": "a2",
"isDeleted": false,
@ -210,8 +210,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
0,
],
[
"86.85786",
"87.29887",
"93.92893",
"82.18136",
],
],
"roughness": 1,
@ -222,8 +222,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
"startArrowhead": null,
"startBinding": {
"elementId": "id0",
"focus": "-0.60000",
"gap": 10,
"focus": "-0.49801",
"gap": 5,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -232,8 +232,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
"updated": 1,
"version": 11,
"versionNonce": 1051383431,
"width": "86.85786",
"x": "107.07107",
"y": "47.07107",
"width": "93.92893",
"x": "103.53553",
"y": "50.01536",
}
`;

View file

@ -50,7 +50,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"type": "arrow",
"updated": 1,
"version": 8,
"versionNonce": 1604849351,
"versionNonce": 1505387817,
"width": 70,
"x": 30,
"y": 30,
@ -106,7 +106,7 @@ exports[`multi point mode in linear elements > line 3`] = `
"type": "line",
"updated": 1,
"version": 8,
"versionNonce": 1604849351,
"versionNonce": 1505387817,
"width": 70,
"x": 30,
"y": 30,

View file

@ -6835,7 +6835,7 @@ History {
exports[`regression tests > draw every type of shape > [end of test] number of elements 1`] = `0`;
exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `33`;
exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `35`;
exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] appState 1`] = `
{
@ -14566,7 +14566,7 @@ History {
exports[`regression tests > undo/redo drawing an element > [end of test] number of elements 1`] = `0`;
exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `20`;
exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `21`;
exports[`regression tests > updates fontSize & fontFamily appState > [end of test] appState 1`] = `
{

View file

@ -467,6 +467,7 @@ export class UI {
height: initialHeight = initialWidth,
angle = 0,
points: initialPoints,
elbowed = false,
}: {
position?: number;
x?: number;
@ -476,6 +477,7 @@ export class UI {
height?: number;
angle?: number;
points?: T extends "line" | "arrow" | "freedraw" ? LocalPoint[] : never;
elbowed?: boolean;
} = {},
): Element<T> & {
/** Returns the actual, current element from the elements array, instead
@ -494,6 +496,17 @@ export class UI {
if (type === "text") {
mouse.reset();
mouse.click(x, y);
} else if (type === "arrow" && points.length === 2 && elbowed) {
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(x + points[0][0], y + points[0][1]);
mouse.click();
mouse.moveTo(
x + points[points.length - 1][0],
y + points[points.length - 1][1],
);
mouse.click();
Keyboard.keyPress(KEYS.ESCAPE);
} else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => {
mouse.reset();

View file

@ -6,6 +6,7 @@ import {
waitFor,
} from "@testing-library/react";
import { vi } from "vitest";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
import { pointFrom } from "@excalidraw/math";
import { newElementWith } from "@excalidraw/element/mutateElement";
@ -4779,12 +4780,12 @@ describe("history", () => {
startBinding: expect.objectContaining({
elementId: rect1.id,
focus: 0,
gap: 1,
gap: FIXED_BINDING_DISTANCE,
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
focus: -0,
gap: 1,
gap: FIXED_BINDING_DISTANCE,
}),
isDeleted: true,
}),

View file

@ -1247,7 +1247,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.width).toBe(400);
expect(arrow.width).toBeCloseTo(408, 0);
expect(rect.x).toBe(400);
expect(rect.y).toBe(0);
expect(
@ -1266,7 +1266,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0);
mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(204, 0);
expect(arrow.width).toBeCloseTo(207, 0);
expect(rect.x).toBe(200);
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(

View file

@ -109,8 +109,10 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([200, 0]);
expect([arrow.x, arrow.y]).toEqual([110, 50]);
expect([arrow.width, arrow.height]).toEqual([80, 80]);
expect([Math.round(arrow.x), Math.round(arrow.y)]).toEqual([104, 50]);
expect([Math.round(arrow.width), Math.round(arrow.height)]).toEqual([
93, 81,
]);
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
@ -128,8 +130,11 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[107.07, 47.07]]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[86.86, 87.3]]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[103.53, 50.01]]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([
[93.9289, 82.1813],
]);
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});

View file

@ -118,8 +118,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER,
});
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
@ -161,8 +161,8 @@ describe("multi point mode in linear elements", () => {
fireEvent.keyDown(document, {
key: KEYS.ENTER,
});
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;

View file

@ -3,6 +3,8 @@ import { expect } from "vitest";
import { reseed } from "@excalidraw/common";
import "@excalidraw/utils/test-utils";
import { Excalidraw } from "../index";
import { UI } from "./helpers/ui";
@ -35,7 +37,7 @@ test("unselected bound arrow updates when rotating its target element", async ()
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.x).toBeCloseTo(-80);
expect(arrow.y).toBeCloseTo(50);
expect(arrow.width).toBeCloseTo(116.7, 1);
expect(arrow.width).toBeCloseTo(119.6, 1);
expect(arrow.height).toBeCloseTo(0);
});
@ -71,14 +73,16 @@ test("unselected bound arrows update when rotating their target elements", async
expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id);
expect(ellipseArrow.x).toEqual(0);
expect(ellipseArrow.y).toEqual(0);
expect(ellipseArrow.points[0]).toEqual([0, 0]);
expect(ellipseArrow.points[1][0]).toBeCloseTo(48.98, 1);
expect(ellipseArrow.points[1][1]).toBeCloseTo(125.79, 1);
expect(ellipseArrow.points).toCloselyEqualPoints([
[0, 0],
[90.1827, 98.5896],
]);
expect(textArrow.endBinding?.elementId).toEqual(text.id);
expect(textArrow.x).toEqual(360);
expect(textArrow.y).toEqual(300);
expect(textArrow.points[0]).toEqual([0, 0]);
expect(textArrow.points[1][0]).toBeCloseTo(-94, 0);
expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 0);
expect(textArrow.points[1][0]).toBeCloseTo(-95, 0);
expect(textArrow.points[1][1]).toBeCloseTo(-129.1, 0);
});

View file

@ -157,22 +157,13 @@ export function curveIntersectLineSegment<
return bezierEquation(c, t);
};
let solution = calculate(initial_guesses[0]);
if (solution) {
return [solution];
}
const solutions = [
calculate(initial_guesses[0]),
calculate(initial_guesses[1]),
calculate(initial_guesses[2]),
].filter((x, i, a): x is Point => x !== null && a.indexOf(x) === i);
solution = calculate(initial_guesses[1]);
if (solution) {
return [solution];
}
solution = calculate(initial_guesses[2]);
if (solution) {
return [solution];
}
return [];
return solutions;
}
/**

View file

@ -91,9 +91,10 @@ export function isPoint(p: unknown): p is LocalPoint | GlobalPoint {
export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
a: Point,
b: Point,
precision: number = PRECISION,
): boolean {
const abs = Math.abs;
return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION;
return abs(a[0] - b[0]) < precision && abs(a[1] - b[1]) < precision;
}
/**

View file

@ -6,7 +6,7 @@ export const clamp = (value: number, min: number, max: number) => {
export const round = (
value: number,
precision: number,
precision: number = (Math.log(1 / PRECISION) * Math.LOG10E + 1) | 0,
func: "round" | "floor" | "ceil" = "round",
) => {
const multiplier = Math.pow(10, precision);

View file

@ -195,11 +195,6 @@ export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>(
};
export const getCurvePathOps = (shape: Drawable): Op[] => {
// NOTE (mtolmacs): Temporary fix for extremely large elements
if (!shape) {
return [];
}
for (const set of shape.sets) {
if (set.type === "path") {
return set.ops;