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 { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import type { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import {
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const framesToBeDeleted = new Set(
getSelectedElements(
@ -29,6 +35,26 @@ const deleteSelectedElements = (
return {
elements: elements.map((el) => {
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 });
}
@ -130,7 +156,11 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(element, selectedPointsIndices);
LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
app.scene,
);
return {
elements,
@ -149,7 +179,7 @@ export const actionDeleteSelected = register({
};
}
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState);
deleteSelectedElements(elements, appState, app);
fixBindingsAfterDeletion(
nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
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 {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
@ -50,8 +50,12 @@ import {
ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon,
fontSizeIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "../components/icons";
import {
ARROW_TYPE,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
@ -67,12 +71,15 @@ import {
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getBoundTextElement } from "../element/textElement";
import {
isArrowElement,
isBoundToContainer,
isElbowArrow,
isLinearElement,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import type {
Arrowhead,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
@ -91,10 +98,23 @@ import {
isSomeElementSelected,
} from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
import {
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
} from "../utils";
import { register } from "./register";
import { StoreAction } from "../store";
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;
@ -1304,8 +1324,12 @@ export const actionChangeRoundness = register({
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
elements: changeProperty(elements, appState, (el) => {
if (isElbowArrow(el)) {
return el;
}
return newElementWith(el, {
roundness:
value === "round"
? {
@ -1314,8 +1338,8 @@ export const actionChangeRoundness = register({
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
}),
),
});
}),
appState: {
...appState,
currentItemRoundness: value,
@ -1355,7 +1379,8 @@ export const actionChangeRoundness = register({
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) => element.hasOwnProperty("roundness"),
(element) =>
!isArrowElement(element) && element.hasOwnProperty("roundness"),
(hasSelection) =>
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"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"

View File

@ -1,5 +1,6 @@
import { COLOR_PALETTE } from "./colors";
import {
ARROW_TYPE,
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
@ -33,6 +34,7 @@ export const getDefaultAppState = (): Omit<
currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
@ -143,6 +145,11 @@ const APP_STATE_STORAGE_CONF = (<
export: false,
server: false,
},
currentItemArrowType: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { 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";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { getNonDeletedGroupIds } from "./groups";
import type Scene from "./scene/Scene";
import { getObservedAppState } from "./store";
import type {
AppState,
@ -1053,6 +1054,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
public applyTo(
elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
scene: Scene,
): [SceneElementsMap, boolean] {
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
let changedElements: Map<string, OrderedExcalidrawElement>;
@ -1100,7 +1102,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
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
// (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(
elements: SceneElementsMap,
changed: Map<string, OrderedExcalidrawElement>,
scene: Scene,
) {
for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements);
updateBoundElements(element, elements, scene, {
changedElements: changed,
});
}
}
}

View File

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

View File

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

View File

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

View File

@ -30,10 +30,13 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.eraserRevert");
}
if (activeTool.type === "arrow" || activeTool.type === "line") {
if (!multiMode) {
return t("hints.linearElement");
if (multiMode) {
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") {

View File

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

View File

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

View File

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

View File

@ -66,8 +66,10 @@ const resizeElementInGroup = (
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement(
@ -76,8 +78,8 @@ const resizeElementInGroup = (
);
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, {
newSize: { width: updates.width, height: updates.height },
updateBoundElements(latestElement, elementsMap, scene, {
oldSize: { width: oldWidth, height: oldHeight },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@ -109,6 +111,7 @@ const resizeGroup = (
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
// keep aspect ratio for groups
if (property === "width") {
@ -132,6 +135,7 @@ const resizeGroup = (
origElement,
elementsMap,
originalElementsMap,
scene,
);
}
};
@ -149,6 +153,7 @@ const handleDimensionChange: DragInputCallbackType<
property,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
@ -185,6 +190,7 @@ const handleDimensionChange: DragInputCallbackType<
originalElements,
elementsMap,
originalElementsMap,
scene,
);
} else {
const [el] = elementsInUnit;
@ -227,6 +233,8 @@ const handleDimensionChange: DragInputCallbackType<
false,
origElement,
elementsMap,
elements,
scene,
false,
);
}
@ -288,6 +296,7 @@ const handleDimensionChange: DragInputCallbackType<
originalElements,
elementsMap,
originalElementsMap,
scene,
);
} else {
const [el] = elementsInUnit;
@ -320,7 +329,15 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
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 {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { rotate } from "../../math";
@ -33,6 +34,7 @@ const moveElements = (
originalElements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
for (let i = 0; i < elements.length; i++) {
const origElement = originalElements[i];
@ -60,6 +62,8 @@ const moveElements = (
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@ -71,6 +75,7 @@ const moveGroupTo = (
nextY: number,
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
originalElementsMap: ElementsMap,
scene: Scene,
) => {
@ -106,6 +111,8 @@ const moveGroupTo = (
topLeftY + offsetY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@ -126,6 +133,7 @@ const handlePositionChange: DragInputCallbackType<
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits(
@ -150,6 +158,7 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY,
elementsInUnit.map((el) => el.original),
elementsMap,
elements,
originalElementsMap,
scene,
);
@ -180,6 +189,8 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@ -206,6 +217,7 @@ const handlePositionChange: DragInputCallbackType<
originalElements,
elementsMap,
originalElementsMap,
scene,
);
scene.triggerUpdate();

View File

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

View File

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

View File

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

View File

@ -2095,6 +2095,35 @@ export const lineEditorIcon = createIcon(
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(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />

View File

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

View File

@ -1,4 +1,5 @@
import type {
ExcalidrawArrowElement,
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLinearElement,
@ -24,6 +25,7 @@ import {
} from "../element";
import {
isArrowElement,
isElbowArrow,
isLinearElement,
isTextElement,
isUsingAdaptiveRadius,
@ -92,11 +94,21 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY;
};
const repairBinding = (binding: PointBinding | null) => {
const repairBinding = (
element: ExcalidrawLinearElement,
binding: PointBinding | null,
): PointBinding | null => {
if (!binding) {
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 = <
@ -242,11 +254,7 @@ const restoreElement = (
// @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough
case "draw":
case "arrow": {
const {
startArrowhead = null,
endArrowhead = element.type === "arrow" ? "arrow" : null,
} = element;
const { startArrowhead = null, endArrowhead = null } = element;
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
@ -266,8 +274,8 @@ const restoreElement = (
(element.type as ExcalidrawElementType | "draw") === "draw"
? "line"
: element.type,
startBinding: repairBinding(element.startBinding),
endBinding: repairBinding(element.endBinding),
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
@ -276,6 +284,36 @@ const restoreElement = (
y,
...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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@ import { getGridPoint } from "../math";
import type Scene from "../scene/Scene";
import {
isArrowElement,
isElbowArrow,
isFrameLikeElement,
isTextElement,
} from "./typeChecks";
@ -18,9 +19,8 @@ import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
selectedElements: NonDeletedExcalidrawElement[],
_selectedElements: NonDeletedExcalidrawElement[],
offset: { x: number; y: number },
appState: AppState,
scene: Scene,
snapOffset: {
x: number;
@ -28,6 +28,25 @@ export const dragSelectedElements = (
},
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
// but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set
@ -72,9 +91,14 @@ export const dragSelectedElements = (
updateElementCoords(pointerDownState, textElement, adjustedOffset);
}
}
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
updateBoundElements(
element,
scene.getElementsMapIncludingDeleted(),
scene,
{
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,
refreshTextDimensions,
newLinearElement,
newArrowElement,
newImageElement,
duplicateElement,
} from "./newElement";

View File

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

View File

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

View File

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

View File

@ -22,6 +22,7 @@ import {
import {
isArrowElement,
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
isFreeDrawElement,
isImageElement,
@ -30,7 +31,7 @@ import {
} from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getFontString } from "../utils";
import { updateBoundElements } from "./binding";
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
import type {
MaybeTransformHandleType,
TransformHandleDirection,
@ -51,6 +52,7 @@ import {
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
export const normalizeAngle = (angle: number): number => {
if (angle < 0) {
@ -75,18 +77,21 @@ export const transformElements = (
pointerY: number,
centerX: number,
centerY: number,
scene: Scene,
) => {
if (selectedElements.length === 1) {
const [element] = selectedElements;
if (transformHandleType === "rotation") {
rotateSingleElement(
element,
elementsMap,
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
);
updateBoundElements(element, elementsMap);
if (!isElbowArrow(element)) {
rotateSingleElement(
element,
elementsMap,
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
);
updateBoundElements(element, elementsMap, scene);
}
} else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement(
originalElements,
@ -97,7 +102,7 @@ export const transformElements = (
pointerX,
pointerY,
);
updateBoundElements(element, elementsMap);
updateBoundElements(element, elementsMap, scene);
} else if (transformHandleType) {
resizeSingleElement(
originalElements,
@ -108,6 +113,7 @@ export const transformElements = (
shouldResizeFromCenter,
pointerX,
pointerY,
scene,
);
}
@ -123,6 +129,7 @@ export const transformElements = (
shouldRotateWithDiscreteAngle,
centerX,
centerY,
scene,
);
return true;
} else if (transformHandleType) {
@ -135,6 +142,7 @@ export const transformElements = (
shouldMaintainAspectRatio,
pointerX,
pointerY,
scene,
);
return true;
}
@ -431,7 +439,17 @@ export const resizeSingleElement = (
shouldResizeFromCenter: boolean,
pointerX: 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)!;
// Gets bounds corners
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
@ -701,8 +719,11 @@ export const resizeSingleElement = (
) {
mutateElement(element, resizedElement);
updateBoundElements(element, elementsMap, {
newSize: { width: resizedElement.width, height: resizedElement.height },
updateBoundElements(element, elementsMap, scene, {
oldSize: {
width: stateAtResizeStart.width,
height: stateAtResizeStart.height,
},
});
if (boundTextElement && boundTextFont != null) {
@ -728,6 +749,7 @@ export const resizeMultipleElements = (
shouldMaintainAspectRatio: boolean,
pointerX: number,
pointerY: number,
scene: Scene,
) => {
// map selected elements to the original elements. While it never should
// happen that pointerDownState.originalElements won't contain the selected
@ -955,13 +977,20 @@ export const resizeMultipleElements = (
element,
update: { boundTextFontSize, ...update },
} of elementsAndUpdates) {
const { width, height, angle } = update;
const { angle } = update;
const { width: oldWidth, height: oldHeight } = element;
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,
newSize: { width, height },
oldSize: { width: oldWidth, height: oldHeight },
});
const boundTextElement = getBoundTextElement(element, elementsMap);
@ -990,6 +1019,7 @@ const rotateMultipleElements = (
shouldRotateWithDiscreteAngle: boolean,
centerX: number,
centerY: number,
scene: Scene,
) => {
let centerAngle =
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
@ -1013,16 +1043,23 @@ const rotateMultipleElements = (
centerY,
centerAngle + origAngle - element.angle,
);
mutateElement(
element,
{
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
},
false,
);
updateBoundElements(element, elementsMap, {
if (isArrowElement(element) && isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap);
mutateElbowArrow(element, scene, points);
} else {
mutateElement(
element,
{
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
},
false,
);
}
updateBoundElements(element, elementsMap, scene, {
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 { rotate } from "../math";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
import {
isElbowArrow,
isFrameLikeElement,
isLinearElement,
} from "./typeChecks";
import {
DEFAULT_TRANSFORM_HANDLE_SPACING,
isAndroid,
@ -262,7 +266,11 @@ export const getTransformHandles = (
// so that when locked element is selected (especially when you toggle lock
// via keyboard) the locked element is visually distinct, indicating
// you can't move/resize
if (element.locked) {
if (
element.locked ||
// Elbow arrows cannot be rotated
isElbowArrow(element)
) {
return {};
}
@ -312,6 +320,9 @@ export const shouldShowBoundingBox = (
return true;
}
const element = elements[0];
if (isElbowArrow(element)) {
return false;
}
if (!isLinearElement(element)) {
return true;
}

View File

@ -21,6 +21,9 @@ import type {
ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
PointBinding,
FixedPointBinding,
} from "./types";
export const isInitializedImageElement = (
@ -106,6 +109,12 @@ export const isArrowElement = (
return element != null && element.type === "arrow";
};
export const isElbowArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawElbowArrowElement => {
return isArrowElement(element) && element.elbowed;
};
export const isLinearElementType = (
elementType: ElementOrToolType,
): 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 = (
element: ExcalidrawElement | null,
includeLocked = true,
@ -263,3 +288,9 @@ export const getDefaultRoundnessTypeForElement = (
return null;
};
export const isFixedPointBinding = (
binding: PointBinding,
): binding is FixedPointBinding => {
return binding.fixedPoint != null;
};

View File

@ -6,7 +6,12 @@ import type {
THEME,
VERTICAL_ALIGN,
} 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";
export type ChartType = "bar" | "line";
@ -228,12 +233,22 @@ export type ExcalidrawTextElementWithContainer = {
containerId: ExcalidrawTextContainer["id"];
} & ExcalidrawTextElement;
export type FixedPoint = [number, number];
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
focus: 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 =
| "arrow"
| "bar"
@ -259,8 +274,18 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
Readonly<{
type: "arrow";
elbowed: boolean;
}>;
export type ExcalidrawElbowArrowElement = Merge<
ExcalidrawArrowElement,
{
elbowed: true;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
}
>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";

View File

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

View File

@ -46,6 +46,10 @@
"arrowhead_triangle_outline": "Triangle (outline)",
"arrowhead_diamond": "Diamond",
"arrowhead_diamond_outline": "Diamond (outline)",
"arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow",
"arrowtype_elbowed": "Elbow arrow",
"fontSize": "Font size",
"fontFamily": "Font family",
"addWatermark": "Add \"Made with Excalidraw\"",
@ -295,6 +299,7 @@
"hints": {
"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",
"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",
"text": "Tip: you can also add text by double-clicking anywhere with the selection tool",
"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", () => {
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);
});
});
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,
NonDeleted,
} from "./element/types";
import type { Bounds } from "./element/bounds";
import { getCurvePathOps } from "./element/bounds";
import type { Mutable } from "./utility-types";
import { ShapeCache } from "./scene/ShapeCache";
import type { Vector } from "../utils/geometry/shape";
export const 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);
};
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 => {
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) => {
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";
import oc from "open-color";
import {
isArrowElement,
isElbowArrow,
isFrameLikeElement,
isLinearElement,
isTextElement,
@ -67,6 +69,7 @@ import type {
InteractiveSceneRenderConfig,
RenderableElementsMap,
} from "../scene/types";
import { getCornerRadius } from "../math";
const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D,
@ -212,13 +215,18 @@ const renderBindingHighlightForBindableElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
const threshold = maxBindingGap(element, width, height);
const thickness = 10;
// So that we don't overlap the element itself
const strokeOffset = 4;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = threshold - strokeOffset;
const padding = strokeOffset / 2 + threshold / 2;
context.lineWidth = thickness - strokeOffset;
const padding = strokeOffset / 2 + thickness / 2;
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
switch (element.type) {
case "rectangle":
@ -237,6 +245,8 @@ const renderBindingHighlightForBindableElement = (
x1 + width / 2,
y1 + height / 2,
element.angle,
undefined,
radius,
);
break;
case "diamond":
@ -474,6 +484,10 @@ const renderLinearPointHandles = (
? POINT_HANDLE_SIZE
: POINT_HANDLE_SIZE / 2;
points.forEach((point, idx) => {
if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) {
return;
}
const isSelected =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
@ -727,7 +741,13 @@ const _renderInteractiveScene = ({
if (
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);
}
@ -771,27 +791,39 @@ const _renderInteractiveScene = ({
for (const element of elementsMap.values()) {
const selectionColors = [];
// local user
if (
locallySelectedIds.has(element.id) &&
!isSelectedViaGroup(appState, element)
) {
selectionColors.push(selectionColor);
}
// remote users
const remoteClients = renderConfig.remoteSelectedElementIds.get(
element.id,
);
if (remoteClients) {
selectionColors.push(
...remoteClients.map((socketId) => {
const background = getClientColor(
socketId,
appState.collaborators.get(socketId),
);
return background;
}),
);
if (
!(
// Elbow arrow elements cannot be selected when bound on either end
(
isSingleLinearElementSelected &&
isArrowElement(element) &&
isElbowArrow(element) &&
(element.startBinding || element.endBinding)
)
)
) {
// local user
if (
locallySelectedIds.has(element.id) &&
!isSelectedViaGroup(appState, element)
) {
selectionColors.push(selectionColor);
}
// remote users
if (remoteClients) {
selectionColors.push(
...remoteClients.map((socketId) => {
const background = getClientColor(
socketId,
appState.collaborators.get(socketId),
);
return background;
}),
);
}
}
if (selectionColors.length) {

View File

@ -9,12 +9,13 @@ import type {
ExcalidrawLinearElement,
Arrowhead,
} from "../element/types";
import { isPathALoop, getCornerRadius } from "../math";
import { isPathALoop, getCornerRadius, distanceSq2d } from "../math";
import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve";
import { ROUGHNESS } from "../constants";
import {
isElbowArrow,
isEmbeddableElement,
isIframeElement,
isIframeLikeElement,
@ -400,9 +401,16 @@ export const _generateElementShape = (
// initial position to it
const points = element.points.length ? element.points : [[0, 0]];
// curve is always the first element
// this simplifies finding the curve for an element
if (!element.roundness) {
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
// this simplifies finding the curve for an element
if (options.fill) {
shape = [generator.polygon(points as [number, number][], options)];
} 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 === "iframe" ||
type === "embeddable" ||
type === "arrow" ||
type === "line" ||
type === "diamond" ||
type === "image";
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
export const getElementAtPosition = (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -798,6 +798,7 @@ describe("multiple selection", () => {
width: 100,
height: 0,
});
const rightBoundArrow = UI.createElement("arrow", {
x: 210,
y: 50,
@ -822,11 +823,16 @@ describe("multiple selection", () => {
expect(leftBoundArrow.x).toBeCloseTo(-110);
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.angle).toEqual(0);
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.y).toBeCloseTo(
@ -836,7 +842,12 @@ describe("multiple selection", () => {
expect(rightBoundArrow.height).toBeCloseTo(0);
expect(rightBoundArrow.angle).toEqual(0);
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 () => {

View File

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

View File

@ -1157,3 +1157,6 @@ export const promiseTry = async <TValue, TArgs extends unknown[]>(
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,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",

View File

@ -16,10 +16,22 @@ const DEFAULT_THRESHOLD = 10e-5;
*/
// 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]);
};
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) => {
const first = polygon[0];
const last = polygon[polygon.length - 1];
@ -36,7 +48,9 @@ export const close = (polygon: Polygon) => {
// convert radians to degress
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