diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx new file mode 100644 index 000000000000..c8a6239cdff2 --- /dev/null +++ b/packages/excalidraw/actions/actionFlip.test.tsx @@ -0,0 +1,211 @@ +import React from "react"; +import { Excalidraw } from "../index"; +import { render } from "../tests/test-utils"; +import { API } from "../tests/helpers/api"; +import { point } from "../../math"; +import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip"; + +const { h } = window; + +describe("flipping re-centers selection", () => { + it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => { + const elements = [ + API.createElement({ + type: "rectangle", + id: "rec1", + x: 100, + y: 100, + width: 100, + height: 100, + boundElements: [{ id: "arr", type: "arrow" }], + }), + API.createElement({ + type: "rectangle", + id: "rec2", + x: 220, + y: 250, + width: 100, + height: 100, + boundElements: [{ id: "arr", type: "arrow" }], + }), + API.createElement({ + type: "arrow", + id: "arr", + x: 149.9, + y: 95, + width: 156, + height: 239.9, + startBinding: { + elementId: "rec1", + focus: 0, + gap: 5, + fixedPoint: [0.49, -0.05], + }, + endBinding: { + elementId: "rec2", + focus: 0, + gap: 5, + fixedPoint: [-0.05, 0.49], + }, + startArrowhead: null, + endArrowhead: "arrow", + points: [ + point(0, 0), + point(0, -35), + point(-90.9, -35), + point(-90.9, 204.9), + point(65.1, 204.9), + ], + elbowed: true, + }), + ]; + await render(); + + API.setSelectedElements(elements); + + expect(Object.keys(h.state.selectedElementIds).length).toBe(3); + + API.executeAction(actionFlipHorizontal); + API.executeAction(actionFlipHorizontal); + API.executeAction(actionFlipHorizontal); + API.executeAction(actionFlipHorizontal); + + const rec1 = h.elements.find((el) => el.id === "rec1"); + expect(rec1?.x).toBeCloseTo(100); + expect(rec1?.y).toBeCloseTo(100); + + const rec2 = h.elements.find((el) => el.id === "rec2"); + expect(rec2?.x).toBeCloseTo(220); + expect(rec2?.y).toBeCloseTo(250); + }); +}); + +describe("flipping arrowheads", () => { + beforeEach(async () => { + await render(); + }); + + it("flipping bound arrow should flip arrowheads only", () => { + const rect = API.createElement({ + type: "rectangle", + boundElements: [{ type: "arrow", id: "arrow1" }], + }); + const arrow = API.createElement({ + type: "arrow", + id: "arrow1", + startArrowhead: "arrow", + endArrowhead: null, + endBinding: { + elementId: rect.id, + focus: 0.5, + gap: 5, + }, + }); + + API.setElements([rect, arrow]); + API.setSelectedElements([arrow]); + + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe(null); + + API.executeAction(actionFlipHorizontal); + expect(API.getElement(arrow).startArrowhead).toBe(null); + expect(API.getElement(arrow).endArrowhead).toBe("arrow"); + + API.executeAction(actionFlipHorizontal); + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe(null); + + API.executeAction(actionFlipVertical); + expect(API.getElement(arrow).startArrowhead).toBe(null); + expect(API.getElement(arrow).endArrowhead).toBe("arrow"); + }); + + it("flipping bound arrow should flip arrowheads only 2", () => { + const rect = API.createElement({ + type: "rectangle", + boundElements: [{ type: "arrow", id: "arrow1" }], + }); + const rect2 = API.createElement({ + type: "rectangle", + boundElements: [{ type: "arrow", id: "arrow1" }], + }); + const arrow = API.createElement({ + type: "arrow", + id: "arrow1", + startArrowhead: "arrow", + endArrowhead: "circle", + startBinding: { + elementId: rect.id, + focus: 0.5, + gap: 5, + }, + endBinding: { + elementId: rect2.id, + focus: 0.5, + gap: 5, + }, + }); + + API.setElements([rect, rect2, arrow]); + API.setSelectedElements([arrow]); + + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe("circle"); + + API.executeAction(actionFlipHorizontal); + expect(API.getElement(arrow).startArrowhead).toBe("circle"); + expect(API.getElement(arrow).endArrowhead).toBe("arrow"); + + API.executeAction(actionFlipVertical); + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe("circle"); + }); + + it("flipping unbound arrow shouldn't flip arrowheads", () => { + const arrow = API.createElement({ + type: "arrow", + id: "arrow1", + startArrowhead: "arrow", + endArrowhead: "circle", + }); + + API.setElements([arrow]); + API.setSelectedElements([arrow]); + + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe("circle"); + + API.executeAction(actionFlipHorizontal); + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe("circle"); + }); + + it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => { + const rect = API.createElement({ + type: "rectangle", + boundElements: [{ type: "arrow", id: "arrow1" }], + }); + const arrow = API.createElement({ + type: "arrow", + id: "arrow1", + startArrowhead: "arrow", + endArrowhead: null, + endBinding: { + elementId: rect.id, + focus: 0.5, + gap: 5, + }, + }); + + API.setElements([rect, arrow]); + API.setSelectedElements([rect, arrow]); + + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe(null); + + API.executeAction(actionFlipHorizontal); + expect(API.getElement(arrow).startArrowhead).toBe("arrow"); + expect(API.getElement(arrow).endArrowhead).toBe(null); + }); +}); diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index a6dad249fb4f..6b75b8facd71 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -2,6 +2,8 @@ import { register } from "./register"; import { getSelectedElements } from "../scene"; import { getNonDeletedElements } from "../element"; import type { + ExcalidrawArrowElement, + ExcalidrawElbowArrowElement, ExcalidrawElement, NonDeleted, NonDeletedSceneElementsMap, @@ -18,7 +20,13 @@ import { import { updateFrameMembershipOfSelectedElements } from "../frame"; import { flipHorizontal, flipVertical } from "../components/icons"; import { StoreAction } from "../store"; -import { isLinearElement } from "../element/typeChecks"; +import { + isArrowElement, + isElbowArrow, + isLinearElement, +} from "../element/typeChecks"; +import { mutateElbowArrow } from "../element/routing"; +import { mutateElement, newElementWith } from "../element/mutateElement"; export const actionFlipHorizontal = register({ name: "flipHorizontal", @@ -109,7 +117,23 @@ const flipElements = ( flipDirection: "horizontal" | "vertical", app: AppClassProperties, ): ExcalidrawElement[] => { - const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements); + if ( + selectedElements.every( + (element) => + isArrowElement(element) && (element.startBinding || element.endBinding), + ) + ) { + return selectedElements.map((element) => { + const _element = element as ExcalidrawArrowElement; + return newElementWith(_element, { + startArrowhead: _element.endArrowhead, + endArrowhead: _element.startArrowhead, + }); + }); + } + + const { minX, minY, maxX, maxY, midX, midY } = + getCommonBoundingBox(selectedElements); resizeMultipleElements( elementsMap, @@ -131,5 +155,48 @@ const flipElements = ( [], ); + // --------------------------------------------------------------------------- + // flipping arrow elements (and potentially other) makes the selection group + // "move" across the canvas because of how arrows can bump against the "wall" + // of the selection, so we need to center the group back to the original + // position so that repeated flips don't accumulate the offset + + const { elbowArrows, otherElements } = selectedElements.reduce( + ( + acc: { + elbowArrows: ExcalidrawElbowArrowElement[]; + otherElements: ExcalidrawElement[]; + }, + element, + ) => + isElbowArrow(element) + ? { ...acc, elbowArrows: acc.elbowArrows.concat(element) } + : { ...acc, otherElements: acc.otherElements.concat(element) }, + { elbowArrows: [], otherElements: [] }, + ); + + const { midX: newMidX, midY: newMidY } = + getCommonBoundingBox(selectedElements); + const [diffX, diffY] = [midX - newMidX, midY - newMidY]; + otherElements.forEach((element) => + mutateElement(element, { + x: element.x + diffX, + y: element.y + diffY, + }), + ); + elbowArrows.forEach((element) => + mutateElbowArrow( + element, + elementsMap, + element.points, + undefined, + undefined, + { + informMutation: false, + }, + ), + ); + // --------------------------------------------------------------------------- + return selectedElements; }; diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index df259009c94d..fd1413b3a7b2 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1907,19 +1907,6 @@ export const actionChangeArrowType = register({ : {}), }, ); - } else { - mutateElement( - newElement, - { - startBinding: newElement.startBinding - ? { ...newElement.startBinding, fixedPoint: null } - : null, - endBinding: newElement.endBinding - ? { ...newElement.endBinding, fixedPoint: null } - : null, - }, - false, - ); } return newElement; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 958fdb136269..8c9c526adffb 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -187,6 +187,7 @@ import type { MagicGenerationData, ExcalidrawNonSelectionElement, ExcalidrawArrowElement, + NonDeletedSceneElementsMap, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -289,6 +290,7 @@ import { getDateTime, isShallowEqual, arrayToMap, + toBrandedType, } from "../utils"; import { createSrcDoc, @@ -3288,22 +3290,44 @@ class App extends React.Component { retainSeed?: boolean; fitToContent?: boolean; }) => { - let elements = opts.elements.map((el) => - isElbowArrow(el) - ? { - ...el, - ...updateElbowArrow( - { - ...el, - startBinding: null, - endBinding: null, - }, - this.scene.getNonDeletedElementsMap(), - [el.points[0], el.points[el.points.length - 1]], + let elements = opts.elements.map((el, _, elements) => { + if (isElbowArrow(el)) { + const startEndElements = [ + el.startBinding && + elements.find((l) => l.id === el.startBinding?.elementId), + el.endBinding && + elements.find((l) => l.id === el.endBinding?.elementId), + ]; + const startBinding = startEndElements[0] ? el.startBinding : null; + const endBinding = startEndElements[1] ? el.endBinding : null; + return { + ...el, + ...updateElbowArrow( + { + ...el, + startBinding, + endBinding, + }, + toBrandedType( + new Map( + startEndElements + .filter((x) => x != null) + .map( + (el) => + [el!.id, el] as [ + string, + Ordered, + ], + ), + ), ), - } - : el, - ); + [el.points[0], el.points[el.points.length - 1]], + ), + }; + } + + return el; + }); elements = restoreElements(elements, null, undefined); const [minX, minY, maxX, maxY] = getCommonBounds(elements); diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 6a82e6ed26c5..1e8317fdcd29 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -5,6 +5,7 @@ import type { ExcalidrawLinearElement, ExcalidrawSelectionElement, ExcalidrawTextElement, + FixedPointBinding, FontFamilyValues, OrderedExcalidrawElement, PointBinding, @@ -21,6 +22,7 @@ import { import { isArrowElement, isElbowArrow, + isFixedPointBinding, isLinearElement, isTextElement, isUsingAdaptiveRadius, @@ -102,8 +104,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => { const repairBinding = ( element: ExcalidrawLinearElement, - binding: PointBinding | null, -): PointBinding | null => { + binding: PointBinding | FixedPointBinding | null, +): PointBinding | FixedPointBinding | null => { if (!binding) { return null; } @@ -111,9 +113,11 @@ const repairBinding = ( return { ...binding, focus: binding.focus || 0, - fixedPoint: isElbowArrow(element) - ? normalizeFixedPoint(binding.fixedPoint ?? [0, 0]) - : null, + ...(isElbowArrow(element) && isFixedPointBinding(binding) + ? { + fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), + } + : {}), }; }; diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index bccef44dcc97..75eb88541d9a 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -39,6 +39,7 @@ import { isBindingElement, isBoundToContainer, isElbowArrow, + isFixedPointBinding, isFrameLikeElement, isLinearElement, isRectangularElement, @@ -799,7 +800,7 @@ export const bindPointToSnapToElementOutline = ( isVertical ? Math.abs(p[1] - i[1]) < 0.1 : Math.abs(p[0] - i[0]) < 0.1, - )[0] ?? point; + )[0] ?? p; } return p; @@ -1015,7 +1016,7 @@ const updateBoundPoint = ( const direction = startOrEnd === "startBinding" ? -1 : 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; - if (isElbowArrow(linearElement)) { + if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) { const fixedPoint = normalizeFixedPoint(binding.fixedPoint) ?? calculateFixedPointForElbowArrowBinding( diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index 18d78fdbeff0..5775f0eb7456 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -35,7 +35,6 @@ export const dragSelectedElements = ( ) => { if ( _selectedElements.length === 1 && - isArrowElement(_selectedElements[0]) && isElbowArrow(_selectedElements[0]) && (_selectedElements[0].startBinding || _selectedElements[0].endBinding) ) { @@ -43,13 +42,7 @@ export const dragSelectedElements = ( } const selectedElements = _selectedElements.filter( - (el) => - !( - isArrowElement(el) && - isElbowArrow(el) && - el.startBinding && - el.endBinding - ), + (el) => !(isElbowArrow(el) && el.startBinding && el.endBinding), ); // we do not want a frame and its elements to be selected at the same time diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 7607a2e162a5..e11c0b158c20 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -102,6 +102,7 @@ export class LinearElementEditor { public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly hoverPointIndex: number; public readonly segmentMidPointHoveredCoords: GlobalPoint | null; + public readonly elbowed: boolean; constructor(element: NonDeleted) { this.elementId = element.id as string & { @@ -131,6 +132,7 @@ export class LinearElementEditor { }; this.hoverPointIndex = -1; this.segmentMidPointHoveredCoords = null; + this.elbowed = isElbowArrow(element) && element.elbowed; } // --------------------------------------------------------------------------- @@ -1477,7 +1479,9 @@ export class LinearElementEditor { nextPoints, vector(offsetX, offsetY), bindings, - options, + { + isDragging: options?.isDragging, + }, ); } else { const nextCoords = getElementPointsCoords(element, nextPoints); diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 303b12ac8c34..5d561f582dc1 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -9,6 +9,7 @@ import type { ExcalidrawTextElementWithContainer, ExcalidrawImageElement, ElementsMap, + ExcalidrawArrowElement, NonDeletedSceneElementsMap, SceneElementsMap, } from "./types"; @@ -930,6 +931,8 @@ export const resizeMultipleElements = ( fontSize?: ExcalidrawTextElement["fontSize"]; scale?: ExcalidrawImageElement["scale"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"]; + startBinding?: ExcalidrawArrowElement["startBinding"]; + endBinding?: ExcalidrawArrowElement["endBinding"]; }; }[] = []; @@ -1018,19 +1021,6 @@ export const resizeMultipleElements = ( mutateElement(element, update, false); - if (isArrowElement(element) && isElbowArrow(element)) { - mutateElbowArrow( - element, - elementsMap, - element.points, - undefined, - undefined, - { - informMutation: false, - }, - ); - } - updateBoundElements(element, elementsMap, { simultaneouslyUpdated: elementsToUpdate, oldSize: { width: oldWidth, height: oldHeight }, @@ -1084,7 +1074,7 @@ const rotateMultipleElements = ( (centerAngle + origAngle - element.angle) as Radians, ); - if (isArrowElement(element) && isElbowArrow(element)) { + if (isElbowArrow(element)) { const points = getArrowLocalFixedPoints(element, elementsMap); mutateElbowArrow(element, elementsMap, points); } else { diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index ac11cddb41b2..895340c91a2c 100644 --- a/packages/excalidraw/element/routing.ts +++ b/packages/excalidraw/element/routing.ts @@ -41,7 +41,6 @@ import { mutateElement } from "./mutateElement"; import { isBindableElement, isRectanguloidElement } from "./typeChecks"; import type { ExcalidrawElbowArrowElement, - FixedPointBinding, NonDeletedSceneElementsMap, SceneElementsMap, } from "./types"; @@ -73,13 +72,12 @@ export const mutateElbowArrow = ( elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, nextPoints: readonly LocalPoint[], offset?: Vector, - otherUpdates?: { - startBinding?: FixedPointBinding | null; - endBinding?: FixedPointBinding | null; - }, + otherUpdates?: Omit< + ElementUpdate, + "angle" | "x" | "y" | "width" | "height" | "elbowed" | "points" + >, options?: { isDragging?: boolean; - disableBinding?: boolean; informMutation?: boolean; }, ) => { diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index 5ba089ab01a7..6bb4269f8705 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -320,9 +320,12 @@ export const getDefaultRoundnessTypeForElement = ( }; export const isFixedPointBinding = ( - binding: PointBinding, + binding: PointBinding | FixedPointBinding, ): binding is FixedPointBinding => { - return binding.fixedPoint != null; + return ( + Object.hasOwn(binding, "fixedPoint") && + (binding as FixedPointBinding).fixedPoint != null + ); }; // TODO: Move this to @excalidraw/math diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 908998992de7..bb305543c9ee 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -195,6 +195,7 @@ export type ExcalidrawElement = | ExcalidrawGenericElement | ExcalidrawTextElement | ExcalidrawLinearElement + | ExcalidrawArrowElement | ExcalidrawFreeDrawElement | ExcalidrawImageElement | ExcalidrawFrameElement @@ -271,15 +272,19 @@ 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; +export type FixedPointBinding = Merge< + PointBinding, + { + // 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; + } +>; export type Arrowhead = | "arrow" diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index e816e0764902..13eb3cf6fe73 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -52,7 +52,6 @@ import { } from "./helpers"; import oc from "open-color"; import { - isArrowElement, isElbowArrow, isFrameLikeElement, isLinearElement, @@ -814,7 +813,6 @@ const _renderInteractiveScene = ({ // Elbow arrow elements cannot be selected when bound on either end ( isSingleLinearElementSelected && - isArrowElement(element) && isElbowArrow(element) && (element.startBinding || element.endBinding) ) diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index c4683267c96a..6e1f5350368f 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -8430,6 +8430,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { + "elbowed": false, "elementId": "id0", "endBindingElement": "keep", "hoverPointIndex": -1, @@ -8649,6 +8650,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { + "elbowed": false, "elementId": "id0", "endBindingElement": "keep", "hoverPointIndex": -1, @@ -9058,6 +9060,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { + "elbowed": false, "elementId": "id0", "endBindingElement": "keep", "hoverPointIndex": -1, @@ -9454,6 +9457,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { + "elbowed": false, "elementId": "id0", "endBindingElement": "keep", "hoverPointIndex": -1, diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index e5ee698bd2d4..5bc3b69cef61 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -9,6 +9,8 @@ import type { ExcalidrawFrameElement, ExcalidrawElementType, ExcalidrawMagicFrameElement, + ExcalidrawElbowArrowElement, + ExcalidrawArrowElement, } from "../../element/types"; import { newElement, newTextElement, newLinearElement } from "../../element"; import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants"; @@ -127,6 +129,10 @@ export class API { expect(API.getSelectedElements().length).toBe(0); }; + static getElement = (element: T): T => { + return h.app.scene.getElementsMapIncludingDeleted().get(element.id) as T || element; + } + static createElement = < T extends Exclude = "rectangle", >({ @@ -179,10 +185,16 @@ export class API { scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; status?: T extends "image" ? ExcalidrawImageElement["status"] : never; startBinding?: T extends "arrow" - ? ExcalidrawLinearElement["startBinding"] + ? ExcalidrawArrowElement["startBinding"] | ExcalidrawElbowArrowElement["startBinding"] : never; endBinding?: T extends "arrow" - ? ExcalidrawLinearElement["endBinding"] + ? ExcalidrawArrowElement["endBinding"] | ExcalidrawElbowArrowElement["endBinding"] + : never; + startArrowhead?: T extends "arrow" + ? ExcalidrawArrowElement["startArrowhead"] | ExcalidrawElbowArrowElement["startArrowhead"] + : never; + endArrowhead?: T extends "arrow" + ? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"] : never; elbowed?: boolean; }): T extends "arrow" | "line" @@ -341,6 +353,8 @@ export class API { if (element.type === "arrow") { element.startBinding = rest.startBinding ?? null; element.endBinding = rest.endBinding ?? null; + element.startArrowhead = rest.startArrowhead ?? null; + element.endArrowhead = rest.endArrowhead ?? null; } if (id) { element.id = id; diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 8e825e41489a..3c807cf9154e 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -31,6 +31,7 @@ import type { ExcalidrawGenericElement, ExcalidrawLinearElement, ExcalidrawTextElement, + FixedPointBinding, FractionalIndex, SceneElementsMap, } from "../element/types"; @@ -2049,13 +2050,13 @@ describe("history", () => { focus: -0.001587301587301948, gap: 5, fixedPoint: [1.0318471337579618, 0.49920634920634904], - }, + } as FixedPointBinding, endBinding: { elementId: "u2JGnnmoJ0VATV4vCNJE5", focus: -0.0016129032258049847, gap: 3.537079145500037, fixedPoint: [0.4991935483870975, -0.03875193720914723], - }, + } as FixedPointBinding, }, ], storeAction: StoreAction.CAPTURE, @@ -4455,7 +4456,7 @@ describe("history", () => { elements: [ h.elements[0], newElementWith(h.elements[1], { boundElements: [] }), - newElementWith(h.elements[2] as ExcalidrawLinearElement, { + newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, { endBinding: { elementId: remoteContainer.id, gap: 1, @@ -4655,7 +4656,7 @@ describe("history", () => { // Simulate remote update API.updateScene({ elements: [ - newElementWith(h.elements[0] as ExcalidrawLinearElement, { + newElementWith(h.elements[0] as ExcalidrawElbowArrowElement, { startBinding: { elementId: rect1.id, gap: 1, diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index d18f5cd498ad..8de7157b18d9 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -4,6 +4,7 @@ import { render } from "./test-utils"; import { reseed } from "../random"; import { UI, Keyboard, Pointer } from "./helpers/ui"; import type { + ExcalidrawElbowArrowElement, ExcalidrawFreeDrawElement, ExcalidrawLinearElement, } from "../element/types"; @@ -333,6 +334,62 @@ describe("arrow element", () => { expect(label.angle).toBeCloseTo(0); expect(label.fontSize).toEqual(20); }); + + it("flips the fixed point binding on negative resize for single bindable", () => { + const rectangle = UI.createElement("rectangle", { + x: -100, + y: -75, + width: 95, + height: 100, + }); + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + mouse.reset(); + mouse.moveTo(-5, 0); + mouse.click(); + mouse.moveTo(120, 200); + mouse.click(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawElbowArrowElement; + + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); + + UI.resize(rectangle, "se", [-200, -150]); + + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); + }); + + it("flips the fixed point binding on negative resize for group selection", () => { + const rectangle = UI.createElement("rectangle", { + x: -100, + y: -75, + width: 95, + height: 100, + }); + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + mouse.reset(); + mouse.moveTo(-5, 0); + mouse.click(); + mouse.moveTo(120, 200); + mouse.click(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawElbowArrowElement; + + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); + + UI.resize([rectangle, arrow], "nw", [300, 350]); + + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2); + expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); + }); }); describe("text element", () => { @@ -828,7 +885,6 @@ describe("multiple selection", () => { expect(leftBoundArrow.endBinding?.elementId).toBe( leftArrowBinding.elementId, ); - expect(leftBoundArrow.endBinding?.fixedPoint).toBeNull(); expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); expect(rightBoundArrow.x).toBeCloseTo(210); @@ -843,7 +899,6 @@ describe("multiple selection", () => { expect(rightBoundArrow.endBinding?.elementId).toBe( rightArrowBinding.elementId, ); - expect(rightBoundArrow.endBinding?.fixedPoint).toBeNull(); expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus); });