Lock drag direction using Shift
(#1858)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
d07099aadd
commit
c6736fa14e
@ -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<typeof isOverScrollBars>;
|
||||
// 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<string, Pick<ExcalidrawElement, "x" | "y" | "angle">>;
|
||||
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<ExcalidrawElement>[];
|
||||
};
|
||||
hit: {
|
||||
// The element the pointer is "hitting", is determined on the initial
|
||||
@ -2435,13 +2436,20 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
),
|
||||
// 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<ExcalidrawProps, AppState> {
|
||||
);
|
||||
if (
|
||||
transformElements(
|
||||
pointerDownState,
|
||||
transformHandleType,
|
||||
(newTransformHandle) => {
|
||||
pointerDownState.resize.handleType = newTransformHandle;
|
||||
@ -2954,7 +2963,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
resizeY,
|
||||
pointerDownState.resize.center.x,
|
||||
pointerDownState.resize.center.y,
|
||||
pointerDownState.resize.originalElements,
|
||||
)
|
||||
) {
|
||||
this.maybeSuggestBindingForAll(selectedElements);
|
||||
@ -3004,7 +3012,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
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
|
||||
|
@ -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 });
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -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,
|
||||
}
|
||||
`;
|
||||
|
@ -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(<App />);
|
||||
const canvas = container.querySelector("canvas")!;
|
||||
render(<App />);
|
||||
|
||||
{
|
||||
// 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]);
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user