Browse Source

Rotation support (#1099)

* rotate rectanble with fixed angle

* rotate dashed rectangle with fixed angle

* fix rotate handler rect

* fix canvas size with rotation

* angle in element base

* fix bug in calculating canvas size

* trial only for rectangle

* hitTest for rectangle rotation

* properly resize rotated rectangle

* fix canvas size calculation

* giving up... workaround for now

* **experimental** handler to rotate rectangle

* remove rotation on copy for debugging

* update snapshots

* better rotation handler with atan2

* rotate when drawImage

* add rotation handler

* hitTest for any shapes

* fix hitTest for curved lines

* rotate text element

* rotation locking

* hint messaage for rotating

* show proper handlers on mobile (a workaround, there should be a better way)

* refactor hitTest

* support exporting png

* support exporting svg

* fix rotating curved line

* refactor drawElementFromCanvas with getElementAbsoluteCoords

* fix export png and svg

* adjust resize positions for lines (N, E, S, W)

* do not make handlers big on mobile

* Update src/locales/en.json

Alright!

Co-Authored-By: Lipis <lipiridis@gmail.com>

* do not show rotation/resizing hints on mobile

* proper calculation for N and W positions

* simplify calculation

* use "rotation" as property name for clarification (may increase bundle size)

* update snapshots excluding rotation handle

* refactor with adjustPositionWithRotation

* refactor with adjustXYWithRotation

* forgot to rename rotation

* rename internal function

* initialize element angle on restore

* rotate wysiwyg editor

* fix shift-rotate around 270deg

* improve rotation locking

* refactor adjustXYWithRotation

* avoid rotation degree becomes >=360

* refactor with generateHandler

Co-authored-by: Lipis <lipiridis@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
Daishi Kato 5 years ago
parent
commit
65be7973be

+ 2 - 0
src/appState.ts

@@ -29,6 +29,7 @@ export function getDefaultAppState(): AppState {
     name: `excalidraw-${getDateTime()}`,
     isCollaborating: false,
     isResizing: false,
+    isRotating: false,
     selectionElement: null,
     zoom: 1,
     openMenu: null,
@@ -47,6 +48,7 @@ export function clearAppStateForLocalStorage(appState: AppState) {
     editingElement,
     selectionElement,
     isResizing,
+    isRotating,
     collaborators,
     isCollaborating,
     isLoading,

+ 46 - 24
src/components/App.tsx

@@ -4,6 +4,7 @@ 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,
@@ -50,6 +51,7 @@ import { restore } from "../data/restore";
 import { renderScene } from "../renderer";
 import { AppState, GestureEvent, Gesture } from "../types";
 import { ExcalidrawElement, ExcalidrawLinearElement } from "../element/types";
+import { rotate, adjustXYWithRotation } from "../math";
 
 import {
   isWritableElement,
@@ -1208,6 +1210,7 @@ export class App extends React.Component<any, AppState> {
       font: element.font,
       opacity: this.state.currentItemOpacity,
       zoom: this.state.zoom,
+      angle: element.angle,
       onSubmit: (text) => {
         if (text) {
           globalSceneState.replaceAllElements([
@@ -1703,6 +1706,7 @@ export class App extends React.Component<any, AppState> {
         opacity: this.state.currentItemOpacity,
         font: this.state.currentItemFont,
         zoom: this.state.zoom,
+        angle: 0,
         onSubmit: (text) => {
           if (text) {
             globalSceneState.replaceAllElements([
@@ -1974,7 +1978,10 @@ export class App extends React.Component<any, AppState> {
       }
 
       if (isResizingElements && this.state.resizingElement) {
-        this.setState({ isResizing: true });
+        this.setState({
+          isResizing: resizeHandle !== "rotation",
+          isRotating: resizeHandle === "rotation",
+        });
         const el = this.state.resizingElement;
         const selectedElements = getSelectedElements(
           globalSceneState.getAllElements(),
@@ -1987,9 +1994,10 @@ export class App extends React.Component<any, AppState> {
             this.canvas,
             window.devicePixelRatio,
           );
-          const deltaX = x - lastX;
-          const deltaY = y - lastY;
           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) {
@@ -2005,16 +2013,12 @@ export class App extends React.Component<any, AppState> {
                 resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
               } else {
                 const width = element.width - deltaX;
-                const height = event.shiftKey
-                  ? element.width
-                  : element.height - deltaY;
+                const height = event.shiftKey ? width : element.height - deltaY;
+                const dY = element.height - height;
                 mutateElement(element, {
-                  x: element.x + deltaX,
-                  y: event.shiftKey
-                    ? element.y + element.height - element.width
-                    : element.y + deltaY,
                   width,
                   height,
+                  ...adjustXYWithRotation("nw", element, deltaX, dY, angle),
                   ...(isLinearElement(element) && width >= 0 && height >= 0
                     ? {
                         points: rescalePoints(
@@ -2041,12 +2045,11 @@ export class App extends React.Component<any, AppState> {
               } else {
                 const width = element.width + deltaX;
                 const height = event.shiftKey ? width : element.height - deltaY;
+                const dY = element.height - height;
                 mutateElement(element, {
-                  y: event.shiftKey
-                    ? element.y + element.height - width
-                    : element.y + deltaY,
                   width,
                   height,
+                  ...adjustXYWithRotation("ne", element, deltaX, dY, angle),
                   ...(isLinearElement(element) && width >= 0 && height >= 0
                     ? {
                         points: rescalePoints(
@@ -2072,13 +2075,12 @@ export class App extends React.Component<any, AppState> {
                 resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
               } else {
                 const width = element.width - deltaX;
-                const height = event.shiftKey
-                  ? element.width
-                  : element.height + deltaY;
+                const height = event.shiftKey ? width : element.height + deltaY;
+                const dY = height - element.height;
                 mutateElement(element, {
-                  x: element.x + deltaX,
                   width,
                   height,
+                  ...adjustXYWithRotation("sw", element, deltaX, dY, angle),
                   ...(isLinearElement(element) && width >= 0 && height >= 0
                     ? {
                         points: rescalePoints(
@@ -2104,12 +2106,12 @@ export class App extends React.Component<any, AppState> {
                 resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
               } else {
                 const width = element.width + deltaX;
-                const height = event.shiftKey
-                  ? element.width
-                  : element.height + deltaY;
+                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(
@@ -2133,13 +2135,13 @@ export class App extends React.Component<any, AppState> {
                 }
                 mutateElement(element, {
                   height,
-                  y: element.y + deltaY,
+                  ...adjustXYWithRotation("n", element, 0, deltaY, angle),
                   points: rescalePoints(1, height, element.points),
                 });
               } else {
                 mutateElement(element, {
                   height,
-                  y: element.y + deltaY,
+                  ...adjustXYWithRotation("n", element, 0, deltaY, angle),
                 });
               }
 
@@ -2157,13 +2159,13 @@ export class App extends React.Component<any, AppState> {
 
                 mutateElement(element, {
                   width,
-                  x: element.x + deltaX,
+                  ...adjustXYWithRotation("w", element, deltaX, 0, angle),
                   points: rescalePoints(0, width, element.points),
                 });
               } else {
                 mutateElement(element, {
                   width,
-                  x: element.x + deltaX,
+                  ...adjustXYWithRotation("w", element, deltaX, 0, angle),
                 });
               }
               break;
@@ -2179,11 +2181,13 @@ export class App extends React.Component<any, AppState> {
                 }
                 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;
@@ -2199,15 +2203,32 @@ export class App extends React.Component<any, AppState> {
                 }
                 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 += Math.PI / 16;
+                angle -= angle % (Math.PI / 8);
+              }
+              if (angle >= 2 * Math.PI) {
+                angle -= 2 * Math.PI;
+              }
+              mutateElement(element, { angle });
+              break;
+            }
           }
 
           if (resizeHandle) {
@@ -2351,6 +2372,7 @@ export class App extends React.Component<any, AppState> {
 
       this.setState({
         isResizing: false,
+        isRotating: false,
         resizingElement: null,
         selectionElement: null,
         editingElement: multiElement ? this.state.editingElement : null,

+ 6 - 2
src/components/HintViewer.tsx

@@ -13,7 +13,7 @@ interface Hint {
 }
 
 const getHints = ({ appState, elements }: Hint) => {
-  const { elementType, isResizing } = appState;
+  const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
   if (elementType === "arrow" || elementType === "line") {
     if (!multiMode) {
@@ -22,7 +22,7 @@ const getHints = ({ appState, elements }: Hint) => {
     return t("hints.linearElementMulti");
   }
 
-  if (isResizing) {
+  if (isResizing && lastPointerDownWith === "mouse") {
     const selectedElements = getSelectedElements(elements, appState);
     const targetElement = selectedElements[0];
     if (isLinearElement(targetElement) && targetElement.points.length > 2) {
@@ -31,6 +31,10 @@ const getHints = ({ appState, elements }: Hint) => {
     return t("hints.resize");
   }
 
+  if (isRotating && lastPointerDownWith === "mouse") {
+    return t("hints.rotate");
+  }
+
   return null;
 };
 

+ 1 - 0
src/data/restore.ts

@@ -70,6 +70,7 @@ export function restore(
           element.opacity === null || element.opacity === undefined
             ? 100
             : element.opacity,
+        angle: element.angle ?? 0,
       };
     });
 

+ 11 - 4
src/element/bounds.ts

@@ -194,10 +194,17 @@ export function getCommonBounds(elements: readonly ExcalidrawElement[]) {
 
   elements.forEach((element) => {
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-    minX = Math.min(minX, x1);
-    minY = Math.min(minY, y1);
-    maxX = Math.max(maxX, x2);
-    maxY = Math.max(maxY, y2);
+    const angle = element.angle;
+    const cx = (x1 + x2) / 2;
+    const cy = (y1 + y2) / 2;
+    const [x11, y11] = rotate(x1, y1, cx, cy, angle);
+    const [x12, y12] = rotate(x1, y2, cx, cy, angle);
+    const [x22, y22] = rotate(x2, y2, cx, cy, angle);
+    const [x21, y21] = rotate(x2, y1, cx, cy, angle);
+    minX = Math.min(minX, x11, x12, x22, x21);
+    minY = Math.min(minY, y11, y12, y22, y21);
+    maxX = Math.max(maxX, x11, x12, x22, x21);
+    maxY = Math.max(maxY, y11, y12, y22, y21);
   });
 
   return [minX, minY, maxX, maxY];

+ 8 - 10
src/element/collision.ts

@@ -2,16 +2,13 @@ import { distanceBetweenPointAndSegment } from "../math";
 
 import { ExcalidrawElement } from "./types";
 
-import {
-  getDiamondPoints,
-  getElementAbsoluteCoords,
-  getLinearElementAbsoluteBounds,
-} from "./bounds";
+import { getDiamondPoints, getElementAbsoluteCoords } from "./bounds";
 import { Point } from "../types";
 import { Drawable, OpSet } from "roughjs/bin/core";
 import { AppState } from "../types";
 import { getShapeForElement } from "../renderer/renderElement";
 import { isLinearElement } from "./typeChecks";
+import { rotate } from "../math";
 
 function isElementDraggableFromInside(
   element: ExcalidrawElement,
@@ -34,6 +31,12 @@ export function hitTest(
   // of the click is less than x pixels of any of the lines that the shape is composed of
   const lineThreshold = 10 / zoom;
 
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+  const cx = (x1 + x2) / 2;
+  const cy = (y1 + y2) / 2;
+  // reverse rotate the pointer
+  [x, y] = rotate(x, y, cx, cy, -element.angle);
+
   if (element.type === "ellipse") {
     // https://stackoverflow.com/a/46007540/232122
     const px = Math.abs(x - element.x - element.width / 2);
@@ -75,8 +78,6 @@ export function hitTest(
     }
     return Math.hypot(a * tx - px, b * ty - py) < lineThreshold;
   } else if (element.type === "rectangle") {
-    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-
     if (isElementDraggableFromInside(element, appState)) {
       return (
         x > x1 - lineThreshold &&
@@ -165,7 +166,6 @@ export function hitTest(
     }
     const shape = getShapeForElement(element) as Drawable[];
 
-    const [x1, y1, x2, y2] = getLinearElementAbsoluteBounds(element);
     if (
       x < x1 - lineThreshold ||
       y < y1 - lineThreshold ||
@@ -183,8 +183,6 @@ export function hitTest(
       hitTestRoughShape(subshape.sets, relX, relY, lineThreshold),
     );
   } else if (element.type === "text") {
-    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-
     return x >= x1 && x <= x2 && y >= y1 && y <= y2;
   } else if (element.type === "selection") {
     console.warn("This should not happen, we need to investigate why it does.");

+ 74 - 18
src/element/handlerRectangles.ts

@@ -1,8 +1,9 @@
 import { ExcalidrawElement, PointerType } from "./types";
 
 import { getElementAbsoluteCoords } from "./bounds";
+import { rotate } from "../math";
 
-type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
+type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se" | "rotation";
 
 const handleSizes: { [k in PointerType]: number } = {
   mouse: 8,
@@ -10,6 +11,21 @@ const handleSizes: { [k in PointerType]: number } = {
   touch: 28,
 };
 
+const ROTATION_HANDLER_GAP = 16;
+
+function generateHandler(
+  x: number,
+  y: number,
+  width: number,
+  height: number,
+  cx: number,
+  cy: number,
+  angle: number,
+): [number, number, number, number] {
+  const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle);
+  return [xx - width / 2, yy - height / 2, width, height];
+}
+
 export function handlerRectangles(
   element: ExcalidrawElement,
   zoom: number,
@@ -28,67 +44,107 @@ export function handlerRectangles(
 
   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 centeringOffset = (size - 8) / (2 * zoom);
 
   const handlers = {
-    nw: [
+    nw: generateHandler(
       elementX1 - dashedLineMargin - handlerMarginX + centeringOffset,
       elementY1 - dashedLineMargin - handlerMarginY + centeringOffset,
       handlerWidth,
       handlerHeight,
-    ],
-    ne: [
+      cx,
+      cy,
+      angle,
+    ),
+    ne: generateHandler(
       elementX2 + dashedLineMargin - centeringOffset,
       elementY1 - dashedLineMargin - handlerMarginY + centeringOffset,
       handlerWidth,
       handlerHeight,
-    ],
-    sw: [
+      cx,
+      cy,
+      angle,
+    ),
+    sw: generateHandler(
       elementX1 - dashedLineMargin - handlerMarginX + centeringOffset,
       elementY2 + dashedLineMargin - centeringOffset,
       handlerWidth,
       handlerHeight,
-    ],
-    se: [
+      cx,
+      cy,
+      angle,
+    ),
+    se: generateHandler(
       elementX2 + dashedLineMargin - centeringOffset,
       elementY2 + dashedLineMargin - centeringOffset,
       handlerWidth,
       handlerHeight,
-    ],
-  } as { [T in Sides]: number[] };
+      cx,
+      cy,
+      angle,
+    ),
+    rotation: generateHandler(
+      elementX1 + elementWidth / 2 - handlerWidth / 2,
+      elementY1 -
+        dashedLineMargin -
+        handlerMarginY +
+        centeringOffset -
+        ROTATION_HANDLER_GAP,
+      handlerWidth,
+      handlerHeight,
+      cx,
+      cy,
+      angle,
+    ),
+  } as { [T in Sides]: [number, number, number, number] };
 
   // 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"] = [
+    handlers["n"] = generateHandler(
       elementX1 + elementWidth / 2 - handlerWidth / 2,
       elementY1 - dashedLineMargin - handlerMarginY + centeringOffset,
       handlerWidth,
       handlerHeight,
-    ];
-    handlers["s"] = [
+      cx,
+      cy,
+      angle,
+    );
+    handlers["s"] = generateHandler(
       elementX1 + elementWidth / 2 - handlerWidth / 2,
       elementY2 + dashedLineMargin - centeringOffset,
       handlerWidth,
       handlerHeight,
-    ];
+      cx,
+      cy,
+      angle,
+    );
   }
   if (Math.abs(elementHeight) > minimumSizeForEightHandlers) {
-    handlers["w"] = [
+    handlers["w"] = generateHandler(
       elementX1 - dashedLineMargin - handlerMarginX + centeringOffset,
       elementY1 + elementHeight / 2 - handlerHeight / 2,
       handlerWidth,
       handlerHeight,
-    ];
-    handlers["e"] = [
+      cx,
+      cy,
+      angle,
+    );
+    handlers["e"] = generateHandler(
       elementX2 + dashedLineMargin - centeringOffset,
       elementY1 + elementHeight / 2 - handlerHeight / 2,
       handlerWidth,
       handlerHeight,
-    ];
+      cx,
+      cy,
+      angle,
+    );
   }
 
   if (element.type === "arrow" || element.type === "line") {

+ 3 - 0
src/element/newElement.ts

@@ -18,6 +18,7 @@ type ElementConstructorOpts = {
   opacity: ExcalidrawGenericElement["opacity"];
   width?: ExcalidrawGenericElement["width"];
   height?: ExcalidrawGenericElement["height"];
+  angle?: ExcalidrawGenericElement["angle"];
 };
 
 function _newElementBase<T extends ExcalidrawElement>(
@@ -33,6 +34,7 @@ function _newElementBase<T extends ExcalidrawElement>(
     opacity,
     width = 0,
     height = 0,
+    angle = 0,
     ...rest
   }: ElementConstructorOpts & Partial<ExcalidrawGenericElement>,
 ) {
@@ -43,6 +45,7 @@ function _newElementBase<T extends ExcalidrawElement>(
     y,
     width,
     height,
+    angle,
     strokeColor,
     backgroundColor,
     fillStyle,

+ 33 - 10
src/element/resizeTest.ts

@@ -6,6 +6,19 @@ import { isLinearElement } from "./typeChecks";
 
 type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
 
+function isInHandlerRect(
+  handler: [number, number, number, number],
+  x: number,
+  y: number,
+) {
+  return (
+    x >= handler[0] &&
+    x <= handler[0] + handler[2] &&
+    y >= handler[1] &&
+    y <= handler[1] + handler[3]
+  );
+}
+
 export function resizeTest(
   element: ExcalidrawElement,
   appState: AppState,
@@ -14,24 +27,31 @@ export function resizeTest(
   zoom: number,
   pointerType: PointerType,
 ): HandlerRectanglesRet | false {
-  if (!appState.selectedElementIds[element.id] || element.type === "text") {
+  if (!appState.selectedElementIds[element.id]) {
     return false;
   }
 
-  const handlers = handlerRectangles(element, zoom, pointerType);
+  const { rotation: rotationHandler, ...handlers } = handlerRectangles(
+    element,
+    zoom,
+    pointerType,
+  );
+
+  if (rotationHandler && isInHandlerRect(rotationHandler, x, y)) {
+    return "rotation" as HandlerRectanglesRet;
+  }
+
+  if (element.type === "text") {
+    // can't resize text elements
+    return false;
+  }
 
   const filter = Object.keys(handlers).filter((key) => {
-    const handler = handlers[key as HandlerRectanglesRet]!;
+    const handler = handlers[key as Exclude<HandlerRectanglesRet, "rotation">]!;
     if (!handler) {
       return false;
     }
-
-    return (
-      x >= handler[0] &&
-      x <= handler[0] + handler[2] &&
-      y >= handler[1] &&
-      y <= handler[1] + handler[3]
-    );
+    return isInHandlerRect(handler, x, y);
   });
 
   if (filter.length > 0) {
@@ -94,6 +114,9 @@ export function getCursorForResizingElement(resizingElement: {
         cursor = "nesw";
       }
       break;
+    case "rotation":
+      cursor = "ew";
+      break;
   }
 
   return cursor ? `${cursor}-resize` : "";

+ 5 - 1
src/element/textWysiwyg.tsx

@@ -20,6 +20,7 @@ type TextWysiwygParams = {
   font: string;
   opacity: number;
   zoom: number;
+  angle: number;
   onSubmit: (text: string) => void;
   onCancel: () => void;
 };
@@ -32,6 +33,7 @@ export function textWysiwyg({
   font,
   opacity,
   zoom,
+  angle,
   onSubmit,
   onCancel,
 }: TextWysiwygParams) {
@@ -45,13 +47,15 @@ export function textWysiwyg({
   editable.innerText = initText;
   editable.dataset.type = "wysiwyg";
 
+  const degree = (180 * angle) / Math.PI;
+
   Object.assign(editable.style, {
     color: strokeColor,
     position: "fixed",
     opacity: opacity / 100,
     top: `${y}px`,
     left: `${x}px`,
-    transform: `translate(-50%, -50%) scale(${zoom})`,
+    transform: `translate(-50%, -50%) scale(${zoom}) rotate(${degree}deg)`,
     textAlign: "left",
     display: "inline-block",
     font: font,

+ 1 - 0
src/element/types.ts

@@ -12,6 +12,7 @@ type _ExcalidrawElementBase = Readonly<{
   opacity: number;
   width: number;
   height: number;
+  angle: number;
   seed: number;
   version: number;
   versionNonce: number;

+ 2 - 1
src/locales/en.json

@@ -98,7 +98,8 @@
   "hints": {
     "linearElement": "Click to start multiple points, drag for single line",
     "linearElementMulti": "Click on last point or press Escape or Enter to finish",
-    "resize": "You can constraint proportions by holding SHIFT while resizing"
+    "resize": "You can constrain proportions by holding SHIFT while resizing",
+    "rotate": "You can constrain angles by holding SHIFT while rotating"
   },
   "errorSplash": {
     "headingMain_pre": "Encountered an error. Try ",

+ 27 - 0
src/math.ts

@@ -55,6 +55,33 @@ export function rotate(
   ];
 }
 
+export function adjustXYWithRotation(
+  side: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
+  position: { x: number; y: number },
+  deltaX: number,
+  deltaY: number,
+  angle: number,
+) {
+  let { x, y } = position;
+  if (side === "e" || side === "ne" || side === "se") {
+    x -= (deltaX / 2) * (1 - Math.cos(angle));
+    y -= (deltaX / 2) * -Math.sin(angle);
+  }
+  if (side === "s" || side === "sw" || side === "se") {
+    x -= (deltaY / 2) * Math.sin(angle);
+    y -= (deltaY / 2) * (1 - Math.cos(angle));
+  }
+  if (side === "w" || side === "nw" || side === "sw") {
+    x += (deltaX / 2) * (1 + Math.cos(angle));
+    y += (deltaX / 2) * Math.sin(angle);
+  }
+  if (side === "n" || side === "nw" || side === "ne") {
+    x += (deltaY / 2) * -Math.sin(angle);
+    y += (deltaY / 2) * (1 + Math.cos(angle));
+  }
+  return { x, y };
+}
+
 export const getPointOnAPath = (point: Point, path: Point[]) => {
   const [px, py] = point;
   const [start, ...other] = path;

+ 36 - 25
src/renderer/renderElement.ts

@@ -263,30 +263,24 @@ function drawElementFromCanvas(
   context: CanvasRenderingContext2D,
   sceneState: SceneState,
 ) {
+  const element = elementWithCanvas.element;
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+  const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
+  const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
   context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
-  context.translate(
-    -CANVAS_PADDING / elementWithCanvas.canvasZoom,
-    -CANVAS_PADDING / elementWithCanvas.canvasZoom,
-  );
+  context.translate(cx, cy);
+  context.rotate(element.angle);
   context.drawImage(
     elementWithCanvas.canvas!,
-    Math.floor(
-      -elementWithCanvas.canvasOffsetX +
-        (Math.floor(elementWithCanvas.element.x) + sceneState.scrollX) *
-          window.devicePixelRatio,
-    ),
-    Math.floor(
-      -elementWithCanvas.canvasOffsetY +
-        (Math.floor(elementWithCanvas.element.y) + sceneState.scrollY) *
-          window.devicePixelRatio,
-    ),
+    (-(x2 - x1) / 2) * window.devicePixelRatio -
+      CANVAS_PADDING / elementWithCanvas.canvasZoom,
+    (-(y2 - y1) / 2) * window.devicePixelRatio -
+      CANVAS_PADDING / elementWithCanvas.canvasZoom,
     elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
     elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
   );
-  context.translate(
-    CANVAS_PADDING / elementWithCanvas.canvasZoom,
-    CANVAS_PADDING / elementWithCanvas.canvasZoom,
-  );
+  context.rotate(-element.angle);
+  context.translate(-cx, -cy);
   context.scale(window.devicePixelRatio, window.devicePixelRatio);
 }
 
@@ -325,11 +319,18 @@ export function renderElement(
       if (renderOptimizations) {
         drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
       } else {
-        const offsetX = Math.floor(element.x + sceneState.scrollX);
-        const offsetY = Math.floor(element.y + sceneState.scrollY);
-        context.translate(offsetX, offsetY);
+        const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+        const cx = (x1 + x2) / 2 + sceneState.scrollX;
+        const cy = (y1 + y2) / 2 + sceneState.scrollY;
+        const shiftX = (x2 - x1) / 2 - (element.x - x1);
+        const shiftY = (y2 - y1) / 2 - (element.y - y1);
+        context.translate(cx, cy);
+        context.rotate(element.angle);
+        context.translate(-shiftX, -shiftY);
         drawElementOnCanvas(element, rc, context);
-        context.translate(-offsetX, -offsetY);
+        context.translate(shiftX, shiftY);
+        context.rotate(-element.angle);
+        context.translate(-cx, -cy);
       }
       break;
     }
@@ -347,6 +348,10 @@ export function renderElementToSvg(
   offsetX?: number,
   offsetY?: number,
 ) {
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+  const cx = (x2 - x1) / 2 - (element.x - x1);
+  const cy = (y2 - y1) / 2 - (element.y - y1);
+  const degree = (180 * element.angle) / Math.PI;
   const generator = rsvg.generator;
   switch (element.type) {
     case "selection": {
@@ -366,7 +371,9 @@ export function renderElementToSvg(
       }
       node.setAttribute(
         "transform",
-        `translate(${offsetX || 0} ${offsetY || 0})`,
+        `translate(${offsetX || 0} ${
+          offsetY || 0
+        }) rotate(${degree} ${cx} ${cy})`,
       );
       svgRoot.appendChild(node);
       break;
@@ -384,7 +391,9 @@ export function renderElementToSvg(
         }
         node.setAttribute(
           "transform",
-          `translate(${offsetX || 0} ${offsetY || 0})`,
+          `translate(${offsetX || 0} ${
+            offsetY || 0
+          }) rotate(${degree} ${cx} ${cy})`,
         );
         group.appendChild(node);
       });
@@ -401,7 +410,9 @@ export function renderElementToSvg(
         }
         node.setAttribute(
           "transform",
-          `translate(${offsetX || 0} ${offsetY || 0})`,
+          `translate(${offsetX || 0} ${
+            offsetY || 0
+          }) rotate(${degree} ${cx} ${cy})`,
         );
         const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
         const lineHeight = element.height / lines.length;

+ 73 - 13
src/renderer/renderScene.ts

@@ -17,6 +17,8 @@ import { getSelectedElements } from "../scene/selection";
 import { renderElement, renderElementToSvg } from "./renderElement";
 import colors from "../colors";
 
+type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
+
 function colorsForClientId(clientId: string) {
   // Naive way of getting an integer out of the clientId
   const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
@@ -26,6 +28,40 @@ function colorsForClientId(clientId: string) {
   };
 }
 
+function strokeRectWithRotation(
+  context: CanvasRenderingContext2D,
+  x: number,
+  y: number,
+  width: number,
+  height: number,
+  cx: number,
+  cy: number,
+  angle: number,
+  fill?: boolean,
+) {
+  context.translate(cx, cy);
+  context.rotate(angle);
+  if (fill) {
+    context.fillRect(x - cx, y - cy, width, height);
+  }
+  context.strokeRect(x - cx, y - cy, width, height);
+  context.rotate(-angle);
+  context.translate(-cx, -cy);
+}
+
+function strokeCircle(
+  context: CanvasRenderingContext2D,
+  x: number,
+  y: number,
+  width: number,
+  height: number,
+) {
+  context.beginPath();
+  context.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2);
+  context.fill();
+  context.stroke();
+}
+
 export function renderScene(
   allElements: readonly ExcalidrawElement[],
   appState: AppState,
@@ -113,7 +149,7 @@ export function renderScene(
   // Pain selected elements
   if (renderSelection) {
     const selectedElements = getSelectedElements(elements, appState);
-    const dashledLinePadding = 4 / sceneState.zoom;
+    const dashedLinePadding = 4 / sceneState.zoom;
 
     context.translate(sceneState.scrollX, sceneState.scrollY);
     selectedElements.forEach((element) => {
@@ -131,11 +167,15 @@ export function renderScene(
       context.setLineDash([8 / sceneState.zoom, 4 / sceneState.zoom]);
       const lineWidth = context.lineWidth;
       context.lineWidth = 1 / sceneState.zoom;
-      context.strokeRect(
-        elementX1 - dashledLinePadding,
-        elementY1 - dashledLinePadding,
-        elementWidth + dashledLinePadding * 2,
-        elementHeight + dashledLinePadding * 2,
+      strokeRectWithRotation(
+        context,
+        elementX1 - dashedLinePadding,
+        elementY1 - dashedLinePadding,
+        elementWidth + dashedLinePadding * 2,
+        elementHeight + dashedLinePadding * 2,
+        elementX1 + elementWidth / 2,
+        elementY1 + elementHeight / 2,
+        element.angle,
       );
       context.lineWidth = lineWidth;
       context.setLineDash(initialLineDash);
@@ -143,19 +183,39 @@ export function renderScene(
     context.translate(-sceneState.scrollX, -sceneState.scrollY);
 
     // Paint resize handlers
-    if (selectedElements.length === 1 && selectedElements[0].type !== "text") {
+    if (selectedElements.length === 1) {
       context.translate(sceneState.scrollX, sceneState.scrollY);
       context.fillStyle = "#fff";
       const handlers = handlerRectangles(selectedElements[0], sceneState.zoom);
-      Object.values(handlers)
-        .filter((handler) => handler !== undefined)
-        .forEach((handler) => {
+      Object.keys(handlers).forEach((key) => {
+        const handler = handlers[key as HandlerRectanglesRet];
+        if (handler !== undefined) {
           const lineWidth = context.lineWidth;
           context.lineWidth = 1 / sceneState.zoom;
-          context.fillRect(handler[0], handler[1], handler[2], handler[3]);
-          context.strokeRect(handler[0], handler[1], handler[2], handler[3]);
+          if (key === "rotation") {
+            strokeCircle(
+              context,
+              handler[0],
+              handler[1],
+              handler[2],
+              handler[3],
+            );
+          } else if (selectedElements[0].type !== "text") {
+            strokeRectWithRotation(
+              context,
+              handler[0],
+              handler[1],
+              handler[2],
+              handler[3],
+              handler[0] + handler[2] / 2,
+              handler[1] + handler[3] / 2,
+              selectedElements[0].angle,
+              true, // fill before stroke
+            );
+          }
           context.lineWidth = lineWidth;
-        });
+        }
+      });
       context.translate(-sceneState.scrollX, -sceneState.scrollY);
     }
   }

+ 5 - 0
src/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -4,6 +4,7 @@ exports[`add element to the scene when pointer dragging long enough arrow 1`] =
 
 exports[`add element to the scene when pointer dragging long enough arrow 2`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -38,6 +39,7 @@ exports[`add element to the scene when pointer dragging long enough diamond 1`]
 
 exports[`add element to the scene when pointer dragging long enough diamond 2`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -61,6 +63,7 @@ exports[`add element to the scene when pointer dragging long enough ellipse 1`]
 
 exports[`add element to the scene when pointer dragging long enough ellipse 2`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -82,6 +85,7 @@ Object {
 
 exports[`add element to the scene when pointer dragging long enough line 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -116,6 +120,7 @@ exports[`add element to the scene when pointer dragging long enough rectangle 1`
 
 exports[`add element to the scene when pointer dragging long enough rectangle 2`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,

+ 3 - 0
src/tests/__snapshots__/move.test.tsx.snap

@@ -2,6 +2,7 @@
 
 exports[`duplicate element on move when ALT is clicked rectangle 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -23,6 +24,7 @@ Object {
 
 exports[`duplicate element on move when ALT is clicked rectangle 2`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -44,6 +46,7 @@ Object {
 
 exports[`move element rectangle 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,

+ 2 - 0
src/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -2,6 +2,7 @@
 
 exports[`multi point mode in linear elements arrow 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 110,
@@ -41,6 +42,7 @@ Object {
 
 exports[`multi point mode in linear elements line 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 110,

File diff suppressed because it is too large
+ 126 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 2 - 0
src/tests/__snapshots__/resize.test.tsx.snap

@@ -2,6 +2,7 @@
 
 exports[`resize element rectangle 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -23,6 +24,7 @@ Object {
 
 exports[`resize element with aspect ratio when SHIFT is clicked rectangle 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,

+ 5 - 0
src/tests/__snapshots__/selection.test.tsx.snap

@@ -2,6 +2,7 @@
 
 exports[`select single element on the scene arrow 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -34,6 +35,7 @@ Object {
 
 exports[`select single element on the scene arrow escape 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -66,6 +68,7 @@ Object {
 
 exports[`select single element on the scene diamond 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -87,6 +90,7 @@ Object {
 
 exports[`select single element on the scene ellipse 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,
@@ -108,6 +112,7 @@ Object {
 
 exports[`select single element on the scene rectangle 1`] = `
 Object {
+  "angle": 0,
   "backgroundColor": "transparent",
   "fillStyle": "hachure",
   "height": 50,

+ 1 - 0
src/tests/regressionTests.test.tsx

@@ -322,6 +322,7 @@ describe("regression tests", () => {
     pointerUp();
 
     const resizeHandles = getResizeHandles();
+    delete resizeHandles.rotation; // exclude rotation handle
     for (const handlePos in resizeHandles) {
       const [x, y] = resizeHandles[handlePos as keyof typeof resizeHandles];
       const { width: prevWidth, height: prevHeight } = getSelectedElement();

+ 1 - 0
src/types.ts

@@ -37,6 +37,7 @@ export type AppState = {
   name: string;
   isCollaborating: boolean;
   isResizing: boolean;
+  isRotating: boolean;
   zoom: number;
   openMenu: "canvas" | "shape" | null;
   lastPointerDownWith: PointerType;

Some files were not shown because too many files changed in this diff