Compare commits

...

52 Commits

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

View File

@ -18,7 +18,7 @@ import {
} from "@excalidraw/math"; } 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,
@ -426,6 +469,7 @@ export class LinearElementEditor {
editingLinearElement: LinearElementEditor, editingLinearElement: LinearElementEditor,
appState: AppState, appState: AppState,
scene: Scene, scene: Scene,
shouldBind?: boolean,
): LinearElementEditor { ): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements(); const elements = scene.getNonDeletedElements();
@ -488,6 +532,19 @@ export class LinearElementEditor {
} }
} }
if (shouldBind) {
const element = scene.getElement(editingLinearElement.elementId);
if (isBindingElement(element) && isBindingEnabled(appState)) {
bindOrUnbindLinearElement(
element,
bindings.startBindingElement || "keep",
bindings.endBindingElement || "keep",
elementsMap,
scene,
);
}
}
return { return {
...editingLinearElement, ...editingLinearElement,
...bindings, ...bindings,

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;
@ -8956,21 +8789,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedLinearElement, this.state.selectedLinearElement,
this.state, this.state,
this.scene, this.scene,
true,
); );
const { startBindingElement, endBindingElement } =
linearElementEditor;
const element = this.scene.getElement(linearElementEditor.elementId);
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
elementsMap,
this.scene,
);
}
if (linearElementEditor !== this.state.selectedLinearElement) { if (linearElementEditor !== this.state.selectedLinearElement) {
this.setState({ this.setState({
selectedLinearElement: { selectedLinearElement: {
@ -9071,66 +8892,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 +9966,6 @@ class App extends React.Component<AppProps, AppState> {
}); });
}; };
private maybeSuggestBindingsForLinearElementAtCoords = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
/** scene coords */
pointerCoords: {
x: number;
y: number;
}[],
// During line creation the start binding hasn't been written yet
// into `linearElement`
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
): void => {
if (!pointerCoords.length) {
return;
}
const suggestedBindings = pointerCoords.reduce(
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
if (
hoveredBindableElement != null &&
!isLinearElementSimpleAndAlreadyBound(
linearElement,
oppositeBindingBoundElement?.id,
hoveredBindableElement,
)
) {
acc.push(hoveredBindableElement);
}
return acc;
},
[],
);
this.setState({ suggestedBindings });
};
private clearSelection(hitElement: ExcalidrawElement | null): void { private clearSelection(hitElement: ExcalidrawElement | null): void {
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds({}, prevState), selectedElementIds: makeNextSelectedElementIds({}, prevState),
@ -10725,12 +10452,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": {
@ -7490,7 +7500,7 @@ History {
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of elements 1`] = `1`; exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of elements 1`] = `1`;
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `10`; exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `11`;
exports[`history > multiplayer undo/redo > should iterate through the history when when element change relates to remotely deleted element > [end of test] appState 1`] = ` exports[`history > multiplayer undo/redo > should iterate through the history when when element change relates to remotely deleted element > [end of test] appState 1`] = `
{ {
@ -10561,7 +10571,7 @@ History {
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`; exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`;
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `15`; exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `16`;
exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] appState 1`] = ` exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] appState 1`] = `
{ {
@ -15161,7 +15171,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endBinding": { "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": {
@ -20188,4 +20198,4 @@ History {
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of elements 1`] = `1`; exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of elements 1`] = `1`;
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `21`; exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `22`;

View File

@ -190,13 +190,13 @@ exports[`move element > rectangles with binding arrow 7`] = `
"endArrowhead": "arrow", "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

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

View File

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

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

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

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;