mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-31 19:04:35 +01:00 
			
		
		
		
	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>
This commit is contained in:
		| @@ -29,6 +29,7 @@ export function getDefaultAppState(): AppState { | |||||||
|     name: `excalidraw-${getDateTime()}`, |     name: `excalidraw-${getDateTime()}`, | ||||||
|     isCollaborating: false, |     isCollaborating: false, | ||||||
|     isResizing: false, |     isResizing: false, | ||||||
|  |     isRotating: false, | ||||||
|     selectionElement: null, |     selectionElement: null, | ||||||
|     zoom: 1, |     zoom: 1, | ||||||
|     openMenu: null, |     openMenu: null, | ||||||
| @@ -47,6 +48,7 @@ export function clearAppStateForLocalStorage(appState: AppState) { | |||||||
|     editingElement, |     editingElement, | ||||||
|     selectionElement, |     selectionElement, | ||||||
|     isResizing, |     isResizing, | ||||||
|  |     isRotating, | ||||||
|     collaborators, |     collaborators, | ||||||
|     isCollaborating, |     isCollaborating, | ||||||
|     isLoading, |     isLoading, | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ 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, | ||||||
| @@ -50,6 +51,7 @@ import { restore } from "../data/restore"; | |||||||
| import { renderScene } from "../renderer"; | import { renderScene } from "../renderer"; | ||||||
| import { AppState, GestureEvent, Gesture } from "../types"; | import { AppState, GestureEvent, Gesture } from "../types"; | ||||||
| import { ExcalidrawElement, ExcalidrawLinearElement } from "../element/types"; | import { ExcalidrawElement, ExcalidrawLinearElement } from "../element/types"; | ||||||
|  | import { rotate, adjustXYWithRotation } from "../math"; | ||||||
|  |  | ||||||
| import { | import { | ||||||
|   isWritableElement, |   isWritableElement, | ||||||
| @@ -1208,6 +1210,7 @@ export class App extends React.Component<any, AppState> { | |||||||
|       font: element.font, |       font: element.font, | ||||||
|       opacity: this.state.currentItemOpacity, |       opacity: this.state.currentItemOpacity, | ||||||
|       zoom: this.state.zoom, |       zoom: this.state.zoom, | ||||||
|  |       angle: element.angle, | ||||||
|       onSubmit: (text) => { |       onSubmit: (text) => { | ||||||
|         if (text) { |         if (text) { | ||||||
|           globalSceneState.replaceAllElements([ |           globalSceneState.replaceAllElements([ | ||||||
| @@ -1703,6 +1706,7 @@ export class App extends React.Component<any, AppState> { | |||||||
|         opacity: this.state.currentItemOpacity, |         opacity: this.state.currentItemOpacity, | ||||||
|         font: this.state.currentItemFont, |         font: this.state.currentItemFont, | ||||||
|         zoom: this.state.zoom, |         zoom: this.state.zoom, | ||||||
|  |         angle: 0, | ||||||
|         onSubmit: (text) => { |         onSubmit: (text) => { | ||||||
|           if (text) { |           if (text) { | ||||||
|             globalSceneState.replaceAllElements([ |             globalSceneState.replaceAllElements([ | ||||||
| @@ -1974,7 +1978,10 @@ export class App extends React.Component<any, AppState> { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       if (isResizingElements && this.state.resizingElement) { |       if (isResizingElements && this.state.resizingElement) { | ||||||
|         this.setState({ isResizing: true }); |         this.setState({ | ||||||
|  |           isResizing: resizeHandle !== "rotation", | ||||||
|  |           isRotating: resizeHandle === "rotation", | ||||||
|  |         }); | ||||||
|         const el = this.state.resizingElement; |         const el = this.state.resizingElement; | ||||||
|         const selectedElements = getSelectedElements( |         const selectedElements = getSelectedElements( | ||||||
|           globalSceneState.getAllElements(), |           globalSceneState.getAllElements(), | ||||||
| @@ -1987,9 +1994,10 @@ export class App extends React.Component<any, AppState> { | |||||||
|             this.canvas, |             this.canvas, | ||||||
|             window.devicePixelRatio, |             window.devicePixelRatio, | ||||||
|           ); |           ); | ||||||
|           const deltaX = x - lastX; |  | ||||||
|           const deltaY = y - lastY; |  | ||||||
|           const element = selectedElements[0]; |           const element = selectedElements[0]; | ||||||
|  |           const angle = element.angle; | ||||||
|  |           // reverse rotate delta | ||||||
|  |           const [deltaX, deltaY] = rotate(x - lastX, y - lastY, 0, 0, -angle); | ||||||
|           switch (resizeHandle) { |           switch (resizeHandle) { | ||||||
|             case "nw": |             case "nw": | ||||||
|               if (isLinearElement(element) && element.points.length === 2) { |               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); |                 resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey); | ||||||
|               } else { |               } else { | ||||||
|                 const width = element.width - deltaX; |                 const width = element.width - deltaX; | ||||||
|                 const height = event.shiftKey |                 const height = event.shiftKey ? width : element.height - deltaY; | ||||||
|                   ? element.width |                 const dY = element.height - height; | ||||||
|                   : element.height - deltaY; |  | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   x: element.x + deltaX, |  | ||||||
|                   y: event.shiftKey |  | ||||||
|                     ? element.y + element.height - element.width |  | ||||||
|                     : element.y + deltaY, |  | ||||||
|                   width, |                   width, | ||||||
|                   height, |                   height, | ||||||
|  |                   ...adjustXYWithRotation("nw", element, deltaX, dY, angle), | ||||||
|                   ...(isLinearElement(element) && width >= 0 && height >= 0 |                   ...(isLinearElement(element) && width >= 0 && height >= 0 | ||||||
|                     ? { |                     ? { | ||||||
|                         points: rescalePoints( |                         points: rescalePoints( | ||||||
| @@ -2041,12 +2045,11 @@ export class App extends React.Component<any, AppState> { | |||||||
|               } else { |               } else { | ||||||
|                 const width = element.width + deltaX; |                 const width = element.width + deltaX; | ||||||
|                 const height = event.shiftKey ? width : element.height - deltaY; |                 const height = event.shiftKey ? width : element.height - deltaY; | ||||||
|  |                 const dY = element.height - height; | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   y: event.shiftKey |  | ||||||
|                     ? element.y + element.height - width |  | ||||||
|                     : element.y + deltaY, |  | ||||||
|                   width, |                   width, | ||||||
|                   height, |                   height, | ||||||
|  |                   ...adjustXYWithRotation("ne", element, deltaX, dY, angle), | ||||||
|                   ...(isLinearElement(element) && width >= 0 && height >= 0 |                   ...(isLinearElement(element) && width >= 0 && height >= 0 | ||||||
|                     ? { |                     ? { | ||||||
|                         points: rescalePoints( |                         points: rescalePoints( | ||||||
| @@ -2072,13 +2075,12 @@ export class App extends React.Component<any, AppState> { | |||||||
|                 resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey); |                 resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey); | ||||||
|               } else { |               } else { | ||||||
|                 const width = element.width - deltaX; |                 const width = element.width - deltaX; | ||||||
|                 const height = event.shiftKey |                 const height = event.shiftKey ? width : element.height + deltaY; | ||||||
|                   ? element.width |                 const dY = height - element.height; | ||||||
|                   : element.height + deltaY; |  | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   x: element.x + deltaX, |  | ||||||
|                   width, |                   width, | ||||||
|                   height, |                   height, | ||||||
|  |                   ...adjustXYWithRotation("sw", element, deltaX, dY, angle), | ||||||
|                   ...(isLinearElement(element) && width >= 0 && height >= 0 |                   ...(isLinearElement(element) && width >= 0 && height >= 0 | ||||||
|                     ? { |                     ? { | ||||||
|                         points: rescalePoints( |                         points: rescalePoints( | ||||||
| @@ -2104,12 +2106,12 @@ export class App extends React.Component<any, AppState> { | |||||||
|                 resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey); |                 resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey); | ||||||
|               } else { |               } else { | ||||||
|                 const width = element.width + deltaX; |                 const width = element.width + deltaX; | ||||||
|                 const height = event.shiftKey |                 const height = event.shiftKey ? width : element.height + deltaY; | ||||||
|                   ? element.width |                 const dY = height - element.height; | ||||||
|                   : element.height + deltaY; |  | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   width, |                   width, | ||||||
|                   height, |                   height, | ||||||
|  |                   ...adjustXYWithRotation("se", element, deltaX, dY, angle), | ||||||
|                   ...(isLinearElement(element) && width >= 0 && height >= 0 |                   ...(isLinearElement(element) && width >= 0 && height >= 0 | ||||||
|                     ? { |                     ? { | ||||||
|                         points: rescalePoints( |                         points: rescalePoints( | ||||||
| @@ -2133,13 +2135,13 @@ export class App extends React.Component<any, AppState> { | |||||||
|                 } |                 } | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   height, |                   height, | ||||||
|                   y: element.y + deltaY, |                   ...adjustXYWithRotation("n", element, 0, deltaY, angle), | ||||||
|                   points: rescalePoints(1, height, element.points), |                   points: rescalePoints(1, height, element.points), | ||||||
|                 }); |                 }); | ||||||
|               } else { |               } else { | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   height, |                   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, { |                 mutateElement(element, { | ||||||
|                   width, |                   width, | ||||||
|                   x: element.x + deltaX, |                   ...adjustXYWithRotation("w", element, deltaX, 0, angle), | ||||||
|                   points: rescalePoints(0, width, element.points), |                   points: rescalePoints(0, width, element.points), | ||||||
|                 }); |                 }); | ||||||
|               } else { |               } else { | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   width, |                   width, | ||||||
|                   x: element.x + deltaX, |                   ...adjustXYWithRotation("w", element, deltaX, 0, angle), | ||||||
|                 }); |                 }); | ||||||
|               } |               } | ||||||
|               break; |               break; | ||||||
| @@ -2179,11 +2181,13 @@ export class App extends React.Component<any, AppState> { | |||||||
|                 } |                 } | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   height, |                   height, | ||||||
|  |                   ...adjustXYWithRotation("s", element, 0, deltaY, angle), | ||||||
|                   points: rescalePoints(1, height, element.points), |                   points: rescalePoints(1, height, element.points), | ||||||
|                 }); |                 }); | ||||||
|               } else { |               } else { | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   height, |                   height, | ||||||
|  |                   ...adjustXYWithRotation("s", element, 0, deltaY, angle), | ||||||
|                 }); |                 }); | ||||||
|               } |               } | ||||||
|               break; |               break; | ||||||
| @@ -2199,15 +2203,32 @@ export class App extends React.Component<any, AppState> { | |||||||
|                 } |                 } | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   width, |                   width, | ||||||
|  |                   ...adjustXYWithRotation("e", element, deltaX, 0, angle), | ||||||
|                   points: rescalePoints(0, width, element.points), |                   points: rescalePoints(0, width, element.points), | ||||||
|                 }); |                 }); | ||||||
|               } else { |               } else { | ||||||
|                 mutateElement(element, { |                 mutateElement(element, { | ||||||
|                   width, |                   width, | ||||||
|  |                   ...adjustXYWithRotation("e", element, deltaX, 0, angle), | ||||||
|                 }); |                 }); | ||||||
|               } |               } | ||||||
|               break; |               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) { |           if (resizeHandle) { | ||||||
| @@ -2351,6 +2372,7 @@ export class App extends React.Component<any, AppState> { | |||||||
|  |  | ||||||
|       this.setState({ |       this.setState({ | ||||||
|         isResizing: false, |         isResizing: false, | ||||||
|  |         isRotating: false, | ||||||
|         resizingElement: null, |         resizingElement: null, | ||||||
|         selectionElement: null, |         selectionElement: null, | ||||||
|         editingElement: multiElement ? this.state.editingElement : null, |         editingElement: multiElement ? this.state.editingElement : null, | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ interface Hint { | |||||||
| } | } | ||||||
|  |  | ||||||
| const getHints = ({ appState, elements }: Hint) => { | const getHints = ({ appState, elements }: Hint) => { | ||||||
|   const { elementType, isResizing } = appState; |   const { elementType, isResizing, isRotating, lastPointerDownWith } = appState; | ||||||
|   const multiMode = appState.multiElement !== null; |   const multiMode = appState.multiElement !== null; | ||||||
|   if (elementType === "arrow" || elementType === "line") { |   if (elementType === "arrow" || elementType === "line") { | ||||||
|     if (!multiMode) { |     if (!multiMode) { | ||||||
| @@ -22,7 +22,7 @@ const getHints = ({ appState, elements }: Hint) => { | |||||||
|     return t("hints.linearElementMulti"); |     return t("hints.linearElementMulti"); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (isResizing) { |   if (isResizing && lastPointerDownWith === "mouse") { | ||||||
|     const selectedElements = getSelectedElements(elements, appState); |     const selectedElements = getSelectedElements(elements, appState); | ||||||
|     const targetElement = selectedElements[0]; |     const targetElement = selectedElements[0]; | ||||||
|     if (isLinearElement(targetElement) && targetElement.points.length > 2) { |     if (isLinearElement(targetElement) && targetElement.points.length > 2) { | ||||||
| @@ -31,6 +31,10 @@ const getHints = ({ appState, elements }: Hint) => { | |||||||
|     return t("hints.resize"); |     return t("hints.resize"); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   if (isRotating && lastPointerDownWith === "mouse") { | ||||||
|  |     return t("hints.rotate"); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   return null; |   return null; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -70,6 +70,7 @@ export function restore( | |||||||
|           element.opacity === null || element.opacity === undefined |           element.opacity === null || element.opacity === undefined | ||||||
|             ? 100 |             ? 100 | ||||||
|             : element.opacity, |             : element.opacity, | ||||||
|  |         angle: element.angle ?? 0, | ||||||
|       }; |       }; | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -194,10 +194,17 @@ export function getCommonBounds(elements: readonly ExcalidrawElement[]) { | |||||||
|  |  | ||||||
|   elements.forEach((element) => { |   elements.forEach((element) => { | ||||||
|     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); |     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); | ||||||
|     minX = Math.min(minX, x1); |     const angle = element.angle; | ||||||
|     minY = Math.min(minY, y1); |     const cx = (x1 + x2) / 2; | ||||||
|     maxX = Math.max(maxX, x2); |     const cy = (y1 + y2) / 2; | ||||||
|     maxY = Math.max(maxY, y2); |     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]; |   return [minX, minY, maxX, maxY]; | ||||||
|   | |||||||
| @@ -2,16 +2,13 @@ import { distanceBetweenPointAndSegment } from "../math"; | |||||||
|  |  | ||||||
| import { ExcalidrawElement } from "./types"; | import { ExcalidrawElement } from "./types"; | ||||||
|  |  | ||||||
| import { | import { getDiamondPoints, getElementAbsoluteCoords } from "./bounds"; | ||||||
|   getDiamondPoints, |  | ||||||
|   getElementAbsoluteCoords, |  | ||||||
|   getLinearElementAbsoluteBounds, |  | ||||||
| } from "./bounds"; |  | ||||||
| import { Point } from "../types"; | import { Point } from "../types"; | ||||||
| import { Drawable, OpSet } from "roughjs/bin/core"; | import { Drawable, OpSet } from "roughjs/bin/core"; | ||||||
| import { AppState } from "../types"; | import { AppState } from "../types"; | ||||||
| import { getShapeForElement } from "../renderer/renderElement"; | import { getShapeForElement } from "../renderer/renderElement"; | ||||||
| import { isLinearElement } from "./typeChecks"; | import { isLinearElement } from "./typeChecks"; | ||||||
|  | import { rotate } from "../math"; | ||||||
|  |  | ||||||
| function isElementDraggableFromInside( | function isElementDraggableFromInside( | ||||||
|   element: ExcalidrawElement, |   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 |   // of the click is less than x pixels of any of the lines that the shape is composed of | ||||||
|   const lineThreshold = 10 / zoom; |   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") { |   if (element.type === "ellipse") { | ||||||
|     // https://stackoverflow.com/a/46007540/232122 |     // https://stackoverflow.com/a/46007540/232122 | ||||||
|     const px = Math.abs(x - element.x - element.width / 2); |     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; |     return Math.hypot(a * tx - px, b * ty - py) < lineThreshold; | ||||||
|   } else if (element.type === "rectangle") { |   } else if (element.type === "rectangle") { | ||||||
|     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); |  | ||||||
|  |  | ||||||
|     if (isElementDraggableFromInside(element, appState)) { |     if (isElementDraggableFromInside(element, appState)) { | ||||||
|       return ( |       return ( | ||||||
|         x > x1 - lineThreshold && |         x > x1 - lineThreshold && | ||||||
| @@ -165,7 +166,6 @@ export function hitTest( | |||||||
|     } |     } | ||||||
|     const shape = getShapeForElement(element) as Drawable[]; |     const shape = getShapeForElement(element) as Drawable[]; | ||||||
|  |  | ||||||
|     const [x1, y1, x2, y2] = getLinearElementAbsoluteBounds(element); |  | ||||||
|     if ( |     if ( | ||||||
|       x < x1 - lineThreshold || |       x < x1 - lineThreshold || | ||||||
|       y < y1 - lineThreshold || |       y < y1 - lineThreshold || | ||||||
| @@ -183,8 +183,6 @@ export function hitTest( | |||||||
|       hitTestRoughShape(subshape.sets, relX, relY, lineThreshold), |       hitTestRoughShape(subshape.sets, relX, relY, lineThreshold), | ||||||
|     ); |     ); | ||||||
|   } else if (element.type === "text") { |   } else if (element.type === "text") { | ||||||
|     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); |  | ||||||
|  |  | ||||||
|     return x >= x1 && x <= x2 && y >= y1 && y <= y2; |     return x >= x1 && x <= x2 && y >= y1 && y <= y2; | ||||||
|   } else if (element.type === "selection") { |   } else if (element.type === "selection") { | ||||||
|     console.warn("This should not happen, we need to investigate why it does."); |     console.warn("This should not happen, we need to investigate why it does."); | ||||||
|   | |||||||
| @@ -1,8 +1,9 @@ | |||||||
| import { ExcalidrawElement, PointerType } from "./types"; | import { ExcalidrawElement, PointerType } from "./types"; | ||||||
|  |  | ||||||
| import { getElementAbsoluteCoords } from "./bounds"; | 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 } = { | const handleSizes: { [k in PointerType]: number } = { | ||||||
|   mouse: 8, |   mouse: 8, | ||||||
| @@ -10,6 +11,21 @@ const handleSizes: { [k in PointerType]: number } = { | |||||||
|   touch: 28, |   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( | export function handlerRectangles( | ||||||
|   element: ExcalidrawElement, |   element: ExcalidrawElement, | ||||||
|   zoom: number, |   zoom: number, | ||||||
| @@ -28,67 +44,107 @@ export function handlerRectangles( | |||||||
|  |  | ||||||
|   const elementWidth = elementX2 - elementX1; |   const elementWidth = elementX2 - elementX1; | ||||||
|   const elementHeight = elementY2 - elementY1; |   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 = { | ||||||
|     nw: [ |     nw: generateHandler( | ||||||
|       elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, |       elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, | ||||||
|       elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, |       elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, | ||||||
|       handlerWidth, |       handlerWidth, | ||||||
|       handlerHeight, |       handlerHeight, | ||||||
|     ], |       cx, | ||||||
|     ne: [ |       cy, | ||||||
|  |       angle, | ||||||
|  |     ), | ||||||
|  |     ne: generateHandler( | ||||||
|       elementX2 + dashedLineMargin - centeringOffset, |       elementX2 + dashedLineMargin - centeringOffset, | ||||||
|       elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, |       elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, | ||||||
|       handlerWidth, |       handlerWidth, | ||||||
|       handlerHeight, |       handlerHeight, | ||||||
|     ], |       cx, | ||||||
|     sw: [ |       cy, | ||||||
|  |       angle, | ||||||
|  |     ), | ||||||
|  |     sw: generateHandler( | ||||||
|       elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, |       elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, | ||||||
|       elementY2 + dashedLineMargin - centeringOffset, |       elementY2 + dashedLineMargin - centeringOffset, | ||||||
|       handlerWidth, |       handlerWidth, | ||||||
|       handlerHeight, |       handlerHeight, | ||||||
|     ], |       cx, | ||||||
|     se: [ |       cy, | ||||||
|  |       angle, | ||||||
|  |     ), | ||||||
|  |     se: generateHandler( | ||||||
|       elementX2 + dashedLineMargin - centeringOffset, |       elementX2 + dashedLineMargin - centeringOffset, | ||||||
|       elementY2 + dashedLineMargin - centeringOffset, |       elementY2 + dashedLineMargin - centeringOffset, | ||||||
|       handlerWidth, |       handlerWidth, | ||||||
|       handlerHeight, |       handlerHeight, | ||||||
|     ], |       cx, | ||||||
|   } as { [T in Sides]: number[] }; |       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 |   // 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(elementWidth) > minimumSizeForEightHandlers) { | ||||||
|     handlers["n"] = [ |     handlers["n"] = generateHandler( | ||||||
|       elementX1 + elementWidth / 2 - handlerWidth / 2, |       elementX1 + elementWidth / 2 - handlerWidth / 2, | ||||||
|       elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, |       elementY1 - dashedLineMargin - handlerMarginY + centeringOffset, | ||||||
|       handlerWidth, |       handlerWidth, | ||||||
|       handlerHeight, |       handlerHeight, | ||||||
|     ]; |       cx, | ||||||
|     handlers["s"] = [ |       cy, | ||||||
|  |       angle, | ||||||
|  |     ); | ||||||
|  |     handlers["s"] = generateHandler( | ||||||
|       elementX1 + elementWidth / 2 - handlerWidth / 2, |       elementX1 + elementWidth / 2 - handlerWidth / 2, | ||||||
|       elementY2 + dashedLineMargin - centeringOffset, |       elementY2 + dashedLineMargin - centeringOffset, | ||||||
|       handlerWidth, |       handlerWidth, | ||||||
|       handlerHeight, |       handlerHeight, | ||||||
|     ]; |       cx, | ||||||
|  |       cy, | ||||||
|  |       angle, | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
|   if (Math.abs(elementHeight) > minimumSizeForEightHandlers) { |   if (Math.abs(elementHeight) > minimumSizeForEightHandlers) { | ||||||
|     handlers["w"] = [ |     handlers["w"] = generateHandler( | ||||||
|       elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, |       elementX1 - dashedLineMargin - handlerMarginX + centeringOffset, | ||||||
|       elementY1 + elementHeight / 2 - handlerHeight / 2, |       elementY1 + elementHeight / 2 - handlerHeight / 2, | ||||||
|       handlerWidth, |       handlerWidth, | ||||||
|       handlerHeight, |       handlerHeight, | ||||||
|     ]; |       cx, | ||||||
|     handlers["e"] = [ |       cy, | ||||||
|  |       angle, | ||||||
|  |     ); | ||||||
|  |     handlers["e"] = generateHandler( | ||||||
|       elementX2 + dashedLineMargin - centeringOffset, |       elementX2 + dashedLineMargin - centeringOffset, | ||||||
|       elementY1 + elementHeight / 2 - handlerHeight / 2, |       elementY1 + elementHeight / 2 - handlerHeight / 2, | ||||||
|       handlerWidth, |       handlerWidth, | ||||||
|       handlerHeight, |       handlerHeight, | ||||||
|     ]; |       cx, | ||||||
|  |       cy, | ||||||
|  |       angle, | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (element.type === "arrow" || element.type === "line") { |   if (element.type === "arrow" || element.type === "line") { | ||||||
|   | |||||||
| @@ -18,6 +18,7 @@ type ElementConstructorOpts = { | |||||||
|   opacity: ExcalidrawGenericElement["opacity"]; |   opacity: ExcalidrawGenericElement["opacity"]; | ||||||
|   width?: ExcalidrawGenericElement["width"]; |   width?: ExcalidrawGenericElement["width"]; | ||||||
|   height?: ExcalidrawGenericElement["height"]; |   height?: ExcalidrawGenericElement["height"]; | ||||||
|  |   angle?: ExcalidrawGenericElement["angle"]; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| function _newElementBase<T extends ExcalidrawElement>( | function _newElementBase<T extends ExcalidrawElement>( | ||||||
| @@ -33,6 +34,7 @@ function _newElementBase<T extends ExcalidrawElement>( | |||||||
|     opacity, |     opacity, | ||||||
|     width = 0, |     width = 0, | ||||||
|     height = 0, |     height = 0, | ||||||
|  |     angle = 0, | ||||||
|     ...rest |     ...rest | ||||||
|   }: ElementConstructorOpts & Partial<ExcalidrawGenericElement>, |   }: ElementConstructorOpts & Partial<ExcalidrawGenericElement>, | ||||||
| ) { | ) { | ||||||
| @@ -43,6 +45,7 @@ function _newElementBase<T extends ExcalidrawElement>( | |||||||
|     y, |     y, | ||||||
|     width, |     width, | ||||||
|     height, |     height, | ||||||
|  |     angle, | ||||||
|     strokeColor, |     strokeColor, | ||||||
|     backgroundColor, |     backgroundColor, | ||||||
|     fillStyle, |     fillStyle, | ||||||
|   | |||||||
| @@ -6,6 +6,19 @@ import { isLinearElement } from "./typeChecks"; | |||||||
|  |  | ||||||
| type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>; | 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( | export function resizeTest( | ||||||
|   element: ExcalidrawElement, |   element: ExcalidrawElement, | ||||||
|   appState: AppState, |   appState: AppState, | ||||||
| @@ -14,24 +27,31 @@ export function resizeTest( | |||||||
|   zoom: number, |   zoom: number, | ||||||
|   pointerType: PointerType, |   pointerType: PointerType, | ||||||
| ): HandlerRectanglesRet | false { | ): HandlerRectanglesRet | false { | ||||||
|   if (!appState.selectedElementIds[element.id] || element.type === "text") { |   if (!appState.selectedElementIds[element.id]) { | ||||||
|     return false; |     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 filter = Object.keys(handlers).filter((key) => { | ||||||
|     const handler = handlers[key as HandlerRectanglesRet]!; |     const handler = handlers[key as Exclude<HandlerRectanglesRet, "rotation">]!; | ||||||
|     if (!handler) { |     if (!handler) { | ||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
|  |     return isInHandlerRect(handler, x, y); | ||||||
|     return ( |  | ||||||
|       x >= handler[0] && |  | ||||||
|       x <= handler[0] + handler[2] && |  | ||||||
|       y >= handler[1] && |  | ||||||
|       y <= handler[1] + handler[3] |  | ||||||
|     ); |  | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   if (filter.length > 0) { |   if (filter.length > 0) { | ||||||
| @@ -94,6 +114,9 @@ export function getCursorForResizingElement(resizingElement: { | |||||||
|         cursor = "nesw"; |         cursor = "nesw"; | ||||||
|       } |       } | ||||||
|       break; |       break; | ||||||
|  |     case "rotation": | ||||||
|  |       cursor = "ew"; | ||||||
|  |       break; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return cursor ? `${cursor}-resize` : ""; |   return cursor ? `${cursor}-resize` : ""; | ||||||
|   | |||||||
| @@ -20,6 +20,7 @@ type TextWysiwygParams = { | |||||||
|   font: string; |   font: string; | ||||||
|   opacity: number; |   opacity: number; | ||||||
|   zoom: number; |   zoom: number; | ||||||
|  |   angle: number; | ||||||
|   onSubmit: (text: string) => void; |   onSubmit: (text: string) => void; | ||||||
|   onCancel: () => void; |   onCancel: () => void; | ||||||
| }; | }; | ||||||
| @@ -32,6 +33,7 @@ export function textWysiwyg({ | |||||||
|   font, |   font, | ||||||
|   opacity, |   opacity, | ||||||
|   zoom, |   zoom, | ||||||
|  |   angle, | ||||||
|   onSubmit, |   onSubmit, | ||||||
|   onCancel, |   onCancel, | ||||||
| }: TextWysiwygParams) { | }: TextWysiwygParams) { | ||||||
| @@ -45,13 +47,15 @@ export function textWysiwyg({ | |||||||
|   editable.innerText = initText; |   editable.innerText = initText; | ||||||
|   editable.dataset.type = "wysiwyg"; |   editable.dataset.type = "wysiwyg"; | ||||||
|  |  | ||||||
|  |   const degree = (180 * angle) / Math.PI; | ||||||
|  |  | ||||||
|   Object.assign(editable.style, { |   Object.assign(editable.style, { | ||||||
|     color: strokeColor, |     color: strokeColor, | ||||||
|     position: "fixed", |     position: "fixed", | ||||||
|     opacity: opacity / 100, |     opacity: opacity / 100, | ||||||
|     top: `${y}px`, |     top: `${y}px`, | ||||||
|     left: `${x}px`, |     left: `${x}px`, | ||||||
|     transform: `translate(-50%, -50%) scale(${zoom})`, |     transform: `translate(-50%, -50%) scale(${zoom}) rotate(${degree}deg)`, | ||||||
|     textAlign: "left", |     textAlign: "left", | ||||||
|     display: "inline-block", |     display: "inline-block", | ||||||
|     font: font, |     font: font, | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ type _ExcalidrawElementBase = Readonly<{ | |||||||
|   opacity: number; |   opacity: number; | ||||||
|   width: number; |   width: number; | ||||||
|   height: number; |   height: number; | ||||||
|  |   angle: number; | ||||||
|   seed: number; |   seed: number; | ||||||
|   version: number; |   version: number; | ||||||
|   versionNonce: number; |   versionNonce: number; | ||||||
|   | |||||||
| @@ -98,7 +98,8 @@ | |||||||
|   "hints": { |   "hints": { | ||||||
|     "linearElement": "Click to start multiple points, drag for single line", |     "linearElement": "Click to start multiple points, drag for single line", | ||||||
|     "linearElementMulti": "Click on last point or press Escape or Enter to finish", |     "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": { |   "errorSplash": { | ||||||
|     "headingMain_pre": "Encountered an error. Try ", |     "headingMain_pre": "Encountered an error. Try ", | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								src/math.ts
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								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[]) => { | export const getPointOnAPath = (point: Point, path: Point[]) => { | ||||||
|   const [px, py] = point; |   const [px, py] = point; | ||||||
|   const [start, ...other] = path; |   const [start, ...other] = path; | ||||||
|   | |||||||
| @@ -263,30 +263,24 @@ function drawElementFromCanvas( | |||||||
|   context: CanvasRenderingContext2D, |   context: CanvasRenderingContext2D, | ||||||
|   sceneState: SceneState, |   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.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); | ||||||
|   context.translate( |   context.translate(cx, cy); | ||||||
|     -CANVAS_PADDING / elementWithCanvas.canvasZoom, |   context.rotate(element.angle); | ||||||
|     -CANVAS_PADDING / elementWithCanvas.canvasZoom, |  | ||||||
|   ); |  | ||||||
|   context.drawImage( |   context.drawImage( | ||||||
|     elementWithCanvas.canvas!, |     elementWithCanvas.canvas!, | ||||||
|     Math.floor( |     (-(x2 - x1) / 2) * window.devicePixelRatio - | ||||||
|       -elementWithCanvas.canvasOffsetX + |       CANVAS_PADDING / elementWithCanvas.canvasZoom, | ||||||
|         (Math.floor(elementWithCanvas.element.x) + sceneState.scrollX) * |     (-(y2 - y1) / 2) * window.devicePixelRatio - | ||||||
|           window.devicePixelRatio, |       CANVAS_PADDING / elementWithCanvas.canvasZoom, | ||||||
|     ), |  | ||||||
|     Math.floor( |  | ||||||
|       -elementWithCanvas.canvasOffsetY + |  | ||||||
|         (Math.floor(elementWithCanvas.element.y) + sceneState.scrollY) * |  | ||||||
|           window.devicePixelRatio, |  | ||||||
|     ), |  | ||||||
|     elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom, |     elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom, | ||||||
|     elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom, |     elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom, | ||||||
|   ); |   ); | ||||||
|   context.translate( |   context.rotate(-element.angle); | ||||||
|     CANVAS_PADDING / elementWithCanvas.canvasZoom, |   context.translate(-cx, -cy); | ||||||
|     CANVAS_PADDING / elementWithCanvas.canvasZoom, |  | ||||||
|   ); |  | ||||||
|   context.scale(window.devicePixelRatio, window.devicePixelRatio); |   context.scale(window.devicePixelRatio, window.devicePixelRatio); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -325,11 +319,18 @@ export function renderElement( | |||||||
|       if (renderOptimizations) { |       if (renderOptimizations) { | ||||||
|         drawElementFromCanvas(elementWithCanvas, rc, context, sceneState); |         drawElementFromCanvas(elementWithCanvas, rc, context, sceneState); | ||||||
|       } else { |       } else { | ||||||
|         const offsetX = Math.floor(element.x + sceneState.scrollX); |         const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); | ||||||
|         const offsetY = Math.floor(element.y + sceneState.scrollY); |         const cx = (x1 + x2) / 2 + sceneState.scrollX; | ||||||
|         context.translate(offsetX, offsetY); |         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); |         drawElementOnCanvas(element, rc, context); | ||||||
|         context.translate(-offsetX, -offsetY); |         context.translate(shiftX, shiftY); | ||||||
|  |         context.rotate(-element.angle); | ||||||
|  |         context.translate(-cx, -cy); | ||||||
|       } |       } | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
| @@ -347,6 +348,10 @@ export function renderElementToSvg( | |||||||
|   offsetX?: number, |   offsetX?: number, | ||||||
|   offsetY?: 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; |   const generator = rsvg.generator; | ||||||
|   switch (element.type) { |   switch (element.type) { | ||||||
|     case "selection": { |     case "selection": { | ||||||
| @@ -366,7 +371,9 @@ export function renderElementToSvg( | |||||||
|       } |       } | ||||||
|       node.setAttribute( |       node.setAttribute( | ||||||
|         "transform", |         "transform", | ||||||
|         `translate(${offsetX || 0} ${offsetY || 0})`, |         `translate(${offsetX || 0} ${ | ||||||
|  |           offsetY || 0 | ||||||
|  |         }) rotate(${degree} ${cx} ${cy})`, | ||||||
|       ); |       ); | ||||||
|       svgRoot.appendChild(node); |       svgRoot.appendChild(node); | ||||||
|       break; |       break; | ||||||
| @@ -384,7 +391,9 @@ export function renderElementToSvg( | |||||||
|         } |         } | ||||||
|         node.setAttribute( |         node.setAttribute( | ||||||
|           "transform", |           "transform", | ||||||
|           `translate(${offsetX || 0} ${offsetY || 0})`, |           `translate(${offsetX || 0} ${ | ||||||
|  |             offsetY || 0 | ||||||
|  |           }) rotate(${degree} ${cx} ${cy})`, | ||||||
|         ); |         ); | ||||||
|         group.appendChild(node); |         group.appendChild(node); | ||||||
|       }); |       }); | ||||||
| @@ -401,7 +410,9 @@ export function renderElementToSvg( | |||||||
|         } |         } | ||||||
|         node.setAttribute( |         node.setAttribute( | ||||||
|           "transform", |           "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 lines = element.text.replace(/\r\n?/g, "\n").split("\n"); | ||||||
|         const lineHeight = element.height / lines.length; |         const lineHeight = element.height / lines.length; | ||||||
|   | |||||||
| @@ -17,6 +17,8 @@ import { getSelectedElements } from "../scene/selection"; | |||||||
| import { renderElement, renderElementToSvg } from "./renderElement"; | import { renderElement, renderElementToSvg } from "./renderElement"; | ||||||
| import colors from "../colors"; | import colors from "../colors"; | ||||||
|  |  | ||||||
|  | type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>; | ||||||
|  |  | ||||||
| function colorsForClientId(clientId: string) { | function colorsForClientId(clientId: string) { | ||||||
|   // Naive way of getting an integer out of the clientId |   // Naive way of getting an integer out of the clientId | ||||||
|   const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0); |   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( | export function renderScene( | ||||||
|   allElements: readonly ExcalidrawElement[], |   allElements: readonly ExcalidrawElement[], | ||||||
|   appState: AppState, |   appState: AppState, | ||||||
| @@ -113,7 +149,7 @@ export function renderScene( | |||||||
|   // Pain selected elements |   // Pain selected elements | ||||||
|   if (renderSelection) { |   if (renderSelection) { | ||||||
|     const selectedElements = getSelectedElements(elements, appState); |     const selectedElements = getSelectedElements(elements, appState); | ||||||
|     const dashledLinePadding = 4 / sceneState.zoom; |     const dashedLinePadding = 4 / sceneState.zoom; | ||||||
|  |  | ||||||
|     context.translate(sceneState.scrollX, sceneState.scrollY); |     context.translate(sceneState.scrollX, sceneState.scrollY); | ||||||
|     selectedElements.forEach((element) => { |     selectedElements.forEach((element) => { | ||||||
| @@ -131,11 +167,15 @@ export function renderScene( | |||||||
|       context.setLineDash([8 / sceneState.zoom, 4 / sceneState.zoom]); |       context.setLineDash([8 / sceneState.zoom, 4 / sceneState.zoom]); | ||||||
|       const lineWidth = context.lineWidth; |       const lineWidth = context.lineWidth; | ||||||
|       context.lineWidth = 1 / sceneState.zoom; |       context.lineWidth = 1 / sceneState.zoom; | ||||||
|       context.strokeRect( |       strokeRectWithRotation( | ||||||
|         elementX1 - dashledLinePadding, |         context, | ||||||
|         elementY1 - dashledLinePadding, |         elementX1 - dashedLinePadding, | ||||||
|         elementWidth + dashledLinePadding * 2, |         elementY1 - dashedLinePadding, | ||||||
|         elementHeight + dashledLinePadding * 2, |         elementWidth + dashedLinePadding * 2, | ||||||
|  |         elementHeight + dashedLinePadding * 2, | ||||||
|  |         elementX1 + elementWidth / 2, | ||||||
|  |         elementY1 + elementHeight / 2, | ||||||
|  |         element.angle, | ||||||
|       ); |       ); | ||||||
|       context.lineWidth = lineWidth; |       context.lineWidth = lineWidth; | ||||||
|       context.setLineDash(initialLineDash); |       context.setLineDash(initialLineDash); | ||||||
| @@ -143,19 +183,39 @@ export function renderScene( | |||||||
|     context.translate(-sceneState.scrollX, -sceneState.scrollY); |     context.translate(-sceneState.scrollX, -sceneState.scrollY); | ||||||
|  |  | ||||||
|     // Paint resize handlers |     // Paint resize handlers | ||||||
|     if (selectedElements.length === 1 && selectedElements[0].type !== "text") { |     if (selectedElements.length === 1) { | ||||||
|       context.translate(sceneState.scrollX, sceneState.scrollY); |       context.translate(sceneState.scrollX, sceneState.scrollY); | ||||||
|       context.fillStyle = "#fff"; |       context.fillStyle = "#fff"; | ||||||
|       const handlers = handlerRectangles(selectedElements[0], sceneState.zoom); |       const handlers = handlerRectangles(selectedElements[0], sceneState.zoom); | ||||||
|       Object.values(handlers) |       Object.keys(handlers).forEach((key) => { | ||||||
|         .filter((handler) => handler !== undefined) |         const handler = handlers[key as HandlerRectanglesRet]; | ||||||
|         .forEach((handler) => { |         if (handler !== undefined) { | ||||||
|           const lineWidth = context.lineWidth; |           const lineWidth = context.lineWidth; | ||||||
|           context.lineWidth = 1 / sceneState.zoom; |           context.lineWidth = 1 / sceneState.zoom; | ||||||
|           context.fillRect(handler[0], handler[1], handler[2], handler[3]); |           if (key === "rotation") { | ||||||
|           context.strokeRect(handler[0], handler[1], handler[2], handler[3]); |             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.lineWidth = lineWidth; | ||||||
|         }); |         } | ||||||
|  |       }); | ||||||
|       context.translate(-sceneState.scrollX, -sceneState.scrollY); |       context.translate(-sceneState.scrollX, -sceneState.scrollY); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -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`] = ` | exports[`add element to the scene when pointer dragging long enough arrow 2`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "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`] = ` | exports[`add element to the scene when pointer dragging long enough diamond 2`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "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`] = ` | exports[`add element to the scene when pointer dragging long enough ellipse 2`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
| @@ -82,6 +85,7 @@ Object { | |||||||
|  |  | ||||||
| exports[`add element to the scene when pointer dragging long enough line 1`] = ` | exports[`add element to the scene when pointer dragging long enough line 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "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`] = ` | exports[`add element to the scene when pointer dragging long enough rectangle 2`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ | |||||||
|  |  | ||||||
| exports[`duplicate element on move when ALT is clicked rectangle 1`] = ` | exports[`duplicate element on move when ALT is clicked rectangle 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
| @@ -23,6 +24,7 @@ Object { | |||||||
|  |  | ||||||
| exports[`duplicate element on move when ALT is clicked rectangle 2`] = ` | exports[`duplicate element on move when ALT is clicked rectangle 2`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
| @@ -44,6 +46,7 @@ Object { | |||||||
|  |  | ||||||
| exports[`move element rectangle 1`] = ` | exports[`move element rectangle 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ | |||||||
|  |  | ||||||
| exports[`multi point mode in linear elements arrow 1`] = ` | exports[`multi point mode in linear elements arrow 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 110, |   "height": 110, | ||||||
| @@ -41,6 +42,7 @@ Object { | |||||||
|  |  | ||||||
| exports[`multi point mode in linear elements line 1`] = ` | exports[`multi point mode in linear elements line 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 110, |   "height": 110, | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -2,6 +2,7 @@ | |||||||
|  |  | ||||||
| exports[`resize element rectangle 1`] = ` | exports[`resize element rectangle 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
| @@ -23,6 +24,7 @@ Object { | |||||||
|  |  | ||||||
| exports[`resize element with aspect ratio when SHIFT is clicked rectangle 1`] = ` | exports[`resize element with aspect ratio when SHIFT is clicked rectangle 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ | |||||||
|  |  | ||||||
| exports[`select single element on the scene arrow 1`] = ` | exports[`select single element on the scene arrow 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
| @@ -34,6 +35,7 @@ Object { | |||||||
|  |  | ||||||
| exports[`select single element on the scene arrow escape 1`] = ` | exports[`select single element on the scene arrow escape 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
| @@ -66,6 +68,7 @@ Object { | |||||||
|  |  | ||||||
| exports[`select single element on the scene diamond 1`] = ` | exports[`select single element on the scene diamond 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
| @@ -87,6 +90,7 @@ Object { | |||||||
|  |  | ||||||
| exports[`select single element on the scene ellipse 1`] = ` | exports[`select single element on the scene ellipse 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
| @@ -108,6 +112,7 @@ Object { | |||||||
|  |  | ||||||
| exports[`select single element on the scene rectangle 1`] = ` | exports[`select single element on the scene rectangle 1`] = ` | ||||||
| Object { | Object { | ||||||
|  |   "angle": 0, | ||||||
|   "backgroundColor": "transparent", |   "backgroundColor": "transparent", | ||||||
|   "fillStyle": "hachure", |   "fillStyle": "hachure", | ||||||
|   "height": 50, |   "height": 50, | ||||||
|   | |||||||
| @@ -322,6 +322,7 @@ describe("regression tests", () => { | |||||||
|     pointerUp(); |     pointerUp(); | ||||||
|  |  | ||||||
|     const resizeHandles = getResizeHandles(); |     const resizeHandles = getResizeHandles(); | ||||||
|  |     delete resizeHandles.rotation; // exclude rotation handle | ||||||
|     for (const handlePos in resizeHandles) { |     for (const handlePos in resizeHandles) { | ||||||
|       const [x, y] = resizeHandles[handlePos as keyof typeof resizeHandles]; |       const [x, y] = resizeHandles[handlePos as keyof typeof resizeHandles]; | ||||||
|       const { width: prevWidth, height: prevHeight } = getSelectedElement(); |       const { width: prevWidth, height: prevHeight } = getSelectedElement(); | ||||||
|   | |||||||
| @@ -37,6 +37,7 @@ export type AppState = { | |||||||
|   name: string; |   name: string; | ||||||
|   isCollaborating: boolean; |   isCollaborating: boolean; | ||||||
|   isResizing: boolean; |   isResizing: boolean; | ||||||
|  |   isRotating: boolean; | ||||||
|   zoom: number; |   zoom: number; | ||||||
|   openMenu: "canvas" | "shape" | null; |   openMenu: "canvas" | "shape" | null; | ||||||
|   lastPointerDownWith: PointerType; |   lastPointerDownWith: PointerType; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Daishi Kato
					Daishi Kato