From a2e7d8d560876f8bb72b70d66fdc85f102a20747 Mon Sep 17 00:00:00 2001 From: Daishi Kato Date: Sun, 26 Jul 2020 19:21:38 +0900 Subject: [PATCH] feat: rotating multiple elements (#1960) --- src/components/App.tsx | 14 ++++ src/element/handlerRectangles.ts | 1 - src/element/resizeElements.ts | 126 ++++++++++++++++++++++++++++--- src/renderer/renderScene.ts | 34 ++++++--- 4 files changed, 151 insertions(+), 24 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index b4b4372f..28414d21 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -205,6 +205,10 @@ type PointerDownState = Readonly<{ offset: { x: number; y: number }; // This is determined on the initial pointer down event arrowDirection: "origin" | "end"; + // This is a center point of selected elements determined on the initial pointer down event (for rotation only) + center: { x: number; y: number }; + // This is a list of selected elements determined on the initial pointer down event (for rotation only) + originalElements: readonly NonDeleted[]; }; hit: { // The element the pointer is "hitting", is determined on the initial @@ -2213,6 +2217,11 @@ class App extends React.Component { this.canvas, window.devicePixelRatio, ); + const selectedElements = getSelectedElements( + globalSceneState.getElements(), + this.state, + ); + const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements); return { origin, @@ -2231,6 +2240,8 @@ class App extends React.Component { isResizing: false, offset: { x: 0, y: 0 }, arrowDirection: "origin", + center: { x: (maxX + minX) / 2, y: (maxY + minY) / 2 }, + originalElements: selectedElements.map((element) => ({ ...element })), }, hit: { element: null, @@ -2709,6 +2720,9 @@ class App extends React.Component { getResizeCenterPointKey(event), resizeX, resizeY, + pointerDownState.resize.center.x, + pointerDownState.resize.center.y, + pointerDownState.resize.originalElements, ) ) { return; diff --git a/src/element/handlerRectangles.ts b/src/element/handlerRectangles.ts index 32e292a1..2cc3d09d 100644 --- a/src/element/handlerRectangles.ts +++ b/src/element/handlerRectangles.ts @@ -18,7 +18,6 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = { s: true, n: true, w: true, - rotation: true, }; const OMIT_SIDES_FOR_TEXT_ELEMENT = { diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index e39263e7..60a4195e 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -23,18 +23,28 @@ import { } from "./resizeTest"; import { measureText, getFontString } from "../utils"; +const normalizeAngle = (angle: number): number => { + if (angle >= 2 * Math.PI) { + return angle - 2 * Math.PI; + } + return angle; +}; + type ResizeTestType = ReturnType; export const resizeElements = ( resizeHandle: ResizeTestType, setResizeHandle: (nextResizeHandle: ResizeTestType) => void, - selectedElements: NonDeletedExcalidrawElement[], + selectedElements: readonly NonDeletedExcalidrawElement[], resizeArrowDirection: "origin" | "end", isRotateWithDiscreteAngle: boolean, isResizeWithSidesSameLength: boolean, isResizeCenterPoint: boolean, pointerX: number, pointerY: number, + centerX: number, + centerY: number, + originalElements: readonly NonDeletedExcalidrawElement[], ) => { if (selectedElements.length === 1) { const [element] = selectedElements; @@ -100,15 +110,32 @@ export const resizeElements = ( }); return true; - } else if ( - selectedElements.length > 1 && - (resizeHandle === "nw" || + } else if (selectedElements.length > 1) { + if (resizeHandle === "rotation") { + rotateMultipleElements( + selectedElements, + pointerX, + pointerY, + isRotateWithDiscreteAngle, + centerX, + centerY, + originalElements, + ); + return true; + } else if ( + resizeHandle === "nw" || resizeHandle === "ne" || resizeHandle === "sw" || - resizeHandle === "se") - ) { - resizeMultipleElements(selectedElements, resizeHandle, pointerX, pointerY); - return true; + resizeHandle === "se" + ) { + resizeMultipleElements( + selectedElements, + resizeHandle, + pointerX, + pointerY, + ); + return true; + } } return false; }; @@ -127,9 +154,7 @@ const rotateSingleElement = ( angle += SHIFT_LOCKING_ANGLE / 2; angle -= angle % SHIFT_LOCKING_ANGLE; } - if (angle >= 2 * Math.PI) { - angle -= 2 * Math.PI; - } + angle = normalizeAngle(angle); mutateElement(element, { angle }); }; @@ -536,6 +561,85 @@ const resizeMultipleElements = ( } }; +const rotateMultipleElements = ( + elements: readonly NonDeletedExcalidrawElement[], + pointerX: number, + pointerY: number, + isRotateWithDiscreteAngle: boolean, + centerX: number, + centerY: number, + originalElements: readonly NonDeletedExcalidrawElement[], +) => { + let centerAngle = + (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX); + if (isRotateWithDiscreteAngle) { + centerAngle += SHIFT_LOCKING_ANGLE / 2; + centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; + } + elements.forEach((element, index) => { + if (isLinearElement(element) && element.points.length === 2) { + // FIXME this is a bit tricky (how can we make this more readable?) + const originalElement = originalElements[index]; + if ( + !isLinearElement(originalElement) || + originalElement.points.length !== 2 + ) { + throw new Error("original element not compatible"); // should not happen + } + const [x1, y1, x2, y2] = getElementAbsoluteCoords(originalElement); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const [rotatedCX, rotatedCY] = rotate( + cx, + cy, + centerX, + centerY, + centerAngle, + ); + const { points } = originalElement; + const [rotatedX, rotatedY] = rotate( + points[1][0], + points[1][1], + points[0][0], + points[0][1], + centerAngle, + ); + mutateElement(element, { + x: + originalElement.x + + (rotatedCX - cx) + + ((originalElement.points[0][0] + originalElement.points[1][0]) / 2 - + (points[0][0] + rotatedX) / 2), + y: + originalElement.y + + (rotatedCY - cy) + + ((originalElement.points[0][1] + originalElement.points[1][1]) / 2 - + (points[0][1] + rotatedY) / 2), + points: [ + [points[0][0], points[0][1]], + [rotatedX, rotatedY], + ], + }); + } else { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const [rotatedCX, rotatedCY] = rotate( + cx, + cy, + centerX, + centerY, + centerAngle + originalElements[index].angle - element.angle, + ); + mutateElement(element, { + x: element.x + (rotatedCX - cx), + y: element.y + (rotatedCY - cy), + angle: normalizeAngle(centerAngle + originalElements[index].angle), + }); + } + }); +}; + export const getResizeOffsetXY = ( resizeHandle: ResizeTestType, selectedElements: NonDeletedExcalidrawElement[], diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 7aaf2cf5..37ba97ea 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -399,7 +399,7 @@ export const renderScene = ( } }); context.translate(-sceneState.scrollX, -sceneState.scrollY); - } else if (locallySelectedElements.length > 1) { + } else if (locallySelectedElements.length > 1 && !appState.isRotating) { const dashedLinePadding = 4 / sceneState.zoom; context.translate(sceneState.scrollX, sceneState.scrollY); context.fillStyle = oc.white; @@ -432,17 +432,27 @@ export const renderScene = ( 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 - ); + if (key === "rotation") { + strokeCircle( + context, + handler[0], + handler[1], + handler[2], + handler[3], + ); + } else { + 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; } });