Compare commits
28 Commits
master
...
mtolmacs/f
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5af4500bbc | ||
![]() |
ff57dd60d8 | ||
![]() |
05d8ce55e4 | ||
![]() |
4184103eb0 | ||
![]() |
22696dc8f2 | ||
![]() |
8d28b47989 | ||
![]() |
1eecd9a56b | ||
![]() |
55ba55fbbb | ||
![]() |
b6dea75d57 | ||
![]() |
979fff566c | ||
![]() |
b33cc74183 | ||
![]() |
5947af5b50 | ||
![]() |
40f25180ea | ||
![]() |
11fe608f9a | ||
![]() |
ad8220c529 | ||
![]() |
ae0fdf2d21 | ||
![]() |
2cf53200ac | ||
![]() |
df1f89efcd | ||
![]() |
8e4fd83f5c | ||
![]() |
d3a41cb453 | ||
![]() |
3fa818b0ce | ||
![]() |
2af0336466 | ||
![]() |
fe58962bfd | ||
![]() |
154855916b | ||
![]() |
8de0a037fd | ||
![]() |
b2799d0a15 | ||
![]() |
fbc5e4a03d | ||
![]() |
ca5e9c3ad9 |
@ -1,7 +1,13 @@
|
||||
import { average } from "@excalidraw/math";
|
||||
import {
|
||||
average,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
pointTranslate,
|
||||
vector,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
} from "@excalidraw/element/types";
|
||||
@ -552,9 +558,6 @@ export const isTransparent = (color: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
|
||||
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined]
|
||||
? (value?: MaybePromise<Awaited<T>>) => void
|
||||
@ -1200,3 +1203,6 @@ export const escapeDoubleQuotes = (str: string) => {
|
||||
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
export const toLocalPoint = (p: GlobalPoint, element: ExcalidrawElement) =>
|
||||
pointTranslate<GlobalPoint, LocalPoint>(p, vector(-element.x, -element.y));
|
||||
|
@ -1,7 +1,6 @@
|
||||
import {
|
||||
KEYS,
|
||||
arrayToMap,
|
||||
isBindingFallthroughEnabled,
|
||||
tupleToCoors,
|
||||
invariant,
|
||||
isDevEnv,
|
||||
@ -26,7 +25,7 @@ import {
|
||||
PRECISION,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { isPointOnShape } from "@excalidraw/utils/collision";
|
||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||
|
||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
@ -80,11 +79,10 @@ import type {
|
||||
NonDeletedSceneElementsMap,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawArrowElement,
|
||||
OrderedExcalidrawElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
FixedPoint,
|
||||
SceneElementsMap,
|
||||
FixedPointBinding,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
|
||||
export type SuggestedBinding =
|
||||
@ -107,6 +105,7 @@ export const isBindingEnabled = (appState: AppState): boolean => {
|
||||
return appState.isBindingEnabled;
|
||||
};
|
||||
|
||||
export const INSIDE_BINDING_BAND_PERCENT = 0.1;
|
||||
export const FIXED_BINDING_DISTANCE = 5;
|
||||
export const BINDING_HIGHLIGHT_THICKNESS = 10;
|
||||
export const BINDING_HIGHLIGHT_OFFSET = 4;
|
||||
@ -427,7 +426,7 @@ export const getSuggestedBindingsForArrows = (
|
||||
export const maybeBindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
pointerCoords: { x: number; y: number },
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
): void => {
|
||||
@ -441,7 +440,20 @@ export const maybeBindLinearElement = (
|
||||
}
|
||||
|
||||
const hoveredElement = getHoveredElementForBinding(
|
||||
pointerCoords,
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
startOrEnd ? 0 : -1,
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
startOrEnd ? -1 : 0,
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
@ -462,26 +474,6 @@ export const maybeBindLinearElement = (
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePointBinding = (
|
||||
binding: { focus: number; gap: number },
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
) => {
|
||||
let gap = binding.gap;
|
||||
const maxGap = maxBindingGap(
|
||||
hoveredElement,
|
||||
hoveredElement.width,
|
||||
hoveredElement.height,
|
||||
);
|
||||
|
||||
if (gap > maxGap) {
|
||||
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
|
||||
}
|
||||
return {
|
||||
...binding,
|
||||
gap,
|
||||
};
|
||||
};
|
||||
|
||||
export const bindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
@ -492,17 +484,25 @@ export const bindLinearElement = (
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = startOrEnd === "start" ? -1 : 1;
|
||||
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
||||
const adjacentPointIndex = edgePointIndex - direction;
|
||||
|
||||
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
edgePointIndex,
|
||||
elementsMap,
|
||||
);
|
||||
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
adjacentPointIndex,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
let binding: PointBinding | FixedPointBinding = {
|
||||
elementId: hoveredElement.id,
|
||||
...normalizePointBinding(
|
||||
calculateFocusAndGap(
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
),
|
||||
hoveredElement,
|
||||
),
|
||||
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
|
||||
gap: FIXED_BINDING_DISTANCE,
|
||||
};
|
||||
|
||||
if (isElbowArrow(linearElement)) {
|
||||
@ -580,6 +580,10 @@ export const getHoveredElementForBinding = (
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
otherPointerCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
@ -587,59 +591,89 @@ export const getHoveredElementForBinding = (
|
||||
considerAllElements?: boolean,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
if (considerAllElements) {
|
||||
let cullRest = false;
|
||||
const candidateElements = getAllElementsAtPositionForBinding(
|
||||
elements,
|
||||
(element) =>
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(
|
||||
element,
|
||||
pointerCoords,
|
||||
elementsMap,
|
||||
zoom,
|
||||
(fullShape ||
|
||||
!isBindingFallthroughEnabled(
|
||||
element as ExcalidrawBindableElement,
|
||||
)) &&
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
!isFrameLikeElement(element),
|
||||
),
|
||||
).filter((element) => {
|
||||
if (cullRest) {
|
||||
return false;
|
||||
}
|
||||
const otherCandidateElement =
|
||||
getAllElementsAtPositionForBinding(
|
||||
elements,
|
||||
(element) =>
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(
|
||||
element,
|
||||
otherPointerCoords,
|
||||
elementsMap,
|
||||
zoom,
|
||||
fullShape ||
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
!isFrameLikeElement(element),
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||
element != null,
|
||||
)
|
||||
// Prefer the shape with the border being tested (if any)
|
||||
.filter(
|
||||
(element, _, arr) =>
|
||||
arr.length <= 1 ||
|
||||
bindingBorderTest(
|
||||
element as NonDeleted<ExcalidrawBindableElement>,
|
||||
otherPointerCoords,
|
||||
elementsMap,
|
||||
zoom,
|
||||
false,
|
||||
),
|
||||
)
|
||||
// Prefer smaller bindables to be consisent with the check for the other
|
||||
// point
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||
)
|
||||
.pop() ?? null;
|
||||
|
||||
if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) {
|
||||
cullRest = true;
|
||||
}
|
||||
const candidateElement =
|
||||
getAllElementsAtPositionForBinding(
|
||||
elements,
|
||||
(element) =>
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(
|
||||
element,
|
||||
pointerCoords,
|
||||
elementsMap,
|
||||
zoom,
|
||||
fullShape ||
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
!isFrameLikeElement(element),
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||
element != null,
|
||||
) // Prefer the shape with the border being tested (if any)
|
||||
.filter(
|
||||
(element, _, arr) =>
|
||||
arr.length <= 1 ||
|
||||
bindingBorderTest(
|
||||
element as NonDeleted<ExcalidrawBindableElement>,
|
||||
pointerCoords,
|
||||
elementsMap,
|
||||
zoom,
|
||||
false,
|
||||
),
|
||||
)
|
||||
// Prefer smaller bindables
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||
)
|
||||
.pop() ?? null;
|
||||
|
||||
return true;
|
||||
}) as NonDeleted<ExcalidrawBindableElement>[] | null;
|
||||
|
||||
// Return early if there are no candidates or just one candidate
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
if (otherCandidateElement === candidateElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
|
||||
}
|
||||
|
||||
// Prefer the shape with the border being tested (if any)
|
||||
const borderTestElements = candidateElements.filter((element) =>
|
||||
bindingBorderTest(element, pointerCoords, elementsMap, zoom, false),
|
||||
);
|
||||
if (borderTestElements.length === 1) {
|
||||
return borderTestElements[0];
|
||||
}
|
||||
|
||||
// Prefer smaller shapes
|
||||
return candidateElements
|
||||
.sort(
|
||||
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||
)
|
||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||
return candidateElement;
|
||||
}
|
||||
|
||||
const hoveredElement = getElementAtPositionForBinding(
|
||||
@ -653,8 +687,7 @@ export const getHoveredElementForBinding = (
|
||||
zoom,
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
(fullShape || !isBindingFallthroughEnabled(element)) &&
|
||||
!isFrameLikeElement(element),
|
||||
fullShape || !isFrameLikeElement(element),
|
||||
),
|
||||
);
|
||||
|
||||
@ -705,33 +738,6 @@ const getAllElementsAtPositionForBinding = (
|
||||
return elementsAtPosition;
|
||||
};
|
||||
|
||||
const calculateFocusAndGap = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
): { focus: number; gap: number } => {
|
||||
const direction = startOrEnd === "start" ? -1 : 1;
|
||||
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
||||
const adjacentPointIndex = edgePointIndex - direction;
|
||||
|
||||
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
edgePointIndex,
|
||||
elementsMap,
|
||||
);
|
||||
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
adjacentPointIndex,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return {
|
||||
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
|
||||
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
|
||||
};
|
||||
};
|
||||
|
||||
// Supports translating, rotating and scaling `changedElement` with bound
|
||||
// linear elements.
|
||||
// Because scaling involves moving the focus points as well, it is
|
||||
@ -742,11 +748,9 @@ export const updateBoundElements = (
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
options?: {
|
||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||
newSize?: { width: number; height: number };
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
},
|
||||
) => {
|
||||
const { newSize, simultaneouslyUpdated } = options ?? {};
|
||||
const { simultaneouslyUpdated } = options ?? {};
|
||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||
simultaneouslyUpdated,
|
||||
);
|
||||
@ -780,22 +784,13 @@ export const updateBoundElements = (
|
||||
endBounds = getElementBounds(endBindingElement, elementsMap);
|
||||
}
|
||||
|
||||
const bindings = {
|
||||
startBinding: maybeCalculateNewGapWhenScaling(
|
||||
changedElement,
|
||||
element.startBinding,
|
||||
newSize,
|
||||
),
|
||||
endBinding: maybeCalculateNewGapWhenScaling(
|
||||
changedElement,
|
||||
element.endBinding,
|
||||
newSize,
|
||||
),
|
||||
};
|
||||
|
||||
// `linearElement` is being moved/scaled already, just update the binding
|
||||
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
||||
mutateElement(element, bindings, true);
|
||||
mutateElement(
|
||||
element,
|
||||
{ startBinding: element.startBinding, endBinding: element.endBinding },
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -817,7 +812,9 @@ export const updateBoundElements = (
|
||||
const point = updateBoundPoint(
|
||||
element,
|
||||
bindingProp,
|
||||
bindings[bindingProp],
|
||||
bindingProp === "startBinding"
|
||||
? element.startBinding
|
||||
: element.endBinding,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
@ -847,10 +844,10 @@ export const updateBoundElements = (
|
||||
updates,
|
||||
{
|
||||
...(changedElement.id === element.startBinding?.elementId
|
||||
? { startBinding: bindings.startBinding }
|
||||
? { startBinding: element.startBinding }
|
||||
: {}),
|
||||
...(changedElement.id === element.endBinding?.elementId
|
||||
? { endBinding: bindings.endBinding }
|
||||
? { endBinding: element.endBinding }
|
||||
: {}),
|
||||
},
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
@ -884,7 +881,6 @@ export const getHeadingForElbowArrowSnap = (
|
||||
otherPoint: Readonly<GlobalPoint>,
|
||||
bindableElement: ExcalidrawBindableElement | undefined | null,
|
||||
aabb: Bounds | undefined | null,
|
||||
elementsMap: ElementsMap,
|
||||
origPoint: GlobalPoint,
|
||||
zoom?: AppState["zoom"],
|
||||
): Heading => {
|
||||
@ -894,12 +890,7 @@ export const getHeadingForElbowArrowSnap = (
|
||||
return otherPointHeading;
|
||||
}
|
||||
|
||||
const distance = getDistanceForBinding(
|
||||
origPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
|
||||
|
||||
if (!distance) {
|
||||
return vectorToHeading(
|
||||
@ -919,7 +910,6 @@ export const getHeadingForElbowArrowSnap = (
|
||||
const getDistanceForBinding = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
const distance = distanceToBindableElement(bindableElement, point);
|
||||
@ -934,40 +924,47 @@ const getDistanceForBinding = (
|
||||
};
|
||||
|
||||
export const bindPointToSnapToElementOutline = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
linearElement: ExcalidrawLinearElement,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint => {
|
||||
if (isDevEnv() || isTestEnv()) {
|
||||
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
|
||||
invariant(
|
||||
linearElement.points.length > 0,
|
||||
"Arrow should have at least 1 point",
|
||||
);
|
||||
}
|
||||
|
||||
const elbowed = isElbowArrow(linearElement);
|
||||
const aabb = aabbForElement(bindableElement);
|
||||
const localP =
|
||||
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
|
||||
const globalP = pointFrom<GlobalPoint>(
|
||||
arrow.x + localP[0],
|
||||
arrow.y + localP[1],
|
||||
);
|
||||
const edgePoint = isRectanguloidElement(bindableElement)
|
||||
? avoidRectangularCorner(bindableElement, globalP)
|
||||
: globalP;
|
||||
const elbowed = isElbowArrow(arrow);
|
||||
const center = getCenterForBounds(aabb);
|
||||
const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2;
|
||||
const adjacentPoint = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
arrow.x + arrow.points[adjacentPointIdx][0],
|
||||
arrow.y + arrow.points[adjacentPointIdx][1],
|
||||
),
|
||||
center,
|
||||
arrow.angle ?? 0,
|
||||
|
||||
const pointIdx = startOrEnd === "start" ? 0 : linearElement.points.length - 1;
|
||||
const p = pointFrom<GlobalPoint>(
|
||||
linearElement.x + linearElement.points[pointIdx][0],
|
||||
linearElement.y + linearElement.points[pointIdx][1],
|
||||
);
|
||||
const edgePoint = avoidRectangularCorner(bindableElement, p);
|
||||
|
||||
const adjacentPointIdx =
|
||||
startOrEnd === "start" ? 1 : linearElement.points.length - 2;
|
||||
const adjacentPoint =
|
||||
linearElement.points.length === 1
|
||||
? center
|
||||
: pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
linearElement.x + linearElement.points[adjacentPointIdx][0],
|
||||
linearElement.y + linearElement.points[adjacentPointIdx][1],
|
||||
),
|
||||
center,
|
||||
linearElement.angle ?? 0,
|
||||
);
|
||||
|
||||
let intersection: GlobalPoint | null = null;
|
||||
if (elbowed) {
|
||||
const isHorizontal = headingIsHorizontal(
|
||||
headingForPointFromElement(bindableElement, aabb, globalP),
|
||||
headingForPointFromElement(bindableElement, aabb, p),
|
||||
);
|
||||
const otherPoint = pointFrom<GlobalPoint>(
|
||||
isHorizontal ? center[0] : edgePoint[0],
|
||||
@ -1032,6 +1029,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;
|
||||
};
|
||||
|
||||
@ -1039,6 +1058,10 @@ export const avoidRectangularCorner = (
|
||||
element: ExcalidrawBindableElement,
|
||||
p: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
if (!isRectanguloidElement(element)) {
|
||||
return p;
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
@ -1199,6 +1222,50 @@ export const snapToMid = (
|
||||
return p;
|
||||
};
|
||||
|
||||
export const getOutlineAvoidingPoint = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
scene: Scene,
|
||||
zoom: AppState["zoom"],
|
||||
fallback?: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
const coords = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
startOrEnd ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
const hoveredElement = getHoveredElementForBinding(
|
||||
tupleToCoors(coords),
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
startOrEnd ? -1 : 0,
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
elements,
|
||||
elementsMap,
|
||||
zoom,
|
||||
true,
|
||||
isElbowArrow(element),
|
||||
);
|
||||
|
||||
const pointIndex = startOrEnd === "start" ? 0 : element.points.length - 1;
|
||||
|
||||
if (hoveredElement) {
|
||||
return bindPointToSnapToElementOutline(
|
||||
element,
|
||||
hoveredElement,
|
||||
pointIndex === 0 ? "start" : "end",
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
|
||||
return fallback ?? coords;
|
||||
};
|
||||
|
||||
const updateBoundPoint = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "startBinding" | "endBinding",
|
||||
@ -1262,66 +1329,58 @@ const updateBoundPoint = (
|
||||
|
||||
let newEdgePoint: GlobalPoint;
|
||||
|
||||
// The linear element was not originally pointing inside the bound shape,
|
||||
// we can point directly at the focus point
|
||||
if (binding.gap === 0) {
|
||||
const edgePointAbsolute =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
edgePointIndex,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + bindableElement.width / 2,
|
||||
bindableElement.y + bindableElement.height / 2,
|
||||
);
|
||||
const interceptorLength =
|
||||
pointDistance(adjacentPoint, edgePointAbsolute) +
|
||||
pointDistance(adjacentPoint, center) +
|
||||
Math.max(bindableElement.width, bindableElement.height) * 2;
|
||||
const intersections = [
|
||||
...intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
lineSegment<GlobalPoint>(
|
||||
adjacentPoint,
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
|
||||
interceptorLength,
|
||||
),
|
||||
adjacentPoint,
|
||||
),
|
||||
),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
).sort(
|
||||
(g, h) =>
|
||||
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
|
||||
),
|
||||
// Fallback when arrow doesn't point to the shape
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
|
||||
pointDistance(adjacentPoint, edgePointAbsolute),
|
||||
),
|
||||
adjacentPoint,
|
||||
),
|
||||
];
|
||||
|
||||
if (intersections.length > 1) {
|
||||
// The adjacent point is outside the shape (+ gap)
|
||||
newEdgePoint = intersections[0];
|
||||
} else if (intersections.length === 1) {
|
||||
// The adjacent point is inside the shape (+ gap)
|
||||
newEdgePoint = focusPointAbsolute;
|
||||
} else {
|
||||
const edgePointAbsolute =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
edgePointIndex,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + bindableElement.width / 2,
|
||||
bindableElement.y + bindableElement.height / 2,
|
||||
);
|
||||
const interceptorLength =
|
||||
pointDistance(adjacentPoint, edgePointAbsolute) +
|
||||
pointDistance(adjacentPoint, center) +
|
||||
Math.max(bindableElement.width, bindableElement.height) * 2;
|
||||
const intersections = [
|
||||
...intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
lineSegment<GlobalPoint>(
|
||||
adjacentPoint,
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(focusPointAbsolute, adjacentPoint),
|
||||
),
|
||||
interceptorLength,
|
||||
),
|
||||
adjacentPoint,
|
||||
),
|
||||
),
|
||||
binding.gap,
|
||||
).sort(
|
||||
(g, h) =>
|
||||
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
|
||||
),
|
||||
// Fallback when arrow doesn't point to the shape
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
|
||||
pointDistance(adjacentPoint, edgePointAbsolute),
|
||||
),
|
||||
adjacentPoint,
|
||||
),
|
||||
];
|
||||
|
||||
if (intersections.length > 1) {
|
||||
// The adjacent point is outside the shape (+ gap)
|
||||
newEdgePoint = intersections[0];
|
||||
} else if (intersections.length === 1) {
|
||||
// The adjacent point is inside the shape (+ gap)
|
||||
newEdgePoint = focusPointAbsolute;
|
||||
} else {
|
||||
// Shouldn't happend, but just in case
|
||||
newEdgePoint = edgePointAbsolute;
|
||||
}
|
||||
// Shouldn't happend, but just in case
|
||||
newEdgePoint = edgePointAbsolute;
|
||||
}
|
||||
|
||||
return LinearElementEditor.pointFromAbsoluteCoords(
|
||||
@ -1332,7 +1391,7 @@ const updateBoundPoint = (
|
||||
};
|
||||
|
||||
export const calculateFixedPointForElbowArrowBinding = (
|
||||
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
|
||||
linearElement: NonDeleted<ExcalidrawArrowElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
@ -1347,6 +1406,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
);
|
||||
const globalMidPoint = pointFrom(
|
||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
@ -1368,28 +1428,6 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
};
|
||||
};
|
||||
|
||||
const maybeCalculateNewGapWhenScaling = (
|
||||
changedElement: ExcalidrawBindableElement,
|
||||
currentBinding: PointBinding | null | undefined,
|
||||
newSize: { width: number; height: number } | undefined,
|
||||
): PointBinding | null | undefined => {
|
||||
if (currentBinding == null || newSize == null) {
|
||||
return currentBinding;
|
||||
}
|
||||
const { width: newWidth, height: newHeight } = newSize;
|
||||
const { width, height } = changedElement;
|
||||
const newGap = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
maxBindingGap(changedElement, newWidth, newHeight),
|
||||
currentBinding.gap *
|
||||
(newWidth < newHeight ? newWidth / width : newHeight / height),
|
||||
),
|
||||
);
|
||||
|
||||
return { ...currentBinding, gap: newGap };
|
||||
};
|
||||
|
||||
const getElligibleElementForBindingElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
|
@ -1254,6 +1254,7 @@ const getElbowArrowData = (
|
||||
"start",
|
||||
arrow.startBinding?.fixedPoint,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
hoveredStartElement,
|
||||
options?.isDragging,
|
||||
);
|
||||
@ -1267,6 +1268,7 @@ const getElbowArrowData = (
|
||||
"end",
|
||||
arrow.endBinding?.fixedPoint,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
hoveredEndElement,
|
||||
options?.isDragging,
|
||||
);
|
||||
@ -2212,6 +2214,7 @@ const getGlobalPoint = (
|
||||
startOrEnd: "start" | "end",
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
isDragging?: boolean,
|
||||
): GlobalPoint => {
|
||||
@ -2221,6 +2224,7 @@ const getGlobalPoint = (
|
||||
arrow,
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return snapToMid(element, snapPoint);
|
||||
@ -2240,7 +2244,7 @@ const getGlobalPoint = (
|
||||
distanceToBindableElement(element, fixedGlobalPoint) -
|
||||
FIXED_BINDING_DISTANCE,
|
||||
) > 0.01
|
||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
|
||||
: fixedGlobalPoint;
|
||||
}
|
||||
|
||||
@ -2268,7 +2272,6 @@ const getBindPointHeading = (
|
||||
number,
|
||||
],
|
||||
),
|
||||
elementsMap,
|
||||
origPoint,
|
||||
);
|
||||
|
||||
|
@ -42,6 +42,7 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
import {
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
getOutlineAvoidingPoint,
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
import {
|
||||
@ -251,27 +252,28 @@ export class LinearElementEditor {
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
): LinearElementEditor | null {
|
||||
if (!linearElementEditor) {
|
||||
return null;
|
||||
}
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elbowed = isElbowArrow(element);
|
||||
|
||||
if (
|
||||
isElbowArrow(element) &&
|
||||
elbowed &&
|
||||
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
|
||||
linearElementEditor.pointerDownState.lastClickedPoint !== 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedPointsIndices = isElbowArrow(element)
|
||||
const selectedPointsIndices = elbowed
|
||||
? [
|
||||
!!linearElementEditor.selectedPointsIndices?.includes(0)
|
||||
? 0
|
||||
@ -281,7 +283,7 @@ export class LinearElementEditor {
|
||||
: undefined,
|
||||
].filter((idx): idx is number => idx !== undefined)
|
||||
: linearElementEditor.selectedPointsIndices;
|
||||
const lastClickedPoint = isElbowArrow(element)
|
||||
const lastClickedPoint = elbowed
|
||||
? linearElementEditor.pointerDownState.lastClickedPoint > 0
|
||||
? element.points.length - 1
|
||||
: 0
|
||||
@ -333,19 +335,43 @@ export class LinearElementEditor {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition: LocalPoint =
|
||||
pointIndex === lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
)
|
||||
: pointFrom(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
);
|
||||
let newPointPosition = pointFrom<LocalPoint>(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
);
|
||||
|
||||
// Check if point dragging is happening
|
||||
if (pointIndex === lastClickedPoint) {
|
||||
let globalNewPointPosition = pointFrom<GlobalPoint>(
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
);
|
||||
|
||||
if (
|
||||
pointIndex === 0 ||
|
||||
pointIndex === element.points.length - 1
|
||||
) {
|
||||
globalNewPointPosition = getOutlineAvoidingPoint(
|
||||
element,
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.points[pointIndex][0] + deltaX,
|
||||
element.y + element.points[pointIndex][1] + deltaY,
|
||||
),
|
||||
pointIndex,
|
||||
app.scene,
|
||||
app.state.zoom,
|
||||
);
|
||||
}
|
||||
|
||||
newPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
globalNewPointPosition[0],
|
||||
globalNewPointPosition[1],
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
index: pointIndex,
|
||||
point: newPointPosition,
|
||||
|
@ -98,28 +98,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => {
|
||||
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
||||
// element position or sizing
|
||||
if (
|
||||
x < -1e6 ||
|
||||
x > 1e6 ||
|
||||
y < -1e6 ||
|
||||
y > 1e6 ||
|
||||
width < -1e6 ||
|
||||
width > 1e6 ||
|
||||
height < -1e6 ||
|
||||
height > 1e6
|
||||
) {
|
||||
console.error("New element size or position is too large", {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
// @ts-ignore
|
||||
points: rest.points,
|
||||
});
|
||||
}
|
||||
|
||||
// assign type to guard against excess properties
|
||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||
id: rest.id || randomId(),
|
||||
|
@ -969,10 +969,7 @@ export const resizeSingleElement = (
|
||||
|
||||
mutateElement(latestElement, updates, shouldInformMutation);
|
||||
|
||||
updateBoundElements(latestElement, elementsMap as SceneElementsMap, {
|
||||
// TODO: confirm with MARK if this actually makes sense
|
||||
newSize: { width: nextWidth, height: nextHeight },
|
||||
});
|
||||
updateBoundElements(latestElement, elementsMap as SceneElementsMap);
|
||||
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
mutateElement(boundTextElement, {
|
||||
@ -1525,7 +1522,7 @@ export const resizeMultipleElements = (
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
const { angle } = update;
|
||||
|
||||
mutateElement(element, update, false, {
|
||||
// needed for the fixed binding point udpate to take effect
|
||||
@ -1534,7 +1531,6 @@ export const resizeMultipleElements = (
|
||||
|
||||
updateBoundElements(element, elementsMap as SceneElementsMap, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
newSize: { width, height },
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
@ -18,7 +18,9 @@ const mouse = new Pointer("mouse");
|
||||
|
||||
describe("element binding", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
mouse.reset();
|
||||
});
|
||||
|
||||
it("should create valid binding if duplicate start/end points", async () => {
|
||||
@ -89,46 +91,55 @@ describe("element binding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
//@TODO fix the test with rotation
|
||||
it.skip("rotation of arrow should rebind both ends", () => {
|
||||
const rectLeft = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const rectRight = UI.createElement("rectangle", {
|
||||
x: 400,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
y: 250,
|
||||
width: 180,
|
||||
height: 1,
|
||||
});
|
||||
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||
// UX RATIONALE: We are not aware of any use-case where the user would want to
|
||||
// have the arrow rebind after rotation but not when the arrow shaft is
|
||||
// dragged so either the start or the end point is in the binding range of a
|
||||
// bindable element. So to remain consistent, we only "rebind" if at the end
|
||||
// of the rotation the original binding would remain the same (i.e. like we
|
||||
// would've evaluated binding only at the end of the operation).
|
||||
it(
|
||||
"rotation of arrow should not rebind on both ends if rotated enough to" +
|
||||
" not be in the binding range of the original elements",
|
||||
() => {
|
||||
const rectLeft = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const rectRight = UI.createElement("rectangle", {
|
||||
x: 400,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
y: 250,
|
||||
width: 180,
|
||||
height: 1,
|
||||
});
|
||||
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||
|
||||
const rotation = getTransformHandles(
|
||||
arrow,
|
||||
h.state.zoom,
|
||||
arrayToMap(h.elements),
|
||||
"mouse",
|
||||
).rotation!;
|
||||
const rotationHandleX = rotation[0] + rotation[2] / 2;
|
||||
const rotationHandleY = rotation[1] + rotation[3] / 2;
|
||||
mouse.down(rotationHandleX, rotationHandleY);
|
||||
mouse.move(300, 400);
|
||||
mouse.up();
|
||||
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
|
||||
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
|
||||
expect(arrow.startBinding?.elementId).toBe(rectRight.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectLeft.id);
|
||||
});
|
||||
const rotation = getTransformHandles(
|
||||
arrow,
|
||||
h.state.zoom,
|
||||
arrayToMap(h.elements),
|
||||
"mouse",
|
||||
).rotation!;
|
||||
const rotationHandleX = rotation[0] + rotation[2] / 2;
|
||||
const rotationHandleY = rotation[1] + rotation[3] / 2;
|
||||
mouse.down(rotationHandleX, rotationHandleY);
|
||||
mouse.move(300, 400);
|
||||
mouse.up();
|
||||
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
|
||||
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
|
||||
expect(arrow.startBinding).toBe(null);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
},
|
||||
);
|
||||
|
||||
// TODO fix & reenable once we rewrite tests to work with concurrency
|
||||
it.skip(
|
||||
it(
|
||||
"editing arrow and moving its head to bind it to element A, finalizing the" +
|
||||
"editing by clicking on element A should end up selecting A",
|
||||
async () => {
|
||||
@ -142,7 +153,10 @@ describe("element binding", () => {
|
||||
mouse.up(0, 80);
|
||||
|
||||
// Edit arrow with multi-point
|
||||
mouse.doubleClick();
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.doubleClick();
|
||||
});
|
||||
|
||||
// move arrow head
|
||||
mouse.down();
|
||||
mouse.up(0, 10);
|
||||
@ -152,11 +166,7 @@ describe("element binding", () => {
|
||||
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
||||
mouse.reset();
|
||||
expect(h.state.editingLinearElement).not.toBe(null);
|
||||
mouse.down(0, 0);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(h.state.editingLinearElement).toBe(null);
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
mouse.up();
|
||||
mouse.click();
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
},
|
||||
);
|
||||
@ -187,12 +197,24 @@ describe("element binding", () => {
|
||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||
expect(API.getSelectedElement().type).toBe("arrow");
|
||||
|
||||
// Sever connection
|
||||
expect(API.getSelectedElement().type).toBe("arrow");
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
// We have to move a significant distance to get out of the binding zone
|
||||
Array.from({ length: 10 }).forEach(() => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
});
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
// We have to move a significant distance to return to the binding
|
||||
Array.from({ length: 10 }).forEach(() => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
});
|
||||
// We are back in the binding zone but we shouldn't rebind
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
@ -481,4 +503,82 @@ 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",
|
||||
() => {
|
||||
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(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[95, 95],
|
||||
]);
|
||||
|
||||
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(arrow2.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[95, 95],
|
||||
]);
|
||||
|
||||
UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const arrow3 = UI.createElement("arrow", {
|
||||
x: 5,
|
||||
y: 5,
|
||||
height: 95,
|
||||
width: 95,
|
||||
elbowed: true,
|
||||
});
|
||||
|
||||
expect(arrow3.startBinding).toBe(null);
|
||||
expect(arrow3.endBinding).toBe(null);
|
||||
expect(arrow3.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[45, 45],
|
||||
[95, 95],
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
@ -10,11 +10,14 @@ import {
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
|
||||
import {
|
||||
actionDuplicateSelection,
|
||||
actionSelectAll,
|
||||
} from "@excalidraw/excalidraw/actions";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
|
||||
import {
|
||||
act,
|
||||
@ -28,7 +31,10 @@ import type { LocalPoint } from "@excalidraw/math";
|
||||
import { mutateElement } from "../src/mutateElement";
|
||||
import { duplicateElement, duplicateElements } from "../src/duplicate";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "../src/types";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawLinearElement,
|
||||
} from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
@ -408,6 +414,122 @@ describe("duplicating multiple elements", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("elbow arrow duplication", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionSelectAll);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(6);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[2] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
});
|
||||
|
||||
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(4);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
[90, 100],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplication z-order", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
@ -3,14 +3,11 @@ import { pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
||||
|
||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
|
||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
queryByTestId,
|
||||
@ -301,114 +298,4 @@ describe("elbow arrow ui", () => {
|
||||
[103, 165],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionSelectAll);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(6);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[2] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
});
|
||||
|
||||
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(4);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
[90, 100],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -15,6 +15,8 @@ import {
|
||||
unmountComponent,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { isLinearElement } from "../src/typeChecks";
|
||||
@ -195,7 +197,7 @@ describe("generic element", () => {
|
||||
UI.resize(rectangle, "w", [50, 0]);
|
||||
|
||||
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(81, 0);
|
||||
});
|
||||
|
||||
it("resizes with a label", async () => {
|
||||
@ -826,8 +828,9 @@ describe("image element", () => {
|
||||
UI.resize(image, "nw", [50, 20]);
|
||||
|
||||
expect(arrow.endBinding?.elementId).toEqual(image.id);
|
||||
expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
|
||||
30 + imageWidth * scale,
|
||||
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(
|
||||
30 + imageWidth * scale + 1,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@ -1003,14 +1006,14 @@ describe("multiple selection", () => {
|
||||
size: 100,
|
||||
});
|
||||
const leftBoundArrow = UI.createElement("arrow", {
|
||||
x: -110,
|
||||
x: -100 - FIXED_BINDING_DISTANCE,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const rightBoundArrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
x: 200 + FIXED_BINDING_DISTANCE,
|
||||
y: 50,
|
||||
width: -100,
|
||||
height: 0,
|
||||
@ -1031,27 +1034,29 @@ describe("multiple selection", () => {
|
||||
shift: true,
|
||||
});
|
||||
|
||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||
expect(leftBoundArrow.x).toBeCloseTo(-100 - FIXED_BINDING_DISTANCE);
|
||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(146 - FIXED_BINDING_DISTANCE, 0);
|
||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||
expect(leftBoundArrow.angle).toEqual(0);
|
||||
expect(leftBoundArrow.startBinding).toBeNull();
|
||||
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
|
||||
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(5);
|
||||
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
||||
leftArrowBinding.elementId,
|
||||
);
|
||||
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
||||
|
||||
expect(rightBoundArrow.x).toBeCloseTo(210);
|
||||
expect(rightBoundArrow.x).toBeCloseTo(210 - FIXED_BINDING_DISTANCE);
|
||||
expect(rightBoundArrow.y).toBeCloseTo(
|
||||
(selectionHeight - 50) * (1 - scale) + 50,
|
||||
0,
|
||||
);
|
||||
expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
|
||||
//console.log(JSON.stringify(h.elements));
|
||||
expect(rightBoundArrow.width).toBeCloseTo(100 * scale + 1, 0);
|
||||
expect(rightBoundArrow.height).toBeCloseTo(0);
|
||||
expect(rightBoundArrow.angle).toEqual(0);
|
||||
expect(rightBoundArrow.startBinding).toBeNull();
|
||||
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
|
||||
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(FIXED_BINDING_DISTANCE);
|
||||
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
||||
rightArrowBinding.elementId,
|
||||
);
|
||||
@ -1338,8 +1343,8 @@ describe("multiple selection", () => {
|
||||
|
||||
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
|
||||
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
|
||||
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX);
|
||||
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY);
|
||||
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX - 2, 0);
|
||||
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY + 2, 0);
|
||||
|
||||
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
|
||||
boundArrow.x + boundArrow.points[1][0] / 2,
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { type GlobalPoint, pointFrom } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
} from "@excalidraw/element/binding";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
import {
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
@ -91,10 +93,26 @@ export const actionFinalize = register({
|
||||
multiPointElement.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points, lastCommittedPoint } = multiPointElement;
|
||||
const { x: rx, y: ry, points, lastCommittedPoint } = multiPointElement;
|
||||
const lastGlobalPoint = pointFrom<GlobalPoint>(
|
||||
rx + points[points.length - 1][0],
|
||||
ry + points[points.length - 1][1],
|
||||
);
|
||||
const hoveredElementForBinding = getHoveredElementForBinding(
|
||||
{
|
||||
x: lastGlobalPoint[0],
|
||||
y: lastGlobalPoint[1],
|
||||
},
|
||||
elements,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
true,
|
||||
isElbowArrow(multiPointElement),
|
||||
);
|
||||
if (
|
||||
!lastCommittedPoint ||
|
||||
points[points.length - 1] !== lastCommittedPoint
|
||||
!hoveredElementForBinding &&
|
||||
(!lastCommittedPoint ||
|
||||
points[points.length - 1] !== lastCommittedPoint)
|
||||
) {
|
||||
mutateElement(multiPointElement, {
|
||||
points: multiPointElement.points.slice(0, -1),
|
||||
|
@ -87,6 +87,16 @@ describe("flipping arrowheads", () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
// UX RATIONALE: If we flip bound arrows by the center axes then there could
|
||||
// be a case where the bindable objects are offset and the arrow would lay
|
||||
// outside both bindable objects binding range, yet remain bound to then,
|
||||
// resulting in a jump on movement.
|
||||
//
|
||||
// We are aware that 2+ point simple arrows behave incorrectly when flipped
|
||||
// this way but it was decided that there is no known use case for this so
|
||||
// left as it is.
|
||||
//
|
||||
// Demo: https://excalidraw.com/#json=isE-S8LqNlD1u-LsS8Ezz,iZZ09PPasp6OWbGtJwOUGQ
|
||||
it("flipping bound arrow should flip arrowheads only", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
@ -123,6 +133,7 @@ describe("flipping arrowheads", () => {
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
||||
});
|
||||
|
||||
// UX RATIONALE: See above for the reasoning.
|
||||
it("flipping bound arrow should flip arrowheads only 2", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
@ -164,7 +175,9 @@ describe("flipping arrowheads", () => {
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||
});
|
||||
|
||||
it("flipping unbound arrow shouldn't flip arrowheads", () => {
|
||||
// UX RATIONALE: Unbound arrows are not constrained by other elements and
|
||||
// should behave like any other element when flipped for consisency.
|
||||
it("flipping unbound arrow should mirror on horizontal or vertical axis", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
|
@ -1655,6 +1655,7 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
startHoveredElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
)
|
||||
: startGlobalPoint;
|
||||
const finalEndPoint = endHoveredElement
|
||||
@ -1662,6 +1663,7 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
endHoveredElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
)
|
||||
: endGlobalPoint;
|
||||
|
||||
|
@ -1508,9 +1508,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||
) {
|
||||
for (const element of changed.values()) {
|
||||
if (!element.isDeleted && isBindableElement(element)) {
|
||||
updateBoundElements(element, elements, {
|
||||
changedElements: changed,
|
||||
});
|
||||
updateBoundElements(element, elements);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -99,6 +99,7 @@ import {
|
||||
isShallowEqual,
|
||||
arrayToMap,
|
||||
type EXPORT_IMAGE_TYPES,
|
||||
toLocalPoint,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
@ -117,6 +118,7 @@ import {
|
||||
shouldEnableBindingForPointerEvent,
|
||||
updateBoundElements,
|
||||
getSuggestedBindingsForArrows,
|
||||
getOutlineAvoidingPoint,
|
||||
} from "@excalidraw/element/binding";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
@ -301,7 +303,7 @@ import {
|
||||
|
||||
import { isNonDeletedElement } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
@ -328,7 +330,7 @@ import type {
|
||||
ExcalidrawArrowElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
actionAddToLibrary,
|
||||
@ -2917,14 +2919,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
maybeBindLinearElement(
|
||||
multiElement,
|
||||
this.state,
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiElement,
|
||||
-1,
|
||||
nonDeletedElementsMap,
|
||||
),
|
||||
),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
"end",
|
||||
nonDeletedElementsMap,
|
||||
this.scene.getNonDeletedElements(),
|
||||
);
|
||||
}
|
||||
@ -5971,9 +5967,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
{
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
pointFrom<LocalPoint>(
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
toLocalPoint(
|
||||
getOutlineAvoidingPoint(
|
||||
multiElement,
|
||||
"end",
|
||||
this.scene,
|
||||
this.state.zoom,
|
||||
pointFrom<GlobalPoint>(
|
||||
multiElement.x + lastCommittedX + dxFromLastCommitted,
|
||||
multiElement.y + lastCommittedY + dyFromLastCommitted,
|
||||
),
|
||||
),
|
||||
multiElement,
|
||||
),
|
||||
],
|
||||
},
|
||||
@ -7715,18 +7720,34 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
const { x: rx, y: ry, lastCommittedPoint } = multiElement;
|
||||
const lastGlobalPoint = pointFrom<GlobalPoint>(
|
||||
rx + multiElement.points[multiElement.points.length - 1][0],
|
||||
ry + multiElement.points[multiElement.points.length - 1][1],
|
||||
);
|
||||
const hoveredElementForBinding = getHoveredElementForBinding(
|
||||
{
|
||||
x: lastGlobalPoint[0],
|
||||
y: lastGlobalPoint[1],
|
||||
},
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state.zoom,
|
||||
true,
|
||||
isElbowArrow(multiElement),
|
||||
);
|
||||
|
||||
// clicking inside commit zone → finalize arrow
|
||||
if (
|
||||
multiElement.points.length > 1 &&
|
||||
lastCommittedPoint &&
|
||||
pointDistance(
|
||||
pointFrom(
|
||||
pointerDownState.origin.x - rx,
|
||||
pointerDownState.origin.y - ry,
|
||||
),
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
hoveredElementForBinding ||
|
||||
(multiElement.points.length > 1 &&
|
||||
lastCommittedPoint &&
|
||||
pointDistance(
|
||||
pointFrom(
|
||||
pointerDownState.origin.x - rx,
|
||||
pointerDownState.origin.y - ry,
|
||||
),
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD)
|
||||
) {
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
return;
|
||||
@ -7770,53 +7791,68 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? [currentItemStartArrowhead, currentItemEndArrowhead]
|
||||
: [null, null];
|
||||
|
||||
const element =
|
||||
elementType === "arrow"
|
||||
? newArrowElement({
|
||||
type: elementType,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
backgroundColor: this.state.currentItemBackgroundColor,
|
||||
fillStyle: this.state.currentItemFillStyle,
|
||||
strokeWidth: this.state.currentItemStrokeWidth,
|
||||
strokeStyle: this.state.currentItemStrokeStyle,
|
||||
roughness: this.state.currentItemRoughness,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness:
|
||||
this.state.currentItemArrowType === ARROW_TYPE.round
|
||||
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
|
||||
: // note, roundness doesn't have any effect for elbow arrows,
|
||||
// but it's best to set it to null as well
|
||||
null,
|
||||
startArrowhead,
|
||||
endArrowhead,
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
|
||||
fixedSegments:
|
||||
this.state.currentItemArrowType === ARROW_TYPE.elbow
|
||||
? []
|
||||
: null,
|
||||
})
|
||||
: newLinearElement({
|
||||
type: elementType,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
backgroundColor: this.state.currentItemBackgroundColor,
|
||||
fillStyle: this.state.currentItemFillStyle,
|
||||
strokeWidth: this.state.currentItemStrokeWidth,
|
||||
strokeStyle: this.state.currentItemStrokeStyle,
|
||||
roughness: this.state.currentItemRoughness,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness:
|
||||
this.state.currentItemRoundness === "round"
|
||||
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
|
||||
: null,
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
let element: NonDeleted<ExcalidrawLinearElement>;
|
||||
if (elementType === "arrow") {
|
||||
const arrow: Mutable<NonDeleted<ExcalidrawArrowElement>> =
|
||||
newArrowElement({
|
||||
type: "arrow",
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
backgroundColor: this.state.currentItemBackgroundColor,
|
||||
fillStyle: this.state.currentItemFillStyle,
|
||||
strokeWidth: this.state.currentItemStrokeWidth,
|
||||
strokeStyle: this.state.currentItemStrokeStyle,
|
||||
roughness: this.state.currentItemRoughness,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness:
|
||||
this.state.currentItemArrowType === ARROW_TYPE.round
|
||||
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
|
||||
: // note, roundness doesn't have any effect for elbow arrows,
|
||||
// but it's best to set it to null as well
|
||||
null,
|
||||
startArrowhead,
|
||||
endArrowhead,
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
|
||||
fixedSegments:
|
||||
this.state.currentItemArrowType === ARROW_TYPE.elbow ? [] : null,
|
||||
});
|
||||
|
||||
const [x, y] = getOutlineAvoidingPoint(
|
||||
arrow,
|
||||
pointFrom<GlobalPoint>(gridX, gridY),
|
||||
0,
|
||||
this.scene,
|
||||
this.state.zoom,
|
||||
);
|
||||
|
||||
element = {
|
||||
...arrow,
|
||||
x,
|
||||
y,
|
||||
};
|
||||
} else {
|
||||
element = newLinearElement({
|
||||
type: elementType,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
backgroundColor: this.state.currentItemBackgroundColor,
|
||||
fillStyle: this.state.currentItemFillStyle,
|
||||
strokeWidth: this.state.currentItemStrokeWidth,
|
||||
strokeStyle: this.state.currentItemStrokeStyle,
|
||||
roughness: this.state.currentItemRoughness,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness:
|
||||
this.state.currentItemRoundness === "round"
|
||||
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
|
||||
: null,
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
}
|
||||
this.setState((prevState) => {
|
||||
const nextSelectedElementIds = {
|
||||
...prevState.selectedElementIds,
|
||||
@ -8131,12 +8167,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||
}
|
||||
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
// for arrows/lines, don't start dragging until a given threshold
|
||||
// to ensure we don't create a 2-point arrow by mistake when
|
||||
// user clicks mouse in a way that it moves a tiny bit (thus
|
||||
@ -8237,7 +8267,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
},
|
||||
linearElementEditor,
|
||||
this.scene,
|
||||
);
|
||||
if (newLinearElementEditor) {
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
@ -8579,6 +8608,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
} else if (isLinearElement(newElement)) {
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
const points = newElement.points;
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
|
||||
);
|
||||
let dx = gridX - newElement.x;
|
||||
let dy = gridY - newElement.y;
|
||||
|
||||
@ -8595,7 +8629,23 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points, pointFrom<LocalPoint>(dx, dy)],
|
||||
points: [
|
||||
...points,
|
||||
toLocalPoint(
|
||||
getOutlineAvoidingPoint(
|
||||
newElement,
|
||||
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
|
||||
newElement.points.length - 1,
|
||||
this.scene,
|
||||
this.state.zoom,
|
||||
pointFrom<GlobalPoint>(
|
||||
newElement.x + dx,
|
||||
newElement.y + dy,
|
||||
),
|
||||
),
|
||||
newElement,
|
||||
),
|
||||
],
|
||||
},
|
||||
false,
|
||||
);
|
||||
@ -8606,7 +8656,32 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
toLocalPoint(
|
||||
getOutlineAvoidingPoint(
|
||||
{
|
||||
...newElement,
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
pointFrom<LocalPoint>(
|
||||
pointerCoords.x - newElement.x,
|
||||
pointerCoords.y - newElement.y,
|
||||
),
|
||||
],
|
||||
},
|
||||
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
|
||||
newElement.points.length - 1,
|
||||
this.scene,
|
||||
this.state.zoom,
|
||||
pointFrom<GlobalPoint>(
|
||||
newElement.x + dx,
|
||||
newElement.y + dy,
|
||||
),
|
||||
),
|
||||
newElement,
|
||||
),
|
||||
],
|
||||
},
|
||||
false,
|
||||
{ isDragging: true },
|
||||
@ -9018,7 +9093,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
maybeBindLinearElement(
|
||||
newElement,
|
||||
this.state,
|
||||
pointerCoords,
|
||||
"end",
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.scene.getNonDeletedElements(),
|
||||
);
|
||||
@ -10634,12 +10709,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
updateBoundElements(
|
||||
croppingElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
{
|
||||
newSize: {
|
||||
width: croppingElement.width,
|
||||
height: croppingElement.height,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.setState({
|
||||
|
@ -87,9 +87,7 @@ const resizeElementInGroup = (
|
||||
);
|
||||
if (boundTextElement) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
updateBoundElements(latestElement, elementsMap, {
|
||||
newSize: { width: updates.width, height: updates.height },
|
||||
});
|
||||
updateBoundElements(latestElement, elementsMap);
|
||||
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
|
||||
mutateElement(
|
||||
|
@ -89,7 +89,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"endBinding": {
|
||||
"elementId": "ellipse-1",
|
||||
"focus": -0.007519379844961235,
|
||||
"gap": 11.562288374879595,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -119,7 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"startBinding": {
|
||||
"elementId": "id49",
|
||||
"focus": -0.0813953488372095,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1864ab",
|
||||
"strokeStyle": "solid",
|
||||
@ -145,7 +145,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"endBinding": {
|
||||
"elementId": "ellipse-1",
|
||||
"focus": 0.10666666666666667,
|
||||
"gap": 3.8343264684446097,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"startBinding": {
|
||||
"elementId": "diamond-1",
|
||||
"focus": 0,
|
||||
"gap": 4.545343408287929,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#e67700",
|
||||
"strokeStyle": "solid",
|
||||
@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"endBinding": {
|
||||
"elementId": "text-2",
|
||||
"focus": 0,
|
||||
"gap": 14,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -365,7 +365,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"startBinding": {
|
||||
"elementId": "text-1",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -437,7 +437,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"endBinding": {
|
||||
"elementId": "id42",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -467,7 +467,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"startBinding": {
|
||||
"elementId": "id41",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -613,7 +613,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"endBinding": {
|
||||
"elementId": "id46",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -643,7 +643,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"startBinding": {
|
||||
"elementId": "id45",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -1475,7 +1475,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"endBinding": {
|
||||
"elementId": "Alice",
|
||||
"focus": -0,
|
||||
"gap": 5.299874999999986,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -1507,7 +1507,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"startBinding": {
|
||||
"elementId": "Bob",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -1538,7 +1538,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"endBinding": {
|
||||
"elementId": "B",
|
||||
"focus": 0,
|
||||
"gap": 14,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -1566,7 +1566,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"startBinding": {
|
||||
"elementId": "Bob",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
|
||||
|
||||
import type { ExcalidrawArrowElement } from "@excalidraw/element/types";
|
||||
|
||||
import { convertToExcalidrawElements } from "./transform";
|
||||
@ -433,7 +435,7 @@ describe("Test Transform", () => {
|
||||
startBinding: {
|
||||
elementId: rectangle.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
gap: FIXED_BINDING_DISTANCE,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: ellipse.id,
|
||||
@ -518,7 +520,7 @@ describe("Test Transform", () => {
|
||||
startBinding: {
|
||||
elementId: text2.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
gap: FIXED_BINDING_DISTANCE,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: text3.id,
|
||||
@ -781,7 +783,7 @@ describe("Test Transform", () => {
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
focus: -0,
|
||||
gap: 14,
|
||||
gap: FIXED_BINDING_DISTANCE,
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
|
@ -197,7 +197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "102.35417",
|
||||
"height": "99.23572",
|
||||
"id": "id172",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@ -211,8 +211,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
0,
|
||||
],
|
||||
[
|
||||
"101.77517",
|
||||
"102.35417",
|
||||
"96.42891",
|
||||
"99.23572",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@ -227,8 +227,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 40,
|
||||
"width": "101.77517",
|
||||
"x": "0.70711",
|
||||
"width": "96.42891",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -294,47 +294,47 @@ History {
|
||||
"deleted": {
|
||||
"endBinding": {
|
||||
"elementId": "id171",
|
||||
"focus": "0.00990",
|
||||
"gap": 1,
|
||||
"focus": "0.01140",
|
||||
"gap": 5,
|
||||
},
|
||||
"height": "0.98586",
|
||||
"height": "1.00000",
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"-0.98586",
|
||||
"92.92893",
|
||||
"-1.00000",
|
||||
],
|
||||
],
|
||||
"startBinding": {
|
||||
"elementId": "id170",
|
||||
"focus": "0.02970",
|
||||
"gap": 1,
|
||||
"focus": "0.03119",
|
||||
"gap": 5,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"endBinding": {
|
||||
"elementId": "id171",
|
||||
"focus": "-0.02000",
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"height": "0.00000",
|
||||
"height": 0,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"0.00000",
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
"startBinding": {
|
||||
"elementId": "id170",
|
||||
"focus": "0.02000",
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -389,15 +389,15 @@ History {
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
"height": "102.35417",
|
||||
"height": "99.23572",
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
"101.77517",
|
||||
"102.35417",
|
||||
"96.42891",
|
||||
"99.23572",
|
||||
],
|
||||
],
|
||||
"startBinding": null,
|
||||
@ -406,26 +406,26 @@ History {
|
||||
"inserted": {
|
||||
"endBinding": {
|
||||
"elementId": "id171",
|
||||
"focus": "0.00990",
|
||||
"gap": 1,
|
||||
"focus": "0.01140",
|
||||
"gap": 5,
|
||||
},
|
||||
"height": "0.98586",
|
||||
"height": "1.00000",
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"-0.98586",
|
||||
"92.92893",
|
||||
"-1.00000",
|
||||
],
|
||||
],
|
||||
"startBinding": {
|
||||
"elementId": "id170",
|
||||
"focus": "0.02970",
|
||||
"gap": 1,
|
||||
"focus": "0.03119",
|
||||
"gap": 5,
|
||||
},
|
||||
"y": "0.99364",
|
||||
"y": "1.00000",
|
||||
},
|
||||
},
|
||||
"id175" => Delta {
|
||||
@ -565,7 +565,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -579,8 +579,8 @@ History {
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -802,7 +802,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -818,8 +818,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 30,
|
||||
"width": 0,
|
||||
"x": "149.29289",
|
||||
"width": "0.00000",
|
||||
"x": "146.46447",
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -852,7 +852,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
0,
|
||||
"0.00000",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -864,7 +864,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -919,7 +919,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -929,7 +929,7 @@ History {
|
||||
"endBinding": {
|
||||
"elementId": "id166",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"points": [
|
||||
[
|
||||
@ -937,14 +937,14 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
0,
|
||||
"0.00000",
|
||||
0,
|
||||
],
|
||||
],
|
||||
"startBinding": {
|
||||
"elementId": "id165",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -1072,7 +1072,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -1086,8 +1086,8 @@ History {
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -1238,7 +1238,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "1.30038",
|
||||
"height": "1.71911",
|
||||
"id": "id178",
|
||||
"index": "Zz",
|
||||
"isDeleted": false,
|
||||
@ -1252,8 +1252,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"1.30038",
|
||||
"92.92893",
|
||||
"1.71911",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@ -1276,8 +1276,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"width": "98.58579",
|
||||
"x": "0.70711",
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -1609,7 +1609,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "1.30038",
|
||||
"height": "1.71911",
|
||||
"id": "id181",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
@ -1623,8 +1623,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"1.30038",
|
||||
"92.92893",
|
||||
"1.71911",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@ -1647,8 +1647,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"width": "98.58579",
|
||||
"x": "0.70711",
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -1767,7 +1767,7 @@ History {
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "11.27227",
|
||||
"height": "12.86717",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
@ -1780,8 +1780,8 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"11.27227",
|
||||
"92.92893",
|
||||
"12.86717",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@ -1802,8 +1802,8 @@ History {
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": "98.58579",
|
||||
"x": "0.70711",
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -2315,12 +2315,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"endBinding": {
|
||||
"elementId": "id185",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "374.05754",
|
||||
"height": "369.23631",
|
||||
"id": "id186",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@ -2334,8 +2334,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
0,
|
||||
],
|
||||
[
|
||||
"502.78936",
|
||||
"-374.05754",
|
||||
"496.83418",
|
||||
"-369.23631",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@ -2346,7 +2346,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"startBinding": {
|
||||
"elementId": "id184",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -2354,9 +2354,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 10,
|
||||
"width": "502.78936",
|
||||
"x": "-0.83465",
|
||||
"y": "-36.58211",
|
||||
"width": "496.83418",
|
||||
"x": "2.19080",
|
||||
"y": "-38.78706",
|
||||
}
|
||||
`;
|
||||
|
||||
@ -2475,7 +2475,7 @@ History {
|
||||
"endBinding": {
|
||||
"elementId": "id185",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -2493,7 +2493,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -2505,14 +2505,14 @@ History {
|
||||
"startBinding": {
|
||||
"elementId": "id184",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -15111,7 +15111,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"endBinding": {
|
||||
"elementId": "id58",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -15130,7 +15130,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -15142,7 +15142,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"startBinding": {
|
||||
"elementId": "id56",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -15150,8 +15150,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 10,
|
||||
"width": "98.58579",
|
||||
"x": "0.70711",
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -15192,7 +15192,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -15205,7 +15205,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -15482,7 +15482,7 @@ History {
|
||||
"endBinding": {
|
||||
"elementId": "id58",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -15500,7 +15500,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -15512,14 +15512,14 @@ History {
|
||||
"startBinding": {
|
||||
"elementId": "id56",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -15808,7 +15808,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"endBinding": {
|
||||
"elementId": "id52",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -15827,7 +15827,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -15839,7 +15839,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"startBinding": {
|
||||
"elementId": "id50",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -15847,8 +15847,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 10,
|
||||
"width": "98.58579",
|
||||
"x": "0.70711",
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -16101,7 +16101,7 @@ History {
|
||||
"endBinding": {
|
||||
"elementId": "id52",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -16119,7 +16119,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -16131,14 +16131,14 @@ History {
|
||||
"startBinding": {
|
||||
"elementId": "id50",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -16427,7 +16427,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"endBinding": {
|
||||
"elementId": "id64",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -16446,7 +16446,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -16458,7 +16458,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"startBinding": {
|
||||
"elementId": "id62",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -16466,8 +16466,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 10,
|
||||
"width": "98.58579",
|
||||
"x": "0.70711",
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -16720,7 +16720,7 @@ History {
|
||||
"endBinding": {
|
||||
"elementId": "id64",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -16738,7 +16738,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -16750,14 +16750,14 @@ History {
|
||||
"startBinding": {
|
||||
"elementId": "id62",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -17044,7 +17044,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"endBinding": {
|
||||
"elementId": "id70",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -17063,7 +17063,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -17075,7 +17075,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"startBinding": {
|
||||
"elementId": "id68",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -17083,8 +17083,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 10,
|
||||
"width": "98.58579",
|
||||
"x": "0.70711",
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -17140,14 +17140,14 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
"startBinding": {
|
||||
"elementId": "id68",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
@ -17157,7 +17157,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -17407,7 +17407,7 @@ History {
|
||||
"endBinding": {
|
||||
"elementId": "id70",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -17425,7 +17425,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -17437,14 +17437,14 @@ History {
|
||||
"startBinding": {
|
||||
"elementId": "id68",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@ -17757,7 +17757,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"endBinding": {
|
||||
"elementId": "id77",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -17776,7 +17776,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.58579",
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -17788,7 +17788,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"startBinding": {
|
||||
"elementId": "id75",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -17796,8 +17796,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"width": "98.58579",
|
||||
"x": "0.70711",
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@ -17859,7 +17859,7 @@ History {
|
||||
"endBinding": {
|
||||
"elementId": "id77",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"points": [
|
||||
[
|
||||
@ -17867,14 +17867,14 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
"startBinding": {
|
||||
"elementId": "id75",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
@ -17885,7 +17885,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -18135,7 +18135,7 @@ History {
|
||||
"endBinding": {
|
||||
"elementId": "id77",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@ -18153,7 +18153,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
100,
|
||||
"92.92893",
|
||||
0,
|
||||
],
|
||||
],
|
||||
@ -18165,14 +18165,14 @@ History {
|
||||
"startBinding": {
|
||||
"elementId": "id75",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"width": "92.92893",
|
||||
"x": "3.53553",
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
|
@ -190,13 +190,13 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id1",
|
||||
"focus": "-0.46667",
|
||||
"gap": 10,
|
||||
"focus": "-0.40764",
|
||||
"gap": 5,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "87.29887",
|
||||
"height": "82.18136",
|
||||
"id": "id2",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@ -210,8 +210,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
0,
|
||||
],
|
||||
[
|
||||
"86.85786",
|
||||
"87.29887",
|
||||
"93.92893",
|
||||
"82.18136",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@ -222,8 +222,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id0",
|
||||
"focus": "-0.60000",
|
||||
"gap": 10,
|
||||
"focus": "-0.49801",
|
||||
"gap": 5,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@ -232,8 +232,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"versionNonce": 1051383431,
|
||||
"width": "86.85786",
|
||||
"x": "107.07107",
|
||||
"y": "47.07107",
|
||||
"width": "93.92893",
|
||||
"x": "103.53553",
|
||||
"y": "50.01536",
|
||||
}
|
||||
`;
|
||||
|
@ -464,6 +464,7 @@ export class UI {
|
||||
height: initialHeight = initialWidth,
|
||||
angle = 0,
|
||||
points: initialPoints,
|
||||
elbowed = false,
|
||||
}: {
|
||||
position?: number;
|
||||
x?: number;
|
||||
@ -473,6 +474,7 @@ export class UI {
|
||||
height?: number;
|
||||
angle?: number;
|
||||
points?: T extends "line" | "arrow" | "freedraw" ? LocalPoint[] : never;
|
||||
elbowed?: boolean;
|
||||
} = {},
|
||||
): Element<T> & {
|
||||
/** Returns the actual, current element from the elements array, instead
|
||||
@ -491,6 +493,17 @@ export class UI {
|
||||
if (type === "text") {
|
||||
mouse.reset();
|
||||
mouse.click(x, y);
|
||||
} else if (type === "arrow" && points.length === 2 && elbowed) {
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
mouse.reset();
|
||||
mouse.moveTo(x + points[0][0], y + points[0][1]);
|
||||
mouse.click();
|
||||
mouse.moveTo(
|
||||
x + points[points.length - 1][0],
|
||||
y + points[points.length - 1][1],
|
||||
);
|
||||
mouse.click();
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
} else if ((type === "line" || type === "arrow") && points.length > 2) {
|
||||
points.forEach((point) => {
|
||||
mouse.reset();
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
@ -4779,12 +4780,12 @@ describe("history", () => {
|
||||
startBinding: expect.objectContaining({
|
||||
elementId: rect1.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
gap: FIXED_BINDING_DISTANCE,
|
||||
}),
|
||||
endBinding: expect.objectContaining({
|
||||
elementId: rect2.id,
|
||||
focus: -0,
|
||||
gap: 1,
|
||||
gap: FIXED_BINDING_DISTANCE,
|
||||
}),
|
||||
isDeleted: true,
|
||||
}),
|
||||
|
@ -1227,7 +1227,7 @@ describe("Test Linear Elements", () => {
|
||||
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
||||
expect(arrow.width).toBe(400);
|
||||
expect(arrow.width).toBeCloseTo(408, 0);
|
||||
expect(rect.x).toBe(400);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(
|
||||
@ -1246,7 +1246,7 @@ describe("Test Linear Elements", () => {
|
||||
mouse.downAt(rect.x, rect.y);
|
||||
mouse.moveTo(200, 0);
|
||||
mouse.upAt(200, 0);
|
||||
expect(arrow.width).toBeCloseTo(204, 0);
|
||||
expect(arrow.width).toBeCloseTo(207, 0);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
|
@ -109,8 +109,10 @@ describe("move element", () => {
|
||||
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
|
||||
expect([rectA.x, rectA.y]).toEqual([0, 0]);
|
||||
expect([rectB.x, rectB.y]).toEqual([200, 0]);
|
||||
expect([arrow.x, arrow.y]).toEqual([110, 50]);
|
||||
expect([arrow.width, arrow.height]).toEqual([80, 80]);
|
||||
expect([Math.round(arrow.x), Math.round(arrow.y)]).toEqual([104, 50]);
|
||||
expect([Math.round(arrow.width), Math.round(arrow.height)]).toEqual([
|
||||
93, 81,
|
||||
]);
|
||||
|
||||
renderInteractiveScene.mockClear();
|
||||
renderStaticScene.mockClear();
|
||||
@ -128,8 +130,11 @@ describe("move element", () => {
|
||||
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
|
||||
expect([rectA.x, rectA.y]).toEqual([0, 0]);
|
||||
expect([rectB.x, rectB.y]).toEqual([201, 2]);
|
||||
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[107.07, 47.07]]);
|
||||
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[86.86, 87.3]]);
|
||||
|
||||
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[103.53, 50.01]]);
|
||||
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([
|
||||
[93.9289, 82.1813],
|
||||
]);
|
||||
|
||||
h.elements.forEach((element) => expect(element).toMatchSnapshot());
|
||||
});
|
||||
|
@ -3,6 +3,8 @@ import { expect } from "vitest";
|
||||
|
||||
import { reseed } from "@excalidraw/common";
|
||||
|
||||
import "@excalidraw/utils/test-utils";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { UI } from "./helpers/ui";
|
||||
@ -35,7 +37,7 @@ test("unselected bound arrow updates when rotating its target element", async ()
|
||||
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||
expect(arrow.x).toBeCloseTo(-80);
|
||||
expect(arrow.y).toBeCloseTo(50);
|
||||
expect(arrow.width).toBeCloseTo(116.7, 1);
|
||||
expect(arrow.width).toBeCloseTo(119.6, 1);
|
||||
expect(arrow.height).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
@ -71,14 +73,16 @@ test("unselected bound arrows update when rotating their target elements", async
|
||||
expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id);
|
||||
expect(ellipseArrow.x).toEqual(0);
|
||||
expect(ellipseArrow.y).toEqual(0);
|
||||
expect(ellipseArrow.points[0]).toEqual([0, 0]);
|
||||
expect(ellipseArrow.points[1][0]).toBeCloseTo(48.98, 1);
|
||||
expect(ellipseArrow.points[1][1]).toBeCloseTo(125.79, 1);
|
||||
|
||||
expect(ellipseArrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[90.1827, 98.5896],
|
||||
]);
|
||||
|
||||
expect(textArrow.endBinding?.elementId).toEqual(text.id);
|
||||
expect(textArrow.x).toEqual(360);
|
||||
expect(textArrow.y).toEqual(300);
|
||||
expect(textArrow.points[0]).toEqual([0, 0]);
|
||||
expect(textArrow.points[1][0]).toBeCloseTo(-94, 0);
|
||||
expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 0);
|
||||
expect(textArrow.points[1][0]).toBeCloseTo(-95, 0);
|
||||
expect(textArrow.points[1][1]).toBeCloseTo(-129.1, 0);
|
||||
});
|
||||
|
@ -157,22 +157,13 @@ export function curveIntersectLineSegment<
|
||||
return bezierEquation(c, t);
|
||||
};
|
||||
|
||||
let solution = calculate(initial_guesses[0]);
|
||||
if (solution) {
|
||||
return [solution];
|
||||
}
|
||||
const solutions = [
|
||||
calculate(initial_guesses[0]),
|
||||
calculate(initial_guesses[1]),
|
||||
calculate(initial_guesses[2]),
|
||||
].filter((x, i, a): x is Point => x !== null && a.indexOf(x) === i);
|
||||
|
||||
solution = calculate(initial_guesses[1]);
|
||||
if (solution) {
|
||||
return [solution];
|
||||
}
|
||||
|
||||
solution = calculate(initial_guesses[2]);
|
||||
if (solution) {
|
||||
return [solution];
|
||||
}
|
||||
|
||||
return [];
|
||||
return solutions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -91,9 +91,10 @@ export function isPoint(p: unknown): p is LocalPoint | GlobalPoint {
|
||||
export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
|
||||
a: Point,
|
||||
b: Point,
|
||||
precision: number = PRECISION,
|
||||
): boolean {
|
||||
const abs = Math.abs;
|
||||
return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION;
|
||||
return abs(a[0] - b[0]) < precision && abs(a[1] - b[1]) < precision;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -6,7 +6,7 @@ export const clamp = (value: number, min: number, max: number) => {
|
||||
|
||||
export const round = (
|
||||
value: number,
|
||||
precision: number,
|
||||
precision: number = (Math.log(1 / PRECISION) * Math.LOG10E + 1) | 0,
|
||||
func: "round" | "floor" | "ceil" = "round",
|
||||
) => {
|
||||
const multiplier = Math.pow(10, precision);
|
||||
|
Loading…
x
Reference in New Issue
Block a user