feat: rotating multiple elements (#1960)
This commit is contained in:
parent
ebf2923c5e
commit
a2e7d8d560
@ -205,6 +205,10 @@ type PointerDownState = Readonly<{
|
|||||||
offset: { x: number; y: number };
|
offset: { x: number; y: number };
|
||||||
// This is determined on the initial pointer down event
|
// This is determined on the initial pointer down event
|
||||||
arrowDirection: "origin" | "end";
|
arrowDirection: "origin" | "end";
|
||||||
|
// This is a center point of selected elements determined on the initial pointer down event (for rotation only)
|
||||||
|
center: { x: number; y: number };
|
||||||
|
// This is a list of selected elements determined on the initial pointer down event (for rotation only)
|
||||||
|
originalElements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
};
|
};
|
||||||
hit: {
|
hit: {
|
||||||
// The element the pointer is "hitting", is determined on the initial
|
// The element the pointer is "hitting", is determined on the initial
|
||||||
@ -2213,6 +2217,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.canvas,
|
this.canvas,
|
||||||
window.devicePixelRatio,
|
window.devicePixelRatio,
|
||||||
);
|
);
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
globalSceneState.getElements(),
|
||||||
|
this.state,
|
||||||
|
);
|
||||||
|
const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
origin,
|
origin,
|
||||||
@ -2231,6 +2240,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
isResizing: false,
|
isResizing: false,
|
||||||
offset: { x: 0, y: 0 },
|
offset: { x: 0, y: 0 },
|
||||||
arrowDirection: "origin",
|
arrowDirection: "origin",
|
||||||
|
center: { x: (maxX + minX) / 2, y: (maxY + minY) / 2 },
|
||||||
|
originalElements: selectedElements.map((element) => ({ ...element })),
|
||||||
},
|
},
|
||||||
hit: {
|
hit: {
|
||||||
element: null,
|
element: null,
|
||||||
@ -2709,6 +2720,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
getResizeCenterPointKey(event),
|
getResizeCenterPointKey(event),
|
||||||
resizeX,
|
resizeX,
|
||||||
resizeY,
|
resizeY,
|
||||||
|
pointerDownState.resize.center.x,
|
||||||
|
pointerDownState.resize.center.y,
|
||||||
|
pointerDownState.resize.originalElements,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
@ -18,7 +18,6 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
|||||||
s: true,
|
s: true,
|
||||||
n: true,
|
n: true,
|
||||||
w: true,
|
w: true,
|
||||||
rotation: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
||||||
|
@ -23,18 +23,28 @@ import {
|
|||||||
} from "./resizeTest";
|
} from "./resizeTest";
|
||||||
import { measureText, getFontString } from "../utils";
|
import { measureText, getFontString } from "../utils";
|
||||||
|
|
||||||
|
const normalizeAngle = (angle: number): number => {
|
||||||
|
if (angle >= 2 * Math.PI) {
|
||||||
|
return angle - 2 * Math.PI;
|
||||||
|
}
|
||||||
|
return angle;
|
||||||
|
};
|
||||||
|
|
||||||
type ResizeTestType = ReturnType<typeof resizeTest>;
|
type ResizeTestType = ReturnType<typeof resizeTest>;
|
||||||
|
|
||||||
export const resizeElements = (
|
export const resizeElements = (
|
||||||
resizeHandle: ResizeTestType,
|
resizeHandle: ResizeTestType,
|
||||||
setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
|
setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
|
||||||
selectedElements: NonDeletedExcalidrawElement[],
|
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
resizeArrowDirection: "origin" | "end",
|
resizeArrowDirection: "origin" | "end",
|
||||||
isRotateWithDiscreteAngle: boolean,
|
isRotateWithDiscreteAngle: boolean,
|
||||||
isResizeWithSidesSameLength: boolean,
|
isResizeWithSidesSameLength: boolean,
|
||||||
isResizeCenterPoint: boolean,
|
isResizeCenterPoint: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
originalElements: readonly NonDeletedExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
if (selectedElements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
const [element] = selectedElements;
|
const [element] = selectedElements;
|
||||||
@ -100,15 +110,32 @@ export const resizeElements = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else if (
|
} else if (selectedElements.length > 1) {
|
||||||
selectedElements.length > 1 &&
|
if (resizeHandle === "rotation") {
|
||||||
(resizeHandle === "nw" ||
|
rotateMultipleElements(
|
||||||
|
selectedElements,
|
||||||
|
pointerX,
|
||||||
|
pointerY,
|
||||||
|
isRotateWithDiscreteAngle,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
originalElements,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} else if (
|
||||||
|
resizeHandle === "nw" ||
|
||||||
resizeHandle === "ne" ||
|
resizeHandle === "ne" ||
|
||||||
resizeHandle === "sw" ||
|
resizeHandle === "sw" ||
|
||||||
resizeHandle === "se")
|
resizeHandle === "se"
|
||||||
) {
|
) {
|
||||||
resizeMultipleElements(selectedElements, resizeHandle, pointerX, pointerY);
|
resizeMultipleElements(
|
||||||
return true;
|
selectedElements,
|
||||||
|
resizeHandle,
|
||||||
|
pointerX,
|
||||||
|
pointerY,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@ -127,9 +154,7 @@ const rotateSingleElement = (
|
|||||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||||
}
|
}
|
||||||
if (angle >= 2 * Math.PI) {
|
angle = normalizeAngle(angle);
|
||||||
angle -= 2 * Math.PI;
|
|
||||||
}
|
|
||||||
mutateElement(element, { angle });
|
mutateElement(element, { angle });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -536,6 +561,85 @@ const resizeMultipleElements = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rotateMultipleElements = (
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
pointerX: number,
|
||||||
|
pointerY: number,
|
||||||
|
isRotateWithDiscreteAngle: boolean,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
originalElements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
let centerAngle =
|
||||||
|
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
||||||
|
if (isRotateWithDiscreteAngle) {
|
||||||
|
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||||
|
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||||
|
}
|
||||||
|
elements.forEach((element, index) => {
|
||||||
|
if (isLinearElement(element) && element.points.length === 2) {
|
||||||
|
// FIXME this is a bit tricky (how can we make this more readable?)
|
||||||
|
const originalElement = originalElements[index];
|
||||||
|
if (
|
||||||
|
!isLinearElement(originalElement) ||
|
||||||
|
originalElement.points.length !== 2
|
||||||
|
) {
|
||||||
|
throw new Error("original element not compatible"); // should not happen
|
||||||
|
}
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(originalElement);
|
||||||
|
const cx = (x1 + x2) / 2;
|
||||||
|
const cy = (y1 + y2) / 2;
|
||||||
|
const [rotatedCX, rotatedCY] = rotate(
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
centerAngle,
|
||||||
|
);
|
||||||
|
const { points } = originalElement;
|
||||||
|
const [rotatedX, rotatedY] = rotate(
|
||||||
|
points[1][0],
|
||||||
|
points[1][1],
|
||||||
|
points[0][0],
|
||||||
|
points[0][1],
|
||||||
|
centerAngle,
|
||||||
|
);
|
||||||
|
mutateElement(element, {
|
||||||
|
x:
|
||||||
|
originalElement.x +
|
||||||
|
(rotatedCX - cx) +
|
||||||
|
((originalElement.points[0][0] + originalElement.points[1][0]) / 2 -
|
||||||
|
(points[0][0] + rotatedX) / 2),
|
||||||
|
y:
|
||||||
|
originalElement.y +
|
||||||
|
(rotatedCY - cy) +
|
||||||
|
((originalElement.points[0][1] + originalElement.points[1][1]) / 2 -
|
||||||
|
(points[0][1] + rotatedY) / 2),
|
||||||
|
points: [
|
||||||
|
[points[0][0], points[0][1]],
|
||||||
|
[rotatedX, rotatedY],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
|
const cx = (x1 + x2) / 2;
|
||||||
|
const cy = (y1 + y2) / 2;
|
||||||
|
const [rotatedCX, rotatedCY] = rotate(
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
centerAngle + originalElements[index].angle - element.angle,
|
||||||
|
);
|
||||||
|
mutateElement(element, {
|
||||||
|
x: element.x + (rotatedCX - cx),
|
||||||
|
y: element.y + (rotatedCY - cy),
|
||||||
|
angle: normalizeAngle(centerAngle + originalElements[index].angle),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getResizeOffsetXY = (
|
export const getResizeOffsetXY = (
|
||||||
resizeHandle: ResizeTestType,
|
resizeHandle: ResizeTestType,
|
||||||
selectedElements: NonDeletedExcalidrawElement[],
|
selectedElements: NonDeletedExcalidrawElement[],
|
||||||
|
@ -399,7 +399,7 @@ export const renderScene = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
context.translate(-sceneState.scrollX, -sceneState.scrollY);
|
context.translate(-sceneState.scrollX, -sceneState.scrollY);
|
||||||
} else if (locallySelectedElements.length > 1) {
|
} else if (locallySelectedElements.length > 1 && !appState.isRotating) {
|
||||||
const dashedLinePadding = 4 / sceneState.zoom;
|
const dashedLinePadding = 4 / sceneState.zoom;
|
||||||
context.translate(sceneState.scrollX, sceneState.scrollY);
|
context.translate(sceneState.scrollX, sceneState.scrollY);
|
||||||
context.fillStyle = oc.white;
|
context.fillStyle = oc.white;
|
||||||
@ -432,17 +432,27 @@ export const renderScene = (
|
|||||||
if (handler !== undefined) {
|
if (handler !== undefined) {
|
||||||
const lineWidth = context.lineWidth;
|
const lineWidth = context.lineWidth;
|
||||||
context.lineWidth = 1 / sceneState.zoom;
|
context.lineWidth = 1 / sceneState.zoom;
|
||||||
strokeRectWithRotation(
|
if (key === "rotation") {
|
||||||
context,
|
strokeCircle(
|
||||||
handler[0],
|
context,
|
||||||
handler[1],
|
handler[0],
|
||||||
handler[2],
|
handler[1],
|
||||||
handler[3],
|
handler[2],
|
||||||
handler[0] + handler[2] / 2,
|
handler[3],
|
||||||
handler[1] + handler[3] / 2,
|
);
|
||||||
0,
|
} else {
|
||||||
true, // fill before stroke
|
strokeRectWithRotation(
|
||||||
);
|
context,
|
||||||
|
handler[0],
|
||||||
|
handler[1],
|
||||||
|
handler[2],
|
||||||
|
handler[3],
|
||||||
|
handler[0] + handler[2] / 2,
|
||||||
|
handler[1] + handler[3] / 2,
|
||||||
|
0,
|
||||||
|
true, // fill before stroke
|
||||||
|
);
|
||||||
|
}
|
||||||
context.lineWidth = lineWidth;
|
context.lineWidth = lineWidth;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user