mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04: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:
parent
3e3ce18755
commit
65be7973be
23 changed files with 664 additions and 108 deletions
|
@ -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];
|
||||
|
|
|
@ -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.");
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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` : "";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -12,6 +12,7 @@ type _ExcalidrawElementBase = Readonly<{
|
|||
opacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
seed: number;
|
||||
version: number;
|
||||
versionNonce: number;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue