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
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
|
@ -70,6 +70,7 @@ export function restore(
|
||||
element.opacity === null || element.opacity === undefined
|
||||
? 100
|
||||
: element.opacity,
|
||||
angle: element.angle ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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 ",
|
||||
|
27
src/math.ts
27
src/math.ts
@ -55,6 +55,33 @@ export function rotate(
|
||||
];
|
||||
}
|
||||
|
||||
export function adjustXYWithRotation(
|
||||
side: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
|
||||
position: { x: number; y: number },
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
angle: number,
|
||||
) {
|
||||
let { x, y } = position;
|
||||
if (side === "e" || side === "ne" || side === "se") {
|
||||
x -= (deltaX / 2) * (1 - Math.cos(angle));
|
||||
y -= (deltaX / 2) * -Math.sin(angle);
|
||||
}
|
||||
if (side === "s" || side === "sw" || side === "se") {
|
||||
x -= (deltaY / 2) * Math.sin(angle);
|
||||
y -= (deltaY / 2) * (1 - Math.cos(angle));
|
||||
}
|
||||
if (side === "w" || side === "nw" || side === "sw") {
|
||||
x += (deltaX / 2) * (1 + Math.cos(angle));
|
||||
y += (deltaX / 2) * Math.sin(angle);
|
||||
}
|
||||
if (side === "n" || side === "nw" || side === "ne") {
|
||||
x += (deltaY / 2) * -Math.sin(angle);
|
||||
y += (deltaY / 2) * (1 + Math.cos(angle));
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
export const getPointOnAPath = (point: Point, path: Point[]) => {
|
||||
const [px, py] = point;
|
||||
const [start, ...other] = path;
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -37,6 +37,7 @@ export type AppState = {
|
||||
name: string;
|
||||
isCollaborating: boolean;
|
||||
isResizing: boolean;
|
||||
isRotating: boolean;
|
||||
zoom: number;
|
||||
openMenu: "canvas" | "shape" | null;
|
||||
lastPointerDownWith: PointerType;
|
||||
|
Loading…
x
Reference in New Issue
Block a user