feat: Orthogonal (elbow) arrows for diagramming (#8299)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács 2024-08-01 18:39:03 +02:00 committed by GitHub
parent a133a70e87
commit 15e019706d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 5415 additions and 1144 deletions

View file

@ -5,19 +5,25 @@ import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import type { AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; import {
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "../element/typeChecks";
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
app: AppClassProperties,
) => { ) => {
const framesToBeDeleted = new Set( const framesToBeDeleted = new Set(
getSelectedElements( getSelectedElements(
@ -29,6 +35,26 @@ const deleteSelectedElements = (
return { return {
elements: elements.map((el) => { elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) { if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene
.getNonDeletedElementsMap()
.get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
});
mutateElbowArrow(bound, app.scene, bound.points);
}
});
}
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
@ -130,7 +156,11 @@ export const actionDeleteSelected = register({
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints(element, selectedPointsIndices); LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
app.scene,
);
return { return {
elements, elements,
@ -149,7 +179,7 @@ export const actionDeleteSelected = register({
}; };
} }
let { elements: nextElements, appState: nextAppState } = let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState); deleteSelectedElements(elements, appState, app);
fixBindingsAfterDeletion( fixBindingsAfterDeletion(
nextElements, nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]), elements.filter(({ id }) => appState.selectedElementIds[id]),

View file

@ -40,12 +40,11 @@ export const actionDuplicateSelection = register({
icon: DuplicateIcon, icon: DuplicateIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => { perform: (elements, appState, formData, app) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
// duplicate selected point(s) if editing a line // duplicate selected point(s) if editing a line
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints( const ret = LinearElementEditor.duplicateSelectedPoints(
appState, appState,
elementsMap, app.scene,
); );
if (!ret) { if (!ret) {

View file

@ -38,6 +38,7 @@ export const actionFinalize = register({
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap, elementsMap,
scene,
); );
} }
return { return {
@ -136,6 +137,7 @@ export const actionFinalize = register({
appState, appState,
{ x, y }, { x, y },
elementsMap, elementsMap,
elements,
); );
} }
} }

View file

@ -120,11 +120,14 @@ const flipElements = (
true, true,
flipDirection === "horizontal" ? maxX : minX, flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY, flipDirection === "horizontal" ? minY : maxY,
app.scene,
); );
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
elementsMap, elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState), isBindingEnabled(appState),
[], [],
); );

View file

@ -50,12 +50,13 @@ export const createUndoAction: ActionCreator = (history, store) => ({
icon: UndoIcon, icon: UndoIcon,
trackEvent: { category: "history" }, trackEvent: { category: "history" },
viewMode: false, viewMode: false,
perform: (elements, appState) => perform: (elements, appState, value, app) =>
writeData(appState, () => writeData(appState, () =>
history.undo( history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState, appState,
store.snapshot, store.snapshot,
app.scene,
), ),
), ),
keyTest: (event) => keyTest: (event) =>
@ -91,12 +92,13 @@ export const createRedoAction: ActionCreator = (history, store) => ({
icon: RedoIcon, icon: RedoIcon,
trackEvent: { category: "history" }, trackEvent: { category: "history" },
viewMode: false, viewMode: false,
perform: (elements, appState) => perform: (elements, appState, _, app) =>
writeData(appState, () => writeData(appState, () =>
history.redo( history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState, appState,
store.snapshot, store.snapshot,
app.scene,
), ),
), ),
keyTest: (event) => keyTest: (event) =>

View file

@ -1,6 +1,6 @@
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks"; import { isElbowArrow, isLinearElement } from "../element/typeChecks";
import type { ExcalidrawLinearElement } from "../element/types"; import type { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@ -29,7 +29,8 @@ export const actionToggleLinearEditor = register({
if ( if (
!appState.editingLinearElement && !appState.editingLinearElement &&
selectedElements.length === 1 && selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0])
) { ) {
return true; return true;
} }

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Point, Primitive } from "../types";
import type { StoreActionType } from "../store"; import type { StoreActionType } from "../store";
import { import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
@ -50,8 +50,12 @@ import {
ArrowheadDiamondIcon, ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon, ArrowheadDiamondOutlineIcon,
fontSizeIcon, fontSizeIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
ARROW_TYPE,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
@ -67,12 +71,15 @@ import {
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { getBoundTextElement } from "../element/textElement"; import { getBoundTextElement } from "../element/textElement";
import { import {
isArrowElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow,
isLinearElement, isLinearElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "../element/typeChecks"; } from "../element/typeChecks";
import type { import type {
Arrowhead, Arrowhead,
ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@ -91,10 +98,23 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils"; import {
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
} from "../utils";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { Fonts, getLineHeight } from "../fonts"; import { Fonts, getLineHeight } from "../fonts";
import {
bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@ -1304,8 +1324,12 @@ export const actionChangeRoundness = register({
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) => {
newElementWith(el, { if (isElbowArrow(el)) {
return el;
}
return newElementWith(el, {
roundness: roundness:
value === "round" value === "round"
? { ? {
@ -1314,8 +1338,8 @@ export const actionChangeRoundness = register({
: ROUNDNESS.PROPORTIONAL_RADIUS, : ROUNDNESS.PROPORTIONAL_RADIUS,
} }
: null, : null,
});
}), }),
),
appState: { appState: {
...appState, ...appState,
currentItemRoundness: value, currentItemRoundness: value,
@ -1355,7 +1379,8 @@ export const actionChangeRoundness = register({
appState, appState,
(element) => (element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp", hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) => element.hasOwnProperty("roundness"), (element) =>
!isArrowElement(element) && element.hasOwnProperty("roundness"),
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemRoundness, hasSelection ? null : appState.currentItemRoundness,
)} )}
@ -1518,3 +1543,219 @@ export const actionChangeArrowhead = register({
); );
}, },
}); });
export const actionChangeArrowType = register({
name: "changeArrowType",
label: "Change arrow types",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (!isArrowElement(el)) {
return el;
}
const newElement = newElementWith(el, {
roundness:
value === ARROW_TYPE.round
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
elbowed: value === ARROW_TYPE.elbow,
points:
value === ARROW_TYPE.elbow || el.elbowed
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
});
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
app.dismissLinearEditor();
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
mutateElbowArrow(
newElement,
app.scene,
[finalStartPoint, finalEndPoint].map(
(point) =>
[point[0] - newElement.x, point[1] - newElement.y] as Point,
),
[0, 0],
{
...(startElement && newElement.startBinding
? {
startBinding: {
// @ts-ignore TS cannot discern check above
...newElement.startBinding!,
...calculateFixedPointForElbowArrowBinding(
newElement,
startElement,
"start",
elementsMap,
),
},
}
: {}),
...(endElement && newElement.endBinding
? {
endBinding: {
// @ts-ignore TS cannot discern check above
...newElement.endBinding,
...calculateFixedPointForElbowArrowBinding(
newElement,
endElement,
"end",
elementsMap,
),
},
}
: {}),
},
);
} else {
mutateElement(
newElement,
{
startBinding: newElement.startBinding
? { ...newElement.startBinding, fixedPoint: null }
: null,
endBinding: newElement.endBinding
? { ...newElement.endBinding, fixedPoint: null }
: null,
},
false,
);
}
return newElement;
}),
appState: {
...appState,
currentItemArrowType: value,
},
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<legend>{t("labels.arrowtypes")}</legend>
<ButtonIconSelect
group="arrowtypes"
options={[
{
value: ARROW_TYPE.sharp,
text: t("labels.arrowtype_sharp"),
icon: sharpArrowIcon,
testId: "sharp-arrow",
},
{
value: ARROW_TYPE.round,
text: t("labels.arrowtype_round"),
icon: roundArrowIcon,
testId: "round-arrow",
},
{
value: ARROW_TYPE.elbow,
text: t("labels.arrowtype_elbowed"),
icon: elbowArrowIcon,
testId: "elbow-arrow",
},
]}
value={getFormValue(
elements,
appState,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? ARROW_TYPE.elbow
: element.roundness
? ARROW_TYPE.round
: ARROW_TYPE.sharp;
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
});

View file

@ -70,6 +70,7 @@ export type ActionName =
| "changeSloppiness" | "changeSloppiness"
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu" | "toggleCanvasMenu"

View file

@ -1,5 +1,6 @@
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
import { import {
ARROW_TYPE,
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
@ -33,6 +34,7 @@ export const getDefaultAppState = (): Omit<
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round", currentItemRoundness: "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
@ -143,6 +145,11 @@ const APP_STATE_STORAGE_CONF = (<
export: false, export: false,
server: false, server: false,
}, },
currentItemArrowType: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false }, currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false }, currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStartArrowhead: { browser: true, export: false, server: false },

View file

@ -0,0 +1,105 @@
export default class BinaryHeap<T> {
private content: T[] = [];
constructor(private scoreFunction: (node: T) => number) {}
sinkDown(idx: number) {
const node = this.content[idx];
while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent;
idx = parentN; // TODO: Optimize
} else {
break;
}
}
}
bubbleUp(idx: number) {
const length = this.content.length;
const node = this.content[idx];
const score = this.scoreFunction(node);
while (true) {
const child2N = (idx + 1) << 1;
const child1N = child2N - 1;
let swap = null;
let child1Score = 0;
if (child1N < length) {
const child1 = this.content[child1N];
child1Score = this.scoreFunction(child1);
if (child1Score < score) {
swap = child1N;
}
}
if (child2N < length) {
const child2 = this.content[child2N];
const child2Score = this.scoreFunction(child2);
if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
}
}
if (swap !== null) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break;
}
}
}
push(node: T) {
this.content.push(node);
this.sinkDown(this.content.length - 1);
}
pop(): T | null {
if (this.content.length === 0) {
return null;
}
const result = this.content[0];
const end = this.content.pop()!;
if (this.content.length > 0) {
this.content[0] = end;
this.bubbleUp(0);
}
return result;
}
remove(node: T) {
if (this.content.length === 0) {
return;
}
const i = this.content.indexOf(node);
const end = this.content.pop()!;
if (i < this.content.length) {
this.content[i] = end;
if (this.scoreFunction(end) < this.scoreFunction(node)) {
this.sinkDown(i);
} else {
this.bubbleUp(i);
}
}
}
size(): number {
return this.content.length;
}
rescoreElement(node: T) {
this.sinkDown(this.content.indexOf(node));
}
}

View file

@ -29,6 +29,7 @@ import type {
} from "./element/types"; } from "./element/types";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { getNonDeletedGroupIds } from "./groups"; import { getNonDeletedGroupIds } from "./groups";
import type Scene from "./scene/Scene";
import { getObservedAppState } from "./store"; import { getObservedAppState } from "./store";
import type { import type {
AppState, AppState,
@ -1053,6 +1054,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
public applyTo( public applyTo(
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>, snapshot: Map<string, OrderedExcalidrawElement>,
scene: Scene,
): [SceneElementsMap, boolean] { ): [SceneElementsMap, boolean] {
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements)); let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
let changedElements: Map<string, OrderedExcalidrawElement>; let changedElements: Map<string, OrderedExcalidrawElement>;
@ -1100,7 +1102,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
try { try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements); ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
ElementsChange.redrawBoundArrows(nextElements, changedElements); ElementsChange.redrawBoundArrows(nextElements, changedElements, scene);
// the following reorder performs also mutations, but only on new instances of changed elements // the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices) // (unless something goes really bad and it fallbacks to fixing all invalid indices)
@ -1457,10 +1459,13 @@ export class ElementsChange implements Change<SceneElementsMap> {
private static redrawBoundArrows( private static redrawBoundArrows(
elements: SceneElementsMap, elements: SceneElementsMap,
changed: Map<string, OrderedExcalidrawElement>, changed: Map<string, OrderedExcalidrawElement>,
scene: Scene,
) { ) {
for (const element of changed.values()) { for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) { if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements); updateBoundElements(element, elements, scene, {
changedElements: changed,
});
} }
} }
} }

View file

@ -257,8 +257,6 @@ const chartLines = (
type: "line", type: "line",
x, x,
y, y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth, width: chartWidth,
points: [ points: [
[0, 0], [0, 0],
@ -273,8 +271,6 @@ const chartLines = (
type: "line", type: "line",
x, x,
y, y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight, height: chartHeight,
points: [ points: [
[0, 0], [0, 0],
@ -289,8 +285,6 @@ const chartLines = (
type: "line", type: "line",
x, x,
y: y - BAR_HEIGHT - BAR_GAP, y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
strokeStyle: "dotted", strokeStyle: "dotted",
width: chartWidth, width: chartWidth,
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
@ -418,8 +412,6 @@ const chartTypeLine = (
type: "line", type: "line",
x: x + BAR_GAP + BAR_WIDTH / 2, x: x + BAR_GAP + BAR_WIDTH / 2,
y: y - BAR_GAP, y: y - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
height: maxY - minY, height: maxY - minY,
width: maxX - minX, width: maxX - minX,
strokeWidth: 2, strokeWidth: 2,
@ -453,8 +445,6 @@ const chartTypeLine = (
type: "line", type: "line",
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2, x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
y: y - cy, y: y - cy,
startArrowhead: null,
endArrowhead: null,
height: cy, height: cy,
strokeStyle: "dotted", strokeStyle: "dotted",
opacity: GRID_OPACITY, opacity: GRID_OPACITY,

View file

@ -21,10 +21,11 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils"; import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { import {
hasBoundTextElement, hasBoundTextElement,
isElbowArrow,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
@ -121,7 +122,8 @@ export const SelectedShapeActions = ({
const showLineEditorAction = const showLineEditorAction =
!appState.editingLinearElement && !appState.editingLinearElement &&
targetElements.length === 1 && targetElements.length === 1 &&
isLinearElement(targetElements[0]); isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
return ( return (
<div className="panelColumn"> <div className="panelColumn">
@ -155,6 +157,11 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</> <>{renderAction("changeRoundness")}</>
)} )}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<>{renderAction("changeArrowType")}</>
)}
{(appState.activeTool.type === "text" || {(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && ( targetElements.some(isTextElement)) && (
<> <>

View file

@ -48,7 +48,7 @@ import {
} from "../appState"; } from "../appState";
import type { PastedMixedContent } from "../clipboard"; import type { PastedMixedContent } from "../clipboard";
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
import type { EXPORT_IMAGE_TYPES } from "../constants"; import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants";
import { import {
APP_NAME, APP_NAME,
CURSOR_TYPE, CURSOR_TYPE,
@ -142,6 +142,7 @@ import {
newEmbeddableElement, newEmbeddableElement,
newMagicFrameElement, newMagicFrameElement,
newIframeElement, newIframeElement,
newArrowElement,
} from "../element/newElement"; } from "../element/newElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
@ -160,6 +161,7 @@ import {
isIframeLikeElement, isIframeLikeElement,
isMagicFrameElement, isMagicFrameElement,
isTextBindableContainer, isTextBindableContainer,
isElbowArrow,
} from "../element/typeChecks"; } from "../element/typeChecks";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
@ -181,6 +183,7 @@ import type {
ExcalidrawIframeElement, ExcalidrawIframeElement,
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
Ordered, Ordered,
ExcalidrawArrowElement,
} from "../element/types"; } from "../element/types";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { import {
@ -425,6 +428,7 @@ import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { actionTextAutoResize } from "../actions/actionTextAutoResize"; import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds"; import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid"; import { isMaybeMermaidDefinition } from "../mermaid";
import { mutateElbowArrow } from "../element/routing";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@ -2112,6 +2116,14 @@ class App extends React.Component<AppProps, AppState> {
}); });
}; };
public dismissLinearEditor = () => {
setTimeout(() => {
this.setState({
editingLinearElement: null,
});
});
};
public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => { public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) { if (this.unmounted || actionResult === false) {
return; return;
@ -2803,6 +2815,7 @@ class App extends React.Component<AppProps, AppState> {
), ),
), ),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
); );
} }
@ -3947,8 +3960,21 @@ class App extends React.Component<AppProps, AppState> {
} }
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
const step = const selectedElements = this.scene.getSelectedElements({
(this.state.gridSize && selectedElementIds: this.state.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const elbowArrow = selectedElements.find(isElbowArrow) as
| ExcalidrawArrowElement
| undefined;
const step = elbowArrow
? elbowArrow.startBinding || elbowArrow.endBinding
? 0
: ELEMENT_TRANSLATE_AMOUNT
: (this.state.gridSize &&
(event.shiftKey (event.shiftKey
? ELEMENT_TRANSLATE_AMOUNT ? ELEMENT_TRANSLATE_AMOUNT
: this.state.gridSize)) || : this.state.gridSize)) ||
@ -3969,26 +3995,27 @@ class App extends React.Component<AppProps, AppState> {
offsetY = step; offsetY = step;
} }
const selectedElements = this.scene.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
mutateElement(element, { mutateElement(element, {
x: element.x + offsetX, x: element.x + offsetX,
y: element.y + offsetY, y: element.y + offsetY,
}); });
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { updateBoundElements(
element,
this.scene.getNonDeletedElementsMap(),
this.scene,
{
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: selectedElements,
}); },
);
}); });
this.setState({ this.setState({
suggestedBindings: getSuggestedBindingsForArrows( suggestedBindings: getSuggestedBindingsForArrows(
selectedElements, selectedElements.filter(
(element) => element.id !== elbowArrow?.id || step !== 0,
),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
), ),
}); });
@ -4006,6 +4033,7 @@ class App extends React.Component<AppProps, AppState> {
selectedElements[0].id selectedElements[0].id
) { ) {
this.store.shouldCaptureIncrement(); this.store.shouldCaptureIncrement();
if (!isElbowArrow(selectedElement)) {
this.setState({ this.setState({
editingLinearElement: new LinearElementEditor( editingLinearElement: new LinearElementEditor(
selectedElement, selectedElement,
@ -4013,6 +4041,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
} }
}
} else if ( } else if (
isTextElement(selectedElement) || isTextElement(selectedElement) ||
isValidTextContainer(selectedElement) isValidTextContainer(selectedElement)
@ -4058,6 +4087,16 @@ class App extends React.Component<AppProps, AppState> {
})`, })`,
); );
} }
if (shape === "arrow" && this.state.activeTool.type === "arrow") {
this.setState((prevState) => ({
currentItemArrowType:
prevState.currentItemArrowType === ARROW_TYPE.sharp
? ARROW_TYPE.round
: prevState.currentItemArrowType === ARROW_TYPE.round
? ARROW_TYPE.elbow
: ARROW_TYPE.sharp,
}));
}
this.setActiveTool({ type: shape }); this.setActiveTool({ type: shape });
event.stopPropagation(); event.stopPropagation();
} else if (event.key === KEYS.Q) { } else if (event.key === KEYS.Q) {
@ -4191,6 +4230,8 @@ class App extends React.Component<AppProps, AppState> {
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
this.scene.getSelectedElements(this.state).filter(isLinearElement), this.scene.getSelectedElements(this.state).filter(isLinearElement),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.scene,
isBindingEnabled(this.state), isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
); );
@ -4422,7 +4463,7 @@ class App extends React.Component<AppProps, AppState> {
onChange: withBatchedUpdates((nextOriginalText) => { onChange: withBatchedUpdates((nextOriginalText) => {
updateElement(nextOriginalText, false); updateElement(nextOriginalText, false);
if (isNonDeletedElement(element)) { if (isNonDeletedElement(element)) {
updateBoundElements(element, elementsMap); updateBoundElements(element, elementsMap, this.scene);
} }
}), }),
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
@ -4871,7 +4912,9 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
(!this.state.editingLinearElement || (!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !== selectedElements[0].id) this.state.editingLinearElement.elementId !==
selectedElements[0].id) &&
!isElbowArrow(selectedElements[0])
) { ) {
this.store.shouldCaptureIncrement(); this.store.shouldCaptureIncrement();
this.setState({ this.setState({
@ -5214,7 +5257,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointerX, scenePointerX,
scenePointerY, scenePointerY,
this.state, this.state,
this.scene.getNonDeletedElementsMap(), this.scene,
); );
if ( if (
@ -5301,7 +5344,9 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
scenePointerX, scenePointerX,
scenePointerY, scenePointerY,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement)
? null
: this.state.gridSize,
); );
const [lastCommittedX, lastCommittedY] = const [lastCommittedX, lastCommittedY] =
@ -5325,6 +5370,24 @@ class App extends React.Component<AppProps, AppState> {
if (isPathALoop(points, this.state.zoom.value)) { if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} }
if (isElbowArrow(multiElement)) {
mutateElbowArrow(
multiElement,
this.scene,
[
...points.slice(0, -1),
[
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
],
],
undefined,
undefined,
{
isDragging: true,
},
);
} else {
// update last uncommitted point // update last uncommitted point
mutateElement(multiElement, { mutateElement(multiElement, {
points: [ points: [
@ -5336,6 +5399,7 @@ class App extends React.Component<AppProps, AppState> {
], ],
}); });
} }
}
return; return;
} }
@ -5369,8 +5433,9 @@ class App extends React.Component<AppProps, AppState> {
} }
if ( if (
!this.state.selectedLinearElement || (!this.state.selectedLinearElement ||
this.state.selectedLinearElement.hoverPointIndex === -1 this.state.selectedLinearElement.hoverPointIndex === -1) &&
!(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
) { ) {
const elementWithTransformHandleType = const elementWithTransformHandleType =
getElementWithTransformHandleType( getElementWithTransformHandleType(
@ -5658,8 +5723,13 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
} else if (this.hitElement(scenePointerX, scenePointerY, element)) { } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
if (
!isElbowArrow(element) ||
!(element.startBinding || element.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
}
if ( if (
this.state.selectedLinearElement.hoverPointIndex !== hoverPointIndex this.state.selectedLinearElement.hoverPointIndex !== hoverPointIndex
@ -6232,6 +6302,7 @@ class App extends React.Component<AppProps, AppState> {
const origin = viewportCoordsToSceneCoords(event, this.state); const origin = viewportCoordsToSceneCoords(event, this.state);
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements); const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
const isElbowArrowOnly = selectedElements.findIndex(isElbowArrow) === 0;
return { return {
origin, origin,
@ -6240,7 +6311,9 @@ class App extends React.Component<AppProps, AppState> {
getGridPoint( getGridPoint(
origin.x, origin.x,
origin.y, origin.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, event[KEYS.CTRL_OR_CMD] || isElbowArrowOnly
? null
: this.state.gridSize,
), ),
), ),
scrollbars: isOverScrollBars( scrollbars: isOverScrollBars(
@ -6421,7 +6494,7 @@ class App extends React.Component<AppProps, AppState> {
this.store, this.store,
pointerDownState.origin, pointerDownState.origin,
linearElementEditor, linearElementEditor,
this, this.scene,
); );
if (ret.hitElement) { if (ret.hitElement) {
pointerDownState.hit.element = ret.hitElement; pointerDownState.hit.element = ret.hitElement;
@ -6753,6 +6826,7 @@ class App extends React.Component<AppProps, AppState> {
const boundElement = getHoveredElementForBinding( const boundElement = getHoveredElementForBinding(
pointerDownState.origin, pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
this.scene.insertElement(element); this.scene.insertElement(element);
@ -6923,6 +6997,17 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
// Elbow arrows cannot be created by putting down points
// only the start and end points can be defined
if (isElbowArrow(multiElement) && multiElement.points.length > 1) {
mutateElement(multiElement, {
lastCommittedPoint:
multiElement.points[multiElement.points.length - 1],
});
this.actionManager.executeAction(actionFinalize);
return;
}
const { x: rx, y: ry, lastCommittedPoint } = multiElement; const { x: rx, y: ry, lastCommittedPoint } = multiElement;
// clicking inside commit zone → finalize arrow // clicking inside commit zone → finalize arrow
@ -6978,7 +7063,32 @@ class App extends React.Component<AppProps, AppState> {
? [currentItemStartArrowhead, currentItemEndArrowhead] ? [currentItemStartArrowhead, currentItemEndArrowhead]
: [null, null]; : [null, null];
const element = newLinearElement({ 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,
})
: newLinearElement({
type: elementType, type: elementType,
x: gridX, x: gridX,
y: gridY, y: gridY,
@ -6993,11 +7103,10 @@ class App extends React.Component<AppProps, AppState> {
this.state.currentItemRoundness === "round" this.state.currentItemRoundness === "round"
? { type: ROUNDNESS.PROPORTIONAL_RADIUS } ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
: null, : null,
startArrowhead,
endArrowhead,
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
this.setState((prevState) => { this.setState((prevState) => {
const nextSelectedElementIds = { const nextSelectedElementIds = {
...prevState.selectedElementIds, ...prevState.selectedElementIds,
@ -7015,7 +7124,9 @@ class App extends React.Component<AppProps, AppState> {
}); });
const boundElement = getHoveredElementForBinding( const boundElement = getHoveredElementForBinding(
pointerDownState.origin, pointerDownState.origin,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
isElbowArrow(element),
); );
this.scene.insertElement(element); this.scene.insertElement(element);
@ -7352,7 +7463,7 @@ class App extends React.Component<AppProps, AppState> {
); );
}, },
linearElementEditor, linearElementEditor,
this.scene.getNonDeletedElementsMap(), this.scene,
); );
if (didDrag) { if (didDrag) {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
@ -7476,18 +7587,24 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState, pointerDownState,
selectedElements, selectedElements,
dragOffset, dragOffset,
this.state,
this.scene, this.scene,
snapOffset, snapOffset,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
); );
if (
selectedElements.length !== 1 ||
!isElbowArrow(selectedElements[0])
) {
this.setState({ this.setState({
suggestedBindings: getSuggestedBindingsForArrows( suggestedBindings: getSuggestedBindingsForArrows(
selectedElements, selectedElements,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
), ),
}); });
}
//}
// We duplicate the selected element if alt is pressed on pointer move // We duplicate the selected element if alt is pressed on pointer move
if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) { if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
@ -7627,6 +7744,17 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(draggingElement, { mutateElement(draggingElement, {
points: [...points, [dx, dy]], points: [...points, [dx, dy]],
}); });
} else if (points.length > 1 && isElbowArrow(draggingElement)) {
mutateElbowArrow(
draggingElement,
this.scene,
[...points.slice(0, -1), [dx, dy]],
[0, 0],
undefined,
{
isDragging: true,
},
);
} else if (points.length === 2) { } else if (points.length === 2) {
mutateElement(draggingElement, { mutateElement(draggingElement, {
points: [...points.slice(0, -1), [dx, dy]], points: [...points.slice(0, -1), [dx, dy]],
@ -7832,7 +7960,7 @@ class App extends React.Component<AppProps, AppState> {
childEvent, childEvent,
this.state.editingLinearElement, this.state.editingLinearElement,
this.state, this.state,
this, this.scene,
); );
if (editingLinearElement !== this.state.editingLinearElement) { if (editingLinearElement !== this.state.editingLinearElement) {
this.setState({ this.setState({
@ -7856,7 +7984,7 @@ class App extends React.Component<AppProps, AppState> {
childEvent, childEvent,
this.state.selectedLinearElement, this.state.selectedLinearElement,
this.state, this.state,
this, this.scene,
); );
const { startBindingElement, endBindingElement } = const { startBindingElement, endBindingElement } =
@ -7868,6 +7996,7 @@ class App extends React.Component<AppProps, AppState> {
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap, elementsMap,
this.scene,
); );
} }
@ -8007,6 +8136,7 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
pointerCoords, pointerCoords,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
); );
} }
this.setState({ suggestedBindings: [], startBoundElement: null }); this.setState({ suggestedBindings: [], startBoundElement: null });
@ -8568,6 +8698,8 @@ class App extends React.Component<AppProps, AppState> {
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
linearElements, linearElements,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.scene,
isBindingEnabled(this.state), isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
); );
@ -9055,6 +9187,7 @@ class App extends React.Component<AppProps, AppState> {
}): void => { }): void => {
const hoveredBindableElement = getHoveredElementForBinding( const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords, pointerCoords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
this.setState({ this.setState({
@ -9082,7 +9215,9 @@ class App extends React.Component<AppProps, AppState> {
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => { (acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding( const hoveredBindableElement = getHoveredElementForBinding(
coords, coords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
isArrowElement(linearElement) && isElbowArrow(linearElement),
); );
if ( if (
hoveredBindableElement != null && hoveredBindableElement != null &&
@ -9610,6 +9745,7 @@ class App extends React.Component<AppProps, AppState> {
resizeY, resizeY,
pointerDownState.resize.center.x, pointerDownState.resize.center.x,
pointerDownState.resize.center.y, pointerDownState.resize.center.y,
this.scene,
) )
) { ) {
const suggestedBindings = getSuggestedBindingsForArrows( const suggestedBindings = getSuggestedBindingsForArrows(
@ -9926,6 +10062,7 @@ class App extends React.Component<AppProps, AppState> {
declare global { declare global {
interface Window { interface Window {
h: { h: {
scene: Scene;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
state: AppState; state: AppState;
setState: React.Component<any, AppState>["setState"]; setState: React.Component<any, AppState>["setState"];
@ -9952,6 +10089,12 @@ export const createTestHook = () => {
); );
}, },
}, },
scene: {
configurable: true,
get() {
return this.app?.scene;
},
},
}); });
} }
}; };

View file

@ -30,11 +30,14 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.eraserRevert"); return t("hints.eraserRevert");
} }
if (activeTool.type === "arrow" || activeTool.type === "line") { if (activeTool.type === "arrow" || activeTool.type === "line") {
if (!multiMode) { if (multiMode) {
return t("hints.linearElement");
}
return t("hints.linearElementMulti"); return t("hints.linearElementMulti");
} }
if (activeTool.type === "arrow") {
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
}
return t("hints.linearElement");
}
if (activeTool.type === "freedraw") { if (activeTool.type === "freedraw") {
return t("hints.freeDraw"); return t("hints.freeDraw");

View file

@ -1,6 +1,6 @@
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement"; import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks"; import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math"; import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons"; import { angleIcon } from "../icons";
@ -27,8 +27,9 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
scene, scene,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0]; const origElement = originalElements[0];
if (origElement) { if (origElement && !isElbowArrow(origElement)) {
const latestElement = elementsMap.get(origElement.id); const latestElement = elementsMap.get(origElement.id);
if (!latestElement) { if (!latestElement) {
return; return;
@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, { mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
updateBindings(latestElement, elementsMap); updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {
@ -65,7 +66,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, { mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
updateBindings(latestElement, elementsMap); updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {

View file

@ -31,6 +31,7 @@ const handleDimensionChange: DragInputCallbackType<
scene, scene,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0]; const origElement = originalElements[0];
if (origElement) { if (origElement) {
const keepAspectRatio = const keepAspectRatio =
@ -61,6 +62,8 @@ const handleDimensionChange: DragInputCallbackType<
keepAspectRatio, keepAspectRatio,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
); );
return; return;
@ -103,6 +106,8 @@ const handleDimensionChange: DragInputCallbackType<
keepAspectRatio, keepAspectRatio,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
); );
} }
}; };

View file

@ -25,9 +25,9 @@ export type DragInputCallbackType<
originalElementsMap: ElementsMap; originalElementsMap: ElementsMap;
shouldKeepAspectRatio: boolean; shouldKeepAspectRatio: boolean;
shouldChangeByStepSize: boolean; shouldChangeByStepSize: boolean;
scene: Scene;
nextValue?: number; nextValue?: number;
property: P; property: P;
scene: Scene;
originalAppState: AppState; originalAppState: AppState;
}) => void; }) => void;
@ -122,9 +122,9 @@ const StatsDragInput = <
originalElementsMap: app.scene.getNonDeletedElementsMap(), originalElementsMap: app.scene.getNonDeletedElementsMap(),
shouldKeepAspectRatio: shouldKeepAspectRatio!!, shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: false, shouldChangeByStepSize: false,
scene,
nextValue: rounded, nextValue: rounded,
property, property,
scene,
originalAppState: appState, originalAppState: appState,
}); });
app.syncActionResult({ storeAction: StoreAction.CAPTURE }); app.syncActionResult({ storeAction: StoreAction.CAPTURE });

View file

@ -66,8 +66,10 @@ const resizeElementInGroup = (
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene,
) => { ) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(latestElement, updates, false); mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
@ -76,8 +78,8 @@ const resizeElementInGroup = (
); );
if (boundTextElement) { if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale; const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, { updateBoundElements(latestElement, elementsMap, scene, {
newSize: { width: updates.width, height: updates.height }, oldSize: { width: oldWidth, height: oldHeight },
}); });
const latestBoundTextElement = elementsMap.get(boundTextElement.id); const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@ -109,6 +111,7 @@ const resizeGroup = (
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene,
) => { ) => {
// keep aspect ratio for groups // keep aspect ratio for groups
if (property === "width") { if (property === "width") {
@ -132,6 +135,7 @@ const resizeGroup = (
origElement, origElement,
elementsMap, elementsMap,
originalElementsMap, originalElementsMap,
scene,
); );
} }
}; };
@ -149,6 +153,7 @@ const handleDimensionChange: DragInputCallbackType<
property, property,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState); const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) { if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) { for (const atomicUnit of atomicUnits) {
@ -185,6 +190,7 @@ const handleDimensionChange: DragInputCallbackType<
originalElements, originalElements,
elementsMap, elementsMap,
originalElementsMap, originalElementsMap,
scene,
); );
} else { } else {
const [el] = elementsInUnit; const [el] = elementsInUnit;
@ -227,6 +233,8 @@ const handleDimensionChange: DragInputCallbackType<
false, false,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
false, false,
); );
} }
@ -288,6 +296,7 @@ const handleDimensionChange: DragInputCallbackType<
originalElements, originalElements,
elementsMap, elementsMap,
originalElementsMap, originalElementsMap,
scene,
); );
} else { } else {
const [el] = elementsInUnit; const [el] = elementsInUnit;
@ -320,7 +329,15 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(nextWidth, nextHeight, false, origElement, elementsMap); resizeElement(
nextWidth,
nextHeight,
false,
origElement,
elementsMap,
elements,
scene,
);
} }
} }
} }

View file

@ -1,6 +1,7 @@
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
} from "../../element/types"; } from "../../element/types";
import { rotate } from "../../math"; import { rotate } from "../../math";
@ -33,6 +34,7 @@ const moveElements = (
originalElements: readonly ExcalidrawElement[], originalElements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene,
) => { ) => {
for (let i = 0; i < elements.length; i++) { for (let i = 0; i < elements.length; i++) {
const origElement = originalElements[i]; const origElement = originalElements[i];
@ -60,6 +62,8 @@ const moveElements = (
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
originalElementsMap, originalElementsMap,
false, false,
); );
@ -71,6 +75,7 @@ const moveGroupTo = (
nextY: number, nextY: number,
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene, scene: Scene,
) => { ) => {
@ -106,6 +111,8 @@ const moveGroupTo = (
topLeftY + offsetY, topLeftY + offsetY,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
originalElementsMap, originalElementsMap,
false, false,
); );
@ -126,6 +133,7 @@ const handlePositionChange: DragInputCallbackType<
originalAppState, originalAppState,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
if (nextValue !== undefined) { if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits( for (const atomicUnit of getAtomicUnits(
@ -150,6 +158,7 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY, newTopLeftY,
elementsInUnit.map((el) => el.original), elementsInUnit.map((el) => el.original),
elementsMap, elementsMap,
elements,
originalElementsMap, originalElementsMap,
scene, scene,
); );
@ -180,6 +189,8 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
originalElementsMap, originalElementsMap,
false, false,
); );
@ -206,6 +217,7 @@ const handlePositionChange: DragInputCallbackType<
originalElements, originalElements,
elementsMap, elementsMap,
originalElementsMap, originalElementsMap,
scene,
); );
scene.triggerUpdate(); scene.triggerUpdate();

View file

@ -26,6 +26,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
scene, scene,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0]; const origElement = originalElements[0];
const [cx, cy] = [ const [cx, cy] = [
origElement.x + origElement.width / 2, origElement.x + origElement.width / 2,
@ -47,6 +48,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
originalElementsMap, originalElementsMap,
); );
return; return;
@ -78,6 +81,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap, elementsMap,
elements,
scene,
originalElementsMap, originalElementsMap,
); );
}; };
@ -104,9 +109,9 @@ const Position = ({
label={property === "x" ? "X" : "Y"} label={property === "x" ? "X" : "Y"}
elements={[element]} elements={[element]}
dragInputCallback={handlePositionChange} dragInputCallback={handlePositionChange}
scene={scene}
value={value} value={value}
property={property} property={property}
scene={scene}
appState={appState} appState={appState}
/> />
); );

View file

@ -21,6 +21,7 @@ import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils"; import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants"; import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
interface StatsProps { interface StatsProps {
scene: Scene; scene: Scene;
@ -209,12 +210,14 @@ export const StatsInner = memo(
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
{!isElbowArrow(singleElement) && (
<Angle <Angle
property="angle" property="angle"
element={singleElement} element={singleElement}
scene={scene} scene={scene}
appState={appState} appState={appState}
/> />
)}
<FontSize <FontSize
property="fontSize" property="fontSize"
element={singleElement} element={singleElement}

View file

@ -31,6 +31,7 @@ import {
isInGroup, isInGroup,
} from "../../groups"; } from "../../groups";
import { rotate } from "../../math"; import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { getFontString } from "../../utils"; import { getFontString } from "../../utils";
@ -124,6 +125,8 @@ export const resizeElement = (
keepAspectRatio: boolean, keepAspectRatio: boolean,
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
const latestElement = elementsMap.get(origElement.id); const latestElement = elementsMap.get(origElement.id);
@ -146,6 +149,8 @@ export const resizeElement = (
nextHeight = Math.max(nextHeight, minHeight); nextHeight = Math.max(nextHeight, minHeight);
} }
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement( mutateElement(
latestElement, latestElement,
{ {
@ -164,7 +169,7 @@ export const resizeElement = (
}, },
shouldInformMutation, shouldInformMutation,
); );
updateBindings(latestElement, elementsMap, { updateBindings(latestElement, elementsMap, elements, scene, {
newSize: { newSize: {
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
@ -193,6 +198,10 @@ export const resizeElement = (
} }
} }
updateBoundElements(latestElement, elementsMap, scene, {
oldSize: { width: oldWidth, height: oldHeight },
});
if (boundTextElement && boundTextFont) { if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, { mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize, fontSize: boundTextFont.fontSize,
@ -206,6 +215,8 @@ export const moveElement = (
newTopLeftY: number, newTopLeftY: number,
originalElement: ExcalidrawElement, originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
@ -244,7 +255,7 @@ export const moveElement = (
}, },
shouldInformMutation, shouldInformMutation,
); );
updateBindings(latestElement, elementsMap); updateBindings(latestElement, elementsMap, elements, scene);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
originalElement, originalElement,
@ -288,14 +299,23 @@ export const getAtomicUnits = (
export const updateBindings = ( export const updateBindings = (
latestElement: ExcalidrawElement, latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
}, },
) => { ) => {
if (isLinearElement(latestElement)) { if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], elementsMap, true, []); bindOrUnbindLinearElements(
[latestElement],
elementsMap,
elements,
scene,
true,
[],
);
} else { } else {
updateBoundElements(latestElement, elementsMap, options); updateBoundElements(latestElement, elementsMap, scene, options);
} }
}; };

View file

@ -2095,6 +2095,35 @@ export const lineEditorIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
// arrow-up-right (modified)
export const sharpArrowIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 18l12 -12" />
<path d="M18 10v-4h-4" />
</g>,
tablerIconProps,
);
// arrow-guide (modified)
export const elbowArrowIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4,19L10,19C11.097,19 12,18.097 12,17L12,9C12,7.903 12.903,7 14,7L21,7" />
<path d="M18 4l3 3l-3 3" />
</g>,
tablerIconProps,
);
// arrow-ramp-right-2 (heavily modified)
export const roundArrowIcon = createIcon(
<g>
<path d="M16,12L20,9L16,6" />
<path d="M6 20c0 -6.075 4.925 -11 11 -11h3" />
</g>,
tablerIconProps,
);
export const collapseDownIcon = createIcon( export const collapseDownIcon = createIcon(
<g> <g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />

View file

@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import type { AppProps } from "./types"; import type { AppProps, AppState } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types"; import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
@ -421,3 +421,9 @@ export const DEFAULT_FILENAME = "Untitled";
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const; export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
export const MIN_WIDTH_OR_HEIGHT = 1; export const MIN_WIDTH_OR_HEIGHT = 1;
export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
sharp: "sharp",
round: "round",
elbow: "elbow",
};

View file

@ -84,9 +84,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "ellipse-1", "elementId": "ellipse-1",
"fixedPoint": null,
"focus": -0.008153707962747813, "focus": -0.008153707962747813,
"gap": 1, "gap": 1,
}, },
@ -117,6 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id47", "elementId": "id47",
"fixedPoint": null,
"focus": -0.08139534883720931, "focus": -0.08139534883720931,
"gap": 1, "gap": 1,
}, },
@ -139,9 +142,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "ellipse-1", "elementId": "ellipse-1",
"fixedPoint": null,
"focus": 0.10666666666666667, "focus": 0.10666666666666667,
"gap": 3.834326468444573, "gap": 3.834326468444573,
}, },
@ -172,6 +177,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "diamond-1", "elementId": "diamond-1",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@ -328,9 +334,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 205, "gap": 205,
}, },
@ -361,6 +369,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "text-1", "elementId": "text-1",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@ -429,9 +438,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id40", "elementId": "id40",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@ -462,6 +473,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id39", "elementId": "id39",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@ -604,9 +616,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id44", "elementId": "id44",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@ -637,6 +651,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id43", "elementId": "id43",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@ -824,6 +839,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@ -871,6 +887,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "triangle", "endArrowhead": "triangle",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@ -1463,9 +1480,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "Alice", "elementId": "Alice",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 5.299874999999986, "gap": 5.299874999999986,
}, },
@ -1498,6 +1517,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "Bob", "elementId": "Bob",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@ -1525,9 +1545,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "B", "elementId": "B",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@ -1556,6 +1578,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "Bob", "elementId": "Bob",
"fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@ -1837,6 +1860,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@ -1889,6 +1913,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@ -1941,6 +1966,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@ -1993,6 +2019,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
}, },
], ],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",

View file

@ -1,4 +1,5 @@
import type { import type {
ExcalidrawArrowElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawElementType, ExcalidrawElementType,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -24,6 +25,7 @@ import {
} from "../element"; } from "../element";
import { import {
isArrowElement, isArrowElement,
isElbowArrow,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
@ -92,11 +94,21 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY; return DEFAULT_FONT_FAMILY;
}; };
const repairBinding = (binding: PointBinding | null) => { const repairBinding = (
element: ExcalidrawLinearElement,
binding: PointBinding | null,
): PointBinding | null => {
if (!binding) { if (!binding) {
return null; return null;
} }
return { ...binding, focus: binding.focus || 0 };
return {
...binding,
focus: binding.focus || 0,
fixedPoint: isElbowArrow(element)
? binding.fixedPoint ?? ([0, 0] as [number, number])
: null,
};
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
@ -242,11 +254,7 @@ const restoreElement = (
// @ts-ignore LEGACY type // @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough // eslint-disable-next-line no-fallthrough
case "draw": case "draw":
case "arrow": { const { startArrowhead = null, endArrowhead = null } = element;
const {
startArrowhead = null,
endArrowhead = element.type === "arrow" ? "arrow" : null,
} = element;
let x = element.x; let x = element.x;
let y = element.y; let y = element.y;
let points = // migrate old arrow model to new one let points = // migrate old arrow model to new one
@ -266,8 +274,8 @@ const restoreElement = (
(element.type as ExcalidrawElementType | "draw") === "draw" (element.type as ExcalidrawElementType | "draw") === "draw"
? "line" ? "line"
: element.type, : element.type,
startBinding: repairBinding(element.startBinding), startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element.endBinding), endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null, lastCommittedPoint: null,
startArrowhead, startArrowhead,
endArrowhead, endArrowhead,
@ -276,6 +284,36 @@ const restoreElement = (
y, y,
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); });
case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [
[0, 0],
[element.width, element.height],
]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
}
// TODO: Separate arrow from linear element
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
type: element.type,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
points,
x,
y,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
});
} }
// generic elements // generic elements

View file

@ -771,6 +771,7 @@ describe("Test Transform", () => {
const [arrow, rect] = excalidrawElements; const [arrow, rect] = excalidrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1", elementId: "rect-1",
fixedPoint: null,
focus: 0, focus: 0,
gap: 205, gap: 205,
}); });

View file

@ -13,6 +13,7 @@ import {
import { bindLinearElement } from "../element/binding"; import { bindLinearElement } from "../element/binding";
import type { ElementConstructorOpts } from "../element/newElement"; import type { ElementConstructorOpts } from "../element/newElement";
import { import {
newArrowElement,
newFrameElement, newFrameElement,
newImageElement, newImageElement,
newMagicFrameElement, newMagicFrameElement,
@ -51,6 +52,7 @@ import { getSizeFromPoints } from "../points";
import { randomId } from "../random"; import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
@ -545,7 +547,7 @@ export const convertToExcalidrawElements = (
case "arrow": { case "arrow": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width; const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height; const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
excalidrawElement = newLinearElement({ excalidrawElement = newArrowElement({
width, width,
height, height,
endArrowhead: "arrow", endArrowhead: "arrow",
@ -554,6 +556,7 @@ export const convertToExcalidrawElements = (
[width, height], [width, height],
], ],
...element, ...element,
type: "arrow",
}); });
Object.assign( Object.assign(
@ -655,7 +658,7 @@ export const convertToExcalidrawElements = (
elementStore.add(container); elementStore.add(container);
elementStore.add(text); elementStore.add(text);
if (container.type === "arrow") { if (isArrowElement(container)) {
const originalStart = const originalStart =
element.type === "arrow" ? element?.start : undefined; element.type === "arrow" ? element?.start : undefined;
const originalEnd = const originalEnd =
@ -674,7 +677,7 @@ export const convertToExcalidrawElements = (
} }
const { linearElement, startBoundElement, endBoundElement } = const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement( bindLinearElementToElement(
container as ExcalidrawArrowElement, container,
originalStart, originalStart,
originalEnd, originalEnd,
elementStore, elementStore,

View file

@ -22,8 +22,12 @@ import type {
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
OrderedExcalidrawElement,
ExcalidrawElbowArrowElement,
FixedPoint,
} from "./types"; } from "./types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import type { AppState, Point } from "../types"; import type { AppState, Point } from "../types";
import { isPointOnShape } from "../../utils/collision"; import { isPointOnShape } from "../../utils/collision";
@ -33,17 +37,38 @@ import {
isBindableElement, isBindableElement,
isBindingElement, isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils"; import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getElementShape } from "../shapes"; import { getElementShape } from "../shapes";
import {
aabbForElement,
clamp,
distanceSq2d,
getCenterForBounds,
getCenterForElement,
pointInsideBounds,
pointToVector,
rotatePoint,
} from "../math";
import {
compareHeading,
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
headingForPointFromElement,
vectorToHeading,
type Heading,
} from "./heading";
export type SuggestedBinding = export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement> | NonDeleted<ExcalidrawBindableElement>
@ -65,6 +90,8 @@ export const isBindingEnabled = (appState: AppState): boolean => {
return appState.isBindingEnabled; return appState.isBindingEnabled;
}; };
export const FIXED_BINDING_DISTANCE = 5;
const getNonDeletedElements = ( const getNonDeletedElements = (
scene: Scene, scene: Scene,
ids: readonly ExcalidrawElement["id"][], ids: readonly ExcalidrawElement["id"][],
@ -84,6 +111,7 @@ export const bindOrUnbindLinearElement = (
startBindingElement: ExcalidrawBindableElement | null | "keep", startBindingElement: ExcalidrawBindableElement | null | "keep",
endBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => { ): void => {
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
@ -95,6 +123,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
elementsMap, elementsMap,
scene,
); );
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
linearElement, linearElement,
@ -104,22 +133,21 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
elementsMap, elementsMap,
scene,
); );
const onlyUnbound = Array.from(unboundFromElementIds).filter( const onlyUnbound = Array.from(unboundFromElementIds).filter(
(id) => !boundToElementIds.has(id), (id) => !boundToElementIds.has(id),
); );
getNonDeletedElements(Scene.getScene(linearElement)!, onlyUnbound).forEach( getNonDeletedElements(scene, onlyUnbound).forEach((element) => {
(element) => {
mutateElement(element, { mutateElement(element, {
boundElements: element.boundElements?.filter( boundElements: element.boundElements?.filter(
(element) => (element) =>
element.type !== "arrow" || element.id !== linearElement.id, element.type !== "arrow" || element.id !== linearElement.id,
), ),
}); });
}, });
);
}; };
const bindOrUnbindLinearElementEdge = ( const bindOrUnbindLinearElementEdge = (
@ -132,6 +160,7 @@ const bindOrUnbindLinearElementEdge = (
// Is mutated // Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>, unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => { ): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out // "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") { if (bindableElement === "keep") {
@ -217,6 +246,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
isBindingEnabled: boolean, isBindingEnabled: boolean,
draggingPoints: readonly number[], draggingPoints: readonly number[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const startIdx = 0; const startIdx = 0;
const endIdx = selectedElement.points.length - 1; const endIdx = selectedElement.points.length - 1;
@ -228,6 +258,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
elements,
) )
: null // If binding is disabled and start is dragged, break all binds : null // If binding is disabled and start is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind : // We have to update the focus and gap of the binding, so let's rebind
@ -235,6 +266,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
elements,
); );
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
@ -242,10 +274,16 @@ const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
elements,
) )
: null // If binding is disabled and end is dragged, break all binds : null // If binding is disabled and end is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind : // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(selectedElement, "end", elementsMap); getElligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
elements,
);
return [start, end]; return [start, end];
}; };
@ -253,6 +291,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const getBindingStrategyForDraggingArrowOrJoints = ( const getBindingStrategyForDraggingArrowOrJoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>, selectedElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
isBindingEnabled: boolean, isBindingEnabled: boolean,
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
@ -265,6 +304,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
elements,
) )
: null : null
: null; : null;
@ -274,6 +314,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
elements,
) )
: null : null
: null; : null;
@ -284,6 +325,8 @@ const getBindingStrategyForDraggingArrowOrJoints = (
export const bindOrUnbindLinearElements = ( export const bindOrUnbindLinearElements = (
selectedElements: NonDeleted<ExcalidrawLinearElement>[], selectedElements: NonDeleted<ExcalidrawLinearElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
isBindingEnabled: boolean, isBindingEnabled: boolean,
draggingPoints: readonly number[] | null, draggingPoints: readonly number[] | null,
): void => { ): void => {
@ -295,15 +338,17 @@ export const bindOrUnbindLinearElements = (
isBindingEnabled, isBindingEnabled,
draggingPoints ?? [], draggingPoints ?? [],
elementsMap, elementsMap,
elements,
) )
: // The arrow itself (the shaft) or the inner joins are dragged : // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints( getBindingStrategyForDraggingArrowOrJoints(
selectedElement, selectedElement,
elementsMap, elementsMap,
elements,
isBindingEnabled, isBindingEnabled,
); );
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap); bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
}); });
}; };
@ -343,6 +388,7 @@ export const maybeBindLinearElement = (
appState: AppState, appState: AppState,
pointerCoords: { x: number; y: number }, pointerCoords: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
): void => { ): void => {
if (appState.startBoundElement != null) { if (appState.startBoundElement != null) {
bindLinearElement( bindLinearElement(
@ -352,12 +398,16 @@ export const maybeBindLinearElement = (
elementsMap, elementsMap,
); );
} }
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
pointerCoords, pointerCoords,
elements,
elementsMap, elementsMap,
isElbowArrow(linearElement) && isElbowArrow(linearElement),
); );
if (hoveredElement !== null) {
if ( if (
hoveredElement != null &&
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
linearElement, linearElement,
hoveredElement, hoveredElement,
@ -366,6 +416,7 @@ export const maybeBindLinearElement = (
) { ) {
bindLinearElement(linearElement, hoveredElement, "end", elementsMap); bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
} }
}
}; };
export const bindLinearElement = ( export const bindLinearElement = (
@ -377,8 +428,7 @@ export const bindLinearElement = (
if (!isArrowElement(linearElement)) { if (!isArrowElement(linearElement)) {
return; return;
} }
mutateElement(linearElement, { const binding: PointBinding = {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
elementId: hoveredElement.id, elementId: hoveredElement.id,
...calculateFocusAndGap( ...calculateFocusAndGap(
linearElement, linearElement,
@ -386,7 +436,18 @@ export const bindLinearElement = (
startOrEnd, startOrEnd,
elementsMap, elementsMap,
), ),
} as PointBinding, ...(isElbowArrow(linearElement)
? calculateFixedPointForElbowArrowBinding(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
)
: { fixedPoint: null }),
};
mutateElement(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
}); });
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []); const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
@ -448,13 +509,15 @@ export const getHoveredElementForBinding = (
x: number; x: number;
y: number; y: number;
}, },
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
fullShape?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition( const hoveredElement = getElementAtPosition(
[...elementsMap].map(([_, value]) => value), elements,
(element) => (element) =>
isBindableElement(element, false) && isBindableElement(element, false) &&
bindingBorderTest(element, pointerCoords, elementsMap), bindingBorderTest(element, pointerCoords, elementsMap, fullShape),
); );
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null; return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
}; };
@ -501,12 +564,14 @@ const calculateFocusAndGap = (
export const updateBoundElements = ( export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement, changedElement: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
scene: Scene,
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; oldSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>;
}, },
) => { ) => {
const { newSize, simultaneouslyUpdated } = options ?? {}; const { oldSize, simultaneouslyUpdated, changedElements } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated, simultaneouslyUpdated,
); );
@ -524,16 +589,17 @@ export const updateBoundElements = (
if (!doesNeedUpdate(element, changedElement)) { if (!doesNeedUpdate(element, changedElement)) {
return; return;
} }
const bindings = { const bindings = {
startBinding: maybeCalculateNewGapWhenScaling( startBinding: maybeCalculateNewGapWhenScaling(
changedElement, changedElement,
element.startBinding, element.startBinding,
newSize, oldSize,
), ),
endBinding: maybeCalculateNewGapWhenScaling( endBinding: maybeCalculateNewGapWhenScaling(
changedElement, changedElement,
element.endBinding, element.endBinding,
newSize, oldSize,
), ),
}; };
@ -543,23 +609,58 @@ export const updateBoundElements = (
return; return;
} }
bindableElementsVisitor( const updates = bindableElementsVisitor(
elementsMap, elementsMap,
element, element,
(bindableElement, bindingProp) => { (bindableElement, bindingProp) => {
if ( if (
bindableElement && bindableElement &&
isBindableElement(bindableElement) && isBindableElement(bindableElement) &&
(bindingProp === "startBinding" || bindingProp === "endBinding") (bindingProp === "startBinding" || bindingProp === "endBinding") &&
changedElement.id === element[bindingProp]?.elementId
) { ) {
updateBoundPoint( const point = updateBoundPoint(
element, element,
bindingProp, bindingProp,
bindings[bindingProp], bindings[bindingProp],
bindableElement, bindableElement,
elementsMap, elementsMap,
); );
if (point) {
return {
index:
bindingProp === "startBinding" ? 0 : element.points.length - 1,
point,
};
} }
}
return null;
},
).filter(
(
update,
): update is NonNullable<{
index: number;
point: Point;
isDragging?: boolean;
}> => update !== null,
);
LinearElementEditor.movePoints(
element,
updates,
scene,
{
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
},
{
changedElements,
}, },
); );
@ -586,24 +687,342 @@ const getSimultaneouslyUpdatedElementIds = (
return new Set((simultaneouslyUpdated || []).map((element) => element.id)); return new Set((simultaneouslyUpdated || []).map((element) => element.id));
}; };
export const getHeadingForElbowArrowSnap = (
point: Readonly<Point>,
otherPoint: Readonly<Point>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: Point,
): Heading => {
const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
if (!bindableElement || !aabb) {
return otherPointHeading;
}
const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
);
if (!distance) {
return vectorToHeading(
pointToVector(point, getCenterForElement(bindableElement)),
);
}
const pointHeading = headingForPointFromElement(bindableElement, aabb, point);
return pointHeading;
};
const getDistanceForBinding = (
point: Readonly<Point>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
) => {
const distance = distanceToBindableElement(
bindableElement,
point,
elementsMap,
);
const bindDistance = maxBindingGap(
bindableElement,
bindableElement.width,
bindableElement.height,
);
return distance > bindDistance ? null : distance;
};
export const bindPointToSnapToElementOutline = (
point: Readonly<Point>,
otherPoint: Readonly<Point>,
bindableElement: ExcalidrawBindableElement | undefined,
elementsMap: ElementsMap,
): Point => {
const aabb = bindableElement && aabbForElement(bindableElement);
if (bindableElement && aabb) {
// TODO: Dirty hack until tangents are properly calculated
const intersections = [
...intersectElementWithLine(
bindableElement,
[point[0], point[1] - 2 * bindableElement.height],
[point[0], point[1] + 2 * bindableElement.height],
FIXED_BINDING_DISTANCE,
elementsMap,
),
...intersectElementWithLine(
bindableElement,
[point[0] - 2 * bindableElement.width, point[1]],
[point[0] + 2 * bindableElement.width, point[1]],
FIXED_BINDING_DISTANCE,
elementsMap,
),
].map((i) =>
distanceToBindableElement(bindableElement, i, elementsMap) >
Math.min(bindableElement.width, bindableElement.height) / 2
? ([-1 * i[0], -1 * i[1]] as Point)
: i,
);
const heading = headingForPointFromElement(bindableElement, aabb, point);
const isVertical =
compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT);
const dist = distanceToBindableElement(bindableElement, point, elementsMap);
const isInner = isVertical
? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1;
intersections.sort(
(a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
);
return isInner
? headingToMidBindPoint(otherPoint, bindableElement, aabb)
: intersections.filter((i) =>
isVertical
? Math.abs(point[1] - i[1]) < 0.1
: Math.abs(point[0] - i[0]) < 0.1,
)[0] ?? point;
}
return point;
};
const headingToMidBindPoint = (
point: Point,
bindableElement: ExcalidrawBindableElement,
aabb: Bounds,
): Point => {
const center = getCenterForBounds(aabb);
const heading = vectorToHeading(pointToVector(point, center));
switch (true) {
case compareHeading(heading, HEADING_UP):
return rotatePoint(
[(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_RIGHT):
return rotatePoint(
[aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_DOWN):
return rotatePoint(
[(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
center,
bindableElement.angle,
);
default:
return rotatePoint(
[aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
center,
bindableElement.angle,
);
}
};
export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
p: Point,
): Point => {
const center = getCenterForElement(element);
const nonRotatedPoint = rotatePoint(p, center, -element.angle);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
return rotatePoint(
[element.x - FIXED_BINDING_DISTANCE, element.y],
center,
element.angle,
);
}
return rotatePoint(
[element.x, element.y - FIXED_BINDING_DISTANCE],
center,
element.angle,
);
} else if (
nonRotatedPoint[0] < element.x &&
nonRotatedPoint[1] > element.y + element.height
) {
// Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
return rotatePoint(
[element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
center,
element.angle,
);
}
return rotatePoint(
[element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
center,
element.angle,
);
} else if (
nonRotatedPoint[0] > element.x + element.width &&
nonRotatedPoint[1] > element.y + element.height
) {
// Bottom right
if (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return rotatePoint(
[
element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE,
],
center,
element.angle,
);
}
return rotatePoint(
[
element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height,
],
center,
element.angle,
);
} else if (
nonRotatedPoint[0] > element.x + element.width &&
nonRotatedPoint[1] < element.y
) {
// Top right
if (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return rotatePoint(
[element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
center,
element.angle,
);
}
return rotatePoint(
[element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
center,
element.angle,
);
}
return p;
};
export const snapToMid = (
element: ExcalidrawBindableElement,
p: Point,
tolerance: number = 0.05,
): Point => {
const { x, y, width, height, angle } = element;
const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
const nonRotated = rotatePoint(p, center, -angle);
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
const verticalThrehsold = clamp(tolerance * height, 5, 80);
const horizontalThrehsold = clamp(tolerance * width, 5, 80);
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
) {
// LEFT
return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
) {
// TOP
return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
) {
// RIGHT
return rotatePoint(
[x + width + FIXED_BINDING_DISTANCE, center[1]],
center,
angle,
);
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
) {
// DOWN
return rotatePoint(
[center[0], y + height + FIXED_BINDING_DISTANCE],
center,
angle,
);
}
return p;
};
const updateBoundPoint = ( const updateBoundPoint = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "startBinding" | "endBinding", startOrEnd: "startBinding" | "endBinding",
binding: PointBinding | null | undefined, binding: PointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): void => { ): Point | null => {
if ( if (
binding == null || binding == null ||
// We only need to update the other end if this is a 2 point line element // We only need to update the other end if this is a 2 point line element
(binding.elementId !== bindableElement.id && (binding.elementId !== bindableElement.id &&
linearElement.points.length > 2) linearElement.points.length > 2)
) { ) {
return; return null;
} }
const direction = startOrEnd === "startBinding" ? -1 : 1; const direction = startOrEnd === "startBinding" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
if (isElbowArrow(linearElement)) {
const fixedPoint =
binding.fixedPoint ??
calculateFixedPointForElbowArrowBinding(
linearElement,
bindableElement,
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint;
const globalMidPoint = [
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
] as Point;
const global = [
bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height,
] as Point;
const rotatedGlobal = rotatePoint(
global,
globalMidPoint,
bindableElement.angle,
);
return LinearElementEditor.pointFromAbsoluteCoords(
linearElement,
rotatedGlobal,
elementsMap,
);
}
const adjacentPointIndex = edgePointIndex - direction; const adjacentPointIndex = edgePointIndex - direction;
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement, linearElement,
@ -616,7 +1035,9 @@ const updateBoundPoint = (
adjacentPoint, adjacentPoint,
elementsMap, elementsMap,
); );
let newEdgePoint;
let newEdgePoint: Point;
// The linear element was not originally pointing inside the bound shape, // The linear element was not originally pointing inside the bound shape,
// we can point directly at the focus point // we can point directly at the focus point
if (binding.gap === 0) { if (binding.gap === 0) {
@ -638,22 +1059,64 @@ const updateBoundPoint = (
newEdgePoint = intersections[0]; newEdgePoint = intersections[0];
} }
} }
LinearElementEditor.movePoints(
linearElement, return LinearElementEditor.pointFromAbsoluteCoords(
[
{
index: edgePointIndex,
point: LinearElementEditor.pointFromAbsoluteCoords(
linearElement, linearElement,
newEdgePoint, newEdgePoint,
elementsMap, elementsMap,
),
},
],
{ [startOrEnd]: binding },
); );
}; };
export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => {
const bounds = [
hoveredElement.x,
hoveredElement.y,
hoveredElement.x + hoveredElement.width,
hoveredElement.y + hoveredElement.height,
] as Bounds;
const edgePointIndex =
startOrEnd === "start" ? 0 : linearElement.points.length - 1;
const globalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edgePointIndex,
elementsMap,
);
const otherGlobalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edgePointIndex,
elementsMap,
);
const snappedPoint = bindPointToSnapToElementOutline(
globalPoint,
otherGlobalPoint,
hoveredElement,
elementsMap,
);
const globalMidPoint = [
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
] as Point;
const nonRotatedSnappedGlobalPoint = rotatePoint(
snappedPoint,
globalMidPoint,
-hoveredElement.angle,
) as Point;
return {
fixedPoint: [
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
hoveredElement.width,
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
hoveredElement.height,
] as [number, number],
};
};
const maybeCalculateNewGapWhenScaling = ( const maybeCalculateNewGapWhenScaling = (
changedElement: ExcalidrawBindableElement, changedElement: ExcalidrawBindableElement,
currentBinding: PointBinding | null | undefined, currentBinding: PointBinding | null | undefined,
@ -662,26 +1125,29 @@ const maybeCalculateNewGapWhenScaling = (
if (currentBinding == null || newSize == null) { if (currentBinding == null || newSize == null) {
return currentBinding; return currentBinding;
} }
const { gap, focus, elementId } = currentBinding;
const { width: newWidth, height: newHeight } = newSize; const { width: newWidth, height: newHeight } = newSize;
const { width, height } = changedElement; const { width, height } = changedElement;
const newGap = Math.max( const newGap = Math.max(
1, 1,
Math.min( Math.min(
maxBindingGap(changedElement, newWidth, newHeight), maxBindingGap(changedElement, newWidth, newHeight),
gap * (newWidth < newHeight ? newWidth / width : newHeight / height), currentBinding.gap *
(newWidth < newHeight ? newWidth / width : newHeight / height),
), ),
); );
return { elementId, gap: newGap, focus };
return { ...currentBinding, gap: newGap };
}; };
const getElligibleElementForBindingElement = ( const getElligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
return getHoveredElementForBinding( return getHoveredElementForBinding(
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
elements,
elementsMap, elementsMap,
); );
}; };
@ -798,11 +1264,9 @@ const newBindingAfterDuplication = (
if (binding == null) { if (binding == null) {
return null; return null;
} }
const { elementId, focus, gap } = binding;
return { return {
focus, ...binding,
gap, elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
elementId: oldIdToDuplicatedId.get(elementId) ?? elementId,
}; };
}; };
@ -843,14 +1307,18 @@ const newBoundElements = (
return nextBoundElements; return nextBoundElements;
}; };
const bindingBorderTest = ( export const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>, element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number }, { x, y }: { x: number; y: number },
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
fullShape?: boolean,
): boolean => { ): boolean => {
const threshold = maxBindingGap(element, element.width, element.height); const threshold = maxBindingGap(element, element.width, element.height);
const shape = getElementShape(element, elementsMap); const shape = getElementShape(element, elementsMap);
return isPointOnShape([x, y], shape, threshold); return (
isPointOnShape([x, y], shape, threshold) ||
(fullShape === true && pointInsideBounds([x, y], aabbForElement(element)))
);
}; };
export const maxBindingGap = ( export const maxBindingGap = (
@ -865,7 +1333,7 @@ export const maxBindingGap = (
return Math.max(16, Math.min(0.25 * smallerDimension, 32)); return Math.max(16, Math.min(0.25 * smallerDimension, 32));
}; };
const distanceToBindableElement = ( export const distanceToBindableElement = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
point: Point, point: Point,
elementsMap: ElementsMap, elementsMap: ElementsMap,
@ -1408,11 +1876,11 @@ type BoundElementsVisitingFunc = (
bindingId: string, bindingId: string,
) => void; ) => void;
type BindableElementVisitingFunc = ( type BindableElementVisitingFunc<T> = (
bindableElement: ExcalidrawElement | undefined, bindableElement: ExcalidrawElement | undefined,
bindingProp: BindingProp, bindingProp: BindingProp,
bindingId: string, bindingId: string,
) => void; ) => T;
/** /**
* Tries to visit each bound element (does not have to be found). * Tries to visit each bound element (does not have to be found).
@ -1436,32 +1904,36 @@ const boundElementsVisitor = (
/** /**
* Tries to visit each bindable element (does not have to be found). * Tries to visit each bindable element (does not have to be found).
*/ */
const bindableElementsVisitor = ( const bindableElementsVisitor = <T>(
elements: ElementsMap, elements: ElementsMap,
element: ExcalidrawElement, element: ExcalidrawElement,
visit: BindableElementVisitingFunc, visit: BindableElementVisitingFunc<T>,
) => { ): T[] => {
const result: T[] = [];
if (element.frameId) { if (element.frameId) {
const id = element.frameId; const id = element.frameId;
visit(elements.get(id), "frameId", id); result.push(visit(elements.get(id), "frameId", id));
} }
if (isBoundToContainer(element)) { if (isBoundToContainer(element)) {
const id = element.containerId; const id = element.containerId;
visit(elements.get(id), "containerId", id); result.push(visit(elements.get(id), "containerId", id));
} }
if (isArrowElement(element)) { if (isArrowElement(element)) {
if (element.startBinding) { if (element.startBinding) {
const id = element.startBinding.elementId; const id = element.startBinding.elementId;
visit(elements.get(id), "startBinding", id); result.push(visit(elements.get(id), "startBinding", id));
} }
if (element.endBinding) { if (element.endBinding) {
const id = element.endBinding.elementId; const id = element.endBinding.elementId;
visit(elements.get(id), "endBinding", id); result.push(visit(elements.get(id), "endBinding", id));
} }
} }
return result;
}; };
/** /**
@ -1689,3 +2161,62 @@ export class BindableElement {
); );
}; };
} }
export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number],
element: ExcalidrawBindableElement,
) => {
const [fixedX, fixedY] = fixedPointRatio;
return rotatePoint(
[element.x + element.width * fixedX, element.y + element.height * fixedY],
getCenterForElement(element),
element.angle,
);
};
const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap,
) => {
const startElement =
arrow.startBinding &&
(elementsMap.get(arrow.startBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const endElement =
arrow.endBinding &&
(elementsMap.get(arrow.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const startPoint: Point =
startElement && arrow.startBinding
? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement,
)
: [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
const endPoint: Point =
endElement && arrow.endBinding
? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement,
)
: [
arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1],
];
return [startPoint, endPoint];
};
export const getArrowLocalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap,
) => {
const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap);
return [
LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap),
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
];
};

View file

@ -10,6 +10,7 @@ import { getGridPoint } from "../math";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import { import {
isArrowElement, isArrowElement,
isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
@ -18,9 +19,8 @@ import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
selectedElements: NonDeletedExcalidrawElement[], _selectedElements: NonDeletedExcalidrawElement[],
offset: { x: number; y: number }, offset: { x: number; y: number },
appState: AppState,
scene: Scene, scene: Scene,
snapOffset: { snapOffset: {
x: number; x: number;
@ -28,6 +28,25 @@ export const dragSelectedElements = (
}, },
gridSize: AppState["gridSize"], gridSize: AppState["gridSize"],
) => { ) => {
if (
_selectedElements.length === 1 &&
isArrowElement(_selectedElements[0]) &&
isElbowArrow(_selectedElements[0]) &&
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
) {
return;
}
const selectedElements = _selectedElements.filter(
(el) =>
!(
isArrowElement(el) &&
isElbowArrow(el) &&
el.startBinding &&
el.endBinding
),
);
// we do not want a frame and its elements to be selected at the same time // 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 // but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set // in the frame twice, hence the use of set
@ -72,9 +91,14 @@ export const dragSelectedElements = (
updateElementCoords(pointerDownState, textElement, adjustedOffset); updateElementCoords(pointerDownState, textElement, adjustedOffset);
} }
} }
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { updateBoundElements(
element,
scene.getElementsMapIncludingDeleted(),
scene,
{
simultaneouslyUpdated: Array.from(elementsToUpdate), simultaneouslyUpdated: Array.from(elementsToUpdate),
}); },
);
}); });
}; };

View file

@ -0,0 +1,146 @@
import { lineAngle } from "../../utils/geometry/geometry";
import type { Point, Vector } from "../../utils/geometry/shape";
import {
getCenterForBounds,
PointInTriangle,
rotatePoint,
scalePointFromOrigin,
} from "../math";
import type { Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
export const HEADING_DOWN = [0, 1] as Heading;
export const HEADING_LEFT = [-1, 0] as Heading;
export const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
export const headingForDiamond = (a: Point, b: Point) => {
const angle = lineAngle([a, b]);
if (angle >= 315 || angle < 45) {
return HEADING_UP;
} else if (angle >= 45 && angle < 135) {
return HEADING_RIGHT;
} else if (angle >= 135 && angle < 225) {
return HEADING_DOWN;
}
return HEADING_LEFT;
};
export const vectorToHeading = (vec: Vector): Heading => {
const [x, y] = vec;
const absX = Math.abs(x);
const absY = Math.abs(y);
if (x > absY) {
return HEADING_RIGHT;
} else if (x <= -absY) {
return HEADING_LEFT;
} else if (y > absX) {
return HEADING_DOWN;
}
return HEADING_UP;
};
export const compareHeading = (a: Heading, b: Heading) =>
a[0] === b[0] && a[1] === b[1];
// 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.
export const headingForPointFromElement = (
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
point: Readonly<Point>,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") {
if (point[0] < element.x) {
return HEADING_LEFT;
} else if (point[1] < element.y) {
return HEADING_UP;
} else if (point[0] > element.x + element.width) {
return HEADING_RIGHT;
} else if (point[1] > element.y + element.height) {
return HEADING_DOWN;
}
const top = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const right = rotatePoint(
scalePointFromOrigin(
[element.x + element.width, element.y + element.height / 2],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const bottom = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y + element.height],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const left = rotatePoint(
scalePointFromOrigin(
[element.x, element.y + element.height / 2],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
if (PointInTriangle(point, top, right, midPoint)) {
return headingForDiamond(top, right);
} else if (PointInTriangle(point, right, bottom, midPoint)) {
return headingForDiamond(right, bottom);
} else if (PointInTriangle(point, bottom, left, midPoint)) {
return headingForDiamond(bottom, left);
}
return headingForDiamond(left, top);
}
const topLeft = scalePointFromOrigin(
[aabb[0], aabb[1]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const topRight = scalePointFromOrigin(
[aabb[2], aabb[1]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomLeft = scalePointFromOrigin(
[aabb[0], aabb[3]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomRight = scalePointFromOrigin(
[aabb[2], aabb[3]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
return PointInTriangle(point, topLeft, topRight, midPoint)
? HEADING_UP
: PointInTriangle(point, topRight, bottomRight, midPoint)
? HEADING_RIGHT
: PointInTriangle(point, bottomRight, bottomLeft, midPoint)
? HEADING_DOWN
: HEADING_LEFT;
};

View file

@ -11,6 +11,7 @@ export {
newTextElement, newTextElement,
refreshTextDimensions, refreshTextDimensions,
newLinearElement, newLinearElement,
newArrowElement,
newImageElement, newImageElement,
duplicateElement, duplicateElement,
} from "./newElement"; } from "./newElement";

View file

@ -7,6 +7,8 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ElementsMap, ElementsMap,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
FixedPointBinding,
} from "./types"; } from "./types";
import { import {
distance2d, distance2d,
@ -33,7 +35,6 @@ import type {
AppState, AppState,
PointerCoords, PointerCoords,
InteractiveCanvasAppState, InteractiveCanvasAppState,
AppClassProperties,
} from "../types"; } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@ -43,13 +44,19 @@ import {
isBindingEnabled, isBindingEnabled,
} from "./binding"; } from "./binding";
import { tupleToCoors } from "../utils"; import { tupleToCoors } from "../utils";
import { isBindingElement } from "./typeChecks"; import {
isBindingElement,
isElbowArrow,
isFixedPointBinding,
} from "./typeChecks";
import { KEYS, shouldRotateWithDiscreteAngle } from "../keys"; import { KEYS, shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { DRAGGING_THRESHOLD } from "../constants"; import { DRAGGING_THRESHOLD } from "../constants";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store"; import type { Store } from "../store";
import { mutateElbowArrow } from "./routing";
import type Scene from "../scene/Scene";
const editorMidPointsCache: { const editorMidPointsCache: {
version: number | null; version: number | null;
@ -67,6 +74,7 @@ export class LinearElementEditor {
prevSelectedPointsIndices: readonly number[] | null; prevSelectedPointsIndices: readonly number[] | null;
/** index */ /** index */
lastClickedPoint: number; lastClickedPoint: number;
lastClickedIsEndPoint: boolean;
origin: Readonly<{ x: number; y: number }> | null; origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: { segmentMidpoint: {
value: Point | null; value: Point | null;
@ -91,7 +99,9 @@ export class LinearElementEditor {
this.elementId = element.id as string & { this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
}; };
LinearElementEditor.normalizePoints(element); if (!arePointsEqual(element.points[0], [0, 0])) {
console.error("Linear element is not normalized", Error().stack);
}
this.selectedPointsIndices = null; this.selectedPointsIndices = null;
this.lastUncommittedPoint = null; this.lastUncommittedPoint = null;
@ -102,6 +112,7 @@ export class LinearElementEditor {
this.pointerDownState = { this.pointerDownState = {
prevSelectedPointsIndices: null, prevSelectedPointsIndices: null,
lastClickedPoint: -1, lastClickedPoint: -1,
lastClickedIsEndPoint: false,
origin: null, origin: null,
segmentMidpoint: { segmentMidpoint: {
@ -162,8 +173,8 @@ export class LinearElementEditor {
elementsMap, elementsMap,
); );
const nextSelectedPoints = pointsSceneCoords.reduce( const nextSelectedPoints = pointsSceneCoords
(acc: number[], point, index) => { .reduce((acc: number[], point, index) => {
if ( if (
(point[0] >= selectionX1 && (point[0] >= selectionX1 &&
point[0] <= selectionX2 && point[0] <= selectionX2 &&
@ -175,9 +186,17 @@ export class LinearElementEditor {
} }
return acc; return acc;
}, }, [])
[], .filter((index) => {
); if (
isElbowArrow(element) &&
index !== 0 &&
index !== element.points.length - 1
) {
return false;
}
return true;
});
setState({ setState({
editingLinearElement: { editingLinearElement: {
@ -200,21 +219,52 @@ export class LinearElementEditor {
pointSceneCoords: { x: number; y: number }[], pointSceneCoords: { x: number; y: number }[],
) => void, ) => void,
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
elementsMap: NonDeletedSceneElementsMap, scene: Scene,
): boolean { ): boolean {
if (!linearElementEditor) { if (!linearElementEditor) {
return false; return false;
} }
const { selectedPointsIndices, elementId } = linearElementEditor; const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
return false; return false;
} }
if (
isElbowArrow(element) &&
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
linearElementEditor.pointerDownState.lastClickedPoint !== 0
) {
return false;
}
const selectedPointsIndices = isElbowArrow(element)
? linearElementEditor.selectedPointsIndices
?.reduce(
(startEnd, index) =>
(index === 0
? [0, startEnd[1]]
: [startEnd[0], element.points.length - 1]) as [
boolean | number,
boolean | number,
],
[false, false] as [number | boolean, number | boolean],
)
.filter(
(idx: number | boolean): idx is number => typeof idx === "number",
)
: linearElementEditor.selectedPointsIndices;
const lastClickedPoint = isElbowArrow(element)
? linearElementEditor.pointerDownState.lastClickedPoint > 0
? element.points.length - 1
: 0
: linearElementEditor.pointerDownState.lastClickedPoint;
// point that's being dragged (out of all selected points) // point that's being dragged (out of all selected points)
const draggingPoint = element.points[ const draggingPoint = element.points[lastClickedPoint] as
linearElementEditor.pointerDownState.lastClickedPoint | [number, number]
] as [number, number] | undefined; | undefined;
if (selectedPointsIndices && draggingPoint) { if (selectedPointsIndices && draggingPoint) {
if ( if (
@ -234,15 +284,17 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
); );
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoints(
element,
[
{ {
index: selectedIndex, index: selectedIndex,
point: [width + referencePoint[0], height + referencePoint[1]], point: [width + referencePoint[0], height + referencePoint[1]],
isDragging: isDragging: selectedIndex === lastClickedPoint,
selectedIndex ===
linearElementEditor.pointerDownState.lastClickedPoint,
}, },
]); ],
scene,
);
} else { } else {
const newDraggingPointPosition = LinearElementEditor.createPointAt( const newDraggingPointPosition = LinearElementEditor.createPointAt(
element, element,
@ -259,8 +311,7 @@ export class LinearElementEditor {
element, element,
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
const newPointPosition = const newPointPosition =
pointIndex === pointIndex === lastClickedPoint
linearElementEditor.pointerDownState.lastClickedPoint
? LinearElementEditor.createPointAt( ? LinearElementEditor.createPointAt(
element, element,
elementsMap, elementsMap,
@ -275,11 +326,10 @@ export class LinearElementEditor {
return { return {
index: pointIndex, index: pointIndex,
point: newPointPosition, point: newPointPosition,
isDragging: isDragging: pointIndex === lastClickedPoint,
pointIndex ===
linearElementEditor.pointerDownState.lastClickedPoint,
}; };
}), }),
scene,
); );
} }
@ -334,9 +384,10 @@ export class LinearElementEditor {
event: PointerEvent, event: PointerEvent,
editingLinearElement: LinearElementEditor, editingLinearElement: LinearElementEditor,
appState: AppState, appState: AppState,
app: AppClassProperties, scene: Scene,
): LinearElementEditor { ): LinearElementEditor {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const { elementId, selectedPointsIndices, isDragging, pointerDownState } = const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
editingLinearElement; editingLinearElement;
@ -361,7 +412,9 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1 selectedPoint === element.points.length - 1
) { ) {
if (isPathALoop(element.points, appState.zoom.value)) { if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoints(
element,
[
{ {
index: selectedPoint, index: selectedPoint,
point: point:
@ -369,7 +422,9 @@ export class LinearElementEditor {
? element.points[element.points.length - 1] ? element.points[element.points.length - 1]
: element.points[0], : element.points[0],
}, },
]); ],
scene,
);
} }
const bindingElement = isBindingEnabled(appState) const bindingElement = isBindingEnabled(appState)
@ -381,6 +436,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
), ),
), ),
elements,
elementsMap, elementsMap,
) )
: null; : null;
@ -645,13 +701,14 @@ export class LinearElementEditor {
store: Store, store: Store,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
app: AppClassProperties, scene: Scene,
): { ): {
didAddPoint: boolean; didAddPoint: boolean;
hitElement: NonDeleted<ExcalidrawElement> | null; hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null; linearElementEditor: LinearElementEditor | null;
} { } {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = { const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false, didAddPoint: false,
@ -685,7 +742,10 @@ export class LinearElementEditor {
); );
} }
if (event.altKey && appState.editingLinearElement) { if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) { if (
linearElementEditor.lastUncommittedPoint == null ||
!isElbowArrow(element)
) {
mutateElement(element, { mutateElement(element, {
points: [ points: [
...element.points, ...element.points,
@ -706,6 +766,7 @@ export class LinearElementEditor {
pointerDownState: { pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: -1, lastClickedPoint: -1,
lastClickedIsEndPoint: false,
origin: { x: scenePointer.x, y: scenePointer.y }, origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: { segmentMidpoint: {
value: segmentMidpoint, value: segmentMidpoint,
@ -717,6 +778,7 @@ export class LinearElementEditor {
lastUncommittedPoint: null, lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding( endBindingElement: getHoveredElementForBinding(
scenePointer, scenePointer,
elements,
elementsMap, elementsMap,
), ),
}; };
@ -749,6 +811,7 @@ export class LinearElementEditor {
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap, elementsMap,
scene,
); );
} }
} }
@ -781,6 +844,7 @@ export class LinearElementEditor {
pointerDownState: { pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: clickedPointIndex, lastClickedPoint: clickedPointIndex,
lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1,
origin: { x: scenePointer.x, y: scenePointer.y }, origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: { segmentMidpoint: {
value: segmentMidpoint, value: segmentMidpoint,
@ -815,12 +879,13 @@ export class LinearElementEditor {
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
appState: AppState, appState: AppState,
elementsMap: ElementsMap, scene: Scene,
): LinearElementEditor | null { ): LinearElementEditor | null {
if (!appState.editingLinearElement) { if (!appState.editingLinearElement) {
return null; return null;
} }
const { elementId, lastUncommittedPoint } = appState.editingLinearElement; const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
return appState.editingLinearElement; return appState.editingLinearElement;
@ -831,7 +896,7 @@ export class LinearElementEditor {
if (!event.altKey) { if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, [points.length - 1]); LinearElementEditor.deletePoints(element, [points.length - 1], scene);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@ -862,19 +927,30 @@ export class LinearElementEditor {
elementsMap, elementsMap,
scenePointerX - appState.editingLinearElement.pointerOffset.x, scenePointerX - appState.editingLinearElement.pointerOffset.x,
scenePointerY - appState.editingLinearElement.pointerOffset.y, scenePointerY - appState.editingLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: appState.gridSize,
); );
} }
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoints(
element,
[
{ {
index: element.points.length - 1, index: element.points.length - 1,
point: newPoint, point: newPoint,
}, },
]); ],
scene,
);
} else { } else {
LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]); LinearElementEditor.addPoints(
element,
appState,
[{ point: newPoint }],
scene,
);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@ -938,6 +1014,11 @@ export class LinearElementEditor {
absoluteCoords: Point, absoluteCoords: Point,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): Point { ): Point {
if (isElbowArrow(element)) {
// No rotation for elbow arrows
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
@ -1028,13 +1109,13 @@ export class LinearElementEditor {
mutateElement(element, LinearElementEditor.getNormalizedPoints(element)); mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
} }
static duplicateSelectedPoints(appState: AppState, elementsMap: ElementsMap) { static duplicateSelectedPoints(appState: AppState, scene: Scene) {
if (!appState.editingLinearElement) { if (!appState.editingLinearElement) {
return false; return false;
} }
const { selectedPointsIndices, elementId } = appState.editingLinearElement; const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element || selectedPointsIndices === null) { if (!element || selectedPointsIndices === null) {
@ -1077,12 +1158,16 @@ export class LinearElementEditor {
// potentially expanding the bounding box // potentially expanding the bounding box
if (pointAddedToEnd) { if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1]; const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoints(
element,
[
{ {
index: element.points.length - 1, index: element.points.length - 1,
point: [lastPoint[0] + 30, lastPoint[1] + 30], point: [lastPoint[0] + 30, lastPoint[1] + 30],
}, },
]); ],
scene,
);
} }
return { return {
@ -1099,6 +1184,7 @@ export class LinearElementEditor {
static deletePoints( static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[], pointIndices: readonly number[],
scene: Scene,
) { ) {
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
@ -1126,25 +1212,46 @@ export class LinearElementEditor {
return acc; return acc;
}, []); }, []);
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); LinearElementEditor._updatePoints(
element,
nextPoints,
offsetX,
offsetY,
scene,
);
} }
static addPoints( static addPoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState, appState: AppState,
targetPoints: { point: Point }[], targetPoints: { point: Point }[],
scene: Scene,
) { ) {
const offsetX = 0; const offsetX = 0;
const offsetY = 0; const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); LinearElementEditor._updatePoints(
element,
nextPoints,
offsetX,
offsetY,
scene,
);
} }
static movePoints( static movePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: Point; isDragging?: boolean }[], targetPoints: { index: number; point: Point; isDragging?: boolean }[],
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding }, scene: Scene,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
},
) { ) {
const { points } = element; const { points } = element;
@ -1192,7 +1299,16 @@ export class LinearElementEditor {
nextPoints, nextPoints,
offsetX, offsetX,
offsetY, offsetY,
scene,
otherUpdates, otherUpdates,
{
isDragging: targetPoints.reduce(
(dragging, targetPoint): boolean =>
dragging || targetPoint.isDragging === true,
false,
),
changedElements: options?.changedElements,
},
); );
} }
@ -1207,6 +1323,11 @@ export class LinearElementEditor {
elementsMap, elementsMap,
); );
// Elbow arrows don't allow midpoints
if (element && isElbowArrow(element)) {
return false;
}
if (!element) { if (!element) {
return false; return false;
} }
@ -1266,7 +1387,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
pointerCoords.x, pointerCoords.x,
pointerCoords.y, pointerCoords.y,
snapToGrid ? appState.gridSize : null, snapToGrid && !isElbowArrow(element) ? appState.gridSize : null,
); );
const points = [ const points = [
...element.points.slice(0, segmentMidpoint.index!), ...element.points.slice(0, segmentMidpoint.index!),
@ -1295,8 +1416,45 @@ export class LinearElementEditor {
nextPoints: readonly Point[], nextPoints: readonly Point[],
offsetX: number, offsetX: number,
offsetY: number, offsetY: number,
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding }, scene: Scene,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
},
) { ) {
if (isElbowArrow(element)) {
const bindings: {
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
} = {};
if (otherUpdates?.startBinding !== undefined) {
bindings.startBinding =
otherUpdates.startBinding !== null &&
isFixedPointBinding(otherUpdates.startBinding)
? otherUpdates.startBinding
: null;
}
if (otherUpdates?.endBinding !== undefined) {
bindings.endBinding =
otherUpdates.endBinding !== null &&
isFixedPointBinding(otherUpdates.endBinding)
? otherUpdates.endBinding
: null;
}
mutateElbowArrow(
element,
scene,
nextPoints,
[offsetX, offsetY],
bindings,
options,
);
} else {
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points); const prevCoords = getElementPointsCoords(element, element.points);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
@ -1313,6 +1471,7 @@ export class LinearElementEditor {
y: element.y + rotated[1], y: element.y + rotated[1],
}); });
} }
}
private static _getShiftLockedDelta( private static _getShiftLockedDelta(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
@ -1327,6 +1486,13 @@ export class LinearElementEditor {
elementsMap, elementsMap,
); );
if (isElbowArrow(element)) {
return [
scenePointer[0] - referencePointCoords[0],
scenePointer[1] - referencePointCoords[1],
];
}
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
scenePointer[0], scenePointer[0],
scenePointer[1], scenePointer[1],

View file

@ -121,6 +121,7 @@ describe("duplicating multiple elements", () => {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [0.5, 1],
}, },
}); });
@ -131,6 +132,7 @@ describe("duplicating multiple elements", () => {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [0.5, 1],
}, },
boundElements: [{ id: "text2", type: "text" }], boundElements: [{ id: "text2", type: "text" }],
}); });
@ -247,6 +249,7 @@ describe("duplicating multiple elements", () => {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [0.5, 1],
}, },
}); });
@ -263,11 +266,13 @@ describe("duplicating multiple elements", () => {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [0.5, 1],
}, },
endBinding: { endBinding: {
elementId: "rectangle-not-exists", elementId: "rectangle-not-exists",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [0.5, 1],
}, },
}); });
@ -278,11 +283,13 @@ describe("duplicating multiple elements", () => {
elementId: "rectangle-not-exists", elementId: "rectangle-not-exists",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [0.5, 1],
}, },
endBinding: { endBinding: {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [0.5, 1],
}, },
}); });

View file

@ -17,6 +17,7 @@ import type {
ExcalidrawMagicFrameElement, ExcalidrawMagicFrameElement,
ExcalidrawIframeElement, ExcalidrawIframeElement,
ElementsMap, ElementsMap,
ExcalidrawArrowElement,
} from "./types"; } from "./types";
import { import {
arrayToMap, arrayToMap,
@ -388,8 +389,6 @@ export const newFreeDrawElement = (
export const newLinearElement = ( export const newLinearElement = (
opts: { opts: {
type: ExcalidrawLinearElement["type"]; type: ExcalidrawLinearElement["type"];
startArrowhead?: Arrowhead | null;
endArrowhead?: Arrowhead | null;
points?: ExcalidrawLinearElement["points"]; points?: ExcalidrawLinearElement["points"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => { ): NonDeleted<ExcalidrawLinearElement> => {
@ -399,8 +398,29 @@ export const newLinearElement = (
lastCommittedPoint: null, lastCommittedPoint: null,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
startArrowhead: null,
endArrowhead: null,
};
};
export const newArrowElement = (
opts: {
type: ExcalidrawArrowElement["type"];
startArrowhead?: Arrowhead | null;
endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"];
elbowed?: boolean;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawArrowElement> => {
return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null, startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null, endArrowhead: opts.endArrowhead || null,
elbowed: opts.elbowed || false,
}; };
}; };

View file

@ -22,6 +22,7 @@ import {
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isFreeDrawElement, isFreeDrawElement,
isImageElement, isImageElement,
@ -30,7 +31,7 @@ import {
} from "./typeChecks"; } from "./typeChecks";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { updateBoundElements } from "./binding"; import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
import type { import type {
MaybeTransformHandleType, MaybeTransformHandleType,
TransformHandleDirection, TransformHandleDirection,
@ -51,6 +52,7 @@ import {
} from "./textElement"; } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups"; import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
export const normalizeAngle = (angle: number): number => { export const normalizeAngle = (angle: number): number => {
if (angle < 0) { if (angle < 0) {
@ -75,10 +77,12 @@ export const transformElements = (
pointerY: number, pointerY: number,
centerX: number, centerX: number,
centerY: number, centerY: number,
scene: Scene,
) => { ) => {
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
const [element] = selectedElements; const [element] = selectedElements;
if (transformHandleType === "rotation") { if (transformHandleType === "rotation") {
if (!isElbowArrow(element)) {
rotateSingleElement( rotateSingleElement(
element, element,
elementsMap, elementsMap,
@ -86,7 +90,8 @@ export const transformElements = (
pointerY, pointerY,
shouldRotateWithDiscreteAngle, shouldRotateWithDiscreteAngle,
); );
updateBoundElements(element, elementsMap); updateBoundElements(element, elementsMap, scene);
}
} else if (isTextElement(element) && transformHandleType) { } else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement( resizeSingleTextElement(
originalElements, originalElements,
@ -97,7 +102,7 @@ export const transformElements = (
pointerX, pointerX,
pointerY, pointerY,
); );
updateBoundElements(element, elementsMap); updateBoundElements(element, elementsMap, scene);
} else if (transformHandleType) { } else if (transformHandleType) {
resizeSingleElement( resizeSingleElement(
originalElements, originalElements,
@ -108,6 +113,7 @@ export const transformElements = (
shouldResizeFromCenter, shouldResizeFromCenter,
pointerX, pointerX,
pointerY, pointerY,
scene,
); );
} }
@ -123,6 +129,7 @@ export const transformElements = (
shouldRotateWithDiscreteAngle, shouldRotateWithDiscreteAngle,
centerX, centerX,
centerY, centerY,
scene,
); );
return true; return true;
} else if (transformHandleType) { } else if (transformHandleType) {
@ -135,6 +142,7 @@ export const transformElements = (
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
pointerX, pointerX,
pointerY, pointerY,
scene,
); );
return true; return true;
} }
@ -431,7 +439,17 @@ export const resizeSingleElement = (
shouldResizeFromCenter: boolean, shouldResizeFromCenter: boolean,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
scene: Scene,
) => { ) => {
// Elbow arrows cannot be resized when bound on either end
if (
isArrowElement(element) &&
isElbowArrow(element) &&
(element.startBinding || element.endBinding)
) {
return;
}
const stateAtResizeStart = originalElements.get(element.id)!; const stateAtResizeStart = originalElements.get(element.id)!;
// Gets bounds corners // Gets bounds corners
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
@ -701,8 +719,11 @@ export const resizeSingleElement = (
) { ) {
mutateElement(element, resizedElement); mutateElement(element, resizedElement);
updateBoundElements(element, elementsMap, { updateBoundElements(element, elementsMap, scene, {
newSize: { width: resizedElement.width, height: resizedElement.height }, oldSize: {
width: stateAtResizeStart.width,
height: stateAtResizeStart.height,
},
}); });
if (boundTextElement && boundTextFont != null) { if (boundTextElement && boundTextFont != null) {
@ -728,6 +749,7 @@ export const resizeMultipleElements = (
shouldMaintainAspectRatio: boolean, shouldMaintainAspectRatio: boolean,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
scene: Scene,
) => { ) => {
// map selected elements to the original elements. While it never should // map selected elements to the original elements. While it never should
// happen that pointerDownState.originalElements won't contain the selected // happen that pointerDownState.originalElements won't contain the selected
@ -955,13 +977,20 @@ export const resizeMultipleElements = (
element, element,
update: { boundTextFontSize, ...update }, update: { boundTextFontSize, ...update },
} of elementsAndUpdates) { } of elementsAndUpdates) {
const { width, height, angle } = update; const { angle } = update;
const { width: oldWidth, height: oldHeight } = element;
mutateElement(element, update, false); mutateElement(element, update, false);
updateBoundElements(element, elementsMap, { if (isArrowElement(element) && isElbowArrow(element)) {
mutateElbowArrow(element, scene, element.points, undefined, undefined, {
informMutation: false,
});
}
updateBoundElements(element, elementsMap, scene, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height }, oldSize: { width: oldWidth, height: oldHeight },
}); });
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
@ -990,6 +1019,7 @@ const rotateMultipleElements = (
shouldRotateWithDiscreteAngle: boolean, shouldRotateWithDiscreteAngle: boolean,
centerX: number, centerX: number,
centerY: number, centerY: number,
scene: Scene,
) => { ) => {
let centerAngle = let centerAngle =
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX); (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
@ -1013,6 +1043,11 @@ const rotateMultipleElements = (
centerY, centerY,
centerAngle + origAngle - element.angle, centerAngle + origAngle - element.angle,
); );
if (isArrowElement(element) && isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap);
mutateElbowArrow(element, scene, points);
} else {
mutateElement( mutateElement(
element, element,
{ {
@ -1022,7 +1057,9 @@ const rotateMultipleElements = (
}, },
false, false,
); );
updateBoundElements(element, elementsMap, { }
updateBoundElements(element, elementsMap, scene, {
simultaneouslyUpdated: elements, simultaneouslyUpdated: elements,
}); });

View file

@ -0,0 +1,216 @@
import React from "react";
import Scene from "../scene/Scene";
import { API } from "../tests/helpers/api";
import { Pointer, UI } from "../tests/helpers/ui";
import {
fireEvent,
GlobalTestState,
queryByTestId,
render,
} from "../tests/test-utils";
import { bindLinearElement } from "./binding";
import { Excalidraw } from "../index";
import { mutateElbowArrow } from "./routing";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElbowArrowElement,
} from "./types";
import { ARROW_TYPE } from "../constants";
const { h } = window;
const mouse = new Pointer("mouse");
const editInput = (input: HTMLInputElement, value: string) => {
input.focus();
fireEvent.change(input, { target: { value } });
input.blur();
};
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
describe("elbow arrow routing", () => {
it("can properly generate orthogonal arrow points", () => {
const scene = new Scene();
const arrow = API.createElement({
type: "arrow",
elbowed: true,
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElbowArrow(arrow, scene, [
[-45 - arrow.x, -100.1 - arrow.y],
[45 - arrow.x, 99.9 - arrow.y],
]);
expect(arrow.points).toEqual([
[0, 0],
[0, 100],
[90, 100],
[90, 200],
]);
expect(arrow.x).toEqual(-45);
expect(arrow.y).toEqual(-100.1);
expect(arrow.width).toEqual(90);
expect(arrow.height).toEqual(200);
});
it("can generate proper points for bound elbow arrow", () => {
const scene = new Scene();
const rectangle1 = API.createElement({
type: "rectangle",
x: -150,
y: -150,
width: 100,
height: 100,
}) as ExcalidrawBindableElement;
const rectangle2 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 100,
height: 100,
}) as ExcalidrawBindableElement;
const arrow = API.createElement({
type: "arrow",
elbowed: true,
x: -45,
y: -100.1,
width: 90,
height: 200,
points: [
[0, 0],
[90, 200],
],
}) as ExcalidrawElbowArrowElement;
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
scene.insertElement(arrow);
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(arrow, rectangle1, "start", elementsMap);
bindLinearElement(arrow, rectangle2, "end", elementsMap);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, scene, [
[0, 0],
[90, 200],
]);
expect(arrow.points).toEqual([
[0, 0],
[45, 0],
[45, 200],
[90, 200],
]);
});
});
describe("elbow arrow ui", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can follow bound shapes", 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");
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
mouse.reset();
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(arrow.type).toBe("arrow");
expect(arrow.elbowed).toBe(true);
expect(arrow.points).toEqual([
[0, 0],
[35, 0],
[35, 200],
[90, 200],
]);
});
it("can follow bound rotated shapes", 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;
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
mouse.click(51, 51);
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
editInput(inputAngle, String("40"));
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0],
[35, 0],
[35, 90],
[25, 90],
[25, 165],
[103, 165],
]);
});
});

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,11 @@ import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import { isFrameLikeElement, isLinearElement } from "./typeChecks"; import {
isElbowArrow,
isFrameLikeElement,
isLinearElement,
} from "./typeChecks";
import { import {
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
isAndroid, isAndroid,
@ -262,7 +266,11 @@ export const getTransformHandles = (
// so that when locked element is selected (especially when you toggle lock // so that when locked element is selected (especially when you toggle lock
// via keyboard) the locked element is visually distinct, indicating // via keyboard) the locked element is visually distinct, indicating
// you can't move/resize // you can't move/resize
if (element.locked) { if (
element.locked ||
// Elbow arrows cannot be rotated
isElbowArrow(element)
) {
return {}; return {};
} }
@ -312,6 +320,9 @@ export const shouldShowBoundingBox = (
return true; return true;
} }
const element = elements[0]; const element = elements[0];
if (isElbowArrow(element)) {
return false;
}
if (!isLinearElement(element)) { if (!isLinearElement(element)) {
return true; return true;
} }

View file

@ -21,6 +21,9 @@ import type {
ExcalidrawIframeLikeElement, ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement, ExcalidrawMagicFrameElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
PointBinding,
FixedPointBinding,
} from "./types"; } from "./types";
export const isInitializedImageElement = ( export const isInitializedImageElement = (
@ -106,6 +109,12 @@ export const isArrowElement = (
return element != null && element.type === "arrow"; return element != null && element.type === "arrow";
}; };
export const isElbowArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawElbowArrowElement => {
return isArrowElement(element) && element.elbowed;
};
export const isLinearElementType = ( export const isLinearElementType = (
elementType: ElementOrToolType, elementType: ElementOrToolType,
): boolean => { ): boolean => {
@ -150,6 +159,22 @@ export const isBindableElement = (
); );
}; };
export const isRectanguloidElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawBindableElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
export const isTextBindableContainer = ( export const isTextBindableContainer = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
includeLocked = true, includeLocked = true,
@ -263,3 +288,9 @@ export const getDefaultRoundnessTypeForElement = (
return null; return null;
}; };
export const isFixedPointBinding = (
binding: PointBinding,
): binding is FixedPointBinding => {
return binding.fixedPoint != null;
};

View file

@ -6,7 +6,12 @@ import type {
THEME, THEME,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import type { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types"; import type {
MakeBrand,
MarkNonNullable,
Merge,
ValueOf,
} from "../utility-types";
import type { MagicCacheData } from "../data/magic"; import type { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
@ -228,12 +233,22 @@ export type ExcalidrawTextElementWithContainer = {
containerId: ExcalidrawTextContainer["id"]; containerId: ExcalidrawTextContainer["id"];
} & ExcalidrawTextElement; } & ExcalidrawTextElement;
export type FixedPoint = [number, number];
export type PointBinding = { export type PointBinding = {
elementId: ExcalidrawBindableElement["id"]; elementId: ExcalidrawBindableElement["id"];
focus: number; focus: number;
gap: number; gap: number;
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint | null;
}; };
export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>;
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
| "bar" | "bar"
@ -259,8 +274,18 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
export type ExcalidrawArrowElement = ExcalidrawLinearElement & export type ExcalidrawArrowElement = ExcalidrawLinearElement &
Readonly<{ Readonly<{
type: "arrow"; type: "arrow";
elbowed: boolean;
}>; }>;
export type ExcalidrawElbowArrowElement = Merge<
ExcalidrawArrowElement,
{
elbowed: true;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
}
>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "freedraw"; type: "freedraw";

View file

@ -1,6 +1,7 @@
import type { AppStateChange, ElementsChange } from "./change"; import type { AppStateChange, ElementsChange } from "./change";
import type { SceneElementsMap } from "./element/types"; import type { SceneElementsMap } from "./element/types";
import { Emitter } from "./emitter"; import { Emitter } from "./emitter";
import type Scene from "./scene/Scene";
import type { Snapshot } from "./store"; import type { Snapshot } from "./store";
import type { AppState } from "./types"; import type { AppState } from "./types";
@ -64,6 +65,7 @@ export class History {
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
snapshot: Readonly<Snapshot>, snapshot: Readonly<Snapshot>,
scene: Scene,
) { ) {
return this.perform( return this.perform(
elements, elements,
@ -71,6 +73,7 @@ export class History {
snapshot, snapshot,
() => History.pop(this.undoStack), () => History.pop(this.undoStack),
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements), (entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
scene,
); );
} }
@ -78,6 +81,7 @@ export class History {
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
snapshot: Readonly<Snapshot>, snapshot: Readonly<Snapshot>,
scene: Scene,
) { ) {
return this.perform( return this.perform(
elements, elements,
@ -85,6 +89,7 @@ export class History {
snapshot, snapshot,
() => History.pop(this.redoStack), () => History.pop(this.redoStack),
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements), (entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
scene,
); );
} }
@ -94,6 +99,7 @@ export class History {
snapshot: Readonly<Snapshot>, snapshot: Readonly<Snapshot>,
pop: () => HistoryEntry | null, pop: () => HistoryEntry | null,
push: (entry: HistoryEntry) => void, push: (entry: HistoryEntry) => void,
scene: Scene,
): [SceneElementsMap, AppState] | void { ): [SceneElementsMap, AppState] | void {
try { try {
let historyEntry = pop(); let historyEntry = pop();
@ -110,7 +116,7 @@ export class History {
while (historyEntry) { while (historyEntry) {
try { try {
[nextElements, nextAppState, containsVisibleChange] = [nextElements, nextAppState, containsVisibleChange] =
historyEntry.applyTo(nextElements, nextAppState, snapshot); historyEntry.applyTo(nextElements, nextAppState, snapshot, scene);
} finally { } finally {
// make sure to always push / pop, even if the increment is corrupted // make sure to always push / pop, even if the increment is corrupted
push(historyEntry); push(historyEntry);
@ -181,9 +187,10 @@ export class HistoryEntry {
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
snapshot: Readonly<Snapshot>, snapshot: Readonly<Snapshot>,
scene: Scene,
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = const [nextElements, elementsContainVisibleChange] =
this.elementsChange.applyTo(elements, snapshot.elements); this.elementsChange.applyTo(elements, snapshot.elements, scene);
const [nextAppState, appStateContainsVisibleChange] = const [nextAppState, appStateContainsVisibleChange] =
this.appStateChange.applyTo(appState, nextElements); this.appStateChange.applyTo(appState, nextElements);

View file

@ -46,6 +46,10 @@
"arrowhead_triangle_outline": "Triangle (outline)", "arrowhead_triangle_outline": "Triangle (outline)",
"arrowhead_diamond": "Diamond", "arrowhead_diamond": "Diamond",
"arrowhead_diamond_outline": "Diamond (outline)", "arrowhead_diamond_outline": "Diamond (outline)",
"arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow",
"arrowtype_elbowed": "Elbow arrow",
"fontSize": "Font size", "fontSize": "Font size",
"fontFamily": "Font family", "fontFamily": "Font family",
"addWatermark": "Add \"Made with Excalidraw\"", "addWatermark": "Add \"Made with Excalidraw\"",
@ -295,6 +299,7 @@
"hints": { "hints": {
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool", "canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
"linearElement": "Click to start multiple points, drag for single line", "linearElement": "Click to start multiple points, drag for single line",
"arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",
"freeDraw": "Click and drag, release when you're finished", "freeDraw": "Click and drag, release when you're finished",
"text": "Tip: you can also add text by double-clicking anywhere with the selection tool", "text": "Tip: you can also add text by double-clicking anywhere with the selection tool",
"embeddable": "Click-drag to create a website embed", "embeddable": "Click-drag to create a website embed",

View file

@ -1,4 +1,9 @@
import { rangeIntersection, rangesOverlap, rotate } from "./math"; import {
isPointOnSymmetricArc,
rangeIntersection,
rangesOverlap,
rotate,
} from "./math";
describe("rotate", () => { describe("rotate", () => {
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => { it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
@ -53,3 +58,42 @@ describe("range intersection", () => {
expect(rangeIntersection([1, 4], [5, 7])).toEqual(null); expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
}); });
}); });
describe("point on arc", () => {
it("should detect point on simple arc", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
[0.92291667, 0.385],
),
).toBe(true);
});
it("should not detect point outside of a simple arc", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
[-0.92291667, 0.385],
),
).toBe(false);
});
it("should not detect point with good angle but incorrect radius", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
[-0.5, 0.5],
),
).toBe(false);
});
});

View file

@ -10,9 +10,11 @@ import type {
ExcalidrawLinearElement, ExcalidrawLinearElement,
NonDeleted, NonDeleted,
} from "./element/types"; } from "./element/types";
import type { Bounds } from "./element/bounds";
import { getCurvePathOps } from "./element/bounds"; import { getCurvePathOps } from "./element/bounds";
import type { Mutable } from "./utility-types"; import type { Mutable } from "./utility-types";
import { ShapeCache } from "./scene/ShapeCache"; import { ShapeCache } from "./scene/ShapeCache";
import type { Vector } from "../utils/geometry/shape";
export const rotate = ( export const rotate = (
// target point to rotate // target point to rotate
@ -153,6 +155,12 @@ export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
return Math.hypot(xd, yd); return Math.hypot(xd, yd);
}; };
export const distanceSq2d = (p1: Point, p2: Point) => {
const xd = p2[0] - p1[0];
const yd = p2[1] - p1[1];
return xd * xd + yd * yd;
};
export const centerPoint = (a: Point, b: Point): Point => { export const centerPoint = (a: Point, b: Point): Point => {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
}; };
@ -519,3 +527,179 @@ export const rangeIntersection = (
export const isValueInRange = (value: number, min: number, max: number) => { export const isValueInRange = (value: number, min: number, max: number) => {
return value >= min && value <= max; return value >= min && value <= max;
}; };
export const translatePoint = (p: Point, v: Vector): Point => [
p[0] + v[0],
p[1] + v[1],
];
export const scaleVector = (v: Vector, scalar: number): Vector => [
v[0] * scalar,
v[1] * scalar,
];
export const pointToVector = (p: Point, origin: Point = [0, 0]): Vector => [
p[0] - origin[0],
p[1] - origin[1],
];
export const scalePointFromOrigin = (
p: Point,
mid: Point,
multiplier: number,
) => translatePoint(mid, scaleVector(pointToVector(p, mid), multiplier));
const triangleSign = (p1: Point, p2: Point, p3: Point): number =>
(p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]);
export const PointInTriangle = (pt: Point, v1: Point, v2: Point, v3: Point) => {
const d1 = triangleSign(pt, v1, v2);
const d2 = triangleSign(pt, v2, v3);
const d3 = triangleSign(pt, v3, v1);
const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
return !(has_neg && has_pos);
};
export const magnitudeSq = (vector: Vector) =>
vector[0] * vector[0] + vector[1] * vector[1];
export const magnitude = (vector: Vector) => Math.sqrt(magnitudeSq(vector));
export const normalize = (vector: Vector): Vector => {
const m = magnitude(vector);
return [vector[0] / m, vector[1] / m];
};
export const addVectors = (
vec1: Readonly<Vector>,
vec2: Readonly<Vector>,
): Vector => [vec1[0] + vec2[0], vec1[1] + vec2[1]];
export const subtractVectors = (
vec1: Readonly<Vector>,
vec2: Readonly<Vector>,
): Vector => [vec1[0] - vec2[0], vec1[1] - vec2[1]];
export const pointInsideBounds = (p: Point, bounds: Bounds): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = [bbox.midX, bbox.midY] as Point;
const [topLeftX, topLeftY] = rotatePoint(
[bbox.minX, bbox.minY],
center,
element.angle,
);
const [topRightX, topRightY] = rotatePoint(
[bbox.maxX, bbox.minY],
center,
element.angle,
);
const [bottomRightX, bottomRightY] = rotatePoint(
[bbox.maxX, bbox.maxY],
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = rotatePoint(
[bbox.minX, bbox.maxY],
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};
type PolarCoords = [number, number];
/**
* Return the polar coordinates for the given carthesian point represented by
* (x, y) for the center point 0,0 where the first number returned is the radius,
* the second is the angle in radians.
*/
export const carthesian2Polar = ([x, y]: Point): PolarCoords => [
Math.hypot(x, y),
Math.atan2(y, x),
];
/**
* Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle
* corresponds to (1, 0) carthesian coordinates (point), i.e. to the "right".
*/
type SymmetricArc = { radius: number; startAngle: number; endAngle: number };
/**
* Determines if a carthesian point lies on a symmetric arc, i.e. an arc which
* is part of a circle contour centered on 0, 0.
*/
export const isPointOnSymmetricArc = (
{ radius: arcRadius, startAngle, endAngle }: SymmetricArc,
point: Point,
): boolean => {
const [radius, angle] = carthesian2Polar(point);
return startAngle < endAngle
? Math.abs(radius - arcRadius) < 0.0000001 &&
startAngle <= angle &&
endAngle >= angle
: startAngle <= angle || endAngle >= angle;
};
export const getCenterForBounds = (bounds: Bounds): Point => [
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
];
export const getCenterForElement = (element: ExcalidrawElement): Point => [
element.x + element.width / 2,
element.y + element.height / 2,
];
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
pointInsideBounds([a[0], a[1]], b) ||
pointInsideBounds([a[2], a[1]], b) ||
pointInsideBounds([a[2], a[3]], b) ||
pointInsideBounds([a[0], a[3]], b) ||
pointInsideBounds([b[0], b[1]], a) ||
pointInsideBounds([b[2], b[1]], a) ||
pointInsideBounds([b[2], b[3]], a) ||
pointInsideBounds([b[0], b[3]], a);
export const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max);
};

View file

@ -48,6 +48,8 @@ import {
} from "./helpers"; } from "./helpers";
import oc from "open-color"; import oc from "open-color";
import { import {
isArrowElement,
isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
@ -67,6 +69,7 @@ import type {
InteractiveSceneRenderConfig, InteractiveSceneRenderConfig,
RenderableElementsMap, RenderableElementsMap,
} from "../scene/types"; } from "../scene/types";
import { getCornerRadius } from "../math";
const renderLinearElementPointHighlight = ( const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
@ -212,13 +215,18 @@ const renderBindingHighlightForBindableElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1; const width = x2 - x1;
const height = y2 - y1; const height = y2 - y1;
const threshold = maxBindingGap(element, width, height); const thickness = 10;
// So that we don't overlap the element itself // So that we don't overlap the element itself
const strokeOffset = 4; const strokeOffset = 4;
context.strokeStyle = "rgba(0,0,0,.05)"; context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = threshold - strokeOffset; context.lineWidth = thickness - strokeOffset;
const padding = strokeOffset / 2 + threshold / 2; const padding = strokeOffset / 2 + thickness / 2;
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
@ -237,6 +245,8 @@ const renderBindingHighlightForBindableElement = (
x1 + width / 2, x1 + width / 2,
y1 + height / 2, y1 + height / 2,
element.angle, element.angle,
undefined,
radius,
); );
break; break;
case "diamond": case "diamond":
@ -474,6 +484,10 @@ const renderLinearPointHandles = (
? POINT_HANDLE_SIZE ? POINT_HANDLE_SIZE
: POINT_HANDLE_SIZE / 2; : POINT_HANDLE_SIZE / 2;
points.forEach((point, idx) => { points.forEach((point, idx) => {
if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) {
return;
}
const isSelected = const isSelected =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx); !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
@ -727,7 +741,13 @@ const _renderInteractiveScene = ({
if ( if (
appState.selectedLinearElement && appState.selectedLinearElement &&
appState.selectedLinearElement.hoverPointIndex >= 0 appState.selectedLinearElement.hoverPointIndex >= 0 &&
!(
isElbowArrow(selectedElements[0]) &&
appState.selectedLinearElement.hoverPointIndex > 0 &&
appState.selectedLinearElement.hoverPointIndex <
selectedElements[0].points.length - 1
)
) { ) {
renderLinearElementPointHighlight(context, appState, elementsMap); renderLinearElementPointHighlight(context, appState, elementsMap);
} }
@ -771,6 +791,20 @@ const _renderInteractiveScene = ({
for (const element of elementsMap.values()) { for (const element of elementsMap.values()) {
const selectionColors = []; const selectionColors = [];
const remoteClients = renderConfig.remoteSelectedElementIds.get(
element.id,
);
if (
!(
// Elbow arrow elements cannot be selected when bound on either end
(
isSingleLinearElementSelected &&
isArrowElement(element) &&
isElbowArrow(element) &&
(element.startBinding || element.endBinding)
)
)
) {
// local user // local user
if ( if (
locallySelectedIds.has(element.id) && locallySelectedIds.has(element.id) &&
@ -779,9 +813,6 @@ const _renderInteractiveScene = ({
selectionColors.push(selectionColor); selectionColors.push(selectionColor);
} }
// remote users // remote users
const remoteClients = renderConfig.remoteSelectedElementIds.get(
element.id,
);
if (remoteClients) { if (remoteClients) {
selectionColors.push( selectionColors.push(
...remoteClients.map((socketId) => { ...remoteClients.map((socketId) => {
@ -793,6 +824,7 @@ const _renderInteractiveScene = ({
}), }),
); );
} }
}
if (selectionColors.length) { if (selectionColors.length) {
const [elementX1, elementY1, elementX2, elementY2, cx, cy] = const [elementX1, elementY1, elementX2, elementY2, cx, cy] =

View file

@ -9,12 +9,13 @@ import type {
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead, Arrowhead,
} from "../element/types"; } from "../element/types";
import { isPathALoop, getCornerRadius } from "../math"; import { isPathALoop, getCornerRadius, distanceSq2d } from "../math";
import { generateFreeDrawShape } from "../renderer/renderElement"; import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils"; import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { ROUGHNESS } from "../constants"; import { ROUGHNESS } from "../constants";
import { import {
isElbowArrow,
isEmbeddableElement, isEmbeddableElement,
isIframeElement, isIframeElement,
isIframeLikeElement, isIframeLikeElement,
@ -400,9 +401,16 @@ export const _generateElementShape = (
// initial position to it // initial position to it
const points = element.points.length ? element.points : [[0, 0]]; const points = element.points.length ? element.points : [[0, 0]];
if (isElbowArrow(element)) {
shape = [
generator.path(
generateElbowArrowShape(points as [number, number][], 16),
generateRoughOptions(element, true),
),
];
} else if (!element.roundness) {
// curve is always the first element // curve is always the first element
// this simplifies finding the curve for an element // this simplifies finding the curve for an element
if (!element.roundness) {
if (options.fill) { if (options.fill) {
shape = [generator.polygon(points as [number, number][], options)]; shape = [generator.polygon(points as [number, number][], options)];
} else { } else {
@ -482,3 +490,60 @@ export const _generateElementShape = (
} }
} }
}; };
const generateElbowArrowShape = (
points: [number, number][],
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 corner = Math.min(
radius,
Math.sqrt(distanceSq2d(points[i], next)) / 2,
Math.sqrt(distanceSq2d(points[i], prev)) / 2,
);
if (prev[0] < points[i][0] && prev[1] === points[i][1]) {
// 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]) {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
} 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]) {
// 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]) {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
} else {
subpoints.push([points[i][0], points[i][1] + corner]);
}
}
const d = [`M ${points[0][0]} ${points[0][1]}`];
for (let i = 0; i < subpoints.length; i += 3) {
d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`);
d.push(
`Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${
subpoints[i + 2][0]
} ${subpoints[i + 2][1]}`,
);
}
d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`);
return d.join(" ");
};

View file

@ -40,11 +40,12 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" || type === "iframe" ||
type === "embeddable" || type === "embeddable" ||
type === "arrow" ||
type === "line" || type === "line" ||
type === "diamond" || type === "diamond" ||
type === "image"; type === "image";
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow"; export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
export const getElementAtPosition = ( export const getElementAtPosition = (

View file

@ -796,6 +796,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
}, },
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -998,6 +999,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -1210,6 +1212,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -1537,6 +1540,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -1864,6 +1868,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -2076,6 +2081,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -2312,6 +2318,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -2609,6 +2616,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -2974,6 +2982,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "#a5d8ff", "currentItemBackgroundColor": "#a5d8ff",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "cross-hatch", "currentItemFillStyle": "cross-hatch",
@ -3445,6 +3454,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -3764,6 +3774,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -4083,6 +4094,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -5265,6 +5277,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
}, },
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -6388,6 +6401,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
}, },
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -7319,6 +7333,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
}, },
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -8227,6 +8242,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
}, },
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -9117,6 +9133,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
}, },
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",

View file

@ -8,6 +8,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",

File diff suppressed because it is too large Load diff

View file

@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"versionNonce": 1984422985, "versionNonce": 745419401,
"width": 300, "width": 300,
"x": 201, "x": 201,
"y": 2, "y": 2,
@ -186,16 +186,18 @@ exports[`move element > rectangles with binding arrow 7`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id1", "elementId": "id1",
"fixedPoint": null,
"focus": "-0.46667", "focus": "-0.46667",
"gap": 10, "gap": 10,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "81.48231", "height": "81.47368",
"id": "id2", "id": "id2",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@ -210,7 +212,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
], ],
[ [
81, 81,
"81.48231", "81.47368",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -221,6 +223,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id0", "elementId": "id0",
"fixedPoint": null,
"focus": "-0.60000", "focus": "-0.60000",
"gap": 10, "gap": 10,
}, },
@ -229,10 +232,10 @@ exports[`move element > rectangles with binding arrow 7`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 14, "version": 11,
"versionNonce": 2066753033, "versionNonce": 1996028265,
"width": 81, "width": 81,
"x": 110, "x": 110,
"y": "49.98179", "y": 50,
} }
`; `;

View file

@ -6,6 +6,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",

View file

@ -13,6 +13,7 @@ exports[`given element A and group of elements B and given both are selected whe
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -421,6 +422,7 @@ exports[`given element A and group of elements B and given both are selected whe
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -820,6 +822,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -1358,6 +1361,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -1555,6 +1559,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -1923,6 +1928,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -2156,6 +2162,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -2329,6 +2336,7 @@ exports[`regression tests > can drag element that covers another element, while
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -2642,6 +2650,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "#ffc9c9", "currentItemBackgroundColor": "#ffc9c9",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -2881,6 +2890,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -3117,6 +3127,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -3340,6 +3351,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -3589,6 +3601,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -3893,6 +3906,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -4300,6 +4314,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -4606,6 +4621,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -4882,6 +4898,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -5115,6 +5132,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -5307,6 +5325,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -5682,6 +5701,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -5965,6 +5985,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -6247,6 +6268,7 @@ History {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@ -6387,6 +6409,7 @@ History {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@ -6764,6 +6787,7 @@ exports[`regression tests > given a group of selected elements with an element t
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -7087,6 +7111,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "#ffc9c9", "currentItemBackgroundColor": "#ffc9c9",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -7356,6 +7381,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -7583,6 +7609,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -7813,6 +7840,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -7986,6 +8014,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -8159,6 +8188,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -8332,6 +8362,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -8408,6 +8439,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"isDragging": false, "isDragging": false,
"lastUncommittedPoint": null, "lastUncommittedPoint": null,
"pointerDownState": { "pointerDownState": {
"lastClickedIsEndPoint": false,
"lastClickedPoint": -1, "lastClickedPoint": -1,
"origin": null, "origin": null,
"prevSelectedPointsIndices": null, "prevSelectedPointsIndices": null,
@ -8480,6 +8512,7 @@ History {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@ -8545,6 +8578,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -8621,6 +8655,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"isDragging": false, "isDragging": false,
"lastUncommittedPoint": null, "lastUncommittedPoint": null,
"pointerDownState": { "pointerDownState": {
"lastClickedIsEndPoint": false,
"lastClickedPoint": -1, "lastClickedPoint": -1,
"origin": null, "origin": null,
"prevSelectedPointsIndices": null, "prevSelectedPointsIndices": null,
@ -8758,6 +8793,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -8945,6 +8981,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -9021,6 +9058,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"isDragging": false, "isDragging": false,
"lastUncommittedPoint": null, "lastUncommittedPoint": null,
"pointerDownState": { "pointerDownState": {
"lastClickedIsEndPoint": false,
"lastClickedPoint": -1, "lastClickedPoint": -1,
"origin": null, "origin": null,
"prevSelectedPointsIndices": null, "prevSelectedPointsIndices": null,
@ -9093,6 +9131,7 @@ History {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@ -9158,6 +9197,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -9331,6 +9371,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -9407,6 +9448,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"isDragging": false, "isDragging": false,
"lastUncommittedPoint": null, "lastUncommittedPoint": null,
"pointerDownState": { "pointerDownState": {
"lastClickedIsEndPoint": false,
"lastClickedPoint": -1, "lastClickedPoint": -1,
"origin": null, "origin": null,
"prevSelectedPointsIndices": null, "prevSelectedPointsIndices": null,
@ -9544,6 +9586,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -9717,6 +9760,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -9904,6 +9948,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -10077,6 +10122,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -10584,6 +10630,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -10854,6 +10901,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -10973,6 +11021,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -11165,6 +11214,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -11469,6 +11519,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -11874,6 +11925,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -12480,6 +12532,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -12602,6 +12655,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -13179,6 +13233,7 @@ exports[`regression tests > switches from group of selected elements to another
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -13540,6 +13595,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -13828,6 +13884,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -13947,6 +14004,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -14146,6 +14204,7 @@ History {
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
@ -14318,6 +14377,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",
@ -14437,6 +14497,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",

View file

@ -6,6 +6,7 @@ exports[`select single element on the scene > arrow 1`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",

View file

@ -62,6 +62,7 @@ describe("element binding", () => {
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect.id, elementId: rect.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
@ -74,11 +75,13 @@ describe("element binding", () => {
// Both the start and the end points should be bound // Both the start and the end points should be bound
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect.id, elementId: rect.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect.id, elementId: rect.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
@ -318,11 +321,13 @@ describe("element binding", () => {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [0.5, 1],
}, },
endBinding: { endBinding: {
elementId: "text1", elementId: "text1",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [1, 0.5],
}, },
}); });
@ -337,11 +342,13 @@ describe("element binding", () => {
elementId: "text1", elementId: "text1",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [0.5, 1],
}, },
endBinding: { endBinding: {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2, focus: 0.2,
gap: 7, gap: 7,
fixedPoint: [1, 0.5],
}, },
}); });

View file

@ -6,6 +6,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [], "boundElements": [],
"customData": undefined, "customData": undefined,
"elbowed": false,
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",

View file

@ -149,8 +149,6 @@ const createLinearElementWithCurveInsideMinMaxPoints = (
[-922.4761962890625, 300.3277587890625], [-922.4761962890625, 300.3277587890625],
[828.0126953125, 410.51605224609375], [828.0126953125, 410.51605224609375],
], ],
startArrowhead: null,
endArrowhead: null,
}); });
}; };
@ -183,8 +181,6 @@ const createLinearElementsWithCurveOutsideMinMaxPoints = (
[-591.2804897585779, 36.09360810181511], [-591.2804897585779, 36.09360810181511],
[-148.56510566829502, 53.96308359105342], [-148.56510566829502, 53.96308359105342],
], ],
startArrowhead: null,
endArrowhead: null,
...extraProps, ...extraProps,
}); });
}; };

View file

@ -19,6 +19,7 @@ import util from "util";
import path from "path"; import path from "path";
import { getMimeType } from "../../data/blob"; import { getMimeType } from "../../data/blob";
import { import {
newArrowElement,
newEmbeddableElement, newEmbeddableElement,
newFrameElement, newFrameElement,
newFreeDrawElement, newFreeDrawElement,
@ -146,6 +147,7 @@ export class API {
endBinding?: T extends "arrow" endBinding?: T extends "arrow"
? ExcalidrawLinearElement["endBinding"] ? ExcalidrawLinearElement["endBinding"]
: never; : never;
elbowed?: boolean;
}): T extends "arrow" | "line" }): T extends "arrow" | "line"
? ExcalidrawLinearElement ? ExcalidrawLinearElement
: T extends "freedraw" : T extends "freedraw"
@ -250,14 +252,24 @@ export class API {
}); });
break; break;
case "arrow": case "arrow":
element = newArrowElement({
...base,
width,
height,
type,
points: rest.points ?? [
[0, 0],
[100, 100],
],
elbowed: rest.elbowed ?? false,
});
break;
case "line": case "line":
element = newLinearElement({ element = newLinearElement({
...base, ...base,
width, width,
height, height,
type, type,
startArrowhead: null,
endArrowhead: null,
points: rest.points ?? [ points: rest.points ?? [
[0, 0], [0, 0],
[100, 100], [100, 100],

View file

@ -1,3 +1,5 @@
import "../global.d.ts";
import React from "react";
import * as StaticScene from "../renderer/staticScene"; import * as StaticScene from "../renderer/staticScene";
import { import {
GlobalTestState, GlobalTestState,
@ -24,6 +26,7 @@ import {
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import type { import type {
ExcalidrawElbowArrowElement,
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -41,6 +44,7 @@ import { queryByText } from "@testing-library/react";
import { HistoryEntry } from "../history"; import { HistoryEntry } from "../history";
import { AppStateChange, ElementsChange } from "../change"; import { AppStateChange, ElementsChange } from "../change";
import { Snapshot, StoreAction } from "../store"; import { Snapshot, StoreAction } from "../store";
import type Scene from "../scene/Scene";
const { h } = window; const { h } = window;
@ -114,6 +118,7 @@ describe("history", () => {
arrayToMap(h.elements) as SceneElementsMap, arrayToMap(h.elements) as SceneElementsMap,
appState, appState,
Snapshot.empty(), Snapshot.empty(),
{} as Scene,
) as any, ) as any,
); );
} catch (e) { } catch (e) {
@ -135,6 +140,7 @@ describe("history", () => {
arrayToMap(h.elements) as SceneElementsMap, arrayToMap(h.elements) as SceneElementsMap,
appState, appState,
Snapshot.empty(), Snapshot.empty(),
{} as Scene,
) as any, ) as any,
); );
} catch (e) { } catch (e) {
@ -1332,11 +1338,13 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(5); expect(API.getUndoStack().length).toBe(5);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
@ -1355,11 +1363,13 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
@ -1378,11 +1388,13 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
@ -1409,11 +1421,13 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
@ -1432,11 +1446,13 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}); });
@ -1466,7 +1482,8 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(5);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [], boundElements: [],
@ -1484,19 +1501,22 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: arrow.id, id: arrow.id,
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
endBinding: { endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
isDeleted: true, isDeleted: true,
}), }),
]); ]),
);
Keyboard.redo(); Keyboard.redo();
Keyboard.redo(); Keyboard.redo();
@ -1506,13 +1526,14 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(5); expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [ boundElements: expect.arrayContaining([
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
{ id: arrow.id, type: "arrow" }, { id: arrow.id, type: "arrow" },
], ]),
isDeleted: false, isDeleted: false,
}), }),
expect.objectContaining({ expect.objectContaining({
@ -1527,19 +1548,22 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: arrow.id, id: arrow.id,
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
endBinding: { endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
isDeleted: false, isDeleted: false,
}), }),
]); ]),
);
}); });
it("should unbind rectangle from arrow on deletion and rebind on undo", async () => { it("should unbind rectangle from arrow on deletion and rebind on undo", async () => {
@ -1547,7 +1571,8 @@ describe("history", () => {
Keyboard.keyPress(KEYS.DELETE); Keyboard.keyPress(KEYS.DELETE);
expect(API.getUndoStack().length).toBe(7); expect(API.getUndoStack().length).toBe(7);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [ boundElements: [
@ -1569,25 +1594,27 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
id: arrow.id, id: arrow.id,
startBinding: null, startBinding: null,
endBinding: { endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
isDeleted: false, isDeleted: false,
}), }),
]); ]),
);
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(6); expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [ boundElements: expect.arrayContaining([
{ id: arrow.id, type: "arrow" }, { id: arrow.id, type: "arrow" },
{ id: text.id, type: "text" }, // order has now changed! { id: text.id, type: "text" }, // order has now changed!
], ]),
isDeleted: false, isDeleted: false,
}), }),
expect.objectContaining({ expect.objectContaining({
@ -1602,19 +1629,22 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: arrow.id, id: arrow.id,
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
endBinding: { endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
isDeleted: false, isDeleted: false,
}), }),
]); ]),
);
}); });
it("should unbind rectangles from arrow on deletion and rebind on undo", async () => { it("should unbind rectangles from arrow on deletion and rebind on undo", async () => {
@ -1652,13 +1682,14 @@ describe("history", () => {
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(7); expect(API.getUndoStack().length).toBe(7);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [ boundElements: expect.arrayContaining([
{ id: arrow.id, type: "arrow" }, { id: arrow.id, type: "arrow" },
{ id: text.id, type: "text" }, // order has now changed! { id: text.id, type: "text" }, // order has now changed!
], ]),
isDeleted: false, isDeleted: false,
}), }),
expect.objectContaining({ expect.objectContaining({
@ -1673,19 +1704,22 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: arrow.id, id: arrow.id,
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
endBinding: { endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
isDeleted: false, isDeleted: false,
}), }),
]); ]),
);
}); });
}); });
@ -1977,6 +2011,110 @@ describe("history", () => {
]); ]);
}); });
it("should redraw arrows on undo", () => {
const rect = API.createElement({
type: "rectangle",
id: "KPrBI4g_v9qUB1XxYLgSz",
x: 873,
y: 212,
width: 157,
height: 126,
});
const diamond = API.createElement({
id: "u2JGnnmoJ0VATV4vCNJE5",
type: "diamond",
x: 1152,
y: 516,
width: 124,
height: 129,
});
const arrow = API.createElement({
type: "arrow",
id: "6Rm4g567UQM4WjLwej2Vc",
elbowed: true,
});
excalidrawAPI.updateScene({
elements: [rect, diamond],
storeAction: StoreAction.CAPTURE,
});
// Connect the arrow
excalidrawAPI.updateScene({
elements: [
{
...rect,
boundElements: [
{
id: "6Rm4g567UQM4WjLwej2Vc",
type: "arrow",
},
],
},
{
...diamond,
boundElements: [
{
id: "6Rm4g567UQM4WjLwej2Vc",
type: "arrow",
},
],
},
{
...arrow,
x: 1035,
y: 274.9,
width: 178.9000000000001,
height: 236.10000000000002,
points: [
[0, 0],
[178.9000000000001, 0],
[178.9000000000001, 236.10000000000002],
],
startBinding: {
elementId: "KPrBI4g_v9qUB1XxYLgSz",
focus: -0.001587301587301948,
gap: 5,
fixedPoint: [1.0318471337579618, 0.49920634920634904],
},
endBinding: {
elementId: "u2JGnnmoJ0VATV4vCNJE5",
focus: -0.0016129032258049847,
gap: 3.537079145500037,
fixedPoint: [0.4991935483870975, -0.03875193720914723],
},
},
],
storeAction: StoreAction.CAPTURE,
});
Keyboard.undo();
excalidrawAPI.updateScene({
elements: h.elements.map((el) =>
el.id === "KPrBI4g_v9qUB1XxYLgSz"
? {
...el,
x: 600,
y: 0,
}
: el,
),
storeAction: StoreAction.UPDATE,
});
Keyboard.redo();
const modifiedArrow = h.elements.filter(
(el) => el.type === "arrow",
)[0] as ExcalidrawElbowArrowElement;
expect(modifiedArrow.points).toEqual([
[0, 0],
[451.9000000000001, 0],
[451.9000000000001, 448.10100010002003],
]);
});
// TODO: #7348 ideally we should not override, but since the order of groupIds matters, right now we cannot ensure that with postprocssed groupIds the order will be consistent after series or undos/redos, we don't postprocess them at all // TODO: #7348 ideally we should not override, but since the order of groupIds matters, right now we cannot ensure that with postprocssed groupIds the order will be consistent after series or undos/redos, we don't postprocess them at all
// in other words, if we would postprocess groupIds, the groupIds order on "redo" below would be ["B", "A"] instead of ["A", "B"] // in other words, if we would postprocess groupIds, the groupIds order on "redo" below would be ["B", "A"] instead of ["A", "B"]
it("should override remotely added groups on undo, but restore them on redo", async () => { it("should override remotely added groups on undo, but restore them on redo", async () => {
@ -4149,7 +4287,8 @@ describe("history", () => {
mouse.moveTo(100, 0); mouse.moveTo(100, 0);
mouse.up(); mouse.up();
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }], boundElements: [{ id: arrowId, type: "arrow" }],
@ -4160,18 +4299,21 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
},
endBinding: {
elementId: rect2.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
},
}), }),
]); endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
}),
]),
);
Keyboard.undo(); // undo start binding Keyboard.undo(); // undo start binding
Keyboard.undo(); // undo end binding Keyboard.undo(); // undo end binding
@ -4214,10 +4356,13 @@ describe("history", () => {
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(4); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }], boundElements: expect.arrayContaining([
{ id: arrowId, type: "arrow" },
]),
}), }),
expect.objectContaining({ expect.objectContaining({
id: rect2.id, id: rect2.id,
@ -4225,18 +4370,21 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
},
endBinding: {
elementId: rect2.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
},
}), }),
]); endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
}),
]),
);
Keyboard.undo(); Keyboard.undo();
Keyboard.undo(); Keyboard.undo();
@ -4277,7 +4425,8 @@ describe("history", () => {
mouse.moveTo(100, 1); mouse.moveTo(100, 1);
mouse.upAt(100, 0); mouse.upAt(100, 0);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }], boundElements: [{ id: arrowId, type: "arrow" }],
@ -4288,18 +4437,21 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
},
endBinding: {
elementId: rect2.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
},
}), }),
]); endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
}),
]),
);
Keyboard.undo(); Keyboard.undo();
Keyboard.undo(); Keyboard.undo();
@ -4331,7 +4483,12 @@ describe("history", () => {
h.elements[0], h.elements[0],
newElementWith(h.elements[1], { boundElements: [] }), newElementWith(h.elements[1], { boundElements: [] }),
newElementWith(h.elements[2] as ExcalidrawLinearElement, { newElementWith(h.elements[2] as ExcalidrawLinearElement, {
endBinding: { elementId: remoteContainer.id, gap: 1, focus: 0 }, endBinding: {
elementId: remoteContainer.id,
gap: 1,
focus: 0,
fixedPoint: [0.5, 1],
},
}), }),
remoteContainer, remoteContainer,
], ],
@ -4343,7 +4500,8 @@ describe("history", () => {
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(4); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }], boundElements: [{ id: arrowId, type: "arrow" }],
@ -4354,29 +4512,33 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
// rebound with previous rectangle // rebound with previous rectangle
endBinding: { endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
}), }),
expect.objectContaining({ expect.objectContaining({
id: remoteContainer.id, id: remoteContainer.id,
boundElements: [], boundElements: [],
}), }),
]); ]),
);
Keyboard.undo(); Keyboard.undo();
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(2);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [], boundElements: [],
@ -4388,27 +4550,42 @@ describe("history", () => {
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
startBinding: null, startBinding: null,
endBinding: { endBinding: expect.objectContaining({
// now we are back in the previous state! // now we are back in the previous state!
elementId: remoteContainer.id, elementId: remoteContainer.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
}), }),
expect.objectContaining({ expect.objectContaining({
id: remoteContainer.id, id: remoteContainer.id,
// leaving as bound until we can rebind arrows! // leaving as bound until we can rebind arrows!
boundElements: [{ id: arrowId, type: "arrow" }], boundElements: [{ id: arrowId, type: "arrow" }],
}), }),
]); ]),
);
}); });
}); });
it("should rebind remotely added arrow when it's bindable elements are added through the history", async () => { it("should rebind remotely added arrow when it's bindable elements are added through the history", async () => {
const arrow = API.createElement({ const arrow = API.createElement({
type: "arrow", type: "arrow",
startBinding: { elementId: rect1.id, gap: 1, focus: 0 }, startBinding: {
endBinding: { elementId: rect2.id, gap: 1, focus: 0 }, elementId: rect1.id,
gap: 1,
focus: 0,
fixedPoint: [1, 0.5],
},
endBinding: {
elementId: rect2.id,
gap: 1,
focus: 0,
fixedPoint: [0.5, 1],
},
}); });
// Simulate remote update // Simulate remote update
@ -4450,21 +4627,30 @@ describe("history", () => {
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: arrow.id, id: arrow.id,
startBinding: { startBinding: expect.objectContaining({
// now we are back in the previous state! // now we are back in the previous state!
elementId: rect1.id, elementId: rect1.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
endBinding: { endBinding: expect.objectContaining({
// now we are back in the previous state! // now we are back in the previous state!
elementId: rect2.id, elementId: rect2.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
}), }),
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
@ -4476,7 +4662,8 @@ describe("history", () => {
boundElements: [{ id: arrow.id, type: "arrow" }], boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: false, isDeleted: false,
}), }),
]); ]),
);
}); });
}); });
@ -4496,8 +4683,18 @@ describe("history", () => {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements: [ elements: [
newElementWith(h.elements[0] as ExcalidrawLinearElement, { newElementWith(h.elements[0] as ExcalidrawLinearElement, {
startBinding: { elementId: rect1.id, gap: 1, focus: 0 }, startBinding: {
endBinding: { elementId: rect2.id, gap: 1, focus: 0 }, elementId: rect1.id,
gap: 1,
focus: 0,
fixedPoint: [0.5, 1],
},
endBinding: {
elementId: rect2.id,
gap: 1,
focus: 0,
fixedPoint: [1, 0.5],
},
}), }),
newElementWith(rect1, { newElementWith(rect1, {
boundElements: [{ id: arrow.id, type: "arrow" }], boundElements: [{ id: arrow.id, type: "arrow" }],
@ -4513,19 +4710,28 @@ describe("history", () => {
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: arrow.id, id: arrow.id,
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
endBinding: { endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
isDeleted: true, isDeleted: true,
}), }),
expect.objectContaining({ expect.objectContaining({
@ -4538,24 +4744,34 @@ describe("history", () => {
boundElements: [], boundElements: [],
isDeleted: false, isDeleted: false,
}), }),
]); ]),
);
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: arrow.id, id: arrow.id,
startBinding: { startBinding: {
elementId: rect1.id, elementId: rect1.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, },
endBinding: { endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
isDeleted: false, isDeleted: false,
}), }),
expect.objectContaining({ expect.objectContaining({
@ -4568,7 +4784,8 @@ describe("history", () => {
boundElements: [{ id: arrow.id, type: "arrow" }], boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: false, isDeleted: false,
}), }),
]); ]),
);
}); });
}); });
@ -4585,7 +4802,8 @@ describe("history", () => {
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [], boundElements: [],
@ -4597,19 +4815,22 @@ describe("history", () => {
[0, 0], [0, 0],
[100, 0], [100, 0],
], ],
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
endBinding: { endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
isDeleted: true, isDeleted: true,
}), }),
]); ]),
);
// Simulate remote update // Simulate remote update
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
@ -4632,7 +4853,8 @@ describe("history", () => {
roundToNearestHundred(points[1]), roundToNearestHundred(points[1]),
]).toEqual([500, -400]); ]).toEqual([500, -400]);
} }
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }], boundElements: [{ id: arrowId, type: "arrow" }],
@ -4643,19 +4865,22 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
startBinding: { startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
endBinding: { endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(), focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
}, }),
isDeleted: false, isDeleted: false,
}), }),
]); ]),
);
}); });
}); });

View file

@ -95,7 +95,12 @@ describe("library", () => {
const arrow = API.createElement({ const arrow = API.createElement({
id: "arrow1", id: "arrow1",
type: "arrow", type: "arrow",
endBinding: { elementId: "rectangle1", focus: -1, gap: 0 }, endBinding: {
elementId: "rectangle1",
focus: -1,
gap: 0,
fixedPoint: [0.5, 1],
},
}); });
await API.drop( await API.drop(

View file

@ -5,7 +5,7 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontString, FontString,
} from "../element/types"; } from "../element/types";
import { Excalidraw } from "../index"; import { Excalidraw, mutateElement } from "../index";
import { centerPoint } from "../math"; import { centerPoint } from "../math";
import { reseed } from "../random"; import { reseed } from "../random";
import * as StaticScene from "../renderer/staticScene"; import * as StaticScene from "../renderer/staticScene";
@ -107,6 +107,7 @@ describe("Test Linear Elements", () => {
], ],
roundness, roundness,
}); });
mutateElement(line, { points: line.points });
h.elements = [line]; h.elements = [line];
mouse.clickAt(p1[0], p1[1]); mouse.clickAt(p1[0], p1[1]);
return line; return line;
@ -307,7 +308,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`9`, `9`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints( const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
h.elements[0] as ExcalidrawLinearElement, h.elements[0] as ExcalidrawLinearElement,
@ -365,7 +366,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect([line.x, line.y]).toEqual([ expect([line.x, line.y]).toEqual([
points[0][0] + deltaX, points[0][0] + deltaX,
@ -427,7 +428,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`, `16`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@ -478,7 +479,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@ -519,7 +520,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@ -567,7 +568,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`18`, `18`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
const newMidPoints = LinearElementEditor.getEditorMidPoints( const newMidPoints = LinearElementEditor.getEditorMidPoints(
line, line,
@ -617,7 +618,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`, `16`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
expect((h.elements[0] as ExcalidrawLinearElement).points) expect((h.elements[0] as ExcalidrawLinearElement).points)
@ -715,7 +716,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@ -843,6 +844,7 @@ describe("Test Linear Elements", () => {
id: textElement.id, id: textElement.id,
}), }),
}; };
const elements: ExcalidrawElement[] = []; const elements: ExcalidrawElement[] = [];
h.elements.forEach((element) => { h.elements.forEach((element) => {
if (element.id === container.id) { if (element.id === container.id) {
@ -1235,7 +1237,7 @@ describe("Test Linear Elements", () => {
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBe(200); expect(arrow.width).toBe(205);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
@ -1356,7 +1358,9 @@ describe("Test Linear Elements", () => {
const line = createThreePointerLinearElement("arrow"); const line = createThreePointerLinearElement("arrow");
const [origStartX, origStartY] = [line.x, line.y]; const [origStartX, origStartY] = [line.x, line.y];
LinearElementEditor.movePoints(line, [ LinearElementEditor.movePoints(
line,
[
{ index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] }, { index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] },
{ {
index: line.points.length - 1, index: line.points.length - 1,
@ -1365,7 +1369,9 @@ describe("Test Linear Elements", () => {
line.points[line.points.length - 1][1] - 10, line.points[line.points.length - 1][1] - 10,
], ],
}, },
]); ],
h.scene,
);
expect(line.x).toBe(origStartX + 10); expect(line.x).toBe(origStartX + 10);
expect(line.y).toBe(origStartY + 10); expect(line.y).toBe(origStartY + 10);

View file

@ -13,6 +13,7 @@ import type {
import { UI, Pointer, Keyboard } from "./helpers/ui"; import { UI, Pointer, Keyboard } from "./helpers/ui";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { vi } from "vitest"; import { vi } from "vitest";
import type Scene from "../scene/Scene";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -85,6 +86,7 @@ describe("move element", () => {
rectA.get() as ExcalidrawRectangleElement, rectA.get() as ExcalidrawRectangleElement,
rectB.get() as ExcalidrawRectangleElement, rectB.get() as ExcalidrawRectangleElement,
elementsMap, elementsMap,
{} as Scene,
); );
// select the second rectangle // select the second rectangle

View file

@ -798,6 +798,7 @@ describe("multiple selection", () => {
width: 100, width: 100,
height: 0, height: 0,
}); });
const rightBoundArrow = UI.createElement("arrow", { const rightBoundArrow = UI.createElement("arrow", {
x: 210, x: 210,
y: 50, y: 50,
@ -822,11 +823,16 @@ describe("multiple selection", () => {
expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50); expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(140, 0); expect(leftBoundArrow.width).toBeCloseTo(137.5, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0); expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull(); expect(leftBoundArrow.startBinding).toBeNull();
expect(leftBoundArrow.endBinding).toMatchObject(leftArrowBinding); expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(12.352);
expect(leftBoundArrow.endBinding?.elementId).toBe(
leftArrowBinding.elementId,
);
expect(leftBoundArrow.endBinding?.fixedPoint).toBeNull();
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
expect(rightBoundArrow.x).toBeCloseTo(210); expect(rightBoundArrow.x).toBeCloseTo(210);
expect(rightBoundArrow.y).toBeCloseTo( expect(rightBoundArrow.y).toBeCloseTo(
@ -836,7 +842,12 @@ describe("multiple selection", () => {
expect(rightBoundArrow.height).toBeCloseTo(0); expect(rightBoundArrow.height).toBeCloseTo(0);
expect(rightBoundArrow.angle).toEqual(0); expect(rightBoundArrow.angle).toEqual(0);
expect(rightBoundArrow.startBinding).toBeNull(); expect(rightBoundArrow.startBinding).toBeNull();
expect(rightBoundArrow.endBinding).toMatchObject(rightArrowBinding); expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
expect(rightBoundArrow.endBinding?.elementId).toBe(
rightArrowBinding.elementId,
);
expect(rightBoundArrow.endBinding?.fixedPoint).toBeNull();
expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus);
}); });
it("resizes with labeled arrows", async () => { it("resizes with labeled arrows", async () => {

View file

@ -281,6 +281,7 @@ export interface AppState {
currentItemEndArrowhead: Arrowhead | null; currentItemEndArrowhead: Arrowhead | null;
currentHoveredFontFamily: FontFamilyValues | null; currentHoveredFontFamily: FontFamilyValues | null;
currentItemRoundness: StrokeRoundness; currentItemRoundness: StrokeRoundness;
currentItemArrowType: "sharp" | "round" | "elbow";
viewBackgroundColor: string; viewBackgroundColor: string;
scrollX: number; scrollX: number;
scrollY: number; scrollY: number;
@ -624,6 +625,7 @@ export type AppClassProperties = {
insertEmbeddableElement: App["insertEmbeddableElement"]; insertEmbeddableElement: App["insertEmbeddableElement"];
onMagicframeToolSelect: App["onMagicframeToolSelect"]; onMagicframeToolSelect: App["onMagicframeToolSelect"];
getName: App["getName"]; getName: App["getName"];
dismissLinearEditor: App["dismissLinearEditor"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{

View file

@ -1157,3 +1157,6 @@ export const promiseTry = async <TValue, TArgs extends unknown[]>(
resolve(fn(...args)); resolve(fn(...args));
}); });
}; };
export const isAnyTrue = (...args: boolean[]): boolean =>
Math.max(...args.map((arg) => (arg ? 1 : 0))) > 0;

View file

@ -13,6 +13,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"contextMenu": null, "contextMenu": null,
"currentChartType": "bar", "currentChartType": "bar",
"currentHoveredFontFamily": null, "currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent", "currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow", "currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid", "currentItemFillStyle": "solid",

View file

@ -16,10 +16,22 @@ const DEFAULT_THRESHOLD = 10e-5;
*/ */
// the two vectors are ao and bo // the two vectors are ao and bo
export const cross = (a: Point, b: Point, o: Point) => { export const cross = (
a: Readonly<Point>,
b: Readonly<Point>,
o: Readonly<Point>,
) => {
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
}; };
export const dot = (
a: Readonly<Point>,
b: Readonly<Point>,
o: Readonly<Point>,
) => {
return (a[0] - o[0]) * (b[0] - o[0]) + (a[1] - o[1]) * (b[1] - o[1]);
};
export const isClosed = (polygon: Polygon) => { export const isClosed = (polygon: Polygon) => {
const first = polygon[0]; const first = polygon[0];
const last = polygon[polygon.length - 1]; const last = polygon[polygon.length - 1];
@ -36,7 +48,9 @@ export const close = (polygon: Polygon) => {
// convert radians to degress // convert radians to degress
export const angleToDegrees = (angle: number) => { export const angleToDegrees = (angle: number) => {
return (angle * 180) / Math.PI; const theta = (angle * 180) / Math.PI;
return theta < 0 ? 360 + theta : theta;
}; };
// convert degrees to radians // convert degrees to radians