feat: Elbow arrow segment fixing & positioning (#8952)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
pull/7970/merge
Márk Tolmács 2 weeks ago committed by GitHub
parent 8551823da9
commit 91ebf8b0ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -18,14 +18,12 @@ import {
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)),
@ -51,7 +49,7 @@ const deleteSelectedElements = (
endBinding:
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
});
mutateElbowArrow(bound, elementsMap, bound.points);
mutateElement(bound, { points: bound.points });
}
});
}
@ -208,12 +206,7 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
elementsMap,
appState.zoom,
);
LinearElementEditor.deletePoints(element, selectedPointsIndices);
return {
elements,

@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => {
},
startArrowhead: null,
endArrowhead: "arrow",
fixedSegments: null,
points: [
pointFrom(0, 0),
pointFrom(0, -35),
pointFrom(-90.9, -35),
pointFrom(-90.9, 204.9),
pointFrom(65.1, 204.9),
pointFrom(-90, -35),
pointFrom(-90, 204),
pointFrom(66, 204),
],
elbowed: true,
}),
@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => {
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1");
expect(rec1?.x).toBeCloseTo(100);
expect(rec1?.y).toBeCloseTo(100);
const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1.x).toBeCloseTo(100, 0);
expect(rec1.y).toBeCloseTo(100, 0);
const rec2 = h.elements.find((el) => el.id === "rec2");
expect(rec2?.x).toBeCloseTo(220);
expect(rec2?.y).toBeCloseTo(250);
const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0);
expect(rec2.y).toBeCloseTo(250, 0);
});
});

@ -24,8 +24,8 @@ import {
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement } from "../element/newElement";
import { getCommonBoundingBox } from "../element/bounds";
export const actionFlipHorizontal = register({
@ -134,12 +134,24 @@ const flipElements = (
const { midX, midY } = getCommonBoundingBox(selectedElements);
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
resizeMultipleElements(
selectedElements,
elementsMap,
"nw",
app.scene,
new Map(
Array.from(elementsMap.values()).map((element) => [
element.id,
deepCopyElement(element),
]),
),
{
flipByX: flipDirection === "horizontal",
flipByY: flipDirection === "vertical",
shouldResizeFromCenter: true,
shouldMaintainAspectRatio: true,
});
},
);
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
@ -181,16 +193,10 @@ const flipElements = (
}),
);
elbowArrows.forEach((element) =>
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
),
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
// ---------------------------------------------------------------------------

@ -116,10 +116,9 @@ import {
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math";
import { pointFrom, vector } from "../../math";
import { pointFrom } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@ -1560,8 +1559,7 @@ export const actionChangeArrowType = register({
label: "Change arrow types",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
elements: changeProperty(elements, appState, (el) => {
const newElements = changeProperty(elements, appState, (el) => {
if (!isArrowElement(el)) {
return el;
}
@ -1603,7 +1601,6 @@ export const actionChangeArrowType = register({
elements,
elementsMap,
appState.zoom,
true,
);
const endHoveredElement =
!newElement.endBinding &&
@ -1612,7 +1609,6 @@ export const actionChangeArrowType = register({
elements,
elementsMap,
appState.zoom,
true,
);
const startElement = startHoveredElement
? startHoveredElement
@ -1652,22 +1648,13 @@ export const actionChangeArrowType = register({
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
mutateElbowArrow(
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
mutateElement(newElement, {
points: [finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
vector(0, 0),
{
...(startElement && newElement.startBinding
? {
startBinding: {
@ -1696,16 +1683,38 @@ export const actionChangeArrowType = register({
},
}
: {}),
},
});
LinearElementEditor.updateEditorMidPointsCache(
newElement,
elementsMap,
app.state,
);
}
return newElement;
}),
appState: {
});
const newState = {
...appState,
currentItemArrowType: value,
},
};
// Change the arrow type and update any other state settings for
// the arrow.
const selectedId = appState.selectedLinearElement?.elementId;
if (selectedId) {
const selected = newElements.find((el) => el.id === selectedId);
if (selected) {
newState.selectedLinearElement = new LinearElementEditor(
selected as ExcalidrawLinearElement,
);
}
}
return {
elements: newElements,
appState: newState,
storeAction: StoreAction.CAPTURE,
};
},

@ -165,6 +165,7 @@ import {
isTextBindableContainer,
isElbowArrow,
isFlowchartNodeElement,
isBindableElement,
} from "../element/typeChecks";
import type {
ExcalidrawBindableElement,
@ -189,7 +190,6 @@ import type {
MagicGenerationData,
ExcalidrawNonSelectionElement,
ExcalidrawArrowElement,
NonDeletedSceneElementsMap,
} from "../element/types";
import { getCenter, getDistance } from "../gesture";
import {
@ -292,7 +292,6 @@ import {
getDateTime,
isShallowEqual,
arrayToMap,
toBrandedType,
} from "../utils";
import {
createSrcDoc,
@ -443,7 +442,6 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid";
import NewElementCanvas from "./canvases/NewElementCanvas";
import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
import {
FlowChartCreator,
FlowChartNavigator,
@ -3184,49 +3182,7 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean;
fitToContent?: boolean;
}) => {
let elements = opts.elements.map((el, _, elements) => {
if (isElbowArrow(el)) {
const startEndElements = [
el.startBinding &&
elements.find((l) => l.id === el.startBinding?.elementId),
el.endBinding &&
elements.find((l) => l.id === el.endBinding?.elementId),
];
const startBinding = startEndElements[0] ? el.startBinding : null;
const endBinding = startEndElements[1] ? el.endBinding : null;
return {
...el,
...updateElbowArrow(
{
...el,
startBinding,
endBinding,
},
toBrandedType<NonDeletedSceneElementsMap>(
new Map(
startEndElements
.filter((x) => x != null)
.map(
(el) =>
[el!.id, el] as [
string,
Ordered<NonDeletedExcalidrawElement>,
],
),
),
),
[el.points[0], el.points[el.points.length - 1]],
undefined,
{
zoom: this.state.zoom,
},
),
};
}
return el;
});
elements = restoreElements(elements, null, undefined);
const elements = restoreElements(opts.elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2;
@ -4377,7 +4333,6 @@ class App extends React.Component<AppProps, AppState> {
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: selectedElements,
zoom: this.state.zoom,
});
});
@ -5365,6 +5320,11 @@ class App extends React.Component<AppProps, AppState> {
const selectedElements = this.scene.getSelectedElements(this.state);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (
event[KEYS.CTRL_OR_CMD] &&
@ -5378,6 +5338,64 @@ class App extends React.Component<AppProps, AppState> {
editingLinearElement: new LinearElementEditor(selectedElements[0]),
});
return;
} else if (
this.state.selectedLinearElement &&
isElbowArrow(selectedElements[0])
) {
const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
this.state.selectedLinearElement,
{ x: sceneX, y: sceneY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
const midPoint = hitCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
hitCoords,
this.scene.getNonDeletedElementsMap(),
)
: -1;
if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement();
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: null,
},
{ x: sceneX, y: sceneY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
const nextIndex = nextCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
nextCoords,
this.scene.getNonDeletedElementsMap(),
)
: null;
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
pointerDownState: {
...this.state.selectedLinearElement.pointerDownState,
segmentMidpoint: {
index: nextIndex,
value: hitCoords,
added: false,
},
},
segmentMidPointHoveredCoords: nextCoords,
},
});
return;
}
}
}
@ -5388,11 +5406,6 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
const selectedGroupIds = getSelectedGroupIds(this.state);
if (selectedGroupIds.length > 0) {
@ -5849,26 +5862,6 @@ class App extends React.Component<AppProps, AppState> {
if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
if (isElbowArrow(multiElement)) {
mutateElbowArrow(
multiElement,
this.scene.getNonDeletedElementsMap(),
[
...points.slice(0, -1),
pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
),
],
undefined,
undefined,
{
isDragging: true,
informMutation: false,
zoom: this.state.zoom,
},
);
} else {
// update last uncommitted point
mutateElement(
multiElement,
@ -5882,8 +5875,10 @@ class App extends React.Component<AppProps, AppState> {
],
},
false,
{
isDragging: true,
},
);
}
// in this path, we're mutating multiElement to reflect
// how it will be after adding pointer position as the next point
@ -6049,7 +6044,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else {
} else if (!hitElement || !isElbowArrow(hitElement)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
@ -6235,14 +6230,18 @@ class App extends React.Component<AppProps, AppState> {
this.state,
this.scene.getNonDeletedElementsMap(),
);
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
const isHoveringAPointHandle = isElbowArrow(element)
? hoverPointIndex === 0 ||
hoverPointIndex === element.points.length - 1
: hoverPointIndex >= 0;
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
if (
// Ebow arrows can only be moved when unconnected
!isElbowArrow(element) ||
!(element.startBinding || element.endBinding)
) {
@ -6972,6 +6971,7 @@ class App extends React.Component<AppProps, AppState> {
if (
selectedElements.length === 1 &&
!this.state.editingLinearElement &&
!isElbowArrow(selectedElements[0]) &&
!(
this.state.selectedLinearElement &&
this.state.selectedLinearElement.hoverPointIndex !== -1
@ -7673,6 +7673,10 @@ class App extends React.Component<AppProps, AppState> {
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,
@ -7913,6 +7917,63 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
if (
this.state.selectedLinearElement &&
this.state.selectedLinearElement.elbowed &&
this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index
) {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
let index =
this.state.selectedLinearElement.pointerDownState.segmentMidpoint
.index;
if (index < 0) {
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: null,
},
{ x: gridX, y: gridY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
index = nextCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
nextCoords,
this.scene.getNonDeletedElementsMap(),
)
: -1;
}
const ret = LinearElementEditor.moveFixedSegment(
this.state.selectedLinearElement,
index,
gridX,
gridY,
this.scene.getNonDeletedElementsMap(),
);
flushSync(() => {
if (this.state.selectedLinearElement) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords,
pointerDownState: ret.pointerDownState,
},
});
}
});
return;
}
const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin;
this.lastPointerMoveCoords = pointerCoords;
@ -8265,7 +8326,7 @@ class App extends React.Component<AppProps, AppState> {
// when we're editing the name of a frame, we want the user to be
// able to select and interact with the text input
!this.state.editingFrame &&
if (!this.state.editingFrame) {
dragSelectedElements(
pointerDownState,
selectedElements,
@ -8274,6 +8335,7 @@ class App extends React.Component<AppProps, AppState> {
snapOffset,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
}
this.setState({
selectedElementsAreBeingDragged: true,
@ -8449,26 +8511,17 @@ class App extends React.Component<AppProps, AppState> {
},
false,
);
} else if (points.length > 1 && isElbowArrow(newElement)) {
mutateElbowArrow(
newElement,
elementsMap,
[...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
vector(0, 0),
undefined,
{
isDragging: true,
informMutation: false,
zoom: this.state.zoom,
},
);
} else if (points.length === 2) {
} else if (
points.length === 2 ||
(points.length > 1 && isElbowArrow(newElement))
) {
mutateElement(
newElement,
{
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
},
false,
{ isDragging: true },
);
}
@ -8663,6 +8716,24 @@ class App extends React.Component<AppProps, AppState> {
selectedElementsAreBeingDragged: false,
});
const elementsMap = this.scene.getNonDeletedElementsMap();
if (
pointerDownState.drag.hasOccurred &&
pointerDownState.hit?.element?.id
) {
const element = elementsMap.get(pointerDownState.hit.element.id);
if (isBindableElement(element)) {
// Renormalize elbow arrows when they are changed via indirect move
element.boundElements
?.filter((e) => e.type === "arrow")
.map((e) => elementsMap.get(e.id))
.filter((e) => isElbowArrow(e))
.forEach((e) => {
!!e && mutateElement(e, {}, true);
});
}
}
// Handle end of dragging a point of a linear element, might close a loop
// and sets binding element
if (this.state.editingLinearElement) {
@ -8687,6 +8758,17 @@ class App extends React.Component<AppProps, AppState> {
}
}
} else if (this.state.selectedLinearElement) {
// Normalize elbow arrow points, remove close parallel segments
if (this.state.selectedLinearElement.elbowed) {
const element = LinearElementEditor.getElement(
this.state.selectedLinearElement.elementId,
this.scene.getNonDeletedElementsMap(),
);
if (element) {
mutateElement(element, {}, true);
}
}
if (
pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId
@ -9126,10 +9208,10 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedLinearElement?.elementId !== hitElement?.id &&
isLinearElement(hitElement)
) {
const selectedELements = this.scene.getSelectedElements(this.state);
const selectedElements = this.scene.getSelectedElements(this.state);
// set selectedLinearElement when no other element selected except
// the one we've hit
if (selectedELements.length === 1) {
if (selectedElements.length === 1) {
this.setState({
selectedLinearElement: new LinearElementEditor(hitElement),
});
@ -9337,6 +9419,8 @@ class App extends React.Component<AppProps, AppState> {
}
if (
// not elbow midpoint dragged
!(hitElement && isElbowArrow(hitElement)) &&
// not dragged
!pointerDownState.drag.hasOccurred &&
// not resized

@ -1,5 +1,6 @@
import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLinearElement,
@ -101,23 +102,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY;
};
const repairBinding = (
element: ExcalidrawLinearElement,
const repairBinding = <T extends ExcalidrawLinearElement>(
element: T,
binding: PointBinding | FixedPointBinding | null,
): PointBinding | FixedPointBinding | null => {
): T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null => {
if (!binding) {
return null;
}
return {
...binding,
focus: binding.focus || 0,
...(isElbowArrow(element) && isFixedPointBinding(binding)
const focus = binding.focus || 0;
if (isElbowArrow(element)) {
const fixedPointBinding:
| ExcalidrawElbowArrowElement["startBinding"]
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
? {
...binding,
focus,
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
}
: {}),
};
: null;
return fixedPointBinding;
}
return {
...binding,
focus,
} as T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null;
};
const restoreElementWithProperties = <
@ -308,8 +324,7 @@ const restoreElement = (
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
}
// TODO: Separate arrow from linear element
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
const base = {
type: element.type,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
@ -321,7 +336,20 @@ const restoreElement = (
y,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
});
} as const;
// TODO: Separate arrow from linear element
return isElbowArrow(element)
? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
...base,
elbowed: true,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
fixedSegments: element.fixedSegments,
startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial,
})
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
}
// generic elements

@ -623,11 +623,9 @@ export const updateBoundElements = (
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>;
zoom?: AppState["zoom"];
},
) => {
const { newSize, simultaneouslyUpdated, changedElements, zoom } =
options ?? {};
const { newSize, simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
@ -661,7 +659,7 @@ export const updateBoundElements = (
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, bindings);
mutateElement(element, bindings, true);
return;
}
@ -703,23 +701,14 @@ export const updateBoundElements = (
}> => update !== null,
);
LinearElementEditor.movePoints(
element,
updates,
elementsMap,
{
LinearElementEditor.movePoints(element, updates, {
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
},
{
changedElements,
zoom,
},
);
});
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) {
@ -778,9 +767,7 @@ export const getHeadingForElbowArrowSnap = (
);
}
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
return pointHeading;
return headingForPointFromElement(bindableElement, aabb, p);
};
const getDistanceForBinding = (
@ -2283,7 +2270,7 @@ export const getGlobalFixedPointForBindableElement = (
);
};
const getGlobalFixedPoints = (
export const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap,
): [GlobalPoint, GlobalPoint] => {

@ -42,9 +42,20 @@ export const dragSelectedElements = (
return;
}
const selectedElements = _selectedElements.filter(
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
const selectedElements = _selectedElements.filter((element) => {
if (isElbowArrow(element) && element.startBinding && element.endBinding) {
const startElement = _selectedElements.find(
(el) => el.id === element.startBinding?.elementId,
);
const endElement = _selectedElements.find(
(el) => el.id === element.endBinding?.elementId,
);
return startElement && endElement;
}
return true;
});
// we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element
@ -78,10 +89,8 @@ export const dragSelectedElements = (
elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, adjustedOffset);
if (
if (!isArrowElement(element)) {
// skip arrow labels since we calculate its position during render
!isArrowElement(element)
) {
const textElement = getBoundTextElement(
element,
scene.getNonDeletedElementsMap(),
@ -89,10 +98,10 @@ export const dragSelectedElements = (
if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset);
}
}
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
}
});
};

@ -9,20 +9,121 @@ import {
render,
} from "../tests/test-utils";
import { bindLinearElement } from "./binding";
import { Excalidraw } from "../index";
import { mutateElbowArrow } from "./routing";
import { Excalidraw, mutateElement } from "../index";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElbowArrowElement,
} from "./types";
import { ARROW_TYPE } from "../constants";
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math";
const { h } = window;
const mouse = new Pointer("mouse");
describe("elbow arrow segment move", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can move the second segment of a fully connected elbow arrow", () => {
UI.createElement("rectangle", {
x: -100,
y: -50,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 200,
y: 150,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(0, 0);
mouse.click();
mouse.moveTo(200, 200);
mouse.click();
mouse.reset();
mouse.moveTo(100, 100);
mouse.down();
mouse.moveTo(115, 100);
mouse.up();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
expect(arrow.fixedSegments?.length).toBe(1);
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[110, 0],
[110, 200],
[190, 200],
]);
mouse.reset();
mouse.moveTo(105, 74.275);
mouse.doubleClick();
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[110, 0],
[110, 200],
[190, 200],
]);
});
it("can move the second segment of an unconnected elbow arrow", () => {
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(0, 0);
mouse.click();
mouse.moveTo(250, 200);
mouse.click();
mouse.reset();
mouse.moveTo(125, 100);
mouse.down();
mouse.moveTo(130, 100);
mouse.up();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[130, 0],
[130, 200],
[250, 200],
]);
mouse.reset();
mouse.moveTo(130, 100);
mouse.doubleClick();
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[125, 0],
[125, 200],
[250, 200],
]);
});
});
describe("elbow arrow routing", () => {
it("can properly generate orthogonal arrow points", () => {
const scene = new Scene();
@ -31,10 +132,12 @@ describe("elbow arrow routing", () => {
elbowed: true,
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
pointFrom(-45 - arrow.x, -100.1 - arrow.y),
pointFrom(45 - arrow.x, 99.9 - arrow.y),
]);
mutateElement(arrow, {
points: [
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
],
});
expect(arrow.points).toEqual([
[0, 0],
[0, 100],
@ -81,7 +184,9 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
});
expect(arrow.points).toEqual([
[0, 0],
@ -182,8 +287,6 @@ describe("elbow arrow ui", () => {
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0],
[35, 0],
[35, 90],
[35, 90], // Note that coordinates are rounded above!
[35, 165],
[103, 165],
]);

File diff suppressed because it is too large Load Diff

@ -452,20 +452,12 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement,
);
LinearElementEditor.movePoints(
bindingArrow,
[
LinearElementEditor.movePoints(bindingArrow, [
{
index: 1,
point: bindingArrow.points[1],
},
],
elementsMap as NonDeletedSceneElementsMap,
undefined,
{
changedElements,
},
);
]);
return bindingArrow;
};

@ -11,6 +11,7 @@ import {
pointScaleFromOrigin,
radiansToDegrees,
triangleIncludesPoint,
vectorFromPoint,
} from "../../math";
import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
@ -52,9 +53,24 @@ export const vectorToHeading = (vec: Vector): Heading => {
return HEADING_UP;
};
export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => vectorToHeading(vectorFromPoint<P>(p, o));
export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => headingIsHorizontal(headingForPoint<P>(p, o));
export const compareHeading = (a: Heading, b: Heading) =>
a[0] === b[0] && a[1] === b[1];
export const headingIsHorizontal = (a: Heading) =>
compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
// Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
@ -63,7 +79,7 @@ export const headingForPointFromElement = <
>(
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
p: Readonly<LocalPoint | GlobalPoint>,
p: Readonly<Point>,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
@ -117,14 +133,22 @@ export const headingForPointFromElement = <
element.angle,
);
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
if (
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(top, right);
} else if (
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
triangleIncludesPoint<Point>(
[right, bottom, midPoint] as Triangle<Point>,
p,
)
) {
return headingForDiamond(right, bottom);
} else if (
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
triangleIncludesPoint<Point>(
[bottom, left, midPoint] as Triangle<Point>,
p,
)
) {
return headingForDiamond(bottom, left);
}
@ -153,17 +177,17 @@ export const headingForPointFromElement = <
SEARCH_CONE_MULTIPLIER,
) as Point;
return triangleIncludesPoint(
return triangleIncludesPoint<Point>(
[topLeft, topRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_UP
: triangleIncludesPoint(
: triangleIncludesPoint<Point>(
[topRight, bottomRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_RIGHT
: triangleIncludesPoint(
: triangleIncludesPoint<Point>(
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
p,
)

@ -7,9 +7,10 @@ import type {
ExcalidrawTextElementWithContainer,
ElementsMap,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
FixedPointBinding,
SceneElementsMap,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import type { Bounds } from "./bounds";
@ -24,6 +25,7 @@ import type {
InteractiveCanvasAppState,
AppClassProperties,
NullableGridSize,
Zoom,
} from "../types";
import { mutateElement } from "./mutateElement";
@ -32,7 +34,7 @@ import {
getHoveredElementForBinding,
isBindingEnabled,
} from "./binding";
import { invariant, toBrandedType, tupleToCoors } from "../utils";
import { invariant, tupleToCoors } from "../utils";
import {
isBindingElement,
isElbowArrow,
@ -44,7 +46,6 @@ import { DRAGGING_THRESHOLD } from "../constants";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store";
import { mutateElbowArrow } from "./routing";
import type Scene from "../scene/Scene";
import type { Radians } from "../../math";
import {
@ -56,6 +57,8 @@ import {
type GlobalPoint,
type LocalPoint,
pointDistance,
pointTranslate,
vectorFromPoint,
} from "../../math";
import {
getBezierCurveLength,
@ -65,6 +68,7 @@ import {
mapIntervalToBezierT,
} from "../shapes";
import { getGridPoint } from "../snapping";
import { headingIsHorizontal, vectorToHeading } from "./heading";
const editorMidPointsCache: {
version: number | null;
@ -144,13 +148,13 @@ export class LinearElementEditor {
* @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement)
*/
static getElement(
static getElement<T extends ExcalidrawLinearElement>(
id: InstanceType<typeof LinearElementEditor>["elementId"],
elementsMap: ElementsMap,
) {
): T | null {
const element = elementsMap.get(id);
if (element) {
return element as NonDeleted<ExcalidrawLinearElement>;
return element as NonDeleted<T>;
}
return null;
}
@ -291,9 +295,7 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
LinearElementEditor.movePoints(
element,
[
LinearElementEditor.movePoints(element, [
{
index: selectedIndex,
point: pointFrom(
@ -302,9 +304,7 @@ export class LinearElementEditor {
),
isDragging: selectedIndex === lastClickedPoint,
},
],
elementsMap,
);
]);
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
@ -339,7 +339,6 @@ export class LinearElementEditor {
isDragging: pointIndex === lastClickedPoint,
};
}),
elementsMap,
);
}
@ -422,9 +421,7 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1
) {
if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints(
element,
[
LinearElementEditor.movePoints(element, [
{
index: selectedPoint,
point:
@ -432,9 +429,7 @@ export class LinearElementEditor {
? element.points[element.points.length - 1]
: element.points[0],
},
],
elementsMap,
);
]);
}
const bindingElement = isBindingEnabled(appState)
@ -495,6 +490,7 @@ export class LinearElementEditor {
// Since its not needed outside editor unless 2 pointer lines or bound text
if (
!isElbowArrow(element) &&
!appState.editingLinearElement &&
element.points.length > 2 &&
!boundText
@ -533,6 +529,7 @@ export class LinearElementEditor {
element,
element.points[index],
element.points[index + 1],
index,
appState.zoom,
)
) {
@ -573,19 +570,23 @@ export class LinearElementEditor {
scenePointer.x,
scenePointer.y,
);
if (clickedPointIndex >= 0) {
if (!isElbowArrow(element) && clickedPointIndex >= 0) {
return null;
}
const points = LinearElementEditor.getPointsGlobalCoordinates(
element,
elementsMap,
);
if (points.length >= 3 && !appState.editingLinearElement) {
if (
points.length >= 3 &&
!appState.editingLinearElement &&
!isElbowArrow(element)
) {
return null;
}
const threshold =
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
(LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords;
@ -604,10 +605,11 @@ export class LinearElementEditor {
let index = 0;
const midPoints: typeof editorMidPointsCache["points"] =
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
while (index < midPoints.length) {
if (midPoints[index] !== null) {
const distance = pointDistance(
pointFrom(midPoints[index]![0], midPoints[index]![1]),
midPoints[index]!,
pointFrom(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
@ -620,16 +622,25 @@ export class LinearElementEditor {
return null;
};
static isSegmentTooShort(
static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: GlobalPoint | LocalPoint,
endPoint: GlobalPoint | LocalPoint,
zoom: AppState["zoom"],
startPoint: P,
endPoint: P,
index: number,
zoom: Zoom,
) {
let distance = pointDistance(
pointFrom(startPoint[0], startPoint[1]),
pointFrom(endPoint[0], endPoint[1]),
if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) {
return (
pointDistance(startPoint, endPoint) * zoom.value <
LinearElementEditor.POINT_HANDLE_SIZE / 2
);
}
return false;
}
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint);
}
@ -748,12 +759,8 @@ export class LinearElementEditor {
segmentMidpoint,
elementsMap,
);
}
if (event.altKey && appState.editingLinearElement) {
if (
linearElementEditor.lastUncommittedPoint == null &&
!isElbowArrow(element)
) {
} else if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
mutateElement(element, {
points: [
...element.points,
@ -909,12 +916,7 @@ export class LinearElementEditor {
if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(
element,
[points.length - 1],
elementsMap,
app.state.zoom,
);
LinearElementEditor.deletePoints(element, [points.length - 1]);
}
return {
...appState.editingLinearElement,
@ -952,23 +954,14 @@ export class LinearElementEditor {
}
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(
element,
[
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
point: newPoint,
},
],
elementsMap,
);
]);
} else {
LinearElementEditor.addPoints(
element,
[{ point: newPoint }],
elementsMap,
app.state.zoom,
);
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
}
return {
...appState.editingLinearElement,
@ -1197,16 +1190,12 @@ export class LinearElementEditor {
// potentially expanding the bounding box
if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(
element,
[
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
},
],
elementsMap,
);
]);
}
return {
@ -1221,8 +1210,6 @@ export class LinearElementEditor {
static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) {
let offsetX = 0;
let offsetY = 0;
@ -1252,47 +1239,27 @@ export class LinearElementEditor {
return acc;
}, []);
LinearElementEditor._updatePoints(
element,
nextPoints,
offsetX,
offsetY,
elementsMap,
);
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
}
static addPoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) {
const offsetX = 0;
const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(
element,
nextPoints,
offsetX,
offsetY,
elementsMap,
);
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
}
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) {
const { points } = element;
@ -1335,7 +1302,6 @@ export class LinearElementEditor {
nextPoints,
offsetX,
offsetY,
elementsMap,
otherUpdates,
{
isDragging: targetPoints.reduce(
@ -1343,8 +1309,6 @@ export class LinearElementEditor {
dragging || targetPoint.isDragging === true,
false,
),
changedElements: options?.changedElements,
zoom: options?.zoom,
},
);
}
@ -1451,54 +1415,49 @@ export class LinearElementEditor {
nextPoints: readonly LocalPoint[],
offsetX: number,
offsetY: number,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) {
if (isElbowArrow(element)) {
const bindings: {
const updates: {
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
points?: LocalPoint[];
} = {};
if (otherUpdates?.startBinding !== undefined) {
bindings.startBinding =
updates.startBinding =
otherUpdates.startBinding !== null &&
isFixedPointBinding(otherUpdates.startBinding)
? otherUpdates.startBinding
: null;
}
if (otherUpdates?.endBinding !== undefined) {
bindings.endBinding =
updates.endBinding =
otherUpdates.endBinding !== null &&
isFixedPointBinding(otherUpdates.endBinding)
? otherUpdates.endBinding
: null;
}
const mergedElementsMap = options?.changedElements
? toBrandedType<SceneElementsMap>(
new Map([...elementsMap, ...options.changedElements]),
)
: elementsMap;
mutateElbowArrow(
element,
mergedElementsMap,
nextPoints,
updates.points = Array.from(nextPoints);
updates.points[0] = pointTranslate(
updates.points[0],
vector(offsetX, offsetY),
bindings,
{
isDragging: options?.isDragging,
zoom: options?.zoom,
},
);
updates.points[updates.points.length - 1] = pointTranslate(
updates.points[updates.points.length - 1],
vector(offsetX, offsetY),
);
mutateElement(element, updates, true, {
isDragging: options?.isDragging,
});
} else {
const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points);
@ -1773,6 +1732,99 @@ export class LinearElementEditor {
return coords;
};
static moveFixedSegment(
linearElement: LinearElementEditor,
index: number,
x: number,
y: number,
elementsMap: ElementsMap,
): LinearElementEditor {
const element = LinearElementEditor.getElement(
linearElement.elementId,
elementsMap,
);
if (!element || !isElbowArrow(element)) {
return linearElement;
}
if (index && index > 0 && index < element.points.length) {
const isHorizontal = headingIsHorizontal(
vectorToHeading(
vectorFromPoint(element.points[index], element.points[index - 1]),
),
);
const fixedSegments = (element.fixedSegments ?? []).reduce(
(segments, s) => {
segments[s.index] = s;
return segments;
},
{} as Record<number, FixedSegment>,
);
fixedSegments[index] = {
index,
start: pointFrom<LocalPoint>(
!isHorizontal ? x - element.x : element.points[index - 1][0],
isHorizontal ? y - element.y : element.points[index - 1][1],
),
end: pointFrom<LocalPoint>(
!isHorizontal ? x - element.x : element.points[index][0],
isHorizontal ? y - element.y : element.points[index][1],
),
};
const nextFixedSegments = Object.values(fixedSegments).sort(
(a, b) => a.index - b.index,
);
const offset = nextFixedSegments
.map((segment) => segment.index)
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
mutateElement(element, {
fixedSegments: nextFixedSegments,
});
const point = pointFrom<GlobalPoint>(
element.x +
(element.fixedSegments![offset].start[0] +
element.fixedSegments![offset].end[0]) /
2,
element.y +
(element.fixedSegments![offset].start[1] +
element.fixedSegments![offset].end[1]) /
2,
);
return {
...linearElement,
segmentMidPointHoveredCoords: point,
pointerDownState: {
...linearElement.pointerDownState,
segmentMidpoint: {
added: false,
index: element.fixedSegments![offset].index,
value: point,
},
},
};
}
return linearElement;
}
static deleteFixedSegment(
element: ExcalidrawElbowArrowElement,
index: number,
): void {
mutateElement(element, {
fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index,
),
});
mutateElement(element, {}, true);
}
}
const normalizeSelectedPoints = (

@ -1,10 +1,13 @@
import type { ExcalidrawElement } from "./types";
import type { ExcalidrawElement, SceneElementsMap } from "./types";
import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
import { getUpdatedTimestamp } from "../utils";
import { getUpdatedTimestamp, toBrandedType } from "../utils";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { isElbowArrow } from "./typeChecks";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Radians } from "../../math";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
@ -19,14 +22,49 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
informMutation = true,
options?: {
// Currently only for elbow arrows.
// If true, the elbow arrow tries to bind to the nearest element. If false
// it tries to keep the same bound element, if any.
isDragging?: boolean;
},
): TElement => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fileId } = updates as any;
const { points, fixedSegments, fileId } = updates as any;
if (typeof points !== "undefined") {
if (
isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined") // segment fixing
) {
const elementsMap = toBrandedType<SceneElementsMap>(
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
);
updates = {
...updates,
angle: 0 as Radians,
...updateElbowArrowPoints(
{
...element,
x: updates.x || element.x,
y: updates.y || element.y,
},
elementsMap,
{
fixedSegments,
points,
},
{
isDragging: options?.isDragging,
},
),
};
} else if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
}

@ -18,6 +18,8 @@ import type {
ExcalidrawIframeElement,
ElementsMap,
ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types";
import {
arrayToMap,
@ -450,15 +452,34 @@ export const newLinearElement = (
};
};
export const newArrowElement = (
export const newArrowElement = <T extends boolean>(
opts: {
type: ExcalidrawArrowElement["type"];
startArrowhead?: Arrowhead | null;
endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"];
elbowed?: boolean;
elbowed?: T;
fixedSegments?: FixedSegment[] | null;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawArrowElement> => {
): T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
: NonDeleted<ExcalidrawArrowElement> => {
if (opts.elbowed) {
return {
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null,
elbowed: true,
fixedSegments: opts.fixedSegments || [],
startIsSpecial: false,
endIsSpecial: false,
} as NonDeleted<ExcalidrawElbowArrowElement>;
}
return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [],
@ -467,8 +488,10 @@ export const newArrowElement = (
endBinding: null,
startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null,
elbowed: opts.elbowed || false,
};
elbowed: false,
} as T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
: NonDeleted<ExcalidrawArrowElement>;
};
export const newImageElement = (

@ -10,6 +10,7 @@ import type {
ExcalidrawImageElement,
ElementsMap,
SceneElementsMap,
ExcalidrawElbowArrowElement,
} from "./types";
import type { Mutable } from "../utility-types";
import {
@ -53,7 +54,6 @@ import {
import { wrapText } from "./textWrapping";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
import type { GlobalPoint } from "../../math";
import {
pointCenter,
@ -177,10 +177,10 @@ export const transformElements = (
elementsMap,
transformHandleType,
scene,
originalElements,
{
shouldResizeFromCenter,
shouldMaintainAspectRatio,
originalElementsMap: originalElements,
flipByX,
flipByY,
nextWidth,
@ -531,8 +531,10 @@ const rotateMultipleElements = (
);
if (isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap);
mutateElbowArrow(element, elementsMap, points);
// Needed to re-route the arrow
mutateElement(element, {
points: getArrowLocalFixedPoints(element, elementsMap),
});
} else {
mutateElement(
element,
@ -1201,6 +1203,7 @@ export const resizeMultipleElements = (
elementsMap: ElementsMap,
handleDirection: TransformHandleDirection,
scene: Scene,
originalElementsMap: ElementsMap,
{
shouldMaintainAspectRatio = false,
shouldResizeFromCenter = false,
@ -1208,7 +1211,6 @@ export const resizeMultipleElements = (
flipByY = false,
nextHeight,
nextWidth,
originalElementsMap,
originalBoundingBox,
}: {
nextWidth?: number;
@ -1217,7 +1219,6 @@ export const resizeMultipleElements = (
shouldResizeFromCenter?: boolean;
flipByX?: boolean;
flipByY?: boolean;
originalElementsMap?: ElementsMap;
// added to improve performance
originalBoundingBox?: BoundingBox;
} = {},
@ -1387,6 +1388,9 @@ export const resizeMultipleElements = (
fontSize?: ExcalidrawTextElement["fontSize"];
scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
startBinding?: ExcalidrawElbowArrowElement["startBinding"];
endBinding?: ExcalidrawElbowArrowElement["endBinding"];
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"];
};
}[] = [];
@ -1427,6 +1431,44 @@ export const resizeMultipleElements = (
...rescaledPoints,
};
if (isElbowArrow(orig)) {
// Mirror fixed point binding for elbow arrows
// when resize goes into the negative direction
if (orig.startBinding) {
update.startBinding = {
...orig.startBinding,
fixedPoint: [
flipByX
? -orig.startBinding.fixedPoint[0] + 1
: orig.startBinding.fixedPoint[0],
flipByY
? -orig.startBinding.fixedPoint[1] + 1
: orig.startBinding.fixedPoint[1],
],
};
}
if (orig.endBinding) {
update.endBinding = {
...orig.endBinding,
fixedPoint: [
flipByX
? -orig.endBinding.fixedPoint[0] + 1
: orig.endBinding.fixedPoint[0],
flipByY
? -orig.endBinding.fixedPoint[1] + 1
: orig.endBinding.fixedPoint[1],
],
};
}
if (orig.fixedSegments && rescaledPoints.points) {
update.fixedSegments = orig.fixedSegments.map((segment) => ({
...segment,
start: rescaledPoints.points[segment.index - 1],
end: rescaledPoints.points[segment.index],
}));
}
}
if (isImageElement(orig)) {
update.scale = [
orig.scale[0] * flipFactorX,
@ -1472,7 +1514,10 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) {
const { width, height, angle } = update;
mutateElement(element, update, false);
mutateElement(element, update, false, {
// needed for the fixed binding point udpate to take effect
isDragging: true,
});
updateBoundElements(element, elementsMap as SceneElementsMap, {
simultaneouslyUpdated: elementsToUpdate,

File diff suppressed because it is too large Load Diff

@ -319,6 +319,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null;
}>;
export type FixedSegment = {
start: LocalPoint;
end: LocalPoint;
index: number;
};
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
Readonly<{
type: "arrow";
@ -331,6 +337,23 @@ export type ExcalidrawElbowArrowElement = Merge<
elbowed: true;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
fixedSegments: FixedSegment[] | null;
/**
* Marks that the 3rd point should be used as the 2nd point of the arrow in
* order to temporarily hide the first segment of the arrow without losing
* the data from the points array. It allows creating the expected arrow
* path when the arrow with fixed segments is bound on a horizontal side and
* moved to a vertical and vica versa.
*/
startIsSpecial: boolean | null;
/**
* Marks that the 3rd point backwards from the end should be used as the 2nd
* point of the arrow in order to temporarily hide the last segment of the
* arrow without losing the data from the points array. It allows creating
* the expected arrow path when the arrow with fixed segments is bound on a
* horizontal side and moved to a vertical and vica versa.
*/
endIsSpecial: boolean | null;
}
>;

@ -190,7 +190,6 @@ export const syncInvalidIndices = (
): OrderedExcalidrawElement[] => {
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false);
}

@ -117,6 +117,7 @@
"fonteditor-core": "2.4.1",
"harfbuzzjs": "0.3.6",
"import-meta-loader": "1.1.0",
"jest-diff": "29.7.0",
"mini-css-extract-plugin": "2.6.1",
"postcss-loader": "7.0.1",
"sass-loader": "13.0.2",

@ -29,7 +29,7 @@ import {
getOmitSidesForDevice,
shouldShowBoundingBox,
} from "../element/transformHandles";
import { arrayToMap, throttleRAF } from "../utils";
import { arrayToMap, invariant, throttleRAF } from "../utils";
import {
DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE,
@ -78,9 +78,32 @@ import type {
InteractiveSceneRenderConfig,
RenderableElementsMap,
} from "../scene/types";
import type { GlobalPoint, LocalPoint, Radians } from "../../math";
import {
pointFrom,
type GlobalPoint,
type LocalPoint,
type Radians,
} from "../../math";
import { getCornerRadius } from "../shapes";
const renderElbowArrowMidPointHighlight = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
) => {
invariant(appState.selectedLinearElement, "selectedLinearElement is null");
const { segmentMidPointHoveredCoords } = appState.selectedLinearElement;
invariant(segmentMidPointHoveredCoords, "midPointCoords is null");
context.save();
context.translate(appState.scrollX, appState.scrollY);
highlightPoint(segmentMidPointHoveredCoords, context, appState);
context.restore();
};
const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
@ -490,7 +513,7 @@ const renderLinearPointHandles = (
context.save();
context.translate(appState.scrollX, appState.scrollY);
context.lineWidth = 1 / appState.zoom.value;
const points = LinearElementEditor.getPointsGlobalCoordinates(
const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates(
element,
elementsMap,
);
@ -510,45 +533,46 @@ const renderLinearPointHandles = (
renderSingleLinearPoint(context, appState, point, radius, isSelected);
});
//Rendering segment mid points
const midPoints = LinearElementEditor.getEditorMidPoints(
element,
elementsMap,
appState,
).filter((midPoint): midPoint is GlobalPoint => midPoint !== null);
midPoints.forEach((segmentMidPoint) => {
// Rendering segment mid points
if (isElbowArrow(element)) {
const fixedSegments =
element.fixedSegments?.map((segment) => segment.index) || [];
points.slice(0, -1).forEach((p, idx) => {
if (
appState?.selectedLinearElement?.segmentMidPointHoveredCoords &&
LinearElementEditor.arePointsEqual(
segmentMidPoint,
appState.selectedLinearElement.segmentMidPointHoveredCoords,
!LinearElementEditor.isSegmentTooShort(
element,
points[idx + 1],
points[idx],
idx,
appState.zoom,
)
) {
// The order of renderingSingleLinearPoint and highLight points is different
// inside vs outside editor as hover states are different,
// in editor when hovered the original point is not visible as hover state fully covers it whereas outside the
// editor original point is visible and hover state is just an outer circle.
if (appState.editingLinearElement) {
renderSingleLinearPoint(
context,
appState,
segmentMidPoint,
radius,
pointFrom<GlobalPoint>(
(p[0] + points[idx + 1][0]) / 2,
(p[1] + points[idx + 1][1]) / 2,
),
POINT_HANDLE_SIZE / 2,
false,
!fixedSegments.includes(idx + 1),
);
highlightPoint(segmentMidPoint, context, appState);
}
});
} else {
highlightPoint(segmentMidPoint, context, appState);
renderSingleLinearPoint(
context,
const midPoints = LinearElementEditor.getEditorMidPoints(
element,
elementsMap,
appState,
segmentMidPoint,
radius,
false,
).filter(
(midPoint, idx, midPoints): midPoint is GlobalPoint =>
midPoint !== null &&
!(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)),
);
}
} else if (appState.editingLinearElement || points.length === 2) {
midPoints.forEach((segmentMidPoint) => {
if (appState.editingLinearElement || points.length === 2) {
renderSingleLinearPoint(
context,
appState,
@ -559,6 +583,7 @@ const renderLinearPointHandles = (
);
}
});
}
context.restore();
};
@ -864,6 +889,12 @@ const _renderInteractiveScene = ({
}
if (
isElbowArrow(selectedElements[0]) &&
appState.selectedLinearElement &&
appState.selectedLinearElement.segmentMidPointHoveredCoords
) {
renderElbowArrowMidPointHighlight(context, appState);
} else if (
appState.selectedLinearElement &&
appState.selectedLinearElement.hoverPointIndex >= 0 &&
!(
@ -875,6 +906,7 @@ const _renderInteractiveScene = ({
) {
renderLinearElementPointHighlight(context, appState, elementsMap);
}
// Paint selected elements
if (!appState.multiElement && !appState.editingLinearElement) {
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);

@ -23,13 +23,9 @@ import {
} from "../element/typeChecks";
import { canChangeRoundness } from "./comparisons";
import type { EmbedsValidationStatus } from "../types";
import {
pointFrom,
pointDistance,
type GlobalPoint,
type LocalPoint,
} from "../../math";
import { pointFrom, pointDistance, type LocalPoint } from "../../math";
import { getCornerRadius, isPathALoop } from "../shapes";
import { headingForPointIsHorizontal } from "../element/heading";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@ -527,45 +523,53 @@ export const _generateElementShape = (
}
};
const generateElbowArrowShape = <Point extends GlobalPoint | LocalPoint>(
points: readonly Point[],
const generateElbowArrowShape = (
points: readonly LocalPoint[],
radius: number,
) => {
const subpoints = [] as [number, number][];
for (let i = 1; i < points.length - 1; i += 1) {
const prev = points[i - 1];
const next = points[i + 1];
const point = points[i];
const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
const nextIsHorizontal = headingForPointIsHorizontal(next, point);
const corner = Math.min(
radius,
pointDistance(points[i], next) / 2,
pointDistance(points[i], prev) / 2,
);
if (prev[0] < points[i][0] && prev[1] === points[i][1]) {
if (prevIsHorizontal) {
if (prev[0] < point[0]) {
// LEFT
subpoints.push([points[i][0] - corner, points[i][1]]);
} else if (prev[0] === points[i][0] && prev[1] < points[i][1]) {
// UP
subpoints.push([points[i][0], points[i][1] - corner]);
} else if (prev[0] > points[i][0] && prev[1] === points[i][1]) {
} else {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
}
} else if (prev[1] < point[1]) {
// UP
subpoints.push([points[i][0], points[i][1] - corner]);
} else {
subpoints.push([points[i][0], points[i][1] + corner]);
}
subpoints.push(points[i] as [number, number]);
if (next[0] < points[i][0] && next[1] === points[i][1]) {
if (nextIsHorizontal) {
if (next[0] < point[0]) {
// LEFT
subpoints.push([points[i][0] - corner, points[i][1]]);
} else if (next[0] === points[i][0] && next[1] < points[i][1]) {
// UP
subpoints.push([points[i][0], points[i][1] - corner]);
} else if (next[0] > points[i][0] && next[1] === points[i][1]) {
} else {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
}
} else if (next[1] < point[1]) {
// UP
subpoints.push([points[i][0], points[i][1] - corner]);
} else {
// DOWN
subpoints.push([points[i][0], points[i][1] + corner]);
}
}

@ -10983,7 +10983,9 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"focus": "-0.00161",
"gap": "3.53708",
},
"endIsSpecial": null,
"fillStyle": "solid",
"fixedSegments": null,
"frameId": null,
"groupIds": [],
"height": "448.10100",
@ -11000,9 +11002,13 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
0,
],
[
"451.90000",
"225.95000",
0,
],
[
"225.95000",
"448.10100",
],
[
"451.90000",
"448.10100",
@ -11022,6 +11028,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"focus": "-0.00159",
"gap": 5,
},
"startIsSpecial": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
@ -11147,7 +11154,9 @@ History {
"focus": "-0.00161",
"gap": "3.53708",
},
"endIsSpecial": false,
"fillStyle": "solid",
"fixedSegments": [],
"frameId": null,
"groupIds": [],
"height": "236.10000",
@ -11185,6 +11194,7 @@ History {
"focus": "-0.00159",
"gap": 5,
},
"startIsSpecial": false,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,

@ -171,8 +171,8 @@ describe("Crop an image", () => {
// test corner handle aspect ratio preserving
UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
expect(image.width).toBeLessThanOrEqual(initialWidth);
expect(image.height).toBeLessThanOrEqual(initialHeight);
expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001);
expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001);
// reset
image = API.createElement({ type: "image", width: 200, height: 100 });
@ -194,7 +194,7 @@ describe("Crop an image", () => {
expect(image.width).toBeCloseTo(image.height);
// max height should be reached
expect(image.height).toBeCloseTo(initialHeight);
expect(image.width).toBe(initialHeight);
expect(image.width).toBeCloseTo(initialHeight);
});
});

@ -11,6 +11,7 @@ import type {
ExcalidrawMagicFrameElement,
ExcalidrawElbowArrowElement,
ExcalidrawArrowElement,
FixedSegment,
} from "../../element/types";
import { newElement, newTextElement, newLinearElement } from "../../element";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@ -197,6 +198,7 @@ export class API {
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
: never;
elbowed?: boolean;
fixedSegments?: FixedSegment[] | null;
}): T extends "arrow" | "line"
? ExcalidrawLinearElement
: T extends "freedraw"

@ -2084,7 +2084,8 @@ describe("history", () => {
)[0] as ExcalidrawElbowArrowElement;
expect(modifiedArrow.points).toEqual([
[0, 0],
[451.9000000000001, 0],
[225.95000000000005, 0],
[225.95000000000005, 448.10100010002003],
[451.9000000000001, 448.10100010002003],
]);
});

@ -5,7 +5,6 @@ import type {
ExcalidrawLinearElement,
ExcalidrawTextElementWithContainer,
FontString,
SceneElementsMap,
} from "../element/types";
import { Excalidraw, mutateElement } from "../index";
import { reseed } from "../random";
@ -1353,9 +1352,7 @@ describe("Test Linear Elements", () => {
const [origStartX, origStartY] = [line.x, line.y];
act(() => {
LinearElementEditor.movePoints(
line,
[
LinearElementEditor.movePoints(line, [
{
index: 0,
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
@ -1367,9 +1364,7 @@ describe("Test Linear Elements", () => {
line.points[line.points.length - 1][1] - 10,
),
},
],
new Map() as SceneElementsMap,
);
]);
});
expect(line.x).toBe(origStartX + 10);
expect(line.y).toBe(origStartY + 10);

@ -535,7 +535,7 @@ describe("arrow element", () => {
UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
});
});

@ -10,6 +10,7 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
import { getSelectedElements } from "../scene/selection";
import type { ExcalidrawElement } from "../element/types";
import { UI } from "./helpers/ui";
import { diffStringsUnified } from "jest-diff";
const customQueries = {
...queries,
@ -246,6 +247,36 @@ expect.extend({
pass: false,
};
},
toCloselyEqualPoints(received, expected, precision) {
if (!Array.isArray(received) || !Array.isArray(expected)) {
throw new Error("expected and received are not point arrays");
}
const COMPARE = 1 / Math.pow(10, precision || 2);
const pass = received.every(
(point, idx) =>
Math.abs(expected[idx]?.[0] - point[0]) < COMPARE &&
Math.abs(expected[idx]?.[1] - point[1]) < COMPARE,
);
if (!pass) {
return {
message: () => ` The provided array of points are not close enough.
${diffStringsUnified(
JSON.stringify(expected, undefined, 2),
JSON.stringify(received, undefined, 2),
)}`,
pass: false,
};
}
return {
message: () => `expected ${received} to not be close to ${expected}`,
pass: true,
};
},
});
/**

@ -3,6 +3,7 @@ import {
lineSegment,
pointFrom,
type GlobalPoint,
type LocalPoint,
} from "../math";
import type { LineSegment } from "../utils";
import type { BoundingBox, Bounds } from "./element/bounds";
@ -15,6 +16,8 @@ declare global {
data: DebugElement[][];
currentFrame?: number;
};
debugDrawPoint: typeof debugDrawPoint;
debugDrawLine: typeof debugDrawLine;
}
}
@ -147,6 +150,23 @@ export const debugDrawBounds = (
);
};
export const debugDrawPoints = (
{
x,
y,
points,
}: {
x: number;
y: number;
points: LocalPoint[];
},
options?: any,
) => {
points.forEach((p) =>
debugDrawPoint(pointFrom<GlobalPoint>(x + p[0], y + p[1]), options),
);
};
export const debugCloseFrame = () => {
window.visualDebug?.data.push([]);
};

@ -1,4 +1,4 @@
import { pointCenter, pointRotateRads } from "./point";
import { pointCenter, pointFrom, pointRotateRads } from "./point";
import type { GlobalPoint, Line, LocalPoint, Radians } from "./types";
/**
@ -38,8 +38,16 @@ export function lineFromPointArray<P extends GlobalPoint | LocalPoint>(
: undefined;
}
// return the coordinates resulting from rotating the given line about an origin by an angle in degrees
// note that when the origin is not given, the midpoint of the given line is used as the origin
/**
* Return the coordinates resulting from rotating the given line about an
* origin by an angle in degrees note that when the origin is not given,
* the midpoint of the given line is used as the origin
*
* @param l
* @param angle
* @param origin
* @returns
*/
export const lineRotate = <Point extends LocalPoint | GlobalPoint>(
l: Line<Point>,
angle: Radians,
@ -50,3 +58,29 @@ export const lineRotate = <Point extends LocalPoint | GlobalPoint>(
pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle),
);
};
/**
* Determines the intersection point (unless the lines are parallel) of two
* lines
*
* @param a
* @param b
* @returns
*/
export const linesIntersectAt = <Point extends GlobalPoint | LocalPoint>(
a: Line<Point>,
b: Line<Point>,
): Point | null => {
const A1 = a[1][1] - a[0][1];
const B1 = a[0][0] - a[1][0];
const A2 = b[1][1] - b[0][1];
const B2 = b[0][0] - b[1][0];
const D = A1 * B2 - A2 * B1;
if (D !== 0) {
const C1 = A1 * a[0][0] + B1 * a[0][1];
const C2 = A2 * b[0][0] + B2 * b[0][1];
return pointFrom<Point>((C1 * B2 - C2 * B1) / D, (A1 * C2 - A2 * C1) / D);
}
return null;
};

@ -61,6 +61,22 @@ export function pointFromVector<P extends GlobalPoint | LocalPoint>(
return v as unknown as P;
}
/**
* Convert the coordiante object to a point.
*
* @param coords The coordinate object with x and y properties
* @returns
*/
export function pointFromCoords<Point extends GlobalPoint | LocalPoint>({
x,
y,
}: {
x: number;
y: number;
}) {
return [x, y] as Point;
}
/**
* Checks if the provided value has the shape of a Point.
*
@ -217,7 +233,10 @@ export function pointDistanceSq<P extends LocalPoint | GlobalPoint>(
a: P,
b: P,
): number {
return Math.hypot(b[0] - a[0], b[1] - a[1]);
const xDiff = b[0] - a[0];
const yDiff = b[1] - a[1];
return xDiff * xDiff + yDiff * yDiff;
}
/**

@ -7263,6 +7263,16 @@ jest-canvas-mock@~2.5.2:
cssfontparser "^1.2.1"
moo-color "^1.0.2"
jest-diff@29.7.0, jest-diff@^29.7.0:
version "29.7.0"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a"
integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==
dependencies:
chalk "^4.0.0"
diff-sequences "^29.6.3"
jest-get-type "^29.6.3"
pretty-format "^29.7.0"
jest-diff@^27.0.0:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def"
@ -7273,16 +7283,6 @@ jest-diff@^27.0.0:
jest-get-type "^27.5.1"
pretty-format "^27.5.1"
jest-diff@^29.7.0:
version "29.7.0"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a"
integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==
dependencies:
chalk "^4.0.0"
diff-sequences "^29.6.3"
jest-get-type "^29.6.3"
pretty-format "^29.7.0"
jest-get-type@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1"

Loading…
Cancel
Save