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 rough from "roughjs/bin/rough";
import { RoughCanvas } from "roughjs/bin/canvas"; import { RoughCanvas } from "roughjs/bin/canvas";
import { FlooredNumber } from "../types"; import { FlooredNumber } from "../types";
import { getElementAbsoluteCoords } from "../element/bounds";
import { import {
newElement, newElement,
newTextElement, newTextElement,
duplicateElement, duplicateElement,
resizeTest, resizeTest,
normalizeResizeHandle,
isInvisiblySmallElement, isInvisiblySmallElement,
isTextElement, isTextElement,
textWysiwyg, textWysiwyg,
@ -24,6 +22,11 @@ import {
getSyncableElements, getSyncableElements,
hasNonDeletedElements, hasNonDeletedElements,
newLinearElement, newLinearElement,
ResizeArrowFnType,
resizeElements,
getElementWithResizeHandler,
canResizeMutlipleElements,
getResizeHandlerFromCoords,
} from "../element"; } from "../element";
import { import {
deleteSelectedElements, deleteSelectedElements,
@ -50,12 +53,7 @@ import {
import { renderScene } from "../renderer"; import { renderScene } from "../renderer";
import { AppState, GestureEvent, Gesture } from "../types"; import { AppState, GestureEvent, Gesture } from "../types";
import { import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
} from "../element/types";
import { rotate, adjustXYWithRotation } from "../math";
import { import {
isWritableElement, isWritableElement,
@ -76,7 +74,6 @@ import { createHistory, SceneHistory } from "../history";
import ContextMenu from "./ContextMenu"; import ContextMenu from "./ContextMenu";
import { getElementWithResizeHandler } from "../element/resizeTest";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import "../actions"; import "../actions";
import { actions } from "../actions/register"; import { actions } from "../actions/register";
@ -102,7 +99,6 @@ import {
DRAGGING_THRESHOLD, DRAGGING_THRESHOLD,
TEXT_TO_CENTER_SNAP_THRESHOLD, TEXT_TO_CENTER_SNAP_THRESHOLD,
ARROW_CONFIRM_THRESHOLD, ARROW_CONFIRM_THRESHOLD,
SHIFT_LOCKING_ANGLE,
} from "../constants"; } from "../constants";
import { LayerUI } from "./LayerUI"; import { LayerUI } from "./LayerUI";
import { ScrollBars, SceneState } from "../scene/types"; import { ScrollBars, SceneState } from "../scene/types";
@ -112,7 +108,6 @@ import { invalidateShapeForElement } from "../renderer/renderElement";
import { unstable_batchedUpdates } from "react-dom"; import { unstable_batchedUpdates } from "react-dom";
import { SceneStateCallbackRemover } from "../scene/globalScene"; import { SceneStateCallbackRemover } from "../scene/globalScene";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { rescalePoints } from "../points";
import { actionFinalize } from "../actions"; 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"); this.broadcastScene("SCENE_INIT");
}); });
@ -1485,19 +1480,34 @@ export class App extends React.Component<any, AppState> {
this.state, this.state,
); );
if (selectedElements.length === 1 && !isOverScrollBar) { if (selectedElements.length === 1 && !isOverScrollBar) {
const resizeElement = getElementWithResizeHandler( const elementWithResizeHandler = getElementWithResizeHandler(
globalSceneState.getAllElements(), globalSceneState.getAllElements(),
this.state, this.state,
{ x, y }, { x, y },
this.state.zoom, this.state.zoom,
event.pointerType, event.pointerType,
); );
if (resizeElement && resizeElement.resizeHandle) { if (elementWithResizeHandler && elementWithResizeHandler.resizeHandle) {
document.documentElement.style.cursor = getCursorForResizingElement( document.documentElement.style.cursor = getCursorForResizingElement(
resizeElement, elementWithResizeHandler,
); );
return; 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( const hitElement = getElementAtPosition(
globalSceneState.getAllElements(), globalSceneState.getAllElements(),
@ -1691,34 +1701,57 @@ export class App extends React.Component<any, AppState> {
type ResizeTestType = ReturnType<typeof resizeTest>; type ResizeTestType = ReturnType<typeof resizeTest>;
let resizeHandle: ResizeTestType = false; let resizeHandle: ResizeTestType = false;
const setResizeHandle = (nextResizeHandle: ResizeTestType) => {
resizeHandle = nextResizeHandle;
};
let isResizingElements = false; let isResizingElements = false;
let draggingOccurred = false; let draggingOccurred = false;
let hitElement: ExcalidrawElement | null = null; let hitElement: ExcalidrawElement | null = null;
let hitElementWasAddedToSelection = false; let hitElementWasAddedToSelection = false;
if (this.state.elementType === "selection") { if (this.state.elementType === "selection") {
const resizeElement = getElementWithResizeHandler(
globalSceneState.getAllElements(),
this.state,
{ x, y },
this.state.zoom,
event.pointerType,
);
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
globalSceneState.getAllElements(), globalSceneState.getAllElements(),
this.state, this.state,
); );
if (selectedElements.length === 1 && resizeElement) { if (selectedElements.length === 1) {
this.setState({ const elementWithResizeHandler = getElementWithResizeHandler(
resizingElement: resizeElement ? resizeElement.element : null, globalSceneState.getAllElements(),
}); this.state,
{ x, y },
resizeHandle = resizeElement.resizeHandle; this.state.zoom,
document.documentElement.style.cursor = getCursorForResizingElement( event.pointerType,
resizeElement,
); );
isResizingElements = true; if (elementWithResizeHandler) {
} else { 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( hitElement = getElementAtPosition(
globalSceneState.getAllElements(), globalSceneState.getAllElements(),
this.state, this.state,
@ -1908,82 +1941,9 @@ export class App extends React.Component<any, AppState> {
} }
} }
let resizeArrowFn: let resizeArrowFn: ResizeArrowFnType | null = null;
| (( const setResizeArrrowFn = (fn: ResizeArrowFnType) => {
element: ExcalidrawLinearElement, resizeArrowFn = fn;
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,
),
});
}
}; };
const onPointerMove = withBatchedUpdates((event: PointerEvent) => { const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
@ -2012,6 +1972,13 @@ export class App extends React.Component<any, AppState> {
return; return;
} }
const { x, y } = viewportCoordsToSceneCoords(
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
// for arrows, don't start dragging until a given threshold // for arrows, don't start dragging until a given threshold
// to ensure we don't create a 2-point arrow by mistake when // 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 // 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 === "arrow" ||
this.state.elementType === "line") this.state.elementType === "line")
) { ) {
const { x, y } = viewportCoordsToSceneCoords(
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) { if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
return; return;
} }
} }
if (isResizingElements && this.state.resizingElement) { const resized =
this.setState({ isResizingElements &&
isResizing: resizeHandle !== "rotation", resizeElements(
isRotating: resizeHandle === "rotation", resizeHandle,
}); setResizeHandle,
const el = this.state.resizingElement;
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
this.state, this.state,
this.setAppState,
resizeArrowFn,
setResizeArrrowFn,
event,
x,
y,
lastX,
lastY,
); );
if (selectedElements.length === 1) { if (resized) {
const { x, y } = viewportCoordsToSceneCoords( lastX = x;
event, lastY = y;
this.state, return;
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 (hitElement && this.state.selectedElementIds[hitElement.id]) { if (hitElement && this.state.selectedElementIds[hitElement.id]) {
@ -2341,13 +2049,6 @@ export class App extends React.Component<any, AppState> {
return; return;
} }
const { x, y } = viewportCoordsToSceneCoords(
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
let width = distance(originX, x); let width = distance(originX, x);
let height = distance(originY, y); let height = distance(originY, y);
@ -2533,7 +2234,7 @@ export class App extends React.Component<any, AppState> {
}, },
})); }));
} else { } else {
this.setState((prevState) => ({ this.setState((_prevState) => ({
selectedElementIds: { [hitElement!.id]: true }, selectedElementIds: { [hitElement!.id]: true },
})); }));
} }

View File

@ -22,8 +22,12 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.linearElementMulti"); 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]; const targetElement = selectedElements[0];
if (isLinearElement(targetElement) && targetElement.points.length > 2) { if (isLinearElement(targetElement) && targetElement.points.length > 2) {
return null; return null;

View File

@ -7,7 +7,9 @@ import { isLinearElement } from "./typeChecks";
// If the element is created from right to left, the width is going to be negative // If the element is created from right to left, the width is going to be negative
// This set of functions retrieves the absolute position of the 4 points. // This set of functions retrieves the absolute position of the 4 points.
export function getElementAbsoluteCoords(element: ExcalidrawElement) { export function getElementAbsoluteCoords(
element: ExcalidrawElement,
): [number, number, number, number] {
if (isLinearElement(element)) { if (isLinearElement(element)) {
return getLinearElementAbsoluteBounds(element); return getLinearElementAbsoluteBounds(element);
} }
@ -36,7 +38,7 @@ export function getDiamondPoints(element: ExcalidrawElement) {
export function getLinearElementAbsoluteBounds( export function getLinearElementAbsoluteBounds(
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
) { ): [number, number, number, number] {
if (element.points.length < 2 || !getShapeForElement(element)) { if (element.points.length < 2 || !getShapeForElement(element)) {
const { minX, minY, maxX, maxY } = element.points.reduce( const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => { (limits, [x, y]) => {
@ -186,7 +188,9 @@ export function getArrowPoints(
return [x2, y2, x3, y3, x4, y4]; return [x2, y2, x3, y3, x4, y4];
} }
export function getCommonBounds(elements: readonly ExcalidrawElement[]) { export function getCommonBounds(
elements: readonly ExcalidrawElement[],
): [number, number, number, number] {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }

View File

@ -13,6 +13,14 @@ const handleSizes: { [k in PointerType]: number } = {
const ROTATION_HANDLER_GAP = 16; const ROTATION_HANDLER_GAP = 16;
export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
e: true,
s: true,
n: true,
w: true,
rotation: true,
};
function generateHandler( function generateHandler(
x: number, x: number,
y: number, y: number,
@ -26,11 +34,13 @@ function generateHandler(
return [xx - width / 2, yy - height / 2, width, height]; return [xx - width / 2, yy - height / 2, width, height];
} }
export function handlerRectangles( export function handlerRectanglesFromCoords(
element: ExcalidrawElement, [x1, y1, x2, y2]: [number, number, number, number],
angle: number,
zoom: number, zoom: number,
pointerType: PointerType = "mouse", pointerType: PointerType = "mouse",
) { omitSides: { [T in Sides]?: boolean } = {},
): Partial<{ [T in Sides]: [number, number, number, number] }> {
const size = handleSizes[pointerType]; const size = handleSizes[pointerType];
const handlerWidth = size / zoom; const handlerWidth = size / zoom;
const handlerHeight = size / zoom; const handlerHeight = size / zoom;
@ -38,116 +48,145 @@ export function handlerRectangles(
const handlerMarginX = size / zoom; const handlerMarginX = size / zoom;
const handlerMarginY = size / zoom; const handlerMarginY = size / zoom;
const [elementX1, elementY1, elementX2, elementY2] = getElementAbsoluteCoords( const width = x2 - x1;
element, const height = y2 - y1;
); const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const elementWidth = elementX2 - elementX1;
const elementHeight = elementY2 - elementY1;
const cx = (elementX1 + elementX2) / 2;
const cy = (elementY1 + elementY2) / 2;
const angle = element.angle;
const dashedLineMargin = 4 / zoom; const dashedLineMargin = 4 / zoom;
const centeringOffset = (size - 8) / (2 * zoom); const centeringOffset = (size - 8) / (2 * zoom);
const handlers = const handlers: Partial<
{ { [T in Sides]: [number, number, number, number] }
nw: generateHandler( > = {
elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, nw: omitSides["nw"]
elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, ? undefined
handlerWidth, : generateHandler(
handlerHeight, x1 - dashedLineMargin - handlerMarginX + centeringOffset,
cx, y1 - dashedLineMargin - handlerMarginY + centeringOffset,
cy, handlerWidth,
angle, handlerHeight,
), cx,
ne: generateHandler( cy,
elementX2 + dashedLineMargin - centeringOffset, angle,
elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, ),
handlerWidth, ne: omitSides["ne"]
handlerHeight, ? undefined
cx, : generateHandler(
cy, x2 + dashedLineMargin - centeringOffset,
angle, y1 - dashedLineMargin - handlerMarginY + centeringOffset,
), handlerWidth,
sw: generateHandler( handlerHeight,
elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, cx,
elementY2 + dashedLineMargin - centeringOffset, cy,
handlerWidth, angle,
handlerHeight, ),
cx, sw: omitSides["sw"]
cy, ? undefined
angle, : generateHandler(
), x1 - dashedLineMargin - handlerMarginX + centeringOffset,
se: generateHandler( y2 + dashedLineMargin - centeringOffset,
elementX2 + dashedLineMargin - centeringOffset, handlerWidth,
elementY2 + dashedLineMargin - centeringOffset, handlerHeight,
handlerWidth, cx,
handlerHeight, cy,
cx, angle,
cy, ),
angle, se: omitSides["se"]
), ? undefined
rotation: generateHandler( : generateHandler(
elementX1 + elementWidth / 2 - handlerWidth / 2, x2 + dashedLineMargin - centeringOffset,
elementY1 - y2 + dashedLineMargin - centeringOffset,
dashedLineMargin - handlerWidth,
handlerMarginY + handlerHeight,
centeringOffset - cx,
ROTATION_HANDLER_GAP / zoom, cy,
handlerWidth, angle,
handlerHeight, ),
cx, rotation: omitSides["rotation"]
cy, ? undefined
angle, : generateHandler(
), x1 + width / 2 - handlerWidth / 2,
} as { [T in Sides]: [number, number, number, number] }; y1 -
dashedLineMargin -
handlerMarginY +
centeringOffset -
ROTATION_HANDLER_GAP / zoom,
handlerWidth,
handlerHeight,
cx,
cy,
angle,
),
};
// We only want to show height handlers (all cardinal directions) above a certain size // We only want to show height handlers (all cardinal directions) above a certain size
const minimumSizeForEightHandlers = (5 * size) / zoom; const minimumSizeForEightHandlers = (5 * size) / zoom;
if (Math.abs(elementWidth) > minimumSizeForEightHandlers) { if (Math.abs(width) > minimumSizeForEightHandlers) {
handlers["n"] = generateHandler( if (!omitSides["n"]) {
elementX1 + elementWidth / 2 - handlerWidth / 2, handlers["n"] = generateHandler(
elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, x1 + width / 2 - handlerWidth / 2,
handlerWidth, y1 - dashedLineMargin - handlerMarginY + centeringOffset,
handlerHeight, handlerWidth,
cx, handlerHeight,
cy, cx,
angle, cy,
); angle,
handlers["s"] = generateHandler( );
elementX1 + elementWidth / 2 - handlerWidth / 2, }
elementY2 + dashedLineMargin - centeringOffset, if (!omitSides["s"]) {
handlerWidth, handlers["s"] = generateHandler(
handlerHeight, x1 + width / 2 - handlerWidth / 2,
cx, y2 + dashedLineMargin - centeringOffset,
cy, handlerWidth,
angle, handlerHeight,
); cx,
cy,
angle,
);
}
} }
if (Math.abs(elementHeight) > minimumSizeForEightHandlers) { if (Math.abs(height) > minimumSizeForEightHandlers) {
handlers["w"] = generateHandler( if (!omitSides["w"]) {
elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, handlers["w"] = generateHandler(
elementY1 + elementHeight / 2 - handlerHeight / 2, x1 - dashedLineMargin - handlerMarginX + centeringOffset,
handlerWidth, y1 + height / 2 - handlerHeight / 2,
handlerHeight, handlerWidth,
cx, handlerHeight,
cy, cx,
angle, cy,
); angle,
handlers["e"] = generateHandler( );
elementX2 + dashedLineMargin - centeringOffset, }
elementY1 + elementHeight / 2 - handlerHeight / 2, if (!omitSides["e"]) {
handlerWidth, handlers["e"] = generateHandler(
handlerHeight, x2 + dashedLineMargin - centeringOffset,
cx, y1 + height / 2 - handlerHeight / 2,
cy, handlerWidth,
angle, handlerHeight,
); cx,
cy,
angle,
);
}
} }
return handlers;
}
export function handlerRectangles(
element: ExcalidrawElement,
zoom: number,
pointerType: PointerType = "mouse",
) {
const handlers = handlerRectanglesFromCoords(
getElementAbsoluteCoords(element),
element.angle,
zoom,
pointerType,
);
if (element.type === "arrow" || element.type === "line") { if (element.type === "arrow" || element.type === "line") {
if (element.points.length === 2) { if (element.points.length === 2) {
// only check the last point because starting point is always (0,0) // only check the last point because starting point is always (0,0)

View File

@ -15,13 +15,21 @@ export {
getLinearElementAbsoluteBounds, getLinearElementAbsoluteBounds,
} from "./bounds"; } from "./bounds";
export { handlerRectangles } from "./handlerRectangles"; export {
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
handlerRectanglesFromCoords,
handlerRectangles,
} from "./handlerRectangles";
export { hitTest } from "./collision"; export { hitTest } from "./collision";
export { export {
resizeTest, resizeTest,
getCursorForResizingElement, getCursorForResizingElement,
normalizeResizeHandle, normalizeResizeHandle,
getElementWithResizeHandler,
getResizeHandlerFromCoords,
} from "./resizeTest"; } from "./resizeTest";
export type { ResizeArrowFnType } from "./resizeElements";
export { resizeElements, canResizeMutlipleElements } from "./resizeElements";
export { isTextElement, isExcalidrawElement } from "./typeChecks"; export { isTextElement, isExcalidrawElement } from "./typeChecks";
export { textWysiwyg } from "./textWysiwyg"; export { textWysiwyg } from "./textWysiwyg";
export { redrawTextBoundingBox } from "./textElement"; export { redrawTextBoundingBox } from "./textElement";

View File

@ -0,0 +1,459 @@
import { AppState } from "../types";
import { SHIFT_LOCKING_ANGLE } from "../constants";
import { getSelectedElements, globalSceneState } from "../scene";
import { rescalePoints } from "../points";
import { rotate, adjustXYWithRotation } from "../math";
import { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
import { getElementAbsoluteCoords, getCommonBounds } from "./bounds";
import { isLinearElement } from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize, normalizeDimensions } from "./sizeHelpers";
import {
resizeTest,
getCursorForResizingElement,
normalizeResizeHandle,
} from "./resizeTest";
type ResizeTestType = ReturnType<typeof resizeTest>;
export type ResizeArrowFnType = (
element: ExcalidrawLinearElement,
pointIndex: number,
deltaX: number,
deltaY: number,
pointerX: number,
pointerY: number,
perfect: boolean,
) => void;
const arrowResizeOrigin: ResizeArrowFnType = (
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: ResizeArrowFnType = (
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,
),
});
}
};
export function resizeElements(
resizeHandle: ResizeTestType,
setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
appState: AppState,
setAppState: (obj: any) => void,
resizeArrowFn: ResizeArrowFnType | null,
setResizeArrowFn: (fn: ResizeArrowFnType) => void,
event: PointerEvent,
x: number,
y: number,
lastX: number,
lastY: number,
) {
setAppState({
isResizing: resizeHandle !== "rotation",
isRotating: resizeHandle === "rotation",
});
const selectedElements = getSelectedElements(
globalSceneState.getAllElements(),
appState,
);
if (selectedElements.length === 1) {
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);
setResizeArrowFn(resizeArrowFn);
} 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);
setResizeArrowFn(resizeArrowFn);
} 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);
setResizeArrowFn(resizeArrowFn);
} 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);
setResizeArrowFn(resizeArrowFn);
} 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) {
setResizeHandle(normalizeResizeHandle(element, resizeHandle));
}
normalizeDimensions(element);
// do we need this?
document.documentElement.style.cursor = getCursorForResizingElement({
element,
resizeHandle,
});
// why do we need this?
if (appState.resizingElement) {
mutateElement(appState.resizingElement, {
x: element.x,
y: element.y,
});
}
return true;
} else if (selectedElements.length > 1) {
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
const handleOffset = 4 / appState.zoom; // XXX import constant
const dashedLinePadding = 4 / appState.zoom; // XXX import constant
const minSize = handleOffset * 4;
const minScale = Math.max(minSize / (x2 - x1), minSize / (y2 - y1));
switch (resizeHandle) {
case "se": {
const scale = Math.max(
(x - handleOffset - dashedLinePadding - x1) / (x2 - x1),
(y - handleOffset - dashedLinePadding - y1) / (y2 - y1),
);
if (scale > minScale) {
selectedElements.forEach((element) => {
const width = element.width * scale;
const height = element.height * scale;
const x = element.x + (element.x - x1) * (scale - 1);
const y = element.y + (element.y - y1) * (scale - 1);
mutateElement(element, { width, height, x, y });
});
}
return true;
}
case "nw": {
const scale = Math.max(
(x2 - handleOffset - dashedLinePadding - x) / (x2 - x1),
(y2 - handleOffset - dashedLinePadding - y) / (y2 - y1),
);
if (scale > minScale) {
selectedElements.forEach((element) => {
const width = element.width * scale;
const height = element.height * scale;
const x = element.x - (x2 - element.x) * (scale - 1);
const y = element.y - (y2 - element.y) * (scale - 1);
mutateElement(element, { width, height, x, y });
});
}
return true;
}
case "ne": {
const scale = Math.max(
(x - handleOffset - dashedLinePadding - x1) / (x2 - x1),
(y2 - handleOffset - dashedLinePadding - y) / (y2 - y1),
);
if (scale > minScale) {
selectedElements.forEach((element) => {
const width = element.width * scale;
const height = element.height * scale;
const x = element.x + (element.x - x1) * (scale - 1);
const y = element.y - (y2 - element.y) * (scale - 1);
mutateElement(element, { width, height, x, y });
});
}
return true;
}
case "sw": {
const scale = Math.max(
(x2 - handleOffset - dashedLinePadding - x) / (x2 - x1),
(y - handleOffset - dashedLinePadding - y1) / (y2 - y1),
);
if (scale > minScale) {
selectedElements.forEach((element) => {
const width = element.width * scale;
const height = element.height * scale;
const x = element.x - (x2 - element.x) * (scale - 1);
const y = element.y + (element.y - y1) * (scale - 1);
mutateElement(element, { width, height, x, y });
});
}
return true;
}
}
}
return false;
}
export function canResizeMutlipleElements(
elements: readonly ExcalidrawElement[],
) {
return elements.every((element) =>
["rectangle", "diamond", "ellipse"].includes(element.type),
);
}

View File

@ -1,6 +1,10 @@
import { ExcalidrawElement, PointerType } from "./types"; import { ExcalidrawElement, PointerType } from "./types";
import { handlerRectangles } from "./handlerRectangles"; import {
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
handlerRectanglesFromCoords,
handlerRectangles,
} from "./handlerRectangles";
import { AppState } from "../types"; import { AppState } from "../types";
import { isLinearElement } from "./typeChecks"; import { isLinearElement } from "./typeChecks";
@ -77,16 +81,37 @@ export function getElementWithResizeHandler(
}, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null); }, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
} }
export function getResizeHandlerFromCoords(
[x1, y1, x2, y2]: readonly [number, number, number, number],
{ x, y }: { x: number; y: number },
zoom: number,
pointerType: PointerType,
) {
const handlers = handlerRectanglesFromCoords(
[x1, y1, x2, y2],
0,
zoom,
pointerType,
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
);
const found = Object.keys(handlers).find((key) => {
const handler = handlers[key as Exclude<HandlerRectanglesRet, "rotation">]!;
return handler && isInHandlerRect(handler, x, y);
});
return (found || false) as HandlerRectanglesRet;
}
/* /*
* Returns bi-directional cursor for the element being resized * Returns bi-directional cursor for the element being resized
*/ */
export function getCursorForResizingElement(resizingElement: { export function getCursorForResizingElement(resizingElement: {
element: ExcalidrawElement; element?: ExcalidrawElement;
resizeHandle: ReturnType<typeof resizeTest>; resizeHandle: ReturnType<typeof resizeTest>;
}): string { }): string {
const { element, resizeHandle } = resizingElement; const { element, resizeHandle } = resizingElement;
const shouldSwapCursors = const shouldSwapCursors =
Math.sign(element.height) * Math.sign(element.width) === -1; element && Math.sign(element.height) * Math.sign(element.width) === -1;
let cursor = null; let cursor = null;
switch (resizeHandle) { switch (resizeHandle) {

View File

@ -3,7 +3,14 @@ import { RoughSVG } from "roughjs/bin/svg";
import { FlooredNumber, AppState } from "../types"; import { FlooredNumber, AppState } from "../types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { getElementAbsoluteCoords, handlerRectangles } from "../element"; import {
getElementAbsoluteCoords,
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
handlerRectanglesFromCoords,
handlerRectangles,
getCommonBounds,
canResizeMutlipleElements,
} from "../element";
import { roundRect } from "./roundRect"; import { roundRect } from "./roundRect";
import { SceneState } from "../scene/types"; import { SceneState } from "../scene/types";
@ -263,6 +270,56 @@ export function renderScene(
} }
}); });
context.translate(-sceneState.scrollX, -sceneState.scrollY); context.translate(-sceneState.scrollX, -sceneState.scrollY);
} else if (locallySelectedElements.length > 1) {
if (canResizeMutlipleElements(locallySelectedElements)) {
const dashedLinePadding = 4 / sceneState.zoom;
context.translate(sceneState.scrollX, sceneState.scrollY);
context.fillStyle = "#fff";
const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
const initialLineDash = context.getLineDash();
context.setLineDash([2 / sceneState.zoom]);
const lineWidth = context.lineWidth;
context.lineWidth = 1 / sceneState.zoom;
strokeRectWithRotation(
context,
x1 - dashedLinePadding,
y1 - dashedLinePadding,
x2 - x1 + dashedLinePadding * 2,
y2 - y1 + dashedLinePadding * 2,
(x1 + x2) / 2,
(y1 + y2) / 2,
0,
);
context.lineWidth = lineWidth;
context.setLineDash(initialLineDash);
const handlers = handlerRectanglesFromCoords(
[x1, y1, x2, y2],
0,
sceneState.zoom,
undefined,
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
);
Object.keys(handlers).forEach((key) => {
const handler = handlers[key as HandlerRectanglesRet];
if (handler !== undefined) {
const lineWidth = context.lineWidth;
context.lineWidth = 1 / sceneState.zoom;
strokeRectWithRotation(
context,
handler[0],
handler[1],
handler[2],
handler[3],
handler[0] + handler[2] / 2,
handler[1] + handler[3] / 2,
0,
true, // fill before stroke
);
context.lineWidth = lineWidth;
}
});
context.translate(-sceneState.scrollX, -sceneState.scrollY);
}
} }
} }

View File

@ -162,12 +162,13 @@ function getSelectedElement(): ExcalidrawElement {
return selectedElements[0]; return selectedElements[0];
} }
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
function getResizeHandles() { function getResizeHandles() {
const rects = handlerRectangles( const rects =
getSelectedElement(), handlerRectangles(getSelectedElement(), h.state.zoom, pointerType) as
h.state.zoom, {
pointerType, [T in HandlerRectanglesRet]: [number, number, number, number];
); };
const rv: { [K in keyof typeof rects]: [number, number] } = {} as any; const rv: { [K in keyof typeof rects]: [number, number] } = {} as any;