Compare commits

...

51 Commits

Author SHA1 Message Date
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
43 changed files with 1750 additions and 1386 deletions

View File

@ -18,7 +18,7 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve"; 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"; import type { Curve } from "@excalidraw/math";

View File

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

View File

@ -437,26 +437,12 @@ export const _generateElementShape = (
: [pointFrom<LocalPoint>(0, 0)]; : [pointFrom<LocalPoint>(0, 0)];
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
// NOTE (mtolmacs): Temporary fix for extremely big arrow shapes shape = [
if ( generator.path(
!points.every( generateElbowArrowShape(points, 16),
(point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6, generateRoughOptions(element, true),
) ),
) { ];
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),
),
];
}
} else if (!element.roundness) { } else if (!element.roundness) {
// curve is always the first element // curve is always the first element
// this simplifies finding the curve for an element // this simplifies finding the curve for an element

View File

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

View File

@ -20,6 +20,7 @@ import {
tupleToCoors, tupleToCoors,
getSizeFromPoints, getSizeFromPoints,
isDevEnv, isDevEnv,
isTestEnv,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
@ -50,7 +51,6 @@ import { isBindableElement } from "./typeChecks";
import { import {
type ExcalidrawElbowArrowElement, type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap, type NonDeletedSceneElementsMap,
type SceneElementsMap,
} from "./types"; } from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes"; import { aabbForElement, pointInsideBounds } from "./shapes";
@ -63,7 +63,6 @@ import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
FixedPointBinding, FixedPointBinding,
FixedSegment, FixedSegment,
NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" }; 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 }; return { points: updates.points ?? arrow.points };
} }
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow if (isDevEnv() || isTestEnv()) {
// 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) {
invariant( invariant(
!updates.points || updates.points.length >= 2, !updates.points || updates.points.length >= 2,
"Updated point array length must match the arrow point length, contain " + "Updated point array length must match the arrow point length, contain " +
@ -1221,19 +1174,31 @@ const getElbowArrowData = (
if (options?.isDragging) { if (options?.isDragging) {
const elements = Array.from(elementsMap.values()); const elements = Array.from(elementsMap.values());
hoveredStartElement = hoveredStartElement =
getHoveredElement( getHoveredElementForBinding(
origStartGlobalPoint, tupleToCoors(origStartGlobalPoint),
elementsMap,
elements, elements,
elementsMap,
options?.zoom, options?.zoom,
true,
true,
) || null; ) || null;
hoveredEndElement = hoveredEndElement =
getHoveredElement( getHoveredElementForBinding(
origEndGlobalPoint, tupleToCoors(origEndGlobalPoint),
elementsMap,
elements, elements,
elementsMap,
options?.zoom, options?.zoom,
true,
true,
) || null; ) || 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 { } else {
hoveredStartElement = arrow.startBinding hoveredStartElement = arrow.startBinding
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) || ? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
@ -1254,6 +1219,7 @@ const getElbowArrowData = (
"start", "start",
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap,
hoveredStartElement, hoveredStartElement,
options?.isDragging, options?.isDragging,
); );
@ -1267,20 +1233,19 @@ const getElbowArrowData = (
"end", "end",
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap,
hoveredEndElement, hoveredEndElement,
options?.isDragging, options?.isDragging,
); );
const startHeading = getBindPointHeading( const startHeading = getBindPointHeading(
startGlobalPoint, startGlobalPoint,
endGlobalPoint, endGlobalPoint,
elementsMap,
hoveredStartElement, hoveredStartElement,
origStartGlobalPoint, origStartGlobalPoint,
); );
const endHeading = getBindPointHeading( const endHeading = getBindPointHeading(
endGlobalPoint, endGlobalPoint,
startGlobalPoint, startGlobalPoint,
elementsMap,
hoveredEndElement, hoveredEndElement,
origEndGlobalPoint, 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]) => points = points.map(([x, y]) =>
pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)), pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)),
); );
@ -2212,6 +2154,7 @@ const getGlobalPoint = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint, initialPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap,
element?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
isDragging?: boolean, isDragging?: boolean,
): GlobalPoint => { ): GlobalPoint => {
@ -2221,6 +2164,7 @@ const getGlobalPoint = (
arrow, arrow,
element, element,
startOrEnd, startOrEnd,
elementsMap,
); );
return snapToMid(element, snapPoint); return snapToMid(element, snapPoint);
@ -2240,7 +2184,7 @@ const getGlobalPoint = (
distanceToBindableElement(element, fixedGlobalPoint) - distanceToBindableElement(element, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
) > 0.01 ) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd) ? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
: fixedGlobalPoint; : fixedGlobalPoint;
} }
@ -2250,7 +2194,6 @@ const getGlobalPoint = (
const getBindPointHeading = ( const getBindPointHeading = (
p: GlobalPoint, p: GlobalPoint,
otherPoint: GlobalPoint, otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined, hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint, origPoint: GlobalPoint,
): Heading => ): Heading =>
@ -2268,26 +2211,9 @@ const getBindPointHeading = (
number, number,
], ],
), ),
elementsMap,
origPoint, 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 => const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
a[0] === b[0] && a[1] === b[1]; a[0] === b[0] && a[1] === b[1];

View File

@ -42,6 +42,7 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
getHoveredElementForBinding, getHoveredElementForBinding,
getOutlineAvoidingPoint,
isBindingEnabled, isBindingEnabled,
} from "./binding"; } from "./binding";
import { import {
@ -56,6 +57,7 @@ import { headingIsHorizontal, vectorToHeading } from "./heading";
import { bumpVersion, mutateElement } from "./mutateElement"; import { bumpVersion, mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { import {
isArrowElement,
isBindingElement, isBindingElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
@ -252,27 +254,28 @@ export class LinearElementEditor {
pointSceneCoords: { x: number; y: number }[], pointSceneCoords: { x: number; y: number }[],
) => void, ) => void,
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
scene: Scene,
): LinearElementEditor | null { ): LinearElementEditor | null {
if (!linearElementEditor) { if (!linearElementEditor) {
return null; return null;
} }
const { elementId } = linearElementEditor; const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
return null; return null;
} }
const elbowed = isElbowArrow(element);
if ( if (
isElbowArrow(element) && elbowed &&
!linearElementEditor.pointerDownState.lastClickedIsEndPoint && !linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
linearElementEditor.pointerDownState.lastClickedPoint !== 0 linearElementEditor.pointerDownState.lastClickedPoint !== 0
) { ) {
return null; return null;
} }
const selectedPointsIndices = isElbowArrow(element) const selectedPointsIndices = elbowed
? [ ? [
!!linearElementEditor.selectedPointsIndices?.includes(0) !!linearElementEditor.selectedPointsIndices?.includes(0)
? 0 ? 0
@ -282,7 +285,7 @@ export class LinearElementEditor {
: undefined, : undefined,
].filter((idx): idx is number => idx !== undefined) ].filter((idx): idx is number => idx !== undefined)
: linearElementEditor.selectedPointsIndices; : linearElementEditor.selectedPointsIndices;
const lastClickedPoint = isElbowArrow(element) const lastClickedPoint = elbowed
? linearElementEditor.pointerDownState.lastClickedPoint > 0 ? linearElementEditor.pointerDownState.lastClickedPoint > 0
? element.points.length - 1 ? element.points.length - 1
: 0 : 0
@ -334,7 +337,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint = let newPointPosition: LocalPoint =
pointIndex === lastClickedPoint pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt( ? LinearElementEditor.createPointAt(
element, element,
@ -347,6 +350,46 @@ export class LinearElementEditor {
element.points[pointIndex][0] + deltaX, element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY, 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 { return {
index: pointIndex, index: pointIndex,
point: newPointPosition, point: newPointPosition,

View File

@ -98,28 +98,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: 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 // assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = { const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
id: rest.id || randomId(), id: rest.id || randomId(),

View File

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

View File

@ -130,7 +130,7 @@ export const isLinearElementType = (
export const isBindingElement = ( export const isBindingElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
includeLocked = true, includeLocked = true,
): element is ExcalidrawLinearElement => { ): element is ExcalidrawArrowElement => {
return ( return (
element != null && element != null &&
(!element.locked || includeLocked === true) && (!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 { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import "@excalidraw/utils/test-utils";
import { getTransformHandles } from "../src/transformHandles"; import { getTransformHandles } from "../src/transformHandles";
const { h } = window; const { h } = window;
@ -18,7 +20,9 @@ const mouse = new Pointer("mouse");
describe("element binding", () => { describe("element binding", () => {
beforeEach(async () => { beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
mouse.reset();
}); });
it("should create valid binding if duplicate start/end points", async () => { it("should create valid binding if duplicate start/end points", async () => {
@ -89,46 +93,55 @@ describe("element binding", () => {
}); });
}); });
//@TODO fix the test with rotation // UX RATIONALE: We are not aware of any use-case where the user would want to
it.skip("rotation of arrow should rebind both ends", () => { // have the arrow rebind after rotation but not when the arrow shaft is
const rectLeft = UI.createElement("rectangle", { // dragged so either the start or the end point is in the binding range of a
x: 0, // bindable element. So to remain consistent, we only "rebind" if at the end
width: 200, // of the rotation the original binding would remain the same (i.e. like we
height: 500, // would've evaluated binding only at the end of the operation).
}); it(
const rectRight = UI.createElement("rectangle", { "rotation of arrow should not rebind on both ends if rotated enough to" +
x: 400, " not be in the binding range of the original elements",
width: 200, () => {
height: 500, const rectLeft = UI.createElement("rectangle", {
}); x: 0,
const arrow = UI.createElement("arrow", { width: 200,
x: 210, height: 500,
y: 250, });
width: 180, const rectRight = UI.createElement("rectangle", {
height: 1, x: 400,
}); width: 200,
expect(arrow.startBinding?.elementId).toBe(rectLeft.id); height: 500,
expect(arrow.endBinding?.elementId).toBe(rectRight.id); });
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( const rotation = getTransformHandles(
arrow, arrow,
h.state.zoom, h.state.zoom,
arrayToMap(h.elements), arrayToMap(h.elements),
"mouse", "mouse",
).rotation!; ).rotation!;
const rotationHandleX = rotation[0] + rotation[2] / 2; const rotationHandleX = rotation[0] + rotation[2] / 2;
const rotationHandleY = rotation[1] + rotation[3] / 2; const rotationHandleY = rotation[1] + rotation[3] / 2;
mouse.down(rotationHandleX, rotationHandleY); mouse.down(rotationHandleX, rotationHandleY);
mouse.move(300, 400); mouse.move(300, 400);
mouse.up(); mouse.up();
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI); expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
expect(arrow.angle).toBeLessThan(1.3 * Math.PI); expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
expect(arrow.startBinding?.elementId).toBe(rectRight.id); expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding?.elementId).toBe(rectLeft.id); expect(arrow.endBinding).toBe(null);
}); },
);
// TODO fix & reenable once we rewrite tests to work with concurrency // 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 arrow and moving its head to bind it to element A, finalizing the" +
"editing by clicking on element A should end up selecting A", "editing by clicking on element A should end up selecting A",
async () => { async () => {
@ -142,7 +155,10 @@ describe("element binding", () => {
mouse.up(0, 80); mouse.up(0, 80);
// Edit arrow with multi-point // Edit arrow with multi-point
mouse.doubleClick(); Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
// move arrow head // move arrow head
mouse.down(); mouse.down();
mouse.up(0, 10); 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 // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
mouse.reset(); mouse.reset();
expect(h.state.editingLinearElement).not.toBe(null); expect(h.state.editingLinearElement).not.toBe(null);
mouse.down(0, 0); mouse.click();
await new Promise((r) => setTimeout(r, 100));
expect(h.state.editingLinearElement).toBe(null);
expect(API.getSelectedElement().type).toBe("rectangle");
mouse.up();
expect(API.getSelectedElement().type).toBe("rectangle"); 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", { const rectangle = UI.createElement("rectangle", {
x: 75, x: 75,
y: 0, y: 0,
@ -187,13 +199,19 @@ describe("element binding", () => {
expect(arrow.endBinding?.elementId).toBe(rectangle.id); expect(arrow.endBinding?.elementId).toBe(rectangle.id);
Keyboard.keyPress(KEYS.ARROW_LEFT); Keyboard.keyPress(KEYS.ARROW_LEFT);
expect(arrow.endBinding?.elementId).toBe(rectangle.id); expect(arrow.endBinding?.elementId).toBe(rectangle.id);
expect(API.getSelectedElement().type).toBe("arrow");
// Sever connection // Sever connection
expect(API.getSelectedElement().type).toBe("arrow"); Keyboard.withModifierKeys({ shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT); // We have to move a significant distance to get out of the binding zone
expect(arrow.endBinding).toBe(null); Array.from({ length: 10 }).forEach(() => {
Keyboard.keyPress(KEYS.ARROW_RIGHT); Keyboard.keyPress(KEYS.ARROW_LEFT);
expect(arrow.endBinding).toBe(null); });
});
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
expect(arrow.x).toBe(0);
expect(arrow.y).toBe(0);
}); });
it("should unbind on bound element deletion", () => { 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 { 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 { 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 { import {
act, act,
@ -28,7 +31,10 @@ import type { LocalPoint } from "@excalidraw/math";
import { mutateElement } from "../src/mutateElement"; import { mutateElement } from "../src/mutateElement";
import { duplicateElement, duplicateElements } from "../src/duplicate"; import { duplicateElement, duplicateElements } from "../src/duplicate";
import type { ExcalidrawLinearElement } from "../src/types"; import type {
ExcalidrawArrowElement,
ExcalidrawLinearElement,
} from "../src/types";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); 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", () => { describe("duplication z-order", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);

View File

@ -3,14 +3,11 @@ import { pointFrom } from "@excalidraw/math";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw"; import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import Scene from "@excalidraw/excalidraw/scene/Scene"; 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 { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import { import {
act,
fireEvent, fireEvent,
GlobalTestState, GlobalTestState,
queryByTestId, queryByTestId,
@ -301,114 +298,4 @@ describe("elbow arrow ui", () => {
[103, 165], [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, unmountComponent,
} from "@excalidraw/excalidraw/tests/test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import { isLinearElement } from "../src/typeChecks"; import { isLinearElement } from "../src/typeChecks";
@ -195,7 +197,7 @@ describe("generic element", () => {
UI.resize(rectangle, "w", [50, 0]); UI.resize(rectangle, "w", [50, 0]);
expect(arrow.endBinding?.elementId).toEqual(rectangle.id); 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 () => { it("resizes with a label", async () => {
@ -826,8 +828,9 @@ describe("image element", () => {
UI.resize(image, "nw", [50, 20]); UI.resize(image, "nw", [50, 20]);
expect(arrow.endBinding?.elementId).toEqual(image.id); 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, 0,
); );
}); });
@ -1003,14 +1006,14 @@ describe("multiple selection", () => {
size: 100, size: 100,
}); });
const leftBoundArrow = UI.createElement("arrow", { const leftBoundArrow = UI.createElement("arrow", {
x: -110, x: -100 - FIXED_BINDING_DISTANCE,
y: 50, y: 50,
width: 100, width: 100,
height: 0, height: 0,
}); });
const rightBoundArrow = UI.createElement("arrow", { const rightBoundArrow = UI.createElement("arrow", {
x: 210, x: 200 + FIXED_BINDING_DISTANCE,
y: 50, y: 50,
width: -100, width: -100,
height: 0, height: 0,
@ -1031,27 +1034,29 @@ describe("multiple selection", () => {
shift: true, shift: true,
}); });
expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.x).toBeCloseTo(-100 - FIXED_BINDING_DISTANCE);
expect(leftBoundArrow.y).toBeCloseTo(50); 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.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull(); expect(leftBoundArrow.startBinding).toBeNull();
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10); expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(5);
expect(leftBoundArrow.endBinding?.elementId).toBe( expect(leftBoundArrow.endBinding?.elementId).toBe(
leftArrowBinding.elementId, leftArrowBinding.elementId,
); );
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
expect(rightBoundArrow.x).toBeCloseTo(210); expect(rightBoundArrow.x).toBeCloseTo(210 - FIXED_BINDING_DISTANCE);
expect(rightBoundArrow.y).toBeCloseTo( expect(rightBoundArrow.y).toBeCloseTo(
(selectionHeight - 50) * (1 - scale) + 50, (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.height).toBeCloseTo(0);
expect(rightBoundArrow.angle).toEqual(0); expect(rightBoundArrow.angle).toEqual(0);
expect(rightBoundArrow.startBinding).toBeNull(); expect(rightBoundArrow.startBinding).toBeNull();
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952); expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(FIXED_BINDING_DISTANCE);
expect(rightBoundArrow.endBinding?.elementId).toBe( expect(rightBoundArrow.endBinding?.elementId).toBe(
rightArrowBinding.elementId, rightArrowBinding.elementId,
); );
@ -1338,8 +1343,8 @@ describe("multiple selection", () => {
expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.x).toBeCloseTo(380 * scaleX);
expect(boundArrow.y).toBeCloseTo(240 * scaleY); expect(boundArrow.y).toBeCloseTo(240 * scaleY);
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX); expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX - 2, 0);
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY); expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY + 2, 0);
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
boundArrow.x + boundArrow.points[1][0] / 2, 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 { import {
maybeBindLinearElement, maybeBindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
getHoveredElementForBinding,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { mutateElement } from "@excalidraw/element/mutateElement"; import { mutateElement } from "@excalidraw/element/mutateElement";
import { import {
isBindingElement, isBindingElement,
isElbowArrow,
isLinearElement, isLinearElement,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element/typeChecks";
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common"; import { KEYS, updateActiveTool } from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element/shapes"; import { isPathALoop } from "@excalidraw/element/shapes";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers"; import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
@ -91,10 +93,26 @@ export const actionFinalize = register({
multiPointElement.type !== "freedraw" && multiPointElement.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch" 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 ( if (
!lastCommittedPoint || !hoveredElementForBinding &&
points[points.length - 1] !== lastCommittedPoint (!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint)
) { ) {
mutateElement(multiPointElement, { mutateElement(multiPointElement, {
points: multiPointElement.points.slice(0, -1), points: multiPointElement.points.slice(0, -1),
@ -135,15 +153,9 @@ export const actionFinalize = register({
!isLoop && !isLoop &&
multiPointElement.points.length > 1 multiPointElement.points.length > 1
) { ) {
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiPointElement,
-1,
arrayToMap(elements),
);
maybeBindLinearElement( maybeBindLinearElement(
multiPointElement, multiPointElement,
appState, appState,
{ x, y },
elementsMap, elementsMap,
elements, elements,
); );

View File

@ -73,12 +73,12 @@ describe("flipping re-centers selection", () => {
API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1")!; const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1.x).toBeCloseTo(100, 0); expect(Math.floor(rec1.x)).toBeCloseTo(100, 0);
expect(rec1.y).toBeCloseTo(100, 0); expect(Math.floor(rec1.y)).toBeCloseTo(100, 0);
const rec2 = h.elements.find((el) => el.id === "rec2")!; const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0); expect(Math.floor(rec2.x)).toBeCloseTo(220, 0);
expect(rec2.y).toBeCloseTo(250, 0); expect(Math.floor(rec2.y)).toBeCloseTo(250, 0);
}); });
}); });
@ -87,6 +87,16 @@ describe("flipping arrowheads", () => {
await render(<Excalidraw />); 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", () => { it("flipping bound arrow should flip arrowheads only", () => {
const rect = API.createElement({ const rect = API.createElement({
type: "rectangle", type: "rectangle",
@ -123,6 +133,7 @@ describe("flipping arrowheads", () => {
expect(API.getElement(arrow).endArrowhead).toBe("arrow"); expect(API.getElement(arrow).endArrowhead).toBe("arrow");
}); });
// UX RATIONALE: See above for the reasoning.
it("flipping bound arrow should flip arrowheads only 2", () => { it("flipping bound arrow should flip arrowheads only 2", () => {
const rect = API.createElement({ const rect = API.createElement({
type: "rectangle", type: "rectangle",
@ -164,7 +175,9 @@ describe("flipping arrowheads", () => {
expect(API.getElement(arrow).endArrowhead).toBe("circle"); 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({ const arrow = API.createElement({
type: "arrow", type: "arrow",
id: "arrow1", id: "arrow1",

View File

@ -1,6 +1,6 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { import {
bindOrUnbindLinearElements, bindOrUnbindLinearElement,
isBindingEnabled, isBindingEnabled,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { getCommonBoundingBox } from "@excalidraw/element/bounds"; import { getCommonBoundingBox } from "@excalidraw/element/bounds";
@ -12,15 +12,15 @@ import { deepCopyElement } from "@excalidraw/element/duplicate";
import { resizeMultipleElements } from "@excalidraw/element/resizeElements"; import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isBindableElement,
isLinearElement, isBindingElement,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element/typeChecks";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
NonDeleted, NonDeleted,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
@ -160,52 +160,54 @@ const flipElements = (
}, },
); );
bindOrUnbindLinearElements( const selectedBindables = selectedElements.filter(
selectedElements.filter(isLinearElement), (e): e is ExcalidrawBindableElement => isBindableElement(e),
elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState),
[],
appState.zoom,
); );
// ---------------------------------------------------------------------------
// 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 } = const { midX: newMidX, midY: newMidY } =
getCommonBoundingBox(selectedElements); getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY]; const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) =>
selectedElements.forEach((element) => {
fixBindings(element, selectedBindables, app, elementsMap);
mutateElement(element, { mutateElement(element, {
x: element.x + diffX, x: element.x + diffX,
y: element.y + diffY, y: element.y + diffY,
}), });
); });
elbowArrows.forEach((element) =>
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
// ---------------------------------------------------------------------------
return selectedElements; 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, newElement,
startHoveredElement, startHoveredElement,
"start", "start",
elementsMap,
) )
: startGlobalPoint; : startGlobalPoint;
const finalEndPoint = endHoveredElement const finalEndPoint = endHoveredElement
@ -1662,6 +1663,7 @@ export const actionChangeArrowType = register({
newElement, newElement,
endHoveredElement, endHoveredElement,
"end", "end",
elementsMap,
) )
: endGlobalPoint; : endGlobalPoint;

View File

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

View File

@ -113,11 +113,11 @@ import {
fixBindingsAfterDeletion, fixBindingsAfterDeletion,
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
isLinearElementSimpleAndAlreadyBound,
maybeBindLinearElement, maybeBindLinearElement,
shouldEnableBindingForPointerEvent, shouldEnableBindingForPointerEvent,
updateBoundElements, updateBoundElements,
getSuggestedBindingsForArrows, getSuggestedBindingsForArrows,
getOutlineAvoidingPoint,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
@ -170,7 +170,6 @@ import {
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element/typeChecks";
import { import {
getLockedLinearCursorAlignSize,
getNormalizedDimensions, getNormalizedDimensions,
isElementCompletelyInViewport, isElementCompletelyInViewport,
isElementInViewport, isElementInViewport,
@ -302,10 +301,9 @@ import {
import { isNonDeletedElement } from "@excalidraw/element"; import { isNonDeletedElement } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawGenericElement, ExcalidrawGenericElement,
@ -329,7 +327,7 @@ import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { ValueOf } from "@excalidraw/common/utility-types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
import { import {
actionAddToLibrary, actionAddToLibrary,
@ -464,6 +462,14 @@ import { isMaybeMermaidDefinition } from "../mermaid";
import { LassoTrail } from "../lasso"; import { LassoTrail } from "../lasso";
import {
handleCanvasPointerMoveForLinearElement,
handleDoubleClickForLinearElement,
maybeSuggestBindingsForLinearElementAtCoords,
onPointerMoveFromPointerDownOnLinearElement,
onPointerUpFromPointerDownOnLinearElementHandler,
} from "../linear";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import BraveMeasureTextError from "./BraveMeasureTextError"; import BraveMeasureTextError from "./BraveMeasureTextError";
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu"; import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
@ -2768,7 +2774,6 @@ class App extends React.Component<AppProps, AppState> {
this.updateEmbeddables(); this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted(); const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap();
if (!this.state.showWelcomeScreen && !elements.length) { if (!this.state.showWelcomeScreen && !elements.length) {
this.setState({ showWelcomeScreen: true }); this.setState({ showWelcomeScreen: true });
@ -2925,13 +2930,6 @@ class App extends React.Component<AppProps, AppState> {
maybeBindLinearElement( maybeBindLinearElement(
multiElement, multiElement,
this.state, this.state,
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiElement,
-1,
nonDeletedElementsMap,
),
),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
); );
@ -4381,7 +4379,7 @@ class App extends React.Component<AppProps, AppState> {
const arrowIdsToRemove = new Set<string>(); const arrowIdsToRemove = new Set<string>();
selectedElements selectedElements
.filter(isElbowArrow) .filter(isArrowElement)
.filter((arrow) => { .filter((arrow) => {
const startElementNotInSelection = const startElementNotInSelection =
arrow.startBinding && arrow.startBinding &&
@ -5453,75 +5451,16 @@ class App extends React.Component<AppProps, AppState> {
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if ( if (
event[KEYS.CTRL_OR_CMD] && handleDoubleClickForLinearElement(
(!this.state.editingLinearElement || this,
this.state.editingLinearElement.elementId !== this.store,
selectedElements[0].id) && selectedElements[0],
!isElbowArrow(selectedElements[0]) event,
sceneX,
sceneY,
)
) { ) {
this.store.shouldCaptureIncrement();
this.setState({
editingLinearElement: new LinearElementEditor(selectedElements[0]),
});
return; 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 // and point
const { newElement } = this.state; const { newElement } = this.state;
if (isBindingElement(newElement, false)) { if (isBindingElement(newElement, false)) {
this.maybeSuggestBindingsForLinearElementAtCoords( maybeSuggestBindingsForLinearElementAtCoords(
newElement, newElement,
[scenePointer], [scenePointer],
this,
this.state.startBoundElement, this.state.startBoundElement,
); );
} else { } else {
@ -5914,106 +5854,14 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.multiElement) { if (this.state.multiElement) {
const { multiElement } = this.state; const { multiElement } = this.state;
const { x: rx, y: ry } = multiElement; handleCanvasPointerMoveForLinearElement(
multiElement,
const { points, lastCommittedPoint } = multiElement; this,
const lastPoint = points[points.length - 1]; scenePointerX,
scenePointerY,
setCursorForShape(this.interactiveCanvas, this.state); event,
this.triggerRender,
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);
}
return; return;
} }
@ -7751,18 +7599,34 @@ class App extends React.Component<AppProps, AppState> {
} }
const { x: rx, y: ry, lastCommittedPoint } = multiElement; const { x: rx, y: ry, lastCommittedPoint } = multiElement;
const lastGlobalPoint = pointFrom<GlobalPoint>(
rx + multiElement.points[multiElement.points.length - 1][0],
ry + multiElement.points[multiElement.points.length - 1][1],
);
const hoveredElementForBinding = getHoveredElementForBinding(
{
x: lastGlobalPoint[0],
y: lastGlobalPoint[1],
},
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
true,
isElbowArrow(multiElement),
);
// clicking inside commit zone → finalize arrow // clicking inside commit zone → finalize arrow
if ( if (
multiElement.points.length > 1 && hoveredElementForBinding ||
lastCommittedPoint && (multiElement.points.length > 1 &&
pointDistance( lastCommittedPoint &&
pointFrom( pointDistance(
pointerDownState.origin.x - rx, pointFrom(
pointerDownState.origin.y - ry, pointerDownState.origin.x - rx,
), pointerDownState.origin.y - ry,
lastCommittedPoint, ),
) < LINE_CONFIRM_THRESHOLD lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD)
) { ) {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
return; return;
@ -7806,53 +7670,68 @@ class App extends React.Component<AppProps, AppState> {
? [currentItemStartArrowhead, currentItemEndArrowhead] ? [currentItemStartArrowhead, currentItemEndArrowhead]
: [null, null]; : [null, null];
const element = let element: NonDeleted<ExcalidrawLinearElement>;
elementType === "arrow" if (elementType === "arrow") {
? newArrowElement({ const arrow: Mutable<NonDeleted<ExcalidrawArrowElement>> =
type: elementType, newArrowElement({
x: gridX, type: "arrow",
y: gridY, x: gridX,
strokeColor: this.state.currentItemStrokeColor, y: gridY,
backgroundColor: this.state.currentItemBackgroundColor, strokeColor: this.state.currentItemStrokeColor,
fillStyle: this.state.currentItemFillStyle, backgroundColor: this.state.currentItemBackgroundColor,
strokeWidth: this.state.currentItemStrokeWidth, fillStyle: this.state.currentItemFillStyle,
strokeStyle: this.state.currentItemStrokeStyle, strokeWidth: this.state.currentItemStrokeWidth,
roughness: this.state.currentItemRoughness, strokeStyle: this.state.currentItemStrokeStyle,
opacity: this.state.currentItemOpacity, roughness: this.state.currentItemRoughness,
roundness: opacity: this.state.currentItemOpacity,
this.state.currentItemArrowType === ARROW_TYPE.round roundness:
? { type: ROUNDNESS.PROPORTIONAL_RADIUS } this.state.currentItemArrowType === ARROW_TYPE.round
: // note, roundness doesn't have any effect for elbow arrows, ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
// but it's best to set it to null as well : // note, roundness doesn't have any effect for elbow arrows,
null, // but it's best to set it to null as well
startArrowhead, null,
endArrowhead, startArrowhead,
locked: false, endArrowhead,
frameId: topLayerFrame ? topLayerFrame.id : null, locked: false,
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, frameId: topLayerFrame ? topLayerFrame.id : null,
fixedSegments: elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
this.state.currentItemArrowType === ARROW_TYPE.elbow fixedSegments:
? [] this.state.currentItemArrowType === ARROW_TYPE.elbow ? [] : null,
: null, });
})
: newLinearElement({ const [x, y] = getOutlineAvoidingPoint(
type: elementType, arrow,
x: gridX, pointFrom<GlobalPoint>(gridX, gridY),
y: gridY, 0,
strokeColor: this.state.currentItemStrokeColor, this.scene,
backgroundColor: this.state.currentItemBackgroundColor, this.state.zoom,
fillStyle: this.state.currentItemFillStyle, );
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle, element = {
roughness: this.state.currentItemRoughness, ...arrow,
opacity: this.state.currentItemOpacity, x,
roundness: y,
this.state.currentItemRoundness === "round" };
? { type: ROUNDNESS.PROPORTIONAL_RADIUS } } else {
: null, element = newLinearElement({
locked: false, type: elementType,
frameId: topLayerFrame ? topLayerFrame.id : null, 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) => { this.setState((prevState) => {
const nextSelectedElementIds = { const nextSelectedElementIds = {
...prevState.selectedElementIds, ...prevState.selectedElementIds,
@ -8167,12 +8046,6 @@ class App extends React.Component<AppProps, AppState> {
this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y); 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 // for arrows/lines, don't start dragging until a given threshold
// to ensure we don't create a 2-point arrow by mistake when // 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 // 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.x,
pointerCoords.y, pointerCoords.y,
(element, pointsSceneCoords) => { (element, pointsSceneCoords) => {
this.maybeSuggestBindingsForLinearElementAtCoords( maybeSuggestBindingsForLinearElementAtCoords(
element, element,
pointsSceneCoords, pointsSceneCoords,
this,
); );
}, },
linearElementEditor, linearElementEditor,
this.scene,
); );
if (newLinearElementEditor) { if (newLinearElementEditor) {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
@ -8658,54 +8531,14 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
} else if (isLinearElement(newElement)) { } else if (isLinearElement(newElement)) {
pointerDownState.drag.hasOccurred = true; onPointerMoveFromPointerDownOnLinearElement(
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({
newElement, newElement,
}); this,
pointerDownState,
if (isBindingElement(newElement, false)) { pointerCoords,
// When creating a linear element by dragging event,
this.maybeSuggestBindingsForLinearElementAtCoords( elementsMap,
newElement, );
[pointerCoords],
this.state.startBoundElement,
);
}
} else { } else {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
@ -9071,66 +8904,15 @@ class App extends React.Component<AppProps, AppState> {
} }
if (isLinearElement(newElement)) { if (isLinearElement(newElement)) {
if (newElement!.points.length > 1) { onPointerUpFromPointerDownOnLinearElementHandler(
this.store.shouldCaptureIncrement(); newElement,
} multiElement,
const pointerCoords = viewportCoordsToSceneCoords( this,
this.store,
pointerDownState,
childEvent, 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; return;
} }
@ -10196,49 +9978,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 { private clearSelection(hitElement: ExcalidrawElement | null): void {
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds({}, prevState), selectedElementIds: makeNextSelectedElementIds({}, prevState),
@ -10725,12 +10464,6 @@ class App extends React.Component<AppProps, AppState> {
updateBoundElements( updateBoundElements(
croppingElement, croppingElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
{
newSize: {
width: croppingElement.width,
height: croppingElement.height,
},
},
); );
this.setState({ this.setState({

View File

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

View File

@ -12,7 +12,7 @@ import { isImageElement } from "@excalidraw/element/typeChecks";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
@ -118,6 +118,9 @@ const handleDimensionChange: DragInputCallbackType<
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth), width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight), height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
}); });
updateBindings(element, elementsMap, scene);
return; return;
} }
@ -150,6 +153,8 @@ const handleDimensionChange: DragInputCallbackType<
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight), height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
}); });
updateBindings(element, elementsMap, scene);
return; return;
} }
@ -184,6 +189,8 @@ const handleDimensionChange: DragInputCallbackType<
}, },
); );
updateBindings(origElement, elementsMap, scene);
return; return;
} }
const changeInWidth = property === "width" ? accumulatedChange : 0; const changeInWidth = property === "width" ? accumulatedChange : 0;
@ -230,6 +237,8 @@ const handleDimensionChange: DragInputCallbackType<
shouldMaintainAspectRatio: keepAspectRatio, 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 type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { CaptureUpdateAction } from "../../store"; import { CaptureUpdateAction } from "../../store";
import { useApp } from "../App"; import { useApp, useExcalidrawSetAppState } from "../App";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
import { SMALLEST_DELTA } from "./utils"; import { SMALLEST_DELTA } from "./utils";
@ -34,6 +34,21 @@ export type DragInputCallbackType<
property: P; property: P;
originalAppState: AppState; originalAppState: AppState;
setInputValue: (value: number) => void; 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; }) => void;
interface StatsDragInputProps< interface StatsDragInputProps<
@ -47,6 +62,7 @@ interface StatsDragInputProps<
editable?: boolean; editable?: boolean;
shouldKeepAspectRatio?: boolean; shouldKeepAspectRatio?: boolean;
dragInputCallback: DragInputCallbackType<T, E>; dragInputCallback: DragInputCallbackType<T, E>;
dragFinishedCallback?: DragFinishedCallbackType<T, E>;
property: T; property: T;
scene: Scene; scene: Scene;
appState: AppState; appState: AppState;
@ -61,6 +77,7 @@ const StatsDragInput = <
label, label,
icon, icon,
dragInputCallback, dragInputCallback,
dragFinishedCallback,
value, value,
elements, elements,
editable = true, editable = true,
@ -71,6 +88,7 @@ const StatsDragInput = <
sensitivity = 1, sensitivity = 1,
}: StatsDragInputProps<T, E>) => { }: StatsDragInputProps<T, E>) => {
const app = useApp(); const app = useApp();
const setAppState = useExcalidrawSetAppState();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null); const labelRef = useRef<HTMLDivElement>(null);
@ -123,6 +141,7 @@ const StatsDragInput = <
// reason: idempotent to avoid unnecessary // reason: idempotent to avoid unnecessary
if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) { if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
stateRef.current.lastUpdatedValue = updatedValue; stateRef.current.lastUpdatedValue = updatedValue;
const originalElementsMap = app.scene.getNonDeletedElementsMap();
dragInputCallback({ dragInputCallback({
accumulatedChange: 0, accumulatedChange: 0,
instantChange: 0, instantChange: 0,
@ -135,6 +154,17 @@ const StatsDragInput = <
property, property,
originalAppState: appState, originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)), setInputValue: (value) => setInputValue(String(value)),
setAppState,
});
dragFinishedCallback?.({
originalElements: elements,
originalElementsMap,
scene,
originalAppState: appState,
accumulatedChange: rounded,
property,
setAppState,
setInputValue: (value) => setInputValue(String(value)),
}); });
app.syncActionResult({ app.syncActionResult({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -262,6 +292,7 @@ const StatsDragInput = <
scene, scene,
originalAppState, originalAppState,
setInputValue: (value) => setInputValue(String(value)), setInputValue: (value) => setInputValue(String(value)),
setAppState,
}); });
stepChange = 0; stepChange = 0;
@ -282,6 +313,19 @@ const StatsDragInput = <
false, false,
); );
if (originalElements !== null && originalElementsMap !== null) {
dragFinishedCallback?.({
originalElements,
originalElementsMap,
scene,
originalAppState,
property,
accumulatedChange,
setAppState,
setInputValue: (value) => setInputValue(String(value)),
});
}
app.syncActionResult({ app.syncActionResult({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}); });

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,11 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "@excalidraw/element/binding";
import { mutateElement } from "@excalidraw/element/mutateElement"; import { mutateElement } from "@excalidraw/element/mutateElement";
import { getBoundTextElement } from "@excalidraw/element/textElement"; import { getBoundTextElement } from "@excalidraw/element/textElement";
import { import {
isBindableElement,
isBindingElement,
isFrameLikeElement, isFrameLikeElement,
isLinearElement,
isTextElement, isTextElement,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element/typeChecks";
@ -18,6 +15,11 @@ import {
isInGroup, isInGroup,
} from "@excalidraw/element/groups"; } from "@excalidraw/element/groups";
import {
bindOrUnbindLinearElement,
updateBoundElements,
} from "@excalidraw/element/binding";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
import type { import type {
@ -27,7 +29,8 @@ import type {
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type Scene from "../../scene/Scene"; import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
export type StatsInputProperty = export type StatsInputProperty =
@ -120,8 +123,6 @@ export const moveElement = (
newTopLeftY: number, newTopLeftY: number,
originalElement: ExcalidrawElement, originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
@ -156,7 +157,10 @@ export const moveElement = (
}, },
shouldInformMutation, shouldInformMutation,
); );
updateBindings(latestElement, elementsMap, elements, scene);
if (isBindableElement(latestElement)) {
updateBoundElements(latestElement, elementsMap);
}
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
originalElement, originalElement,
@ -200,25 +204,34 @@ export const getAtomicUnits = (
export const updateBindings = ( export const updateBindings = (
latestElement: ExcalidrawElement, latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene, scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => { ) => {
if (isLinearElement(latestElement)) { if (isBindingElement(latestElement)) {
bindOrUnbindLinearElements( if (latestElement.startBinding || latestElement.endBinding) {
[latestElement], bindOrUnbindLinearElement(latestElement, null, null, elementsMap, scene);
elementsMap, }
elements, } else if (isBindableElement(latestElement)) {
scene, updateBoundElements(latestElement, elementsMap);
true, }
[], };
options?.zoom,
); export const updateSelectionBindings = (
} else { elements: readonly ExcalidrawElement[],
updateBoundElements(latestElement, elementsMap, options); 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": { "endBinding": {
"elementId": "ellipse-1", "elementId": "ellipse-1",
"focus": -0.007519379844961235, "focus": -0.007519379844961235,
"gap": 11.562288374879595, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 35, "height": 33.53813187180941,
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@ -108,8 +108,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0, 0,
], ],
[ [
394, 377.5017739818493,
34, 32.53813187180941,
], ],
], ],
"roughness": 1, "roughness": 1,
@ -119,7 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startBinding": { "startBinding": {
"elementId": "id49", "elementId": "id49",
"focus": -0.0813953488372095, "focus": -0.0813953488372095,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1864ab", "strokeColor": "#1864ab",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -128,7 +128,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 395, "width": 378.5017739818493,
"x": 247.5, "x": 247.5,
"y": 420.5, "y": 420.5,
} }
@ -145,7 +145,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endBinding": { "endBinding": {
"elementId": "ellipse-1", "elementId": "ellipse-1",
"focus": 0.10666666666666667, "focus": 0.10666666666666667,
"gap": 3.8343264684446097, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -164,7 +164,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0, 0,
], ],
[ [
399, 397.82801810964474,
0, 0,
], ],
], ],
@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startBinding": { "startBinding": {
"elementId": "diamond-1", "elementId": "diamond-1",
"focus": 0, "focus": 0,
"gap": 4.545343408287929, "gap": 5,
}, },
"strokeColor": "#e67700", "strokeColor": "#e67700",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -184,7 +184,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 400, "width": 398.82801810964474,
"x": 227.5, "x": 227.5,
"y": 450, "y": 450,
} }
@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"focus": 0, "focus": 0,
"gap": 14, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -354,7 +354,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
0, 0,
], ],
[ [
99, 299,
0, 0,
], ],
], ],
@ -365,7 +365,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startBinding": { "startBinding": {
"elementId": "text-1", "elementId": "text-1",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -374,7 +374,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 300,
"x": 255.5, "x": 255.5,
"y": 239, "y": 239,
} }
@ -437,7 +437,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"endBinding": { "endBinding": {
"elementId": "id42", "elementId": "id42",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -456,7 +456,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
0, 0,
], ],
[ [
99, 89,
0, 0,
], ],
], ],
@ -467,7 +467,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startBinding": { "startBinding": {
"elementId": "id41", "elementId": "id41",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -476,7 +476,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 90,
"x": 255.5, "x": 255.5,
"y": 239, "y": 239,
} }
@ -592,7 +592,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 355, "x": 350,
"y": 189, "y": 189,
} }
`; `;
@ -613,7 +613,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"endBinding": { "endBinding": {
"elementId": "id46", "elementId": "id46",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -632,7 +632,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
0, 0,
], ],
[ [
99, 89,
0, 0,
], ],
], ],
@ -643,7 +643,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startBinding": { "startBinding": {
"elementId": "id45", "elementId": "id45",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -652,7 +652,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 90,
"x": 255.5, "x": 255.5,
"y": 239, "y": 239,
} }
@ -786,7 +786,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "top", "verticalAlign": "top",
"width": 100, "width": 100,
"x": 355, "x": 350,
"y": 226.5, "y": 226.5,
} }
`; `;
@ -1475,12 +1475,12 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endBinding": { "endBinding": {
"elementId": "Alice", "elementId": "Alice",
"focus": -0, "focus": -0,
"gap": 5.299874999999986, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 7.105427357601002e-15,
"id": Any<String>, "id": Any<String>,
"index": "a4", "index": "a4",
"isDeleted": false, "isDeleted": false,
@ -1494,8 +1494,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
0, 0,
], ],
[ [
271.985, 272.28487500000006,
0, -0.9999999999999929,
], ],
], ],
"roughness": 1, "roughness": 1,
@ -1507,7 +1507,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startBinding": { "startBinding": {
"elementId": "Bob", "elementId": "Bob",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1516,9 +1516,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 272.985, "width": 273.28487500000006,
"x": 111.762, "x": 111.762,
"y": 57, "y": 57.5,
} }
`; `;
@ -1538,7 +1538,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endBinding": { "endBinding": {
"elementId": "B", "elementId": "B",
"focus": 0, "focus": 0,
"gap": 14, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -1566,7 +1566,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startBinding": { "startBinding": {
"elementId": "Bob", "elementId": "Bob",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",

View File

@ -20,10 +20,6 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { normalizeFixedPoint } from "@excalidraw/element/binding"; import { normalizeFixedPoint } from "@excalidraw/element/binding";
import {
updateElbowArrowPoints,
validateElbowPoints,
} from "@excalidraw/element/elbowArrow";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { bumpVersion } from "@excalidraw/element/mutateElement"; import { bumpVersion } from "@excalidraw/element/mutateElement";
import { getContainerElement } from "@excalidraw/element/textElement"; import { getContainerElement } from "@excalidraw/element/textElement";
@ -57,7 +53,6 @@ import type {
ExcalidrawTextElement, ExcalidrawTextElement,
FixedPointBinding, FixedPointBinding,
FontFamilyValues, FontFamilyValues,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement, OrderedExcalidrawElement,
PointBinding, PointBinding,
StrokeRoundness, StrokeRoundness,
@ -585,73 +580,7 @@ export const restoreElements = (
} }
} }
// NOTE (mtolmacs): Temporary fix for extremely large arrows return restoredElements;
// 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;
});
}; };
const coalesceAppStateValue = < const coalesceAppStateValue = <

View File

@ -1,6 +1,8 @@
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { vi } from "vitest"; import { vi } from "vitest";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
import type { ExcalidrawArrowElement } from "@excalidraw/element/types"; import type { ExcalidrawArrowElement } from "@excalidraw/element/types";
import { convertToExcalidrawElements } from "./transform"; import { convertToExcalidrawElements } from "./transform";
@ -433,7 +435,7 @@ describe("Test Transform", () => {
startBinding: { startBinding: {
elementId: rectangle.id, elementId: rectangle.id,
focus: 0, focus: 0,
gap: 1, gap: FIXED_BINDING_DISTANCE,
}, },
endBinding: { endBinding: {
elementId: ellipse.id, elementId: ellipse.id,
@ -462,7 +464,7 @@ describe("Test Transform", () => {
}); });
expect(ellipse).toMatchObject({ expect(ellipse).toMatchObject({
x: 355, x: 350,
y: 189, y: 189,
type: "ellipse", type: "ellipse",
boundElements: [ boundElements: [
@ -518,7 +520,7 @@ describe("Test Transform", () => {
startBinding: { startBinding: {
elementId: text2.id, elementId: text2.id,
focus: 0, focus: 0,
gap: 1, gap: FIXED_BINDING_DISTANCE,
}, },
endBinding: { endBinding: {
elementId: text3.id, elementId: text3.id,
@ -547,7 +549,7 @@ describe("Test Transform", () => {
}); });
expect(text3).toMatchObject({ expect(text3).toMatchObject({
x: 355, x: 350,
y: 226.5, y: 226.5,
type: "text", type: "text",
boundElements: [ boundElements: [
@ -781,7 +783,7 @@ describe("Test Transform", () => {
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1", elementId: "rect-1",
focus: -0, focus: -0,
gap: 14, gap: FIXED_BINDING_DISTANCE,
}); });
expect(rect.boundElements).toStrictEqual([ 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", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "102.35417", "height": "99.23572",
"id": "id172", "id": "id172",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@ -212,8 +212,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"101.77517", "96.42891",
"102.35417", "99.23572",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -228,8 +228,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 40, "version": 40,
"width": "101.77517", "width": "96.42891",
"x": "0.70711", "x": "3.53553",
"y": 0, "y": 0,
} }
`; `;
@ -295,47 +295,47 @@ History {
"deleted": { "deleted": {
"endBinding": { "endBinding": {
"elementId": "id171", "elementId": "id171",
"focus": "0.00990", "focus": "0.01065",
"gap": 1, "gap": 5,
}, },
"height": "0.98586", "height": "1.00000",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
"98.58579", "92.92893",
"-0.98586", "-1.00000",
], ],
], ],
"startBinding": { "startBinding": {
"elementId": "id170", "elementId": "id170",
"focus": "0.02970", "focus": "0.03194",
"gap": 1, "gap": 5,
}, },
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
"elementId": "id171", "elementId": "id171",
"focus": "-0.02000", "focus": "-0.02251",
"gap": 1, "gap": 5,
}, },
"height": "0.00000", "height": "0.08238",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
"98.58579", "92.92893",
"0.00000", "0.08238",
], ],
], ],
"startBinding": { "startBinding": {
"elementId": "id170", "elementId": "id170",
"focus": "0.02000", "focus": "0.01897",
"gap": 1, "gap": 5,
}, },
}, },
}, },
@ -390,43 +390,47 @@ History {
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
"height": "102.35417", "height": "99.23572",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
"101.77517", "96.42891",
"102.35417", "99.23572",
], ],
], ],
"startBinding": null, "startBinding": null,
"width": "96.42891",
"x": "3.53553",
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
"elementId": "id171", "elementId": "id171",
"focus": "0.00990", "focus": "0.01065",
"gap": 1, "gap": 5,
}, },
"height": "0.98586", "height": "1.00000",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
"98.58579", "92.92893",
"-0.98586", "-1.00000",
], ],
], ],
"startBinding": { "startBinding": {
"elementId": "id170", "elementId": "id170",
"focus": "0.02970", "focus": "0.03194",
"gap": 1, "gap": 5,
}, },
"y": "0.99364", "width": "92.92893",
"x": "3.53553",
"y": "1.03339",
}, },
}, },
"id175" => Delta { "id175" => Delta {
@ -566,7 +570,7 @@ History {
0, 0,
], ],
[ [
100, "96.46447",
0, 0,
], ],
], ],
@ -580,8 +584,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": 100, "width": "96.46447",
"x": 0, "x": "3.53553",
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@ -804,7 +808,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
100, "96.46447",
0, 0,
], ],
], ],
@ -820,8 +824,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 30, "version": 30,
"width": 0, "width": "96.46447",
"x": "149.29289", "x": 150,
"y": 0, "y": 0,
} }
`; `;
@ -854,10 +858,11 @@ History {
0, 0,
], ],
[ [
0, "0.00000",
0, 0,
], ],
], ],
"width": "0.00000",
}, },
"inserted": { "inserted": {
"points": [ "points": [
@ -866,10 +871,11 @@ History {
0, 0,
], ],
[ [
100, "89.39340",
0, 0,
], ],
], ],
"width": "89.39340",
}, },
}, },
}, },
@ -921,17 +927,19 @@ History {
0, 0,
], ],
[ [
100, "96.46447",
0, 0,
], ],
], ],
"startBinding": null, "startBinding": null,
"width": "96.46447",
"x": 150,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
"elementId": "id166", "elementId": "id166",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"points": [ "points": [
[ [
@ -939,15 +947,17 @@ History {
0, 0,
], ],
[ [
0, "0.00000",
0, 0,
], ],
], ],
"startBinding": { "startBinding": {
"elementId": "id165", "elementId": "id165",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"width": "0.00000",
"x": "146.46447",
}, },
}, },
}, },
@ -1074,7 +1084,7 @@ History {
0, 0,
], ],
[ [
100, "96.46447",
0, 0,
], ],
], ],
@ -1088,8 +1098,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": 100, "width": "96.46447",
"x": 0, "x": "3.53553",
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@ -1241,7 +1251,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "1.30038", "height": "1.71911",
"id": "id178", "id": "id178",
"index": "Zz", "index": "Zz",
"isDeleted": false, "isDeleted": false,
@ -1255,8 +1265,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"98.58579", "92.92893",
"1.30038", "1.71911",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -1279,8 +1289,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"width": "98.58579", "width": "92.92893",
"x": "0.70711", "x": "3.53553",
"y": 0, "y": 0,
} }
`; `;
@ -1613,7 +1623,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "1.30038", "height": "1.71911",
"id": "id181", "id": "id181",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
@ -1627,8 +1637,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"98.58579", "92.92893",
"1.30038", "1.71911",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -1651,8 +1661,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"width": "98.58579", "width": "92.92893",
"x": "0.70711", "x": "3.53553",
"y": 0, "y": 0,
} }
`; `;
@ -1771,7 +1781,7 @@ History {
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "11.27227", "height": "12.86717",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@ -1784,8 +1794,8 @@ History {
0, 0,
], ],
[ [
"98.58579", "92.92893",
"11.27227", "12.86717",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -1806,8 +1816,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.58579", "width": "92.92893",
"x": "0.70711", "x": "3.53553",
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@ -2321,12 +2331,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endBinding": { "endBinding": {
"elementId": "id185", "elementId": "id185",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "374.05754", "height": "369.23631",
"id": "id186", "id": "id186",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@ -2340,8 +2350,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"502.78936", "496.83418",
"-374.05754", "-369.23631",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -2352,7 +2362,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"startBinding": { "startBinding": {
"elementId": "id184", "elementId": "id184",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -2360,9 +2370,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 10,
"width": "502.78936", "width": "496.83418",
"x": "-0.83465", "x": "2.19080",
"y": "-36.58211", "y": "-38.78706",
} }
`; `;
@ -2481,7 +2491,7 @@ History {
"endBinding": { "endBinding": {
"elementId": "id185", "elementId": "id185",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -2499,7 +2509,7 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
@ -2511,14 +2521,14 @@ History {
"startBinding": { "startBinding": {
"elementId": "id184", "elementId": "id184",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": 100, "width": "92.92893",
"x": 0, "x": "3.53553",
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@ -15161,7 +15171,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": { "endBinding": {
"elementId": "id58", "elementId": "id58",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -15180,7 +15190,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0, 0,
], ],
[ [
"98.58579", "92.92893",
0, 0,
], ],
], ],
@ -15192,7 +15202,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": { "startBinding": {
"elementId": "id56", "elementId": "id56",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -15200,8 +15210,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 10,
"width": "98.58579", "width": "92.92893",
"x": "0.70711", "x": "3.53553",
"y": 0, "y": 0,
} }
`; `;
@ -15242,7 +15252,7 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
@ -15255,7 +15265,7 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
@ -15532,7 +15542,7 @@ History {
"endBinding": { "endBinding": {
"elementId": "id58", "elementId": "id58",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -15550,7 +15560,7 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
@ -15562,14 +15572,14 @@ History {
"startBinding": { "startBinding": {
"elementId": "id56", "elementId": "id56",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": 100, "width": "92.92893",
"x": 0, "x": "3.53553",
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@ -15859,7 +15869,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": { "endBinding": {
"elementId": "id52", "elementId": "id52",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -15878,7 +15888,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0, 0,
], ],
[ [
"98.58579", "92.92893",
0, 0,
], ],
], ],
@ -15890,7 +15900,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": { "startBinding": {
"elementId": "id50", "elementId": "id50",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -15898,8 +15908,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 10,
"width": "98.58579", "width": "92.92893",
"x": "0.70711", "x": "3.53553",
"y": 0, "y": 0,
} }
`; `;
@ -16152,7 +16162,7 @@ History {
"endBinding": { "endBinding": {
"elementId": "id52", "elementId": "id52",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -16170,7 +16180,7 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
@ -16182,14 +16192,14 @@ History {
"startBinding": { "startBinding": {
"elementId": "id50", "elementId": "id50",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": 100, "width": "92.92893",
"x": 0, "x": "3.53553",
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@ -16479,7 +16489,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": { "endBinding": {
"elementId": "id64", "elementId": "id64",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -16498,7 +16508,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0, 0,
], ],
[ [
"98.58579", "92.92893",
0, 0,
], ],
], ],
@ -16510,7 +16520,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": { "startBinding": {
"elementId": "id62", "elementId": "id62",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -16518,8 +16528,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 10,
"width": "98.58579", "width": "92.92893",
"x": "0.70711", "x": "3.53553",
"y": 0, "y": 0,
} }
`; `;
@ -16772,7 +16782,7 @@ History {
"endBinding": { "endBinding": {
"elementId": "id64", "elementId": "id64",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -16790,7 +16800,7 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
@ -16802,14 +16812,14 @@ History {
"startBinding": { "startBinding": {
"elementId": "id62", "elementId": "id62",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": 100, "width": "92.92893",
"x": 0, "x": "3.53553",
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@ -17097,7 +17107,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": { "endBinding": {
"elementId": "id70", "elementId": "id70",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -17116,7 +17126,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0, 0,
], ],
[ [
"98.58579", "92.92893",
0, 0,
], ],
], ],
@ -17128,7 +17138,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": { "startBinding": {
"elementId": "id68", "elementId": "id68",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -17136,8 +17146,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 10,
"width": "98.58579", "width": "92.92893",
"x": "0.70711", "x": "3.53553",
"y": 0, "y": 0,
} }
`; `;
@ -17193,14 +17203,14 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
"startBinding": { "startBinding": {
"elementId": "id68", "elementId": "id68",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
}, },
"inserted": { "inserted": {
@ -17210,7 +17220,7 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
@ -17460,7 +17470,7 @@ History {
"endBinding": { "endBinding": {
"elementId": "id70", "elementId": "id70",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -17478,7 +17488,7 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
@ -17490,14 +17500,14 @@ History {
"startBinding": { "startBinding": {
"elementId": "id68", "elementId": "id68",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": 100, "width": "92.92893",
"x": 0, "x": "3.53553",
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@ -17811,7 +17821,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": { "endBinding": {
"elementId": "id77", "elementId": "id77",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -17830,7 +17840,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0, 0,
], ],
[ [
"98.58579", "92.92893",
0, 0,
], ],
], ],
@ -17842,7 +17852,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startBinding": { "startBinding": {
"elementId": "id75", "elementId": "id75",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -17850,8 +17860,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"width": "98.58579", "width": "92.92893",
"x": "0.70711", "x": "3.53553",
"y": 0, "y": 0,
} }
`; `;
@ -17913,7 +17923,7 @@ History {
"endBinding": { "endBinding": {
"elementId": "id77", "elementId": "id77",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"points": [ "points": [
[ [
@ -17921,14 +17931,14 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
"startBinding": { "startBinding": {
"elementId": "id75", "elementId": "id75",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
}, },
"inserted": { "inserted": {
@ -17939,7 +17949,7 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
@ -18189,7 +18199,7 @@ History {
"endBinding": { "endBinding": {
"elementId": "id77", "elementId": "id77",
"focus": -0, "focus": -0,
"gap": 1, "gap": 5,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -18207,7 +18217,7 @@ History {
0, 0,
], ],
[ [
100, "92.92893",
0, 0,
], ],
], ],
@ -18219,14 +18229,14 @@ History {
"startBinding": { "startBinding": {
"elementId": "id75", "elementId": "id75",
"focus": 0, "focus": 0,
"gap": 1, "gap": 5,
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": 100, "width": "92.92893",
"x": 0, "x": "3.53553",
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {

View File

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

View File

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

View File

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

View File

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

View File

@ -109,8 +109,10 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([200, 0]); expect([rectB.x, rectB.y]).toEqual([200, 0]);
expect([arrow.x, arrow.y]).toEqual([110, 50]); expect([Math.round(arrow.x), Math.round(arrow.y)]).toEqual([104, 50]);
expect([arrow.width, arrow.height]).toEqual([80, 80]); expect([Math.round(arrow.width), Math.round(arrow.height)]).toEqual([
93, 81,
]);
renderInteractiveScene.mockClear(); renderInteractiveScene.mockClear();
renderStaticScene.mockClear(); renderStaticScene.mockClear();
@ -128,8 +130,11 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]); 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()); h.elements.forEach((element) => expect(element).toMatchSnapshot());
}); });

View File

@ -3,6 +3,8 @@ import { expect } from "vitest";
import { reseed } from "@excalidraw/common"; import { reseed } from "@excalidraw/common";
import "@excalidraw/utils/test-utils";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { UI } from "./helpers/ui"; 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.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.x).toBeCloseTo(-80); expect(arrow.x).toBeCloseTo(-80);
expect(arrow.y).toBeCloseTo(50); expect(arrow.y).toBeCloseTo(50);
expect(arrow.width).toBeCloseTo(116.7, 1); expect(arrow.width).toBeCloseTo(119.6, 1);
expect(arrow.height).toBeCloseTo(0); 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.endBinding?.elementId).toEqual(ellipse.id);
expect(ellipseArrow.x).toEqual(0); expect(ellipseArrow.x).toEqual(0);
expect(ellipseArrow.y).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).toCloselyEqualPoints([
expect(ellipseArrow.points[1][1]).toBeCloseTo(125.79, 1); [0, 0],
[90.1827, 98.5896],
]);
expect(textArrow.endBinding?.elementId).toEqual(text.id); expect(textArrow.endBinding?.elementId).toEqual(text.id);
expect(textArrow.x).toEqual(360); expect(textArrow.x).toEqual(360);
expect(textArrow.y).toEqual(300); expect(textArrow.y).toEqual(300);
expect(textArrow.points[0]).toEqual([0, 0]); expect(textArrow.points[0]).toEqual([0, 0]);
expect(textArrow.points[1][0]).toBeCloseTo(-94, 0); expect(textArrow.points[1][0]).toBeCloseTo(-95, 0);
expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 0); expect(textArrow.points[1][1]).toBeCloseTo(-129.1, 0);
}); });

View File

@ -157,22 +157,13 @@ export function curveIntersectLineSegment<
return bezierEquation(c, t); return bezierEquation(c, t);
}; };
let solution = calculate(initial_guesses[0]); const solutions = [
if (solution) { calculate(initial_guesses[0]),
return [solution]; 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]); return solutions;
if (solution) {
return [solution];
}
solution = calculate(initial_guesses[2]);
if (solution) {
return [solution];
}
return [];
} }
/** /**

View File

@ -91,9 +91,10 @@ export function isPoint(p: unknown): p is LocalPoint | GlobalPoint {
export function pointsEqual<Point extends GlobalPoint | LocalPoint>( export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
a: Point, a: Point,
b: Point, b: Point,
precision: number = PRECISION,
): boolean { ): boolean {
const abs = Math.abs; 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 = ( export const round = (
value: number, value: number,
precision: number, precision: number = (Math.log(1 / PRECISION) * Math.LOG10E + 1) | 0,
func: "round" | "floor" | "ceil" = "round", func: "round" | "floor" | "ceil" = "round",
) => { ) => {
const multiplier = Math.pow(10, precision); 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[] => { export const getCurvePathOps = (shape: Drawable): Op[] => {
// NOTE (mtolmacs): Temporary fix for extremely large elements
if (!shape) {
return [];
}
for (const set of shape.sets) { for (const set of shape.sets) {
if (set.type === "path") { if (set.type === "path") {
return set.ops; return set.ops;