diff --git a/src/components/App.tsx b/src/components/App.tsx index 36f77b84..2b91a74b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 { }; }); }); - 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 { 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 { type ResizeTestType = ReturnType; 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 { } } - 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 { 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 { (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 { 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 { }, })); } else { - this.setState((prevState) => ({ + this.setState((_prevState) => ({ selectedElementIds: { [hitElement!.id]: true }, })); } diff --git a/src/components/HintViewer.tsx b/src/components/HintViewer.tsx index d88b2705..efc5ddd5 100644 --- a/src/components/HintViewer.tsx +++ b/src/components/HintViewer.tsx @@ -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; diff --git a/src/element/bounds.ts b/src/element/bounds.ts index ed424396..df492766 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -7,7 +7,9 @@ import { isLinearElement } from "./typeChecks"; // 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. -export function getElementAbsoluteCoords(element: ExcalidrawElement) { +export function getElementAbsoluteCoords( + element: ExcalidrawElement, +): [number, number, number, number] { if (isLinearElement(element)) { return getLinearElementAbsoluteBounds(element); } @@ -36,7 +38,7 @@ export function getDiamondPoints(element: ExcalidrawElement) { export function getLinearElementAbsoluteBounds( element: ExcalidrawLinearElement, -) { +): [number, number, number, number] { if (element.points.length < 2 || !getShapeForElement(element)) { const { minX, minY, maxX, maxY } = element.points.reduce( (limits, [x, y]) => { @@ -186,7 +188,9 @@ export function getArrowPoints( 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) { return [0, 0, 0, 0]; } diff --git a/src/element/handlerRectangles.ts b/src/element/handlerRectangles.ts index d9a00604..2971700e 100644 --- a/src/element/handlerRectangles.ts +++ b/src/element/handlerRectangles.ts @@ -13,6 +13,14 @@ const handleSizes: { [k in PointerType]: number } = { const ROTATION_HANDLER_GAP = 16; +export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = { + e: true, + s: true, + n: true, + w: true, + rotation: true, +}; + function generateHandler( x: number, y: number, @@ -26,11 +34,13 @@ function generateHandler( return [xx - width / 2, yy - height / 2, width, height]; } -export function handlerRectangles( - element: ExcalidrawElement, +export function handlerRectanglesFromCoords( + [x1, y1, x2, y2]: [number, number, number, number], + angle: number, zoom: number, pointerType: PointerType = "mouse", -) { + omitSides: { [T in Sides]?: boolean } = {}, +): Partial<{ [T in Sides]: [number, number, number, number] }> { const size = handleSizes[pointerType]; const handlerWidth = size / zoom; const handlerHeight = size / zoom; @@ -38,116 +48,145 @@ export function handlerRectangles( const handlerMarginX = size / zoom; const handlerMarginY = size / zoom; - const [elementX1, elementY1, elementX2, elementY2] = getElementAbsoluteCoords( - element, - ); - - const elementWidth = elementX2 - elementX1; - const elementHeight = elementY2 - elementY1; - const cx = (elementX1 + elementX2) / 2; - const cy = (elementY1 + elementY2) / 2; - const angle = element.angle; + const width = x2 - x1; + const height = y2 - y1; + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; const dashedLineMargin = 4 / zoom; const centeringOffset = (size - 8) / (2 * zoom); - const handlers = - { - nw: generateHandler( - elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, - elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, - handlerWidth, - handlerHeight, - cx, - cy, - angle, - ), - ne: generateHandler( - elementX2 + dashedLineMargin - centeringOffset, - elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, - handlerWidth, - handlerHeight, - cx, - cy, - angle, - ), - sw: generateHandler( - elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, - elementY2 + dashedLineMargin - centeringOffset, - handlerWidth, - handlerHeight, - cx, - cy, - angle, - ), - se: generateHandler( - elementX2 + dashedLineMargin - centeringOffset, - elementY2 + dashedLineMargin - centeringOffset, - handlerWidth, - handlerHeight, - cx, - cy, - angle, - ), - rotation: generateHandler( - elementX1 + elementWidth / 2 - handlerWidth / 2, - elementY1 - - dashedLineMargin - - handlerMarginY + - centeringOffset - - ROTATION_HANDLER_GAP / zoom, - handlerWidth, - handlerHeight, - cx, - cy, - angle, - ), - } as { [T in Sides]: [number, number, number, number] }; + const handlers: Partial< + { [T in Sides]: [number, number, number, number] } + > = { + nw: omitSides["nw"] + ? undefined + : generateHandler( + x1 - dashedLineMargin - handlerMarginX + centeringOffset, + y1 - dashedLineMargin - handlerMarginY + centeringOffset, + handlerWidth, + handlerHeight, + cx, + cy, + angle, + ), + ne: omitSides["ne"] + ? undefined + : generateHandler( + x2 + dashedLineMargin - centeringOffset, + y1 - dashedLineMargin - handlerMarginY + centeringOffset, + handlerWidth, + handlerHeight, + cx, + cy, + angle, + ), + sw: omitSides["sw"] + ? undefined + : generateHandler( + x1 - dashedLineMargin - handlerMarginX + centeringOffset, + y2 + dashedLineMargin - centeringOffset, + handlerWidth, + handlerHeight, + cx, + cy, + angle, + ), + se: omitSides["se"] + ? undefined + : generateHandler( + x2 + dashedLineMargin - centeringOffset, + y2 + dashedLineMargin - centeringOffset, + handlerWidth, + handlerHeight, + cx, + cy, + angle, + ), + rotation: omitSides["rotation"] + ? undefined + : generateHandler( + x1 + width / 2 - handlerWidth / 2, + 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 const minimumSizeForEightHandlers = (5 * size) / zoom; - if (Math.abs(elementWidth) > minimumSizeForEightHandlers) { - handlers["n"] = generateHandler( - elementX1 + elementWidth / 2 - handlerWidth / 2, - elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, - handlerWidth, - handlerHeight, - cx, - cy, - angle, - ); - handlers["s"] = generateHandler( - elementX1 + elementWidth / 2 - handlerWidth / 2, - elementY2 + dashedLineMargin - centeringOffset, - handlerWidth, - handlerHeight, - cx, - cy, - angle, - ); + if (Math.abs(width) > minimumSizeForEightHandlers) { + if (!omitSides["n"]) { + handlers["n"] = generateHandler( + x1 + width / 2 - handlerWidth / 2, + y1 - dashedLineMargin - handlerMarginY + centeringOffset, + handlerWidth, + handlerHeight, + cx, + cy, + angle, + ); + } + if (!omitSides["s"]) { + handlers["s"] = generateHandler( + x1 + width / 2 - handlerWidth / 2, + y2 + dashedLineMargin - centeringOffset, + handlerWidth, + handlerHeight, + cx, + cy, + angle, + ); + } } - if (Math.abs(elementHeight) > minimumSizeForEightHandlers) { - handlers["w"] = generateHandler( - elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, - elementY1 + elementHeight / 2 - handlerHeight / 2, - handlerWidth, - handlerHeight, - cx, - cy, - angle, - ); - handlers["e"] = generateHandler( - elementX2 + dashedLineMargin - centeringOffset, - elementY1 + elementHeight / 2 - handlerHeight / 2, - handlerWidth, - handlerHeight, - cx, - cy, - angle, - ); + if (Math.abs(height) > minimumSizeForEightHandlers) { + if (!omitSides["w"]) { + handlers["w"] = generateHandler( + x1 - dashedLineMargin - handlerMarginX + centeringOffset, + y1 + height / 2 - handlerHeight / 2, + handlerWidth, + handlerHeight, + cx, + cy, + angle, + ); + } + if (!omitSides["e"]) { + handlers["e"] = generateHandler( + x2 + dashedLineMargin - centeringOffset, + y1 + height / 2 - handlerHeight / 2, + handlerWidth, + 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.points.length === 2) { // only check the last point because starting point is always (0,0) diff --git a/src/element/index.ts b/src/element/index.ts index f87490d6..42086543 100644 --- a/src/element/index.ts +++ b/src/element/index.ts @@ -15,13 +15,21 @@ export { getLinearElementAbsoluteBounds, } from "./bounds"; -export { handlerRectangles } from "./handlerRectangles"; +export { + OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, + handlerRectanglesFromCoords, + handlerRectangles, +} from "./handlerRectangles"; export { hitTest } from "./collision"; export { resizeTest, getCursorForResizingElement, normalizeResizeHandle, + getElementWithResizeHandler, + getResizeHandlerFromCoords, } from "./resizeTest"; +export type { ResizeArrowFnType } from "./resizeElements"; +export { resizeElements, canResizeMutlipleElements } from "./resizeElements"; export { isTextElement, isExcalidrawElement } from "./typeChecks"; export { textWysiwyg } from "./textWysiwyg"; export { redrawTextBoundingBox } from "./textElement"; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts new file mode 100644 index 00000000..78d9a5b2 --- /dev/null +++ b/src/element/resizeElements.ts @@ -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; + +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), + ); +} diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index e5828d44..02d61fcd 100644 --- a/src/element/resizeTest.ts +++ b/src/element/resizeTest.ts @@ -1,6 +1,10 @@ import { ExcalidrawElement, PointerType } from "./types"; -import { handlerRectangles } from "./handlerRectangles"; +import { + OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, + handlerRectanglesFromCoords, + handlerRectangles, +} from "./handlerRectangles"; import { AppState } from "../types"; import { isLinearElement } from "./typeChecks"; @@ -77,16 +81,37 @@ export function getElementWithResizeHandler( }, null as { element: ExcalidrawElement; resizeHandle: ReturnType } | 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]!; + return handler && isInHandlerRect(handler, x, y); + }); + return (found || false) as HandlerRectanglesRet; +} + /* * Returns bi-directional cursor for the element being resized */ export function getCursorForResizingElement(resizingElement: { - element: ExcalidrawElement; + element?: ExcalidrawElement; resizeHandle: ReturnType; }): string { const { element, resizeHandle } = resizingElement; const shouldSwapCursors = - Math.sign(element.height) * Math.sign(element.width) === -1; + element && Math.sign(element.height) * Math.sign(element.width) === -1; let cursor = null; switch (resizeHandle) { diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 9f8ac98f..74aefb79 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -3,7 +3,14 @@ import { RoughSVG } from "roughjs/bin/svg"; import { FlooredNumber, AppState } from "../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 { SceneState } from "../scene/types"; @@ -263,6 +270,56 @@ export function renderScene( } }); 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); + } } } diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index 6821f5b4..03718d42 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -162,12 +162,13 @@ function getSelectedElement(): ExcalidrawElement { return selectedElements[0]; } +type HandlerRectanglesRet = keyof ReturnType; function getResizeHandles() { - const rects = handlerRectangles( - getSelectedElement(), - h.state.zoom, - pointerType, - ); + const rects = + handlerRectangles(getSelectedElement(), h.state.zoom, pointerType) as + { + [T in HandlerRectanglesRet]: [number, number, number, number]; + }; const rv: { [K in keyof typeof rects]: [number, number] } = {} as any;