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:
Daishi Kato 2020-04-02 17:40:26 +09:00 committed by GitHub
parent 3e3ce18755
commit 65be7973be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 664 additions and 108 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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;
}; };

View File

@ -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,
}; };
}); });

View File

@ -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];

View File

@ -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.");

View File

@ -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") {

View File

@ -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,

View File

@ -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` : "";

View File

@ -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,

View File

@ -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;

View File

@ -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 ",

View File

@ -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;

View File

@ -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;

View File

@ -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);
} }
} }

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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();

View File

@ -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;