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()}`,
isCollaborating: false,
isResizing: false,
isRotating: false,
selectionElement: null,
zoom: 1,
openMenu: null,
@ -47,6 +48,7 @@ export function clearAppStateForLocalStorage(appState: AppState) {
editingElement,
selectionElement,
isResizing,
isRotating,
collaborators,
isCollaborating,
isLoading,

View File

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

View File

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

View File

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

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;

View File

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

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[]) => {
const [px, py] = point;
const [start, ...other] = path;

View File

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

View File

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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