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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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