fix: Add binding update to manual stat changes (#8183)

Manual stats changes now respect previous element bindings.
pull/8198/head
Márk Tolmács 7 months ago committed by GitHub
parent 04668d8263
commit 66a2f24296
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -131,7 +131,12 @@ export const actionFinalize = register({
-1, -1,
arrayToMap(elements), arrayToMap(elements),
); );
maybeBindLinearElement(multiPointElement, appState, { x, y }, app); maybeBindLinearElement(
multiPointElement,
appState,
{ x, y },
elementsMap,
);
} }
} }

@ -124,7 +124,7 @@ const flipElements = (
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
app, elementsMap,
isBindingEnabled(appState), isBindingEnabled(appState),
[], [],
); );

@ -225,16 +225,9 @@ import type {
ScrollBars, ScrollBars,
} from "../scene/types"; } from "../scene/types";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes"; import { findShapeByKey, getElementShape } from "../shapes";
import type { GeometricShape } from "../../utils/geometry/shape"; import type { GeometricShape } from "../../utils/geometry/shape";
import { import { getSelectionBoxShape } from "../../utils/geometry/shape";
getClosedCurveShape,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
getSelectionBoxShape,
} from "../../utils/geometry/shape";
import { isPointInShape } from "../../utils/collision"; import { isPointInShape } from "../../utils/collision";
import type { import type {
AppClassProperties, AppClassProperties,
@ -424,7 +417,6 @@ import {
hitElementBoundText, hitElementBoundText,
hitElementBoundingBoxOnly, hitElementBoundingBoxOnly,
hitElementItself, hitElementItself,
shouldTestInside,
} from "../element/collision"; } from "../element/collision";
import { textWysiwyg } from "../element/textWysiwyg"; import { textWysiwyg } from "../element/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars"; import { isOverScrollBars } from "../scene/scrollbars";
@ -2819,7 +2811,7 @@ class App extends React.Component<AppProps, AppState> {
nonDeletedElementsMap, nonDeletedElementsMap,
), ),
), ),
this, this.scene.getNonDeletedElementsMap(),
); );
} }
@ -4008,7 +4000,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
suggestedBindings: getSuggestedBindingsForArrows( suggestedBindings: getSuggestedBindingsForArrows(
selectedElements, selectedElements,
this, this.scene.getNonDeletedElementsMap(),
), ),
}); });
@ -4179,7 +4171,7 @@ class App extends React.Component<AppProps, AppState> {
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
this.scene.getSelectedElements(this.state).filter(isLinearElement), this.scene.getSelectedElements(this.state).filter(isLinearElement),
this, this.scene.getNonDeletedElementsMap(),
isBindingEnabled(this.state), isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
); );
@ -4491,59 +4483,6 @@ class App extends React.Component<AppProps, AppState> {
return null; return null;
} }
/**
* get the pure geometric shape of an excalidraw element
* which is then used for hit detection
*/
public getElementShape(element: ExcalidrawElement): GeometricShape {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
this.scene.getNonDeletedElementsMap(),
);
return shouldTestInside(element)
? getClosedCurveShape(
element,
roughShape,
[element.x, element.y],
element.angle,
[cx, cy],
)
: getCurveShape(roughShape, [element.x, element.y], element.angle, [
cx,
cy,
]);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
this.scene.getNonDeletedElementsMap(),
);
return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
}
}
}
private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null { private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null {
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
element, element,
@ -4552,18 +4491,24 @@ class App extends React.Component<AppProps, AppState> {
if (boundTextElement) { if (boundTextElement) {
if (element.type === "arrow") { if (element.type === "arrow") {
return this.getElementShape({ return getElementShape(
...boundTextElement, {
// arrow's bound text accurate position is not stored in the element's property ...boundTextElement,
// but rather calculated and returned from the following static method // arrow's bound text accurate position is not stored in the element's property
...LinearElementEditor.getBoundTextElementPosition( // but rather calculated and returned from the following static method
element, ...LinearElementEditor.getBoundTextElementPosition(
boundTextElement, element,
this.scene.getNonDeletedElementsMap(), boundTextElement,
), this.scene.getNonDeletedElementsMap(),
}); ),
},
this.scene.getNonDeletedElementsMap(),
);
} }
return this.getElementShape(boundTextElement); return getElementShape(
boundTextElement,
this.scene.getNonDeletedElementsMap(),
);
} }
return null; return null;
@ -4602,7 +4547,10 @@ class App extends React.Component<AppProps, AppState> {
x, x,
y, y,
element: elementWithHighestZIndex, element: elementWithHighestZIndex,
shape: this.getElementShape(elementWithHighestZIndex), shape: getElementShape(
elementWithHighestZIndex,
this.scene.getNonDeletedElementsMap(),
),
// when overlapping, we would like to be more precise // when overlapping, we would like to be more precise
// this also avoids the need to update past tests // this also avoids the need to update past tests
threshold: this.getElementHitThreshold() / 2, threshold: this.getElementHitThreshold() / 2,
@ -4707,7 +4655,7 @@ class App extends React.Component<AppProps, AppState> {
x, x,
y, y,
element, element,
shape: this.getElementShape(element), shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(element) frameNameBound: isFrameLikeElement(element)
? this.frameNameBoundsCache.get(element) ? this.frameNameBoundsCache.get(element)
@ -4739,7 +4687,10 @@ class App extends React.Component<AppProps, AppState> {
x, x,
y, y,
element: elements[index], element: elements[index],
shape: this.getElementShape(elements[index]), shape: getElementShape(
elements[index],
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
}) })
) { ) {
@ -4997,7 +4948,10 @@ class App extends React.Component<AppProps, AppState> {
x: sceneX, x: sceneX,
y: sceneY, y: sceneY,
element: container, element: container,
shape: this.getElementShape(container), shape: getElementShape(
container,
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
}) })
) { ) {
@ -5689,7 +5643,10 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX, x: scenePointerX,
y: scenePointerY, y: scenePointerY,
element, element,
shape: this.getElementShape(element), shape: getElementShape(
element,
this.scene.getNonDeletedElementsMap(),
),
}) })
) { ) {
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
@ -6808,7 +6765,7 @@ class App extends React.Component<AppProps, AppState> {
const boundElement = getHoveredElementForBinding( const boundElement = getHoveredElementForBinding(
pointerDownState.origin, pointerDownState.origin,
this, this.scene.getNonDeletedElementsMap(),
); );
this.scene.insertElement(element); this.scene.insertElement(element);
this.setState({ this.setState({
@ -7070,7 +7027,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
const boundElement = getHoveredElementForBinding( const boundElement = getHoveredElementForBinding(
pointerDownState.origin, pointerDownState.origin,
this, this.scene.getNonDeletedElementsMap(),
); );
this.scene.insertElement(element); this.scene.insertElement(element);
@ -7540,7 +7497,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
suggestedBindings: getSuggestedBindingsForArrows( suggestedBindings: getSuggestedBindingsForArrows(
selectedElements, selectedElements,
this, this.scene.getNonDeletedElementsMap(),
), ),
}); });
@ -8061,7 +8018,7 @@ class App extends React.Component<AppProps, AppState> {
draggingElement, draggingElement,
this.state, this.state,
pointerCoords, pointerCoords,
this, this.scene.getNonDeletedElementsMap(),
); );
} }
this.setState({ suggestedBindings: [], startBoundElement: null }); this.setState({ suggestedBindings: [], startBoundElement: null });
@ -8551,7 +8508,10 @@ class App extends React.Component<AppProps, AppState> {
x: pointerDownState.origin.x, x: pointerDownState.origin.x,
y: pointerDownState.origin.y, y: pointerDownState.origin.y,
element: hitElement, element: hitElement,
shape: this.getElementShape(hitElement), shape: getElementShape(
hitElement,
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(hitElement) frameNameBound: isFrameLikeElement(hitElement)
? this.frameNameBoundsCache.get(hitElement) ? this.frameNameBoundsCache.get(hitElement)
@ -8619,7 +8579,7 @@ class App extends React.Component<AppProps, AppState> {
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
linearElements, linearElements,
this, this.scene.getNonDeletedElementsMap(),
isBindingEnabled(this.state), isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
); );
@ -9107,7 +9067,7 @@ class App extends React.Component<AppProps, AppState> {
}): void => { }): void => {
const hoveredBindableElement = getHoveredElementForBinding( const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords, pointerCoords,
this, this.scene.getNonDeletedElementsMap(),
); );
this.setState({ this.setState({
suggestedBindings: suggestedBindings:
@ -9134,7 +9094,7 @@ class App extends React.Component<AppProps, AppState> {
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => { (acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding( const hoveredBindableElement = getHoveredElementForBinding(
coords, coords,
this, this.scene.getNonDeletedElementsMap(),
); );
if ( if (
hoveredBindableElement != null && hoveredBindableElement != null &&
@ -9666,7 +9626,7 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
const suggestedBindings = getSuggestedBindingsForArrows( const suggestedBindings = getSuggestedBindingsForArrows(
selectedElements, selectedElements,
this, this.scene.getNonDeletedElementsMap(),
); );
const elementsToHighlight = new Set<ExcalidrawElement>(); const elementsToHighlight = new Set<ExcalidrawElement>();

@ -6,7 +6,7 @@ import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons"; import { angleIcon } from "../icons";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
@ -33,11 +33,13 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
if (!latestElement) { if (!latestElement) {
return; return;
} }
if (nextValue !== undefined) { if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue); const nextAngle = degreeToRadian(nextValue);
mutateElement(latestElement, { mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {
@ -63,6 +65,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, { mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {

@ -7,7 +7,11 @@ import {
getBoundTextElement, getBoundTextElement,
handleBindTextResize, handleBindTextResize,
} from "../../element/textElement"; } from "../../element/textElement";
import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type {
ElementsMap,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState, Point } from "../../types"; import type { AppState, Point } from "../../types";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
@ -20,7 +24,7 @@ import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
interface MultiDimensionProps { interface MultiDimensionProps {
property: "width" | "height"; property: "width" | "height";
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap; elementsMap: NonDeletedSceneElementsMap;
atomicUnits: AtomicUnit[]; atomicUnits: AtomicUnit[];
scene: Scene; scene: Scene;
appState: AppState; appState: AppState;
@ -60,7 +64,7 @@ const resizeElementInGroup = (
scale: number, scale: number,
latestElement: ExcalidrawElement, latestElement: ExcalidrawElement,
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
) => { ) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
@ -103,7 +107,7 @@ const resizeGroup = (
property: MultiDimensionProps["property"], property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[], latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
) => { ) => {
// keep aspect ratio for groups // keep aspect ratio for groups

@ -1,4 +1,8 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types"; import type {
ElementsMap,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { rotate } from "../../math"; import { rotate } from "../../math";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
@ -27,7 +31,7 @@ const moveElements = (
changeInTopY: number, changeInTopY: number,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
originalElements: readonly ExcalidrawElement[], originalElements: readonly ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
) => { ) => {
for (let i = 0; i < elements.length; i++) { for (let i = 0; i < elements.length; i++) {
@ -66,8 +70,9 @@ const moveGroupTo = (
nextX: number, nextX: number,
nextY: number, nextY: number,
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene,
) => { ) => {
const [x1, y1, ,] = getCommonBounds(originalElements); const [x1, y1, ,] = getCommonBounds(originalElements);
const offsetX = nextX - x1; const offsetX = nextX - x1;
@ -146,6 +151,7 @@ const handlePositionChange: DragInputCallbackType<
elementsInUnit.map((el) => el.original), elementsInUnit.map((el) => el.original),
elementsMap, elementsMap,
originalElementsMap, originalElementsMap,
scene,
); );
} else { } else {
const origElement = elementsInUnit[0]?.original; const origElement = elementsInUnit[0]?.original;

@ -15,6 +15,7 @@ import { Excalidraw, mutateElement } from "../..";
import { t } from "../../i18n"; import { t } from "../../i18n";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
} from "../../element/types"; } from "../../element/types";
import { degreeToRadian, rotate } from "../../math"; import { degreeToRadian, rotate } from "../../math";
@ -23,6 +24,7 @@ import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api"; import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions"; import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups"; import { isInGroup } from "../../groups";
import React from "react";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@ -99,6 +101,92 @@ describe("step sized value", () => {
}); });
}); });
describe("binding with linear elements", () => {
beforeEach(async () => {
localStorage.clear();
renderStaticScene.mockClear();
reseed(19);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.elements = [];
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
UI.clickTool("rectangle");
mouse.down();
mouse.up(200, 100);
UI.clickTool("arrow");
mouse.down(5, 0);
mouse.up(300, 50);
elementStats = stats?.querySelector("#elementStats");
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should remain bound to linear element on small position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
editInput(inputX, String("204"));
expect(linear.startBinding).not.toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
editInput(inputAngle, String("1"));
expect(linear.startBinding).not.toBe(null);
});
it("should unbind linear element on large position change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = getStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
editInput(inputX, String("254"));
expect(linear.startBinding).toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
editInput(inputAngle, String("45"));
expect(linear.startBinding).toBe(null);
});
});
// single element // single element
describe("stats for a generic element", () => { describe("stats for a generic element", () => {
beforeEach(async () => { beforeEach(async () => {

@ -1,4 +1,7 @@
import { updateBoundElements } from "../../element/binding"; import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "../../element/binding";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { import {
measureFontSizeFromWidth, measureFontSizeFromWidth,
@ -11,11 +14,16 @@ import {
getBoundTextMaxWidth, getBoundTextMaxWidth,
handleBindTextResize, handleBindTextResize,
} from "../../element/textElement"; } from "../../element/textElement";
import { isFrameLikeElement, isTextElement } from "../../element/typeChecks"; import {
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "../../element/typeChecks";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types"; } from "../../element/types";
import { import {
getSelectedGroupIds, getSelectedGroupIds,
@ -115,7 +123,7 @@ export const resizeElement = (
nextHeight: number, nextHeight: number,
keepAspectRatio: boolean, keepAspectRatio: boolean,
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
const latestElement = elementsMap.get(origElement.id); const latestElement = elementsMap.get(origElement.id);
@ -156,6 +164,12 @@ export const resizeElement = (
}, },
shouldInformMutation, shouldInformMutation,
); );
updateBindings(latestElement, elementsMap, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement) { if (boundTextElement) {
boundTextFont = { boundTextFont = {
@ -179,13 +193,6 @@ export const resizeElement = (
} }
} }
updateBoundElements(latestElement, elementsMap, {
newSize: {
width: nextWidth,
height: nextHeight,
},
});
if (boundTextElement && boundTextFont) { if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, { mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize, fontSize: boundTextFont.fontSize,
@ -198,7 +205,7 @@ export const moveElement = (
newTopLeftX: number, newTopLeftX: number,
newTopLeftY: number, newTopLeftY: number,
originalElement: ExcalidrawElement, originalElement: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
@ -237,6 +244,7 @@ export const moveElement = (
}, },
shouldInformMutation, shouldInformMutation,
); );
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
originalElement, originalElement,
@ -276,3 +284,18 @@ export const getAtomicUnits = (
}); });
return _atomicUnits; return _atomicUnits;
}; };
export const updateBindings = (
latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], elementsMap, true, []);
} else {
updateBoundElements(latestElement, elementsMap, options);
}
};

@ -25,7 +25,7 @@ import type {
} from "./types"; } from "./types";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import type { AppClassProperties, AppState, Point } from "../types"; import type { AppState, Point } from "../types";
import { isPointOnShape } from "../../utils/collision"; import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene"; import { getElementAtPosition } from "../scene";
import { import {
@ -43,6 +43,7 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils"; import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getElementShape } from "../shapes";
export type SuggestedBinding = export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement> | NonDeleted<ExcalidrawBindableElement>
@ -179,9 +180,8 @@ const bindOrUnbindLinearElementEdge = (
const getOriginalBindingIfStillCloseOfLinearElementEdge = ( const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end", edge: "start" | "end",
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): NonDeleted<ExcalidrawElement> | null => { ): NonDeleted<ExcalidrawElement> | null => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap); const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId = const elementId =
edge === "start" edge === "start"
@ -189,7 +189,10 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
: linearElement.endBinding?.elementId; : linearElement.endBinding?.elementId;
if (elementId) { if (elementId) {
const element = elementsMap.get(elementId); const element = elementsMap.get(elementId);
if (isBindableElement(element) && bindingBorderTest(element, coors, app)) { if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap)
) {
return element; return element;
} }
} }
@ -199,13 +202,13 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
const getOriginalBindingsIfStillCloseToArrowEnds = ( const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): (NonDeleted<ExcalidrawElement> | null)[] => ): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) => ["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge( getOriginalBindingIfStillCloseOfLinearElementEdge(
linearElement, linearElement,
edge as "start" | "end", edge as "start" | "end",
app, elementsMap,
), ),
); );
@ -213,7 +216,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>, selectedElement: NonDeleted<ExcalidrawLinearElement>,
isBindingEnabled: boolean, isBindingEnabled: boolean,
draggingPoints: readonly number[], draggingPoints: readonly number[],
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const startIdx = 0; const startIdx = 0;
const endIdx = selectedElement.points.length - 1; const endIdx = selectedElement.points.length - 1;
@ -221,37 +224,57 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
const start = startDragged const start = startDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "start", app) ? getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
)
: null // If binding is disabled and start is dragged, break all binds : null // If binding is disabled and start is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind : // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(selectedElement, "start", app); getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
);
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "end", app) ? getElligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
)
: null // If binding is disabled and end is dragged, break all binds : null // If binding is disabled and end is dragged, break all binds
: // We have to update the focus and gap of the binding, so let's rebind : // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(selectedElement, "end", app); getElligibleElementForBindingElement(selectedElement, "end", elementsMap);
return [start, end]; return [start, end];
}; };
const getBindingStrategyForDraggingArrowOrJoints = ( const getBindingStrategyForDraggingArrowOrJoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>, selectedElement: NonDeleted<ExcalidrawLinearElement>,
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
isBindingEnabled: boolean, isBindingEnabled: boolean,
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
selectedElement, selectedElement,
app, elementsMap,
); );
const start = startIsClose const start = startIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "start", app) ? getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
)
: null : null
: null; : null;
const end = endIsClose const end = endIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement(selectedElement, "end", app) ? getElligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
)
: null : null
: null; : null;
@ -260,7 +283,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
export const bindOrUnbindLinearElements = ( export const bindOrUnbindLinearElements = (
selectedElements: NonDeleted<ExcalidrawLinearElement>[], selectedElements: NonDeleted<ExcalidrawLinearElement>[],
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
isBindingEnabled: boolean, isBindingEnabled: boolean,
draggingPoints: readonly number[] | null, draggingPoints: readonly number[] | null,
): void => { ): void => {
@ -271,27 +294,22 @@ export const bindOrUnbindLinearElements = (
selectedElement, selectedElement,
isBindingEnabled, isBindingEnabled,
draggingPoints ?? [], draggingPoints ?? [],
app, elementsMap,
) )
: // The arrow itself (the shaft) or the inner joins are dragged : // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints( getBindingStrategyForDraggingArrowOrJoints(
selectedElement, selectedElement,
app, elementsMap,
isBindingEnabled, isBindingEnabled,
); );
bindOrUnbindLinearElement( bindOrUnbindLinearElement(selectedElement, start, end, elementsMap);
selectedElement,
start,
end,
app.scene.getNonDeletedElementsMap(),
);
}); });
}; };
export const getSuggestedBindingsForArrows = ( export const getSuggestedBindingsForArrows = (
selectedElements: NonDeleted<ExcalidrawElement>[], selectedElements: NonDeleted<ExcalidrawElement>[],
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): SuggestedBinding[] => { ): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large // HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) { if (selectedElements.length > 50) {
@ -302,7 +320,7 @@ export const getSuggestedBindingsForArrows = (
selectedElements selectedElements
.filter(isLinearElement) .filter(isLinearElement)
.flatMap((element) => .flatMap((element) =>
getOriginalBindingsIfStillCloseToArrowEnds(element, app), getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
) )
.filter( .filter(
(element): element is NonDeleted<ExcalidrawBindableElement> => (element): element is NonDeleted<ExcalidrawBindableElement> =>
@ -324,17 +342,20 @@ export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState, appState: AppState,
pointerCoords: { x: number; y: number }, pointerCoords: { x: number; y: number },
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): void => { ): void => {
if (appState.startBoundElement != null) { if (appState.startBoundElement != null) {
bindLinearElement( bindLinearElement(
linearElement, linearElement,
appState.startBoundElement, appState.startBoundElement,
"start", "start",
app.scene.getNonDeletedElementsMap(), elementsMap,
); );
} }
const hoveredElement = getHoveredElementForBinding(pointerCoords, app); const hoveredElement = getHoveredElementForBinding(
pointerCoords,
elementsMap,
);
if ( if (
hoveredElement != null && hoveredElement != null &&
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
@ -343,12 +364,7 @@ export const maybeBindLinearElement = (
"end", "end",
) )
) { ) {
bindLinearElement( bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
linearElement,
hoveredElement,
"end",
app.scene.getNonDeletedElementsMap(),
);
} }
}; };
@ -432,13 +448,13 @@ export const getHoveredElementForBinding = (
x: number; x: number;
y: number; y: number;
}, },
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition( const hoveredElement = getElementAtPosition(
app.scene.getNonDeletedElements(), [...elementsMap].map(([_, value]) => value),
(element) => (element) =>
isBindableElement(element, false) && isBindableElement(element, false) &&
bindingBorderTest(element, pointerCoords, app), bindingBorderTest(element, pointerCoords, elementsMap),
); );
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null; return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
}; };
@ -662,15 +678,11 @@ const maybeCalculateNewGapWhenScaling = (
const getElligibleElementForBindingElement = ( const getElligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
app: AppClassProperties, elementsMap: NonDeletedSceneElementsMap,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
return getHoveredElementForBinding( return getHoveredElementForBinding(
getLinearElementEdgeCoors( getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
linearElement, elementsMap,
startOrEnd,
app.scene.getNonDeletedElementsMap(),
),
app,
); );
}; };
@ -834,10 +846,10 @@ const newBoundElements = (
const bindingBorderTest = ( const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>, element: NonDeleted<ExcalidrawBindableElement>,
{ x, y }: { x: number; y: number }, { x, y }: { x: number; y: number },
app: AppClassProperties, elementsMap: ElementsMap,
): boolean => { ): boolean => {
const threshold = maxBindingGap(element, element.width, element.height); const threshold = maxBindingGap(element, element.width, element.height);
const shape = app.getElementShape(element); const shape = getElementShape(element, elementsMap);
return isPointOnShape([x, y], shape, threshold); return isPointOnShape([x, y], shape, threshold);
}; };

@ -381,7 +381,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
), ),
), ),
app, elementsMap,
) )
: null; : null;
@ -715,7 +715,10 @@ export class LinearElementEditor {
}, },
selectedPointsIndices: [element.points.length - 1], selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null, lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(scenePointer, app), endBindingElement: getHoveredElementForBinding(
scenePointer,
elementsMap,
),
}; };
ret.didAddPoint = true; ret.didAddPoint = true;

@ -1,3 +1,11 @@
import {
getClosedCurveShape,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
type GeometricShape,
} from "../utils/geometry/shape";
import { import {
ArrowIcon, ArrowIcon,
DiamondIcon, DiamondIcon,
@ -10,7 +18,11 @@ import {
SelectionIcon, SelectionIcon,
TextIcon, TextIcon,
} from "./components/icons"; } from "./components/icons";
import { getElementAbsoluteCoords } from "./element";
import { shouldTestInside } from "./element/collision";
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { KEYS } from "./keys"; import { KEYS } from "./keys";
import { ShapeCache } from "./scene/ShapeCache";
export const SHAPES = [ export const SHAPES = [
{ {
@ -97,3 +109,53 @@ export const findShapeByKey = (key: string) => {
}); });
return shape?.value || null; return shape?.value || null;
}; };
/**
* get the pure geometric shape of an excalidraw element
* which is then used for hit detection
*/
export const getElementShape = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape => {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
? getClosedCurveShape(
element,
roughShape,
[element.x, element.y],
element.angle,
[cx, cy],
)
: getCurveShape(roughShape, [element.x, element.y], element.angle, [
cx,
cy,
]);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
}
}
};

@ -614,7 +614,6 @@ export type AppClassProperties = {
setOpenDialog: App["setOpenDialog"]; setOpenDialog: App["setOpenDialog"];
insertEmbeddableElement: App["insertEmbeddableElement"]; insertEmbeddableElement: App["insertEmbeddableElement"];
onMagicframeToolSelect: App["onMagicframeToolSelect"]; onMagicframeToolSelect: App["onMagicframeToolSelect"];
getElementShape: App["getElementShape"];
getName: App["getName"]; getName: App["getName"];
}; };

Loading…
Cancel
Save