Resize multiple elements (rectangles, diamonds and ellipses onl… (#1193)

* experiment resizing multiple elements

* hack common component

* calculate scale properly (still se only)fg

* prioritize multi selection

* take handle offset into calculation

* fix master merge

* refactor resizeElements out from App

* wip: handlerRectanglesFromCoords

* fix test with type assertion

* properly show handles wip

* revert previous one and do a tweak

* remove unnecessary assignments

* replace hack code with good one

* refactor coords in arg

* resize NW

* resize from sw,ne

* fix with setResizeHandle

* do not show hint while resizing multiple elements

* empty commit

* fix format
This commit is contained in:
Daishi Kato 2020-04-07 17:49:59 +09:00 committed by GitHub
parent b60f5fcf06
commit 2cc1105ff5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 808 additions and 510 deletions

View file

@ -4,14 +4,12 @@ import socketIOClient from "socket.io-client";
import rough from "roughjs/bin/rough";
import { RoughCanvas } from "roughjs/bin/canvas";
import { FlooredNumber } from "../types";
import { getElementAbsoluteCoords } from "../element/bounds";
import {
newElement,
newTextElement,
duplicateElement,
resizeTest,
normalizeResizeHandle,
isInvisiblySmallElement,
isTextElement,
textWysiwyg,
@ -24,6 +22,11 @@ import {
getSyncableElements,
hasNonDeletedElements,
newLinearElement,
ResizeArrowFnType,
resizeElements,
getElementWithResizeHandler,
canResizeMutlipleElements,
getResizeHandlerFromCoords,
} from "../element";
import {
deleteSelectedElements,
@ -50,12 +53,7 @@ import {
import { renderScene } from "../renderer";
import { AppState, GestureEvent, Gesture } from "../types";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
} from "../element/types";
import { rotate, adjustXYWithRotation } from "../math";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import {
isWritableElement,
@ -76,7 +74,6 @@ import { createHistory, SceneHistory } from "../history";
import ContextMenu from "./ContextMenu";
import { getElementWithResizeHandler } from "../element/resizeTest";
import { ActionManager } from "../actions/manager";
import "../actions";
import { actions } from "../actions/register";
@ -102,7 +99,6 @@ import {
DRAGGING_THRESHOLD,
TEXT_TO_CENTER_SNAP_THRESHOLD,
ARROW_CONFIRM_THRESHOLD,
SHIFT_LOCKING_ANGLE,
} from "../constants";
import { LayerUI } from "./LayerUI";
import { ScrollBars, SceneState } from "../scene/types";
@ -112,7 +108,6 @@ import { invalidateShapeForElement } from "../renderer/renderElement";
import { unstable_batchedUpdates } from "react-dom";
import { SceneStateCallbackRemover } from "../scene/globalScene";
import { isLinearElement } from "../element/typeChecks";
import { rescalePoints } from "../points";
import { actionFinalize } from "../actions";
/**
@ -929,7 +924,7 @@ export class App extends React.Component<any, AppState> {
};
});
});
this.socket.on("new-user", async (socketID: string) => {
this.socket.on("new-user", async (_socketID: string) => {
this.broadcastScene("SCENE_INIT");
});
@ -1485,19 +1480,34 @@ export class App extends React.Component<any, AppState> {
this.state,
);
if (selectedElements.length === 1 && !isOverScrollBar) {
const resizeElement = getElementWithResizeHandler(
const elementWithResizeHandler = getElementWithResizeHandler(
globalSceneState.getAllElements(),
this.state,
{ x, y },
this.state.zoom,
event.pointerType,
);
if (resizeElement && resizeElement.resizeHandle) {
if (elementWithResizeHandler && elementWithResizeHandler.resizeHandle) {
document.documentElement.style.cursor = getCursorForResizingElement(
resizeElement,
elementWithResizeHandler,
);
return;
}
} else if (selectedElements.length > 1 && !isOverScrollBar) {
if (canResizeMutlipleElements(selectedElements)) {
const resizeHandle = getResizeHandlerFromCoords(
getCommonBounds(selectedElements),
{ x, y },
this.state.zoom,
event.pointerType,
);
if (resizeHandle) {
document.documentElement.style.cursor = getCursorForResizingElement({
resizeHandle,
});
return;
}
}
}
const hitElement = getElementAtPosition(
globalSceneState.getAllElements(),
@ -1691,34 +1701,57 @@ export class App extends React.Component<any, AppState> {
type ResizeTestType = ReturnType<typeof resizeTest>;
let resizeHandle: ResizeTestType = false;
const setResizeHandle = (nextResizeHandle: ResizeTestType) => {
resizeHandle = nextResizeHandle;
};
let isResizingElements = false;
let draggingOccurred = false;
let hitElement: ExcalidrawElement | null = null;
let hitElementWasAddedToSelection = false;
if (this.state.elementType === "selection") {
const resizeElement = getElementWithResizeHandler(
globalSceneState.getAllElements(),
this.state,
{ x, y },
this.state.zoom,
event.pointerType,
);
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
this.state,
);
if (selectedElements.length === 1 && resizeElement) {
this.setState({
resizingElement: resizeElement ? resizeElement.element : null,
});
resizeHandle = resizeElement.resizeHandle;
document.documentElement.style.cursor = getCursorForResizingElement(
resizeElement,
if (selectedElements.length === 1) {
const elementWithResizeHandler = getElementWithResizeHandler(
globalSceneState.getAllElements(),
this.state,
{ x, y },
this.state.zoom,
event.pointerType,
);
isResizingElements = true;
} else {
if (elementWithResizeHandler) {
this.setState({
resizingElement: elementWithResizeHandler
? elementWithResizeHandler.element
: null,
});
resizeHandle = elementWithResizeHandler.resizeHandle;
document.documentElement.style.cursor = getCursorForResizingElement(
elementWithResizeHandler,
);
isResizingElements = true;
}
} else if (selectedElements.length > 1) {
if (canResizeMutlipleElements(selectedElements)) {
resizeHandle = getResizeHandlerFromCoords(
getCommonBounds(selectedElements),
{ x, y },
this.state.zoom,
event.pointerType,
);
if (resizeHandle) {
document.documentElement.style.cursor = getCursorForResizingElement(
{
resizeHandle,
},
);
isResizingElements = true;
}
}
}
if (!isResizingElements) {
hitElement = getElementAtPosition(
globalSceneState.getAllElements(),
this.state,
@ -1908,82 +1941,9 @@ export class App extends React.Component<any, AppState> {
}
}
let resizeArrowFn:
| ((
element: ExcalidrawLinearElement,
pointIndex: number,
deltaX: number,
deltaY: number,
pointerX: number,
pointerY: number,
perfect: boolean,
) => void)
| null = null;
const arrowResizeOrigin = (
element: ExcalidrawLinearElement,
pointIndex: number,
deltaX: number,
deltaY: number,
pointerX: number,
pointerY: number,
perfect: boolean,
) => {
const [px, py] = element.points[pointIndex];
let x = element.x + deltaX;
let y = element.y + deltaY;
let pointX = px - deltaX;
let pointY = py - deltaY;
if (perfect) {
const { width, height } = getPerfectElementSize(
element.type,
px + element.x - pointerX,
py + element.y - pointerY,
);
x = px + element.x - width;
y = py + element.y - height;
pointX = width;
pointY = height;
}
mutateElement(element, {
x,
y,
points: element.points.map((point, i) =>
i === pointIndex ? ([pointX, pointY] as const) : point,
),
});
};
const arrowResizeEnd = (
element: ExcalidrawLinearElement,
pointIndex: number,
deltaX: number,
deltaY: number,
pointerX: number,
pointerY: number,
perfect: boolean,
) => {
const [px, py] = element.points[pointIndex];
if (perfect) {
const { width, height } = getPerfectElementSize(
element.type,
pointerX - element.x,
pointerY - element.y,
);
mutateElement(element, {
points: element.points.map((point, i) =>
i === pointIndex ? ([width, height] as const) : point,
),
});
} else {
mutateElement(element, {
points: element.points.map((point, i) =>
i === pointIndex ? ([px + deltaX, py + deltaY] as const) : point,
),
});
}
let resizeArrowFn: ResizeArrowFnType | null = null;
const setResizeArrrowFn = (fn: ResizeArrowFnType) => {
resizeArrowFn = fn;
};
const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
@ -2012,6 +1972,13 @@ export class App extends React.Component<any, AppState> {
return;
}
const { x, y } = viewportCoordsToSceneCoords(
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
// for arrows, don't start dragging until a given threshold
// to ensure we don't create a 2-point arrow by mistake when
// user clicks mouse in a way that it moves a tiny bit (thus
@ -2021,289 +1988,30 @@ export class App extends React.Component<any, AppState> {
(this.state.elementType === "arrow" ||
this.state.elementType === "line")
) {
const { x, y } = viewportCoordsToSceneCoords(
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
return;
}
}
if (isResizingElements && this.state.resizingElement) {
this.setState({
isResizing: resizeHandle !== "rotation",
isRotating: resizeHandle === "rotation",
});
const el = this.state.resizingElement;
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
const resized =
isResizingElements &&
resizeElements(
resizeHandle,
setResizeHandle,
this.state,
this.setAppState,
resizeArrowFn,
setResizeArrrowFn,
event,
x,
y,
lastX,
lastY,
);
if (selectedElements.length === 1) {
const { x, y } = viewportCoordsToSceneCoords(
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
const element = selectedElements[0];
const angle = element.angle;
// reverse rotate delta
const [deltaX, deltaY] = rotate(x - lastX, y - lastY, 0, 0, -angle);
switch (resizeHandle) {
case "nw":
if (isLinearElement(element) && element.points.length === 2) {
const [, p1] = element.points;
if (!resizeArrowFn) {
if (p1[0] < 0 || p1[1] < 0) {
resizeArrowFn = arrowResizeEnd;
} else {
resizeArrowFn = arrowResizeOrigin;
}
}
resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
} else {
const width = element.width - deltaX;
const height = event.shiftKey ? width : element.height - deltaY;
const dY = element.height - height;
mutateElement(element, {
width,
height,
...adjustXYWithRotation("nw", element, deltaX, dY, angle),
...(isLinearElement(element) && width >= 0 && height >= 0
? {
points: rescalePoints(
0,
width,
rescalePoints(1, height, element.points),
),
}
: {}),
});
}
break;
case "ne":
if (isLinearElement(element) && element.points.length === 2) {
const [, p1] = element.points;
if (!resizeArrowFn) {
if (p1[0] >= 0) {
resizeArrowFn = arrowResizeEnd;
} else {
resizeArrowFn = arrowResizeOrigin;
}
}
resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
} else {
const width = element.width + deltaX;
const height = event.shiftKey ? width : element.height - deltaY;
const dY = element.height - height;
mutateElement(element, {
width,
height,
...adjustXYWithRotation("ne", element, deltaX, dY, angle),
...(isLinearElement(element) && width >= 0 && height >= 0
? {
points: rescalePoints(
0,
width,
rescalePoints(1, height, element.points),
),
}
: {}),
});
}
break;
case "sw":
if (isLinearElement(element) && element.points.length === 2) {
const [, p1] = element.points;
if (!resizeArrowFn) {
if (p1[0] <= 0) {
resizeArrowFn = arrowResizeEnd;
} else {
resizeArrowFn = arrowResizeOrigin;
}
}
resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
} else {
const width = element.width - deltaX;
const height = event.shiftKey ? width : element.height + deltaY;
const dY = height - element.height;
mutateElement(element, {
width,
height,
...adjustXYWithRotation("sw", element, deltaX, dY, angle),
...(isLinearElement(element) && width >= 0 && height >= 0
? {
points: rescalePoints(
0,
width,
rescalePoints(1, height, element.points),
),
}
: {}),
});
}
break;
case "se":
if (isLinearElement(element) && element.points.length === 2) {
const [, p1] = element.points;
if (!resizeArrowFn) {
if (p1[0] > 0 || p1[1] > 0) {
resizeArrowFn = arrowResizeEnd;
} else {
resizeArrowFn = arrowResizeOrigin;
}
}
resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
} else {
const width = element.width + deltaX;
const height = event.shiftKey ? width : element.height + deltaY;
const dY = height - element.height;
mutateElement(element, {
width,
height,
...adjustXYWithRotation("se", element, deltaX, dY, angle),
...(isLinearElement(element) && width >= 0 && height >= 0
? {
points: rescalePoints(
0,
width,
rescalePoints(1, height, element.points),
),
}
: {}),
});
}
break;
case "n": {
const height = element.height - deltaY;
if (isLinearElement(element)) {
if (element.points.length > 2 && height <= 0) {
// Someday we should implement logic to flip the shape.
// But for now, just stop.
break;
}
mutateElement(element, {
height,
...adjustXYWithRotation("n", element, 0, deltaY, angle),
points: rescalePoints(1, height, element.points),
});
} else {
mutateElement(element, {
height,
...adjustXYWithRotation("n", element, 0, deltaY, angle),
});
}
break;
}
case "w": {
const width = element.width - deltaX;
if (isLinearElement(element)) {
if (element.points.length > 2 && width <= 0) {
// Someday we should implement logic to flip the shape.
// But for now, just stop.
break;
}
mutateElement(element, {
width,
...adjustXYWithRotation("w", element, deltaX, 0, angle),
points: rescalePoints(0, width, element.points),
});
} else {
mutateElement(element, {
width,
...adjustXYWithRotation("w", element, deltaX, 0, angle),
});
}
break;
}
case "s": {
const height = element.height + deltaY;
if (isLinearElement(element)) {
if (element.points.length > 2 && height <= 0) {
// Someday we should implement logic to flip the shape.
// But for now, just stop.
break;
}
mutateElement(element, {
height,
...adjustXYWithRotation("s", element, 0, deltaY, angle),
points: rescalePoints(1, height, element.points),
});
} else {
mutateElement(element, {
height,
...adjustXYWithRotation("s", element, 0, deltaY, angle),
});
}
break;
}
case "e": {
const width = element.width + deltaX;
if (isLinearElement(element)) {
if (element.points.length > 2 && width <= 0) {
// Someday we should implement logic to flip the shape.
// But for now, just stop.
break;
}
mutateElement(element, {
width,
...adjustXYWithRotation("e", element, deltaX, 0, angle),
points: rescalePoints(0, width, element.points),
});
} else {
mutateElement(element, {
width,
...adjustXYWithRotation("e", element, deltaX, 0, angle),
});
}
break;
}
case "rotation": {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle = (5 * Math.PI) / 2 + Math.atan2(y - cy, x - cx);
if (event.shiftKey) {
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
}
if (angle >= 2 * Math.PI) {
angle -= 2 * Math.PI;
}
mutateElement(element, { angle });
break;
}
}
if (resizeHandle) {
resizeHandle = normalizeResizeHandle(element, resizeHandle);
}
normalizeDimensions(element);
document.documentElement.style.cursor = getCursorForResizingElement({
element,
resizeHandle,
});
mutateElement(el, {
x: element.x,
y: element.y,
});
lastX = x;
lastY = y;
return;
}
if (resized) {
lastX = x;
lastY = y;
return;
}
if (hitElement && this.state.selectedElementIds[hitElement.id]) {
@ -2341,13 +2049,6 @@ export class App extends React.Component<any, AppState> {
return;
}
const { x, y } = viewportCoordsToSceneCoords(
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
let width = distance(originX, x);
let height = distance(originY, y);
@ -2533,7 +2234,7 @@ export class App extends React.Component<any, AppState> {
},
}));
} else {
this.setState((prevState) => ({
this.setState((_prevState) => ({
selectedElementIds: { [hitElement!.id]: true },
}));
}

View file

@ -22,8 +22,12 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.linearElementMulti");
}
if (isResizing && lastPointerDownWith === "mouse") {
const selectedElements = getSelectedElements(elements, appState);
const selectedElements = getSelectedElements(elements, appState);
if (
isResizing &&
lastPointerDownWith === "mouse" &&
selectedElements.length === 1
) {
const targetElement = selectedElements[0];
if (isLinearElement(targetElement) && targetElement.points.length > 2) {
return null;