diff --git a/src/components/App.tsx b/src/components/App.tsx index bc849f11..bb8beb2a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -135,7 +135,7 @@ import { import LayerUI from "./LayerUI"; import { ScrollBars, SceneState } from "../scene/types"; import { generateCollaborationLink, getCollaborationLinkData } from "../data"; -import { mutateElement, newElementWith } from "../element/mutateElement"; +import { mutateElement } from "../element/mutateElement"; import { invalidateShapeForElement } from "../renderer/renderElement"; import { unstable_batchedUpdates } from "react-dom"; import { @@ -173,6 +173,7 @@ import { fixBindingsAfterDeletion, isLinearElementSimpleAndAlreadyBound, isBindingEnabled, + updateBoundElements, } from "../element/binding"; import { MaybeTransformHandleType } from "../element/transformHandles"; @@ -608,7 +609,7 @@ class App extends React.Component { this.setState({}); }); - private onHashChange = (event: HashChangeEvent) => { + private onHashChange = (_: HashChangeEvent) => { if (window.location.hash.length > 1) { this.initializeScene(); } @@ -1486,24 +1487,35 @@ class App extends React.Component { (event.shiftKey ? ELEMENT_SHIFT_TRANSLATE_AMOUNT : ELEMENT_TRANSLATE_AMOUNT); - this.scene.replaceAllElements( - this.scene.getElementsIncludingDeleted().map((el) => { - if (this.state.selectedElementIds[el.id]) { - const update: { x?: number; y?: number } = {}; - if (event.key === KEYS.ARROW_LEFT) { - update.x = el.x - step; - } else if (event.key === KEYS.ARROW_RIGHT) { - update.x = el.x + step; - } else if (event.key === KEYS.ARROW_UP) { - update.y = el.y - step; - } else if (event.key === KEYS.ARROW_DOWN) { - update.y = el.y + step; - } - return newElementWith(el, update); - } - return el; - }), - ); + + const selectedElements = this.scene + .getElements() + .filter((element) => this.state.selectedElementIds[element.id]); + + let offsetX = 0; + let offsetY = 0; + + if (event.key === KEYS.ARROW_LEFT) { + offsetX = -step; + } else if (event.key === KEYS.ARROW_RIGHT) { + offsetX = step; + } else if (event.key === KEYS.ARROW_UP) { + offsetY = -step; + } else if (event.key === KEYS.ARROW_DOWN) { + offsetY = step; + } + + selectedElements.forEach((element) => { + mutateElement(element, { + x: element.x + offsetX, + y: element.y + offsetY, + }); + + updateBoundElements(element, { + simultaneouslyUpdated: selectedElements, + }); + }); + event.preventDefault(); } else if (event.key === KEYS.ENTER) { const selectedElements = getSelectedElements( diff --git a/src/tests/__snapshots__/move.test.tsx.snap b/src/tests/__snapshots__/move.test.tsx.snap index c711943e..71e0ddd6 100644 --- a/src/tests/__snapshots__/move.test.tsx.snap +++ b/src/tests/__snapshots__/move.test.tsx.snap @@ -77,3 +77,106 @@ Object { "y": 40, } `; + +exports[`move element rectangles with binding arrow 1`] = ` +Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": Array [ + "id2", + ], + "fillStyle": "hachure", + "groupIds": Array [], + "height": 100, + "id": "id0", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 337897, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 3, + "versionNonce": 1014066025, + "width": 100, + "x": 0, + "y": 0, +} +`; + +exports[`move element rectangles with binding arrow 2`] = ` +Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": Array [ + "id2", + ], + "fillStyle": "hachure", + "groupIds": Array [], + "height": 300, + "id": "id1", + "isDeleted": false, + "opacity": 100, + "roughness": 1, + "seed": 449462985, + "strokeColor": "#000000", + "strokeSharpness": "sharp", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "rectangle", + "version": 6, + "versionNonce": 1723083209, + "width": 300, + "x": 201, + "y": 2, +} +`; + +exports[`move element rectangles with binding arrow 3`] = ` +Object { + "angle": 0, + "backgroundColor": "transparent", + "boundElementIds": null, + "endBinding": Object { + "elementId": "id1", + "focus": -0.46666666666666673, + "gap": 10, + }, + "fillStyle": "hachure", + "groupIds": Array [], + "height": 81.48231043525051, + "id": "id2", + "isDeleted": false, + "lastCommittedPoint": null, + "opacity": 100, + "points": Array [ + Array [ + 0, + 0, + ], + Array [ + 81, + 81.48231043525051, + ], + ], + "roughness": 1, + "seed": 401146281, + "startBinding": Object { + "elementId": "id0", + "focus": -0.6000000000000001, + "gap": 10, + }, + "strokeColor": "#000000", + "strokeSharpness": "round", + "strokeStyle": "solid", + "strokeWidth": 1, + "type": "line", + "version": 11, + "versionNonce": 1006504105, + "width": 81, + "x": 110, + "y": 49.981789081137734, +} +`; diff --git a/src/tests/move.test.tsx b/src/tests/move.test.tsx index 8279baa1..4676a336 100644 --- a/src/tests/move.test.tsx +++ b/src/tests/move.test.tsx @@ -4,6 +4,13 @@ import { render, fireEvent } from "./test-utils"; import App from "../components/App"; import * as Renderer from "../renderer/renderScene"; import { reseed } from "../random"; +import { bindOrUnbindLinearElement } from "../element/binding"; +import { + ExcalidrawLinearElement, + NonDeleted, + ExcalidrawRectangleElement, +} from "../element/types"; +import { UI, Pointer, Keyboard } from "./helpers/ui"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -50,6 +57,53 @@ describe("move element", () => { h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); + + it("rectangles with binding arrow", () => { + render(); + + // create elements + const rectA = UI.createElement("rectangle", { size: 100 }); + const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 }); + const line = UI.createElement("line", { x: 110, y: 50, size: 80 }); + + // bind line to two rectangles + bindOrUnbindLinearElement( + line as NonDeleted, + rectA as ExcalidrawRectangleElement, + rectB as ExcalidrawRectangleElement, + ); + + // select the second rectangles + new Pointer("mouse").clickOn(rectB); + + expect(renderScene).toHaveBeenCalledTimes(19); + expect(h.state.selectionElement).toBeNull(); + expect(h.elements.length).toEqual(3); + expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); + expect([rectA.x, rectA.y]).toEqual([0, 0]); + expect([rectB.x, rectB.y]).toEqual([200, 0]); + expect([line.x, line.y]).toEqual([110, 50]); + expect([line.width, line.height]).toEqual([80, 80]); + + renderScene.mockClear(); + + // Move selected rectangle + Keyboard.keyDown("ArrowRight"); + Keyboard.keyDown("ArrowDown"); + Keyboard.keyDown("ArrowDown"); + + // Check that the arrow size has been changed according to moving the rectangle + expect(renderScene).toHaveBeenCalledTimes(3); + expect(h.state.selectionElement).toBeNull(); + expect(h.elements.length).toEqual(3); + expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); + expect([rectA.x, rectA.y]).toEqual([0, 0]); + expect([rectB.x, rectB.y]).toEqual([201, 2]); + expect([Math.round(line.x), Math.round(line.y)]).toEqual([110, 50]); + expect([Math.round(line.width), Math.round(line.height)]).toEqual([81, 81]); + + h.elements.forEach((element) => expect(element).toMatchSnapshot()); + }); }); describe("duplicate element on move when ALT is clicked", () => {