From c6736fa14e62bca74a2021ee7b38e8552372dffb Mon Sep 17 00:00:00 2001 From: Robert van Hoesel Date: Fri, 11 Sep 2020 17:22:40 +0200 Subject: [PATCH] Lock drag direction using `Shift` (#1858) Co-authored-by: dwelle --- src/components/App.tsx | 38 ++++++++++--- src/element/dragElements.ts | 26 +++++++-- src/element/resizeElements.ts | 13 +++-- src/tests/__snapshots__/resize.test.tsx.snap | 26 --------- src/tests/resize.test.tsx | 56 ++++++++------------ 5 files changed, 84 insertions(+), 75 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 55867202..c178af34 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -209,7 +209,7 @@ const gesture: Gesture = { initialScale: null, }; -type PointerDownState = Readonly<{ +export type PointerDownState = Readonly<{ // The first position at which pointerDown happened origin: Readonly<{ x: number; y: number }>; // Same as "origin" but snapped to the grid, if grid is on @@ -218,6 +218,9 @@ type PointerDownState = Readonly<{ scrollbars: ReturnType; // The previous pointer position lastCoords: { x: number; y: number }; + // map of original elements data + // (for now only a subset of props for perf reasons) + originalElements: Map>; resize: { // Handle when resizing, might change during the pointer interaction handleType: MaybeTransformHandleType; @@ -229,8 +232,6 @@ type PointerDownState = Readonly<{ arrowDirection: "origin" | "end"; // This is a center point of selected elements determined on the initial pointer down event (for rotation only) center: { x: number; y: number }; - // This is a list of selected elements determined on the initial pointer down event (for rotation only) - originalElements: readonly NonDeleted[]; }; hit: { // The element the pointer is "hitting", is determined on the initial @@ -2435,13 +2436,20 @@ class App extends React.Component { ), // we need to duplicate because we'll be updating this state lastCoords: { ...origin }, + originalElements: this.scene.getElements().reduce((acc, element) => { + acc.set(element.id, { + x: element.x, + y: element.y, + angle: element.angle, + }); + return acc; + }, new Map() as PointerDownState["originalElements"]), resize: { handleType: false, isResizing: false, offset: { x: 0, y: 0 }, arrowDirection: "origin", center: { x: (maxX + minX) / 2, y: (maxY + minY) / 2 }, - originalElements: selectedElements.map((element) => ({ ...element })), }, hit: { element: null, @@ -2941,6 +2949,7 @@ class App extends React.Component { ); if ( transformElements( + pointerDownState, transformHandleType, (newTransformHandle) => { pointerDownState.resize.handleType = newTransformHandle; @@ -2954,7 +2963,6 @@ class App extends React.Component { resizeY, pointerDownState.resize.center.x, pointerDownState.resize.center.y, - pointerDownState.resize.originalElements, ) ) { this.maybeSuggestBindingForAll(selectedElements); @@ -3004,7 +3012,25 @@ class App extends React.Component { pointerCoords.y - pointerDownState.drag.offset.y, this.state.gridSize, ); - dragSelectedElements(selectedElements, dragX, dragY, this.scene); + + const [dragDistanceX, dragDistanceY] = [ + Math.abs(pointerCoords.x - pointerDownState.origin.x), + Math.abs(pointerCoords.y - pointerDownState.origin.y), + ]; + + // We only drag in one direction if shift is pressed + const lockDirection = event.shiftKey; + + dragSelectedElements( + pointerDownState, + selectedElements, + dragX, + dragY, + this.scene, + lockDirection, + dragDistanceX, + dragDistanceY, + ); this.maybeSuggestBindingForAll(selectedElements); // We duplicate the selected element if alt is pressed on pointer move diff --git a/src/element/dragElements.ts b/src/element/dragElements.ts index 92c21b22..abab1eaf 100644 --- a/src/element/dragElements.ts +++ b/src/element/dragElements.ts @@ -5,21 +5,41 @@ import { mutateElement } from "./mutateElement"; import { getPerfectElementSize } from "./sizeHelpers"; import Scene from "../scene/Scene"; import { NonDeletedExcalidrawElement } from "./types"; +import { PointerDownState } from "../components/App"; export const dragSelectedElements = ( + pointerDownState: PointerDownState, selectedElements: NonDeletedExcalidrawElement[], pointerX: number, pointerY: number, scene: Scene, + lockDirection: boolean = false, + distanceX: number = 0, + distanceY: number = 0, ) => { const [x1, y1] = getCommonBounds(selectedElements); const offset = { x: pointerX - x1, y: pointerY - y1 }; selectedElements.forEach((element) => { + let x, y; + if (lockDirection) { + const lockX = lockDirection && distanceX < distanceY; + const lockY = lockDirection && distanceX > distanceY; + const original = pointerDownState.originalElements.get(element.id); + x = lockX && original ? original.x : element.x + offset.x; + y = lockY && original ? original.y : element.y + offset.y; + } else { + x = element.x + offset.x; + y = element.y + offset.y; + } + mutateElement(element, { - x: element.x + offset.x, - y: element.y + offset.y, + x, + y, + }); + + updateBoundElements(element, { + simultaneouslyUpdated: selectedElements, }); - updateBoundElements(element, { simultaneouslyUpdated: selectedElements }); }); }; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 8958b007..f4041062 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -26,6 +26,7 @@ import { TransformHandleType, MaybeTransformHandleType, } from "./transformHandles"; +import { PointerDownState } from "../components/App"; const normalizeAngle = (angle: number): number => { if (angle >= 2 * Math.PI) { @@ -36,6 +37,7 @@ const normalizeAngle = (angle: number): number => { // Returns true when transform (resizing/rotation) happened export const transformElements = ( + pointerDownState: PointerDownState, transformHandleType: MaybeTransformHandleType, setTransformHandle: (nextTransformHandle: MaybeTransformHandleType) => void, selectedElements: readonly NonDeletedExcalidrawElement[], @@ -47,7 +49,6 @@ export const transformElements = ( pointerY: number, centerX: number, centerY: number, - originalElements: readonly NonDeletedExcalidrawElement[], ) => { if (selectedElements.length === 1) { const [element] = selectedElements; @@ -120,13 +121,13 @@ export const transformElements = ( } else if (selectedElements.length > 1) { if (transformHandleType === "rotation") { rotateMultipleElements( + pointerDownState, selectedElements, pointerX, pointerY, isRotateWithDiscreteAngle, centerX, centerY, - originalElements, ); return true; } else if ( @@ -619,13 +620,13 @@ const resizeMultipleElements = ( }; const rotateMultipleElements = ( + pointerDownState: PointerDownState, elements: readonly NonDeletedExcalidrawElement[], pointerX: number, pointerY: number, isRotateWithDiscreteAngle: boolean, centerX: number, centerY: number, - originalElements: readonly NonDeletedExcalidrawElement[], ) => { let centerAngle = (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX); @@ -637,17 +638,19 @@ const rotateMultipleElements = ( const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; + const origAngle = + pointerDownState.originalElements.get(element.id)?.angle ?? element.angle; const [rotatedCX, rotatedCY] = rotate( cx, cy, centerX, centerY, - centerAngle + originalElements[index].angle - element.angle, + centerAngle + origAngle - element.angle, ); mutateElement(element, { x: element.x + (rotatedCX - cx), y: element.y + (rotatedCY - cy), - angle: normalizeAngle(centerAngle + originalElements[index].angle), + angle: normalizeAngle(centerAngle + origAngle), }); }); }; diff --git a/src/tests/__snapshots__/resize.test.tsx.snap b/src/tests/__snapshots__/resize.test.tsx.snap index 3bca6a95..46e5e05a 100644 --- a/src/tests/__snapshots__/resize.test.tsx.snap +++ b/src/tests/__snapshots__/resize.test.tsx.snap @@ -25,29 +25,3 @@ Object { "y": 47, } `; - -exports[`resize element with aspect ratio when SHIFT is clicked rectangle 1`] = ` -Object { - "angle": 0, - "backgroundColor": "transparent", - "boundElementIds": null, - "fillStyle": "hachure", - "groupIds": Array [], - "height": 50, - "id": "id0", - "isDeleted": false, - "opacity": 100, - "roughness": 1, - "seed": 337897, - "strokeColor": "#000000", - "strokeSharpness": "sharp", - "strokeStyle": "solid", - "strokeWidth": 1, - "type": "rectangle", - "version": 3, - "versionNonce": 401146281, - "width": 30, - "x": 29, - "y": 47, -} -`; diff --git a/src/tests/resize.test.tsx b/src/tests/resize.test.tsx index 1c27c7ec..72a75abe 100644 --- a/src/tests/resize.test.tsx +++ b/src/tests/resize.test.tsx @@ -4,6 +4,10 @@ import { render, fireEvent } from "./test-utils"; import App from "../components/App"; import * as Renderer from "../renderer/renderScene"; import { reseed } from "../random"; +import { UI, Pointer, Keyboard } from "./helpers/ui"; +import { getTransformHandles } from "../element/transformHandles"; + +const mouse = new Pointer("mouse"); // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -62,43 +66,25 @@ describe("resize element", () => { describe("resize element with aspect ratio when SHIFT is clicked", () => { it("rectangle", () => { - const { getByToolName, container } = render(); - const canvas = container.querySelector("canvas")!; + render(); - { - // create element - const tool = getByToolName("rectangle"); - fireEvent.click(tool); - fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); - fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); - fireEvent.pointerUp(canvas); + const rectangle = UI.createElement("rectangle", { + x: 0, + width: 30, + height: 50, + }); - expect(renderScene).toHaveBeenCalledTimes(5); - expect(h.state.selectionElement).toBeNull(); - expect(h.elements.length).toEqual(1); - expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); - expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]); - expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]); - expect([h.elements[0].width, h.elements[0].height]).toEqual([30, 50]); + mouse.select(rectangle); - renderScene.mockClear(); - } - - // select the element first - fireEvent.pointerDown(canvas, { clientX: 50, clientY: 20 }); - fireEvent.pointerUp(canvas); - - // select a handler rectangle (top-left) - fireEvent.pointerDown(canvas, { clientX: 21, clientY: 13 }); - fireEvent.pointerMove(canvas, { clientX: 20, clientY: 40, shiftKey: true }); - fireEvent.pointerUp(canvas); - - expect(renderScene).toHaveBeenCalledTimes(5); - expect(h.state.selectionElement).toBeNull(); - expect(h.elements.length).toEqual(1); - expect([h.elements[0].x, h.elements[0].y]).toEqual([29, 47]); - expect([h.elements[0].width, h.elements[0].height]).toEqual([30, 50]); - - h.elements.forEach((element) => expect(element).toMatchSnapshot()); + const se = getTransformHandles(rectangle, h.state.zoom, "mouse").se!; + const clientX = se[0] + se[2] / 2; + const clientY = se[1] + se[3] / 2; + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.reset(); + mouse.down(clientX, clientY); + mouse.move(1, 1); + mouse.up(); + }); + expect([h.elements[0].width, h.elements[0].height]).toEqual([51, 51]); }); });