Enhance aspect ratio tools | Rectangle, Diamond, Ellipses (#2439)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
João Forja 2020-12-06 22:39:31 +00:00 committed by GitHub
parent 4c90ea5667
commit aa221837fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 488 additions and 9492 deletions

View File

@ -164,6 +164,7 @@ import {
shouldEnableBindingForPointerEvent, shouldEnableBindingForPointerEvent,
} from "../element/binding"; } from "../element/binding";
import { MaybeTransformHandleType } from "../element/transformHandles"; import { MaybeTransformHandleType } from "../element/transformHandles";
import { deepCopyElement } from "../element/newElement";
import { renderSpreadsheet } from "../charts"; import { renderSpreadsheet } from "../charts";
import { isValidLibrary } from "../data/json"; import { isValidLibrary } from "../data/json";
import { getNewZoom } from "../scene/zoom"; import { getNewZoom } from "../scene/zoom";
@ -206,8 +207,7 @@ export type PointerDownState = Readonly<{
// The previous pointer position // The previous pointer position
lastCoords: { x: number; y: number }; lastCoords: { x: number; y: number };
// map of original elements data // map of original elements data
// (for now only a subset of props for perf reasons) originalElements: Map<string, NonDeleted<ExcalidrawElement>>;
originalElements: Map<string, Pick<ExcalidrawElement, "x" | "y" | "angle">>;
resize: { resize: {
// Handle when resizing, might change during the pointer interaction // Handle when resizing, might change during the pointer interaction
handleType: MaybeTransformHandleType; handleType: MaybeTransformHandleType;
@ -246,6 +246,10 @@ export type PointerDownState = Readonly<{
onMove: null | ((event: PointerEvent) => void); onMove: null | ((event: PointerEvent) => void);
// It's defined on the initial pointer down event // It's defined on the initial pointer down event
onUp: null | ((event: PointerEvent) => void); onUp: null | ((event: PointerEvent) => void);
// It's defined on the initial pointer down event
onKeyDown: null | ((event: KeyboardEvent) => void);
// It's defined on the initial pointer down event
onKeyUp: null | ((event: KeyboardEvent) => void);
}; };
}>; }>;
@ -2002,12 +2006,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
pointerDownState, pointerDownState,
); );
const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState);
const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState);
lastPointerUp = onPointerUp; lastPointerUp = onPointerUp;
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp); window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
window.addEventListener(EVENT.KEYUP, onKeyUp);
pointerDownState.eventListeners.onMove = onPointerMove; pointerDownState.eventListeners.onMove = onPointerMove;
pointerDownState.eventListeners.onUp = onPointerUp; pointerDownState.eventListeners.onUp = onPointerUp;
pointerDownState.eventListeners.onKeyUp = onKeyUp;
pointerDownState.eventListeners.onKeyDown = onKeyDown;
}; };
private maybeOpenContextMenuAfterPointerDownOnTouchDevices = ( private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
@ -2182,11 +2193,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
// we need to duplicate because we'll be updating this state // we need to duplicate because we'll be updating this state
lastCoords: { ...origin }, lastCoords: { ...origin },
originalElements: this.scene.getElements().reduce((acc, element) => { originalElements: this.scene.getElements().reduce((acc, element) => {
acc.set(element.id, { acc.set(element.id, deepCopyElement(element));
x: element.x,
y: element.y,
angle: element.angle,
});
return acc; return acc;
}, new Map() as PointerDownState["originalElements"]), }, new Map() as PointerDownState["originalElements"]),
resize: { resize: {
@ -2213,6 +2220,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
eventListeners: { eventListeners: {
onMove: null, onMove: null,
onUp: null, onUp: null,
onKeyUp: null,
onKeyDown: null,
}, },
}; };
} }
@ -2614,6 +2623,30 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
}; };
private onKeyDownFromPointerDownHandler(
pointerDownState: PointerDownState,
): (event: KeyboardEvent) => void {
return withBatchedUpdates((event: KeyboardEvent) => {
if (this.maybeHandleResize(pointerDownState, event)) {
return;
}
this.maybeDragNewGenericElement(pointerDownState, event);
});
}
private onKeyUpFromPointerDownHandler(
pointerDownState: PointerDownState,
): (event: KeyboardEvent) => void {
return withBatchedUpdates((event: KeyboardEvent) => {
// Prevents focus from escaping excalidraw tab
event.key === KEYS.ALT && event.preventDefault();
if (this.maybeHandleResize(pointerDownState, event)) {
return;
}
this.maybeDragNewGenericElement(pointerDownState, event);
});
}
private onPointerMoveFromPointerDownHandler( private onPointerMoveFromPointerDownHandler(
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
): (event: PointerEvent) => void { ): (event: PointerEvent) => void {
@ -2670,43 +2703,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
if (pointerDownState.resize.isResizing) { if (pointerDownState.resize.isResizing) {
const selectedElements = getSelectedElements( pointerDownState.lastCoords.x = pointerCoords.x;
this.scene.getElements(), pointerDownState.lastCoords.y = pointerCoords.y;
this.state, if (this.maybeHandleResize(pointerDownState, event)) {
); return true;
const transformHandleType = pointerDownState.resize.handleType;
this.setState({
// TODO: rename this state field to "isScaling" to distinguish
// it from the generic "isResizing" which includes scaling and
// rotating
isResizing: transformHandleType && transformHandleType !== "rotation",
isRotating: transformHandleType === "rotation",
});
const [resizeX, resizeY] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
this.state.gridSize,
);
if (
transformElements(
pointerDownState,
transformHandleType,
(newTransformHandle) => {
pointerDownState.resize.handleType = newTransformHandle;
},
selectedElements,
pointerDownState.resize.arrowDirection,
getRotateWithDiscreteAngleKey(event),
getResizeWithSidesSameLengthKey(event),
getResizeCenterPointKey(event),
resizeX,
resizeY,
pointerDownState.resize.center.x,
pointerDownState.resize.center.y,
)
) {
this.maybeSuggestBindingForAll(selectedElements);
return;
} }
} }
@ -2881,33 +2881,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.state.startBoundElement, this.state.startBoundElement,
); );
} }
} else if (draggingElement.type === "selection") {
dragNewElement(
draggingElement,
this.state.elementType,
pointerDownState.origin.x,
pointerDownState.origin.y,
pointerCoords.x,
pointerCoords.y,
distance(pointerDownState.origin.x, pointerCoords.x),
distance(pointerDownState.origin.y, pointerCoords.y),
getResizeWithSidesSameLengthKey(event),
getResizeCenterPointKey(event),
);
} else { } else {
dragNewElement( pointerDownState.lastCoords.x = pointerCoords.x;
draggingElement, pointerDownState.lastCoords.y = pointerCoords.y;
this.state.elementType, this.maybeDragNewGenericElement(pointerDownState, event);
pointerDownState.originInGrid.x,
pointerDownState.originInGrid.y,
gridX,
gridY,
distance(pointerDownState.originInGrid.x, gridX),
distance(pointerDownState.originInGrid.y, gridY),
getResizeWithSidesSameLengthKey(event),
getResizeCenterPointKey(event),
);
this.maybeSuggestBindingForAll([draggingElement]);
} }
if (this.state.elementType === "selection") { if (this.state.elementType === "selection") {
@ -3029,6 +3006,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
EVENT.POINTER_UP, EVENT.POINTER_UP,
pointerDownState.eventListeners.onUp!, pointerDownState.eventListeners.onUp!,
); );
window.removeEventListener(
EVENT.KEYDOWN,
pointerDownState.eventListeners.onKeyDown!,
);
window.removeEventListener(
EVENT.KEYUP,
pointerDownState.eventListeners.onKeyUp!,
);
if (draggingElement?.type === "draw") { if (draggingElement?.type === "draw") {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
@ -3451,6 +3436,96 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.openContextMenu(event); this.openContextMenu(event);
}; };
private maybeDragNewGenericElement = (
pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent,
): void => {
const draggingElement = this.state.draggingElement;
const pointerCoords = pointerDownState.lastCoords;
if (!draggingElement) {
return;
}
if (draggingElement.type === "selection") {
dragNewElement(
draggingElement,
this.state.elementType,
pointerDownState.origin.x,
pointerDownState.origin.y,
pointerCoords.x,
pointerCoords.y,
distance(pointerDownState.origin.x, pointerCoords.x),
distance(pointerDownState.origin.y, pointerCoords.y),
getResizeWithSidesSameLengthKey(event),
getResizeCenterPointKey(event),
);
} else {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
this.state.gridSize,
);
dragNewElement(
draggingElement,
this.state.elementType,
pointerDownState.originInGrid.x,
pointerDownState.originInGrid.y,
gridX,
gridY,
distance(pointerDownState.originInGrid.x, gridX),
distance(pointerDownState.originInGrid.y, gridY),
getResizeWithSidesSameLengthKey(event),
getResizeCenterPointKey(event),
);
this.maybeSuggestBindingForAll([draggingElement]);
}
};
private maybeHandleResize = (
pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent,
): boolean => {
const selectedElements = getSelectedElements(
this.scene.getElements(),
this.state,
);
const transformHandleType = pointerDownState.resize.handleType;
this.setState({
// TODO: rename this state field to "isScaling" to distinguish
// it from the generic "isResizing" which includes scaling and
// rotating
isResizing: transformHandleType && transformHandleType !== "rotation",
isRotating: transformHandleType === "rotation",
});
const pointerCoords = pointerDownState.lastCoords;
const [resizeX, resizeY] = getGridPoint(
pointerCoords.x - pointerDownState.resize.offset.x,
pointerCoords.y - pointerDownState.resize.offset.y,
this.state.gridSize,
);
if (
transformElements(
pointerDownState,
transformHandleType,
(newTransformHandle) => {
pointerDownState.resize.handleType = newTransformHandle;
},
selectedElements,
pointerDownState.resize.arrowDirection,
getRotateWithDiscreteAngleKey(event),
getResizeCenterPointKey(event),
getResizeWithSidesSameLengthKey(event),
resizeX,
resizeY,
pointerDownState.resize.center.x,
pointerDownState.resize.center.y,
)
) {
this.maybeSuggestBindingForAll(selectedElements);
return true;
}
return false;
};
private openContextMenu = ({ private openContextMenu = ({
clientX, clientX,
clientY, clientY,

View File

@ -1,19 +1,27 @@
import { SHIFT_LOCKING_ANGLE } from "../constants"; import { SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
import { rotate, adjustXYWithRotation, getFlipAdjustment } from "../math"; import {
rotate,
adjustXYWithRotation,
getFlipAdjustment,
centerPoint,
rotatePoint,
} from "../math";
import { import {
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawGenericElement,
ExcalidrawElement,
} from "./types"; } from "./types";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getCommonBounds, getCommonBounds,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
import { isLinearElement } from "./typeChecks"; import { isGenericElement, isLinearElement, isTextElement } from "./typeChecks";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import { import {
@ -25,8 +33,10 @@ import { updateBoundElements } from "./binding";
import { import {
TransformHandleType, TransformHandleType,
MaybeTransformHandleType, MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles"; } from "./transformHandles";
import { PointerDownState } from "../components/App"; import { PointerDownState } from "../components/App";
import { Point } from "../types";
const normalizeAngle = (angle: number): number => { const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) { if (angle >= 2 * Math.PI) {
@ -43,8 +53,8 @@ export const transformElements = (
selectedElements: readonly NonDeletedExcalidrawElement[], selectedElements: readonly NonDeletedExcalidrawElement[],
resizeArrowDirection: "origin" | "end", resizeArrowDirection: "origin" | "end",
isRotateWithDiscreteAngle: boolean, isRotateWithDiscreteAngle: boolean,
isResizeWithSidesSameLength: boolean,
isResizeCenterPoint: boolean, isResizeCenterPoint: boolean,
shouldKeepSidesRatio: boolean,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
centerX: number, centerX: number,
@ -76,7 +86,7 @@ export const transformElements = (
pointerY, pointerY,
); );
} else if ( } else if (
element.type === "text" && isTextElement(element) &&
(transformHandleType === "nw" || (transformHandleType === "nw" ||
transformHandleType === "ne" || transformHandleType === "ne" ||
transformHandleType === "sw" || transformHandleType === "sw" ||
@ -91,22 +101,35 @@ export const transformElements = (
); );
updateBoundElements(element); updateBoundElements(element);
} else if (transformHandleType) { } else if (transformHandleType) {
resizeSingleElement( if (isGenericElement(element)) {
element, resizeSingleGenericElement(
transformHandleType, pointerDownState.originalElements.get(element.id) as typeof element,
isResizeWithSidesSameLength, shouldKeepSidesRatio,
isResizeCenterPoint, element,
pointerX, transformHandleType,
pointerY, isResizeCenterPoint,
); pointerX,
setTransformHandle( pointerY,
normalizeTransformHandleType(element, transformHandleType), );
); } else {
if (element.width < 0) { const keepSquareAspectRatio = shouldKeepSidesRatio;
mutateElement(element, { width: -element.width }); resizeSingleNonGenericElement(
} element,
if (element.height < 0) { transformHandleType,
mutateElement(element, { height: -element.height }); isResizeCenterPoint,
keepSquareAspectRatio,
pointerX,
pointerY,
);
setTransformHandle(
normalizeTransformHandleType(element, transformHandleType),
);
if (element.width < 0) {
mutateElement(element, { width: -element.width });
}
if (element.height < 0) {
mutateElement(element, { height: -element.height });
}
} }
} }
@ -391,17 +414,153 @@ const resizeSingleTextElement = (
} }
}; };
const resizeSingleElement = ( const resizeSingleGenericElement = (
stateAtResizeStart: NonDeleted<ExcalidrawGenericElement>,
shouldKeepSidesRatio: boolean,
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
transformHandleType: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se", transformHandleDirection: TransformHandleDirection,
sidesWithSameLength: boolean,
isResizeFromCenter: boolean, isResizeFromCenter: boolean,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(stateAtResizeStart);
const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2];
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
// Calculate new dimensions based on cursor position
let newWidth = stateAtResizeStart.width;
let newHeight = stateAtResizeStart.height;
const rotatedPointer = rotatePoint(
[pointerX, pointerY],
startCenter,
-stateAtResizeStart.angle,
);
if (transformHandleDirection.includes("e")) {
newWidth = rotatedPointer[0] - startTopLeft[0];
}
if (transformHandleDirection.includes("s")) {
newHeight = rotatedPointer[1] - startTopLeft[1];
}
if (transformHandleDirection.includes("w")) {
newWidth = startBottomRight[0] - rotatedPointer[0];
}
if (transformHandleDirection.includes("n")) {
newHeight = startBottomRight[1] - rotatedPointer[1];
}
// adjust dimensions for resizing from center
if (isResizeFromCenter) {
newWidth = 2 * newWidth - stateAtResizeStart.width;
newHeight = 2 * newHeight - stateAtResizeStart.height;
}
// adjust dimensions to keep sides ratio
if (shouldKeepSidesRatio) {
const widthRatio = Math.abs(newWidth) / stateAtResizeStart.width;
const heightRatio = Math.abs(newHeight) / stateAtResizeStart.height;
if (transformHandleDirection.length === 1) {
newHeight *= widthRatio;
newWidth *= heightRatio;
}
if (transformHandleDirection.length === 2) {
const ratio = Math.max(widthRatio, heightRatio);
newWidth = stateAtResizeStart.width * ratio * Math.sign(newWidth);
newHeight = stateAtResizeStart.height * ratio * Math.sign(newHeight);
}
}
// Calculate new topLeft based on fixed corner during resize
let newTopLeft = startTopLeft as [number, number];
if (["n", "w", "nw"].includes(transformHandleDirection)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newWidth),
startBottomRight[1] - Math.abs(newHeight),
];
}
if (transformHandleDirection === "ne") {
const bottomLeft = [
stateAtResizeStart.x,
stateAtResizeStart.y + stateAtResizeStart.height,
];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newHeight)];
}
if (transformHandleDirection === "sw") {
const topRight = [
stateAtResizeStart.x + stateAtResizeStart.width,
stateAtResizeStart.y,
];
newTopLeft = [topRight[0] - Math.abs(newWidth), topRight[1]];
}
// Keeps opposite handle fixed during resize
if (shouldKeepSidesRatio) {
if (["s", "n"].includes(transformHandleDirection)) {
newTopLeft[0] = startCenter[0] - newWidth / 2;
}
if (["e", "w"].includes(transformHandleDirection)) {
newTopLeft[1] = startCenter[1] - newHeight / 2;
}
}
// Flip horizontally
if (newWidth < 0) {
if (transformHandleDirection.includes("e")) {
newTopLeft[0] -= Math.abs(newWidth);
}
if (transformHandleDirection.includes("w")) {
newTopLeft[0] += Math.abs(newWidth);
}
}
// Flip vertically
if (newHeight < 0) {
if (transformHandleDirection.includes("s")) {
newTopLeft[1] -= Math.abs(newHeight);
}
if (transformHandleDirection.includes("n")) {
newTopLeft[1] += Math.abs(newHeight);
}
}
if (isResizeFromCenter) {
newTopLeft[0] = startCenter[0] - Math.abs(newWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(newHeight) / 2;
}
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
const newCenter: Point = [
newTopLeft[0] + Math.abs(newWidth) / 2,
newTopLeft[1] + Math.abs(newHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
const resizedElement = {
width: Math.abs(newWidth),
height: Math.abs(newHeight),
x: newTopLeft[0],
y: newTopLeft[1],
};
updateBoundElements(element, {
newSize: { width: resizedElement.width, height: resizedElement.height },
});
mutateElement(element, resizedElement);
};
const resizeSingleNonGenericElement = (
element: NonDeleted<Exclude<ExcalidrawElement, ExcalidrawGenericElement>>,
transformHandleType: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
isResizeFromCenter: boolean,
keepSquareAspectRatio: boolean,
pointerX: number,
pointerY: number,
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
// rotation pointer with reverse angle // rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate( const [rotatedX, rotatedY] = rotate(
pointerX, pointerX,
@ -410,6 +569,7 @@ const resizeSingleElement = (
cy, cy,
-element.angle, -element.angle,
); );
let scaleX = 1; let scaleX = 1;
let scaleY = 1; let scaleY = 1;
if ( if (
@ -442,9 +602,10 @@ const resizeSingleElement = (
} }
let nextWidth = element.width * scaleX; let nextWidth = element.width * scaleX;
let nextHeight = element.height * scaleY; let nextHeight = element.height * scaleY;
if (sidesWithSameLength) { if (keepSquareAspectRatio) {
nextWidth = nextHeight = Math.max(nextWidth, nextHeight); nextWidth = nextHeight = Math.max(nextWidth, nextHeight);
} }
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
element, element,
nextWidth, nextWidth,
@ -454,7 +615,9 @@ const resizeSingleElement = (
const deltaY1 = (y1 - nextY1) / 2; const deltaY1 = (y1 - nextY1) / 2;
const deltaX2 = (x2 - nextX2) / 2; const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2; const deltaY2 = (y2 - nextY2) / 2;
const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight); const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight);
updateBoundElements(element, { updateBoundElements(element, {
newSize: { width: nextWidth, height: nextHeight }, newSize: { width: nextWidth, height: nextHeight },
}); });
@ -491,6 +654,7 @@ const resizeSingleElement = (
deltaX2, deltaX2,
deltaY2, deltaY2,
); );
if ( if (
nextWidth !== 0 && nextWidth !== 0 &&
nextHeight !== 0 && nextHeight !== 0 &&

View File

@ -4,7 +4,7 @@ import { getElementAbsoluteCoords, Bounds } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { Zoom } from "../types"; import { Zoom } from "../types";
export type TransformHandleType = export type TransformHandleDirection =
| "n" | "n"
| "s" | "s"
| "w" | "w"
@ -12,8 +12,9 @@ export type TransformHandleType =
| "nw" | "nw"
| "ne" | "ne"
| "sw" | "sw"
| "se" | "se";
| "rotation";
export type TransformHandleType = TransformHandleDirection | "rotation";
export type TransformHandle = [number, number, number, number]; export type TransformHandle = [number, number, number, number];
export type TransformHandles = Partial< export type TransformHandles = Partial<

View File

@ -3,8 +3,21 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawGenericElement,
} from "./types"; } from "./types";
export const isGenericElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawGenericElement => {
return (
element != null &&
(element.type === "selection" ||
element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse")
);
};
export const isTextElement = ( export const isTextElement = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
): element is ExcalidrawTextElement => { ): element is ExcalidrawTextElement => {

View File

@ -26,6 +26,7 @@ export const KEYS = {
ARROW_RIGHT: "ArrowRight", ARROW_RIGHT: "ArrowRight",
ARROW_UP: "ArrowUp", ARROW_UP: "ArrowUp",
BACKSPACE: "Backspace", BACKSPACE: "Backspace",
ALT: "Alt",
CTRL_OR_CMD: isDarwin ? "metaKey" : "ctrlKey", CTRL_OR_CMD: isDarwin ? "metaKey" : "ctrlKey",
DELETE: "Delete", DELETE: "Delete",
ENTER: "Enter", ENTER: "Enter",
@ -59,8 +60,10 @@ export const isArrowKey = (key: string) =>
export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) => export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) =>
event.altKey; event.altKey;
export const getResizeWithSidesSameLengthKey = (event: MouseEvent) => export const getResizeWithSidesSameLengthKey = (
event.shiftKey; event: MouseEvent | KeyboardEvent,
) => event.shiftKey;
export const getRotateWithDiscreteAngleKey = (event: MouseEvent) => export const getRotateWithDiscreteAngleKey = (
event.shiftKey; event: MouseEvent | KeyboardEvent,
) => event.shiftKey;

View File

@ -17,6 +17,12 @@ export const rotate = (
(x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2, (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2,
]; ];
export const rotatePoint = (
point: Point,
center: Point,
angle: number,
): [number, number] => rotate(point[0], point[1], center[0], center[1], angle);
export const adjustXYWithRotation = ( export const adjustXYWithRotation = (
sides: { sides: {
n?: boolean; n?: boolean;

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`resize element 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,
}
`;

View File

@ -14,11 +14,13 @@ let altKey = false;
let shiftKey = false; let shiftKey = false;
let ctrlKey = false; let ctrlKey = false;
export type KeyboardModifiers = {
alt?: boolean;
shift?: boolean;
ctrl?: boolean;
};
export class Keyboard { export class Keyboard {
static withModifierKeys = ( static withModifierKeys = (modifiers: KeyboardModifiers, cb: () => void) => {
modifiers: { alt?: boolean; shift?: boolean; ctrl?: boolean },
cb: () => void,
) => {
const prevAltKey = altKey; const prevAltKey = altKey;
const prevShiftKey = shiftKey; const prevShiftKey = shiftKey;
const prevCtrlKey = ctrlKey; const prevCtrlKey = ctrlKey;

View File

@ -13,7 +13,6 @@ import Excalidraw from "../packages/excalidraw/index";
import { setLanguage } from "../i18n"; import { setLanguage } from "../i18n";
import { setDateTimeForTests } from "../utils"; import { setDateTimeForTests } from "../utils";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { getTransformHandles as _getTransformHandles } from "../element";
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { copiedStyles } from "../actions/actionStyles"; import { copiedStyles } from "../actions/actionStyles";
import { UI, Pointer, Keyboard } from "./helpers/ui"; import { UI, Pointer, Keyboard } from "./helpers/ui";
@ -44,27 +43,6 @@ const clickLabeledElement = (label: string) => {
fireEvent.click(element); fireEvent.click(element);
}; };
type HandlerRectanglesRet = keyof ReturnType<typeof _getTransformHandles>;
const getTransformHandles = (pointerType: "mouse" | "touch" | "pen") => {
const rects = _getTransformHandles(
API.getSelectedElement(),
h.state.zoom,
pointerType,
) as {
[T in HandlerRectanglesRet]: [number, number, number, number];
};
const rv: { [K in keyof typeof rects]: [number, number] } = {} as any;
for (const handlePos in rects) {
const [x, y, width, height] = rects[handlePos as keyof typeof rects];
rv[handlePos as keyof typeof rects] = [x + width / 2, y + height / 2];
}
return rv;
};
/** /**
* This is always called at the end of your test, so usually you don't need to call it. * This is always called at the end of your test, so usually you don't need to call it.
* However, if you have a long test, you might want to call it during the test so it's easier * However, if you have a long test, you might want to call it during the test so it's easier
@ -204,67 +182,6 @@ describe("regression tests", () => {
expect(API.getSelectedElement().strokeColor).toBe("#5f3dc4"); expect(API.getSelectedElement().strokeColor).toBe("#5f3dc4");
}); });
it("resize an element, trying every resize handle", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(10, 10);
const transformHandles = getTransformHandles("mouse");
// @ts-ignore
delete transformHandles.rotation; // exclude rotation handle
for (const handlePos in transformHandles) {
const [x, y] = transformHandles[
handlePos as keyof typeof transformHandles
];
const { width: prevWidth, height: prevHeight } = API.getSelectedElement();
mouse.restorePosition(x, y);
mouse.down();
mouse.up(-5, -5);
const {
width: nextWidthNegative,
height: nextHeightNegative,
} = API.getSelectedElement();
expect(
prevWidth !== nextWidthNegative || prevHeight !== nextHeightNegative,
).toBeTruthy();
checkpoint(`resize handle ${handlePos} (-5, -5)`);
mouse.down();
mouse.up(5, 5);
const { width, height } = API.getSelectedElement();
expect(width).toBe(prevWidth);
expect(height).toBe(prevHeight);
checkpoint(`unresize handle ${handlePos} (-5, -5)`);
mouse.restorePosition(x, y);
mouse.down();
mouse.up(5, 5);
const {
width: nextWidthPositive,
height: nextHeightPositive,
} = API.getSelectedElement();
expect(
prevWidth !== nextWidthPositive || prevHeight !== nextHeightPositive,
).toBeTruthy();
checkpoint(`resize handle ${handlePos} (+5, +5)`);
mouse.down();
mouse.up(-5, -5);
const {
width: finalWidth,
height: finalHeight,
} = API.getSelectedElement();
expect(finalWidth).toBe(prevWidth);
expect(finalHeight).toBe(prevHeight);
checkpoint(`unresize handle ${handlePos} (+5, +5)`);
}
});
it("click on an element and drag it", () => { it("click on an element and drag it", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(10, 10);

View File

@ -1,11 +1,15 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { render, fireEvent } from "./test-utils"; import { render } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app"; import App from "../components/App";
import * as Renderer from "../renderer/renderScene"; import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random"; import { reseed } from "../random";
import { UI, Pointer, Keyboard } from "./helpers/ui"; import { UI, Pointer, Keyboard, KeyboardModifiers } from "./helpers/ui";
import { getTransformHandles } from "../element/transformHandles"; import {
getTransformHandles,
TransformHandleDirection,
} from "../element/transformHandles";
import { ExcalidrawElement } from "../element/types";
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@ -21,70 +25,119 @@ beforeEach(() => {
const { h } = window; const { h } = window;
describe("resize element", () => { describe("resize rectangle ellipses and diamond elements", () => {
it("rectangle", async () => { const elemData = {
const { getByToolName, container } = await render(<ExcalidrawApp />); x: 0,
const canvas = container.querySelector("canvas")!; y: 0,
width: 100,
height: 100,
};
// Value for irrelevant cursor movements
const _ = 234;
{ it.each`
// create element handle | move | dimensions | topLeft
const tool = getByToolName("rectangle"); ${"n"} | ${[_, -100]} | ${[100, 200]} | ${[elemData.x, -100]}
fireEvent.click(tool); ${"s"} | ${[_, 39]} | ${[100, 139]} | ${[elemData.x, elemData.x]}
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); ${"e"} | ${[-20, _]} | ${[80, 100]} | ${[elemData.x, elemData.y]}
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); ${"w"} | ${[-20, _]} | ${[120, 100]} | ${[-20, elemData.y]}
fireEvent.pointerUp(canvas); ${"ne"} | ${[10, 55]} | ${[110, 45]} | ${[elemData.x, 55]}
${"se"} | ${[-30, -10]} | ${[70, 90]} | ${[elemData.x, elemData.y]}
expect(renderScene).toHaveBeenCalledTimes(6); ${"nw"} | ${[-300, -200]} | ${[400, 300]} | ${[-300, -200]}
expect(h.state.selectionElement).toBeNull(); ${"sw"} | ${[40, -20]} | ${[60, 80]} | ${[40, 0]}
expect(h.elements.length).toEqual(1); `("resizes with handle $handle", ({ handle, move, dimensions, topLeft }) => {
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); render(<App />);
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]); const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move);
expect([h.elements[0].width, h.elements[0].height]).toEqual([30, 50]); const element = h.elements[0];
expect([element.width, element.height]).toEqual(dimensions);
renderScene.mockClear(); expect([element.x, element.y]).toEqual(topLeft);
}
// 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 });
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());
}); });
it.each`
handle | move | dimensions | topLeft
${"n"} | ${[_, -100]} | ${[200, 200]} | ${[-50, -100]}
${"nw"} | ${[-300, -200]} | ${[400, 400]} | ${[-300, -300]}
${"sw"} | ${[40, -20]} | ${[80, 80]} | ${[20, 0]}
`(
"resizes with fixed side ratios on handle $handle",
({ handle, move, dimensions, topLeft }) => {
render(<App />);
const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move, { shift: true });
const element = h.elements[0];
expect([element.width, element.height]).toEqual(dimensions);
expect([element.x, element.y]).toEqual(topLeft);
},
);
it.each`
handle | move | dimensions | topLeft
${"nw"} | ${[0, 120]} | ${[100, 100]} | ${[0, 100]}
${"ne"} | ${[-120, 0]} | ${[100, 100]} | ${[-100, 0]}
${"sw"} | ${[200, -200]} | ${[100, 100]} | ${[100, -100]}
${"n"} | ${[_, 150]} | ${[50, 50]} | ${[25, 100]}
`(
"Flips while resizing and keeping side ratios on handle $handle",
({ handle, move, dimensions, topLeft }) => {
render(<App />);
const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move, { shift: true });
const element = h.elements[0];
expect([element.width, element.height]).toEqual(dimensions);
expect([element.x, element.y]).toEqual(topLeft);
},
);
it.each`
handle | move | dimensions | topLeft
${"ne"} | ${[50, -100]} | ${[200, 300]} | ${[-50, -100]}
${"s"} | ${[_, -20]} | ${[100, 60]} | ${[0, 20]}
`(
"Resizes from center on handle $handle",
({ handle, move, dimensions, topLeft }) => {
render(<App />);
const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move, { alt: true });
const element = h.elements[0];
expect([element.width, element.height]).toEqual(dimensions);
expect([element.x, element.y]).toEqual(topLeft);
},
);
it.each`
handle | move | dimensions | topLeft
${"nw"} | ${[100, 120]} | ${[140, 140]} | ${[-20, -20]}
${"e"} | ${[-130, _]} | ${[160, 160]} | ${[-30, -30]}
`(
"Resizes from center, flips and keeps side rations on handle $handle",
({ handle, move, dimensions, topLeft }) => {
render(<App />);
const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move, { alt: true, shift: true });
const element = h.elements[0];
expect([element.width, element.height]).toEqual(dimensions);
expect([element.x, element.y]).toEqual(topLeft);
},
);
}); });
describe("resize element with aspect ratio when SHIFT is clicked", () => { function resize(
it("rectangle", async () => { element: ExcalidrawElement,
await render(<ExcalidrawApp />); handleDir: TransformHandleDirection,
mouseMove: [number, number],
const rectangle = UI.createElement("rectangle", { keyboardModifiers: KeyboardModifiers = {},
x: 0, ) {
width: 30, mouse.select(element);
height: 50, const handle = getTransformHandles(element, h.state.zoom, "mouse")[
}); handleDir
]!;
mouse.select(rectangle); const clientX = handle[0] + handle[2] / 2;
const clientY = handle[1] + handle[3] / 2;
const se = getTransformHandles(rectangle, h.state.zoom, "mouse").se!; Keyboard.withModifierKeys(keyboardModifiers, () => {
const clientX = se[0] + se[2] / 2; mouse.reset();
const clientY = se[1] + se[3] / 2; mouse.down(clientX, clientY);
Keyboard.withModifierKeys({ shift: true }, () => { mouse.move(mouseMove[0], mouseMove[1]);
mouse.reset(); mouse.up();
mouse.down(clientX, clientY);
mouse.move(1, 1);
mouse.up();
});
expect([h.elements[0].width, h.elements[0].height]).toEqual([51, 51]);
}); });
}); }