123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702 |
- import { SHIFT_LOCKING_ANGLE } from "../constants";
- import { rescalePoints } from "../points";
- import { rotate, adjustXYWithRotation, getFlipAdjustment } from "../math";
- import {
- ExcalidrawLinearElement,
- ExcalidrawTextElement,
- NonDeletedExcalidrawElement,
- NonDeleted,
- } from "./types";
- import {
- getElementAbsoluteCoords,
- getCommonBounds,
- getResizedElementAbsoluteCoords,
- } from "./bounds";
- import { isLinearElement } from "./typeChecks";
- import { mutateElement } from "./mutateElement";
- import { getPerfectElementSize } from "./sizeHelpers";
- import {
- getCursorForResizingElement,
- normalizeTransformHandleType,
- } from "./resizeTest";
- import { measureText, getFontString } from "../utils";
- import { updateBoundElements } from "./binding";
- import {
- TransformHandleType,
- MaybeTransformHandleType,
- } from "./transformHandles";
- const normalizeAngle = (angle: number): number => {
- if (angle >= 2 * Math.PI) {
- return angle - 2 * Math.PI;
- }
- return angle;
- };
- // Returns true when transform (resizing/rotation) happened
- export const transformElements = (
- transformHandleType: MaybeTransformHandleType,
- setTransformHandle: (nextTransformHandle: MaybeTransformHandleType) => void,
- selectedElements: readonly NonDeletedExcalidrawElement[],
- resizeArrowDirection: "origin" | "end",
- isRotateWithDiscreteAngle: boolean,
- isResizeWithSidesSameLength: boolean,
- isResizeCenterPoint: boolean,
- pointerX: number,
- pointerY: number,
- centerX: number,
- centerY: number,
- originalElements: readonly NonDeletedExcalidrawElement[],
- ) => {
- if (selectedElements.length === 1) {
- const [element] = selectedElements;
- if (transformHandleType === "rotation") {
- rotateSingleElement(
- element,
- pointerX,
- pointerY,
- isRotateWithDiscreteAngle,
- );
- updateBoundElements(element);
- } else if (
- isLinearElement(element) &&
- element.points.length === 2 &&
- (transformHandleType === "nw" ||
- transformHandleType === "ne" ||
- transformHandleType === "sw" ||
- transformHandleType === "se")
- ) {
- reshapeSingleTwoPointElement(
- element,
- resizeArrowDirection,
- isRotateWithDiscreteAngle,
- pointerX,
- pointerY,
- );
- } else if (
- element.type === "text" &&
- (transformHandleType === "nw" ||
- transformHandleType === "ne" ||
- transformHandleType === "sw" ||
- transformHandleType === "se")
- ) {
- resizeSingleTextElement(
- element,
- transformHandleType,
- isResizeCenterPoint,
- pointerX,
- pointerY,
- );
- updateBoundElements(element);
- } else if (transformHandleType) {
- resizeSingleElement(
- element,
- transformHandleType,
- isResizeWithSidesSameLength,
- isResizeCenterPoint,
- pointerX,
- pointerY,
- );
- setTransformHandle(
- normalizeTransformHandleType(element, transformHandleType),
- );
- if (element.width < 0) {
- mutateElement(element, { width: -element.width });
- }
- if (element.height < 0) {
- mutateElement(element, { height: -element.height });
- }
- }
- // update cursor
- // FIXME it is not very nice to have this here
- document.documentElement.style.cursor = getCursorForResizingElement({
- element,
- transformHandleType,
- });
- return true;
- } else if (selectedElements.length > 1) {
- if (transformHandleType === "rotation") {
- rotateMultipleElements(
- selectedElements,
- pointerX,
- pointerY,
- isRotateWithDiscreteAngle,
- centerX,
- centerY,
- originalElements,
- );
- return true;
- } else if (
- transformHandleType === "nw" ||
- transformHandleType === "ne" ||
- transformHandleType === "sw" ||
- transformHandleType === "se"
- ) {
- resizeMultipleElements(
- selectedElements,
- transformHandleType,
- pointerX,
- pointerY,
- );
- return true;
- }
- }
- return false;
- };
- const rotateSingleElement = (
- element: NonDeletedExcalidrawElement,
- pointerX: number,
- pointerY: number,
- isRotateWithDiscreteAngle: boolean,
- ) => {
- 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(pointerY - cy, pointerX - cx);
- if (isRotateWithDiscreteAngle) {
- angle += SHIFT_LOCKING_ANGLE / 2;
- angle -= angle % SHIFT_LOCKING_ANGLE;
- }
- angle = normalizeAngle(angle);
- mutateElement(element, { angle });
- };
- // used in DEV only
- const validateTwoPointElementNormalized = (
- element: NonDeleted<ExcalidrawLinearElement>,
- ) => {
- if (
- element.points.length !== 2 ||
- element.points[0][0] !== 0 ||
- element.points[0][1] !== 0 ||
- Math.abs(element.points[1][0]) !== element.width ||
- Math.abs(element.points[1][1]) !== element.height
- ) {
- throw new Error("Two-point element is not normalized");
- }
- };
- const getPerfectElementSizeWithRotation = (
- elementType: string,
- width: number,
- height: number,
- angle: number,
- ): [number, number] => {
- const size = getPerfectElementSize(
- elementType,
- ...rotate(width, height, 0, 0, angle),
- );
- return rotate(size.width, size.height, 0, 0, -angle);
- };
- const reshapeSingleTwoPointElement = (
- element: NonDeleted<ExcalidrawLinearElement>,
- resizeArrowDirection: "origin" | "end",
- isRotateWithDiscreteAngle: boolean,
- pointerX: number,
- pointerY: number,
- ) => {
- if (process.env.NODE_ENV !== "production") {
- validateTwoPointElementNormalized(element);
- }
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
- const cx = (x1 + x2) / 2;
- const cy = (y1 + y2) / 2;
- // rotation pointer with reverse angle
- const [rotatedX, rotatedY] = rotate(
- pointerX,
- pointerY,
- cx,
- cy,
- -element.angle,
- );
- let [width, height] =
- resizeArrowDirection === "end"
- ? [rotatedX - element.x, rotatedY - element.y]
- : [
- element.x + element.points[1][0] - rotatedX,
- element.y + element.points[1][1] - rotatedY,
- ];
- if (isRotateWithDiscreteAngle) {
- [width, height] = getPerfectElementSizeWithRotation(
- element.type,
- width,
- height,
- element.angle,
- );
- }
- const [nextElementX, nextElementY] = adjustXYWithRotation(
- resizeArrowDirection === "end"
- ? { s: true, e: true }
- : { n: true, w: true },
- element.x,
- element.y,
- element.angle,
- 0,
- 0,
- (element.points[1][0] - width) / 2,
- (element.points[1][1] - height) / 2,
- );
- mutateElement(element, {
- x: nextElementX,
- y: nextElementY,
- points: [
- [0, 0],
- [width, height],
- ],
- });
- };
- const rescalePointsInElement = (
- element: NonDeletedExcalidrawElement,
- width: number,
- height: number,
- ) =>
- isLinearElement(element)
- ? {
- points: rescalePoints(
- 0,
- width,
- rescalePoints(1, height, element.points),
- ),
- }
- : {};
- const MIN_FONT_SIZE = 1;
- const measureFontSizeFromWH = (
- element: NonDeleted<ExcalidrawTextElement>,
- nextWidth: number,
- nextHeight: number,
- ): { size: number; baseline: number } | null => {
- // We only use width to scale font on resize
- const nextFontSize = element.fontSize * (nextWidth / element.width);
- if (nextFontSize < MIN_FONT_SIZE) {
- return null;
- }
- const metrics = measureText(
- element.text,
- getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
- );
- return {
- size: nextFontSize,
- baseline: metrics.baseline + (nextHeight - metrics.height),
- };
- };
- const getSidesForTransformHandle = (
- transformHandleType: TransformHandleType,
- isResizeFromCenter: boolean,
- ) => {
- return {
- n:
- /^(n|ne|nw)$/.test(transformHandleType) ||
- (isResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
- s:
- /^(s|se|sw)$/.test(transformHandleType) ||
- (isResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
- w:
- /^(w|nw|sw)$/.test(transformHandleType) ||
- (isResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
- e:
- /^(e|ne|se)$/.test(transformHandleType) ||
- (isResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
- };
- };
- const resizeSingleTextElement = (
- element: NonDeleted<ExcalidrawTextElement>,
- transformHandleType: "nw" | "ne" | "sw" | "se",
- isResizeFromCenter: boolean,
- pointerX: number,
- pointerY: number,
- ) => {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
- const cx = (x1 + x2) / 2;
- const cy = (y1 + y2) / 2;
- // rotation pointer with reverse angle
- const [rotatedX, rotatedY] = rotate(
- pointerX,
- pointerY,
- cx,
- cy,
- -element.angle,
- );
- let scale;
- switch (transformHandleType) {
- case "se":
- scale = Math.max(
- (rotatedX - x1) / (x2 - x1),
- (rotatedY - y1) / (y2 - y1),
- );
- break;
- case "nw":
- scale = Math.max(
- (x2 - rotatedX) / (x2 - x1),
- (y2 - rotatedY) / (y2 - y1),
- );
- break;
- case "ne":
- scale = Math.max(
- (rotatedX - x1) / (x2 - x1),
- (y2 - rotatedY) / (y2 - y1),
- );
- break;
- case "sw":
- scale = Math.max(
- (x2 - rotatedX) / (x2 - x1),
- (rotatedY - y1) / (y2 - y1),
- );
- break;
- }
- if (scale > 0) {
- const nextWidth = element.width * scale;
- const nextHeight = element.height * scale;
- const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight);
- if (nextFont === null) {
- return;
- }
- const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
- element,
- nextWidth,
- nextHeight,
- );
- const deltaX1 = (x1 - nextX1) / 2;
- const deltaY1 = (y1 - nextY1) / 2;
- const deltaX2 = (x2 - nextX2) / 2;
- const deltaY2 = (y2 - nextY2) / 2;
- const [nextElementX, nextElementY] = adjustXYWithRotation(
- getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
- element.x,
- element.y,
- element.angle,
- deltaX1,
- deltaY1,
- deltaX2,
- deltaY2,
- );
- mutateElement(element, {
- fontSize: nextFont.size,
- width: nextWidth,
- height: nextHeight,
- baseline: nextFont.baseline,
- x: nextElementX,
- y: nextElementY,
- });
- }
- };
- const resizeSingleElement = (
- element: NonDeletedExcalidrawElement,
- transformHandleType: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
- sidesWithSameLength: boolean,
- isResizeFromCenter: boolean,
- pointerX: number,
- pointerY: number,
- ) => {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
- const cx = (x1 + x2) / 2;
- const cy = (y1 + y2) / 2;
- // rotation pointer with reverse angle
- const [rotatedX, rotatedY] = rotate(
- pointerX,
- pointerY,
- cx,
- cy,
- -element.angle,
- );
- let scaleX = 1;
- let scaleY = 1;
- if (
- transformHandleType === "e" ||
- transformHandleType === "ne" ||
- transformHandleType === "se"
- ) {
- scaleX = (rotatedX - x1) / (x2 - x1);
- }
- if (
- transformHandleType === "s" ||
- transformHandleType === "sw" ||
- transformHandleType === "se"
- ) {
- scaleY = (rotatedY - y1) / (y2 - y1);
- }
- if (
- transformHandleType === "w" ||
- transformHandleType === "nw" ||
- transformHandleType === "sw"
- ) {
- scaleX = (x2 - rotatedX) / (x2 - x1);
- }
- if (
- transformHandleType === "n" ||
- transformHandleType === "nw" ||
- transformHandleType === "ne"
- ) {
- scaleY = (y2 - rotatedY) / (y2 - y1);
- }
- let nextWidth = element.width * scaleX;
- let nextHeight = element.height * scaleY;
- if (sidesWithSameLength) {
- nextWidth = nextHeight = Math.max(nextWidth, nextHeight);
- }
- const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
- element,
- nextWidth,
- nextHeight,
- );
- const deltaX1 = (x1 - nextX1) / 2;
- const deltaY1 = (y1 - nextY1) / 2;
- const deltaX2 = (x2 - nextX2) / 2;
- const deltaY2 = (y2 - nextY2) / 2;
- const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight);
- updateBoundElements(element, {
- newSize: { width: nextWidth, height: nextHeight },
- });
- const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords(
- {
- ...element,
- ...rescaledPoints,
- },
- Math.abs(nextWidth),
- Math.abs(nextHeight),
- );
- const [flipDiffX, flipDiffY] = getFlipAdjustment(
- transformHandleType,
- nextWidth,
- nextHeight,
- nextX1,
- nextY1,
- nextX2,
- nextY2,
- finalX1,
- finalY1,
- finalX2,
- finalY2,
- isLinearElement(element),
- element.angle,
- );
- const [nextElementX, nextElementY] = adjustXYWithRotation(
- getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
- element.x - flipDiffX,
- element.y - flipDiffY,
- element.angle,
- deltaX1,
- deltaY1,
- deltaX2,
- deltaY2,
- );
- if (
- nextWidth !== 0 &&
- nextHeight !== 0 &&
- Number.isFinite(nextElementX) &&
- Number.isFinite(nextElementY)
- ) {
- mutateElement(element, {
- width: nextWidth,
- height: nextHeight,
- x: nextElementX,
- y: nextElementY,
- ...rescaledPoints,
- });
- }
- };
- const resizeMultipleElements = (
- elements: readonly NonDeletedExcalidrawElement[],
- transformHandleType: "nw" | "ne" | "sw" | "se",
- pointerX: number,
- pointerY: number,
- ) => {
- const [x1, y1, x2, y2] = getCommonBounds(elements);
- let scale: number;
- let getNextXY: (
- element: NonDeletedExcalidrawElement,
- origCoords: readonly [number, number, number, number],
- finalCoords: readonly [number, number, number, number],
- ) => { x: number; y: number };
- switch (transformHandleType) {
- case "se":
- scale = Math.max(
- (pointerX - x1) / (x2 - x1),
- (pointerY - y1) / (y2 - y1),
- );
- getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => {
- const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
- const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
- return { x, y };
- };
- break;
- case "nw":
- scale = Math.max(
- (x2 - pointerX) / (x2 - x1),
- (y2 - pointerY) / (y2 - y1),
- );
- getNextXY = (element, [, , origX2, origY2], [, , finalX2, finalY2]) => {
- const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
- const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
- return { x, y };
- };
- break;
- case "ne":
- scale = Math.max(
- (pointerX - x1) / (x2 - x1),
- (y2 - pointerY) / (y2 - y1),
- );
- getNextXY = (element, [origX1, , , origY2], [finalX1, , , finalY2]) => {
- const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
- const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
- return { x, y };
- };
- break;
- case "sw":
- scale = Math.max(
- (x2 - pointerX) / (x2 - x1),
- (pointerY - y1) / (y2 - y1),
- );
- getNextXY = (element, [, origY1, origX2], [, finalY1, finalX2]) => {
- const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
- const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
- return { x, y };
- };
- break;
- }
- if (scale > 0) {
- const updates = elements.reduce(
- (prev, element) => {
- if (!prev) {
- return prev;
- }
- const width = element.width * scale;
- const height = element.height * scale;
- let font: { fontSize?: number; baseline?: number } = {};
- if (element.type === "text") {
- const nextFont = measureFontSizeFromWH(element, width, height);
- if (nextFont === null) {
- return null;
- }
- font = { fontSize: nextFont.size, baseline: nextFont.baseline };
- }
- const origCoords = getElementAbsoluteCoords(element);
- const rescaledPoints = rescalePointsInElement(element, width, height);
- updateBoundElements(element, {
- newSize: { width, height },
- simultaneouslyUpdated: elements,
- });
- const finalCoords = getResizedElementAbsoluteCoords(
- {
- ...element,
- ...rescaledPoints,
- },
- width,
- height,
- );
- const { x, y } = getNextXY(element, origCoords, finalCoords);
- return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
- },
- [] as
- | {
- width: number;
- height: number;
- x: number;
- y: number;
- points?: (readonly [number, number])[];
- fontSize?: number;
- baseline?: number;
- }[]
- | null,
- );
- if (updates) {
- elements.forEach((element, index) => {
- mutateElement(element, updates[index]);
- });
- }
- }
- };
- 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) => {
- 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 = (
- transformHandleType: MaybeTransformHandleType,
- selectedElements: NonDeletedExcalidrawElement[],
- x: number,
- y: number,
- ): [number, number] => {
- const [x1, y1, x2, y2] =
- selectedElements.length === 1
- ? getElementAbsoluteCoords(selectedElements[0])
- : getCommonBounds(selectedElements);
- const cx = (x1 + x2) / 2;
- const cy = (y1 + y2) / 2;
- const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0;
- [x, y] = rotate(x, y, cx, cy, -angle);
- switch (transformHandleType) {
- case "n":
- return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle);
- case "s":
- return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle);
- case "w":
- return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle);
- case "e":
- return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle);
- case "nw":
- return rotate(x - x1, y - y1, 0, 0, angle);
- case "ne":
- return rotate(x - x2, y - y1, 0, 0, angle);
- case "sw":
- return rotate(x - x1, y - y2, 0, 0, angle);
- case "se":
- return rotate(x - x2, y - y2, 0, 0, angle);
- default:
- return [0, 0];
- }
- };
- export const getResizeArrowDirection = (
- transformHandleType: MaybeTransformHandleType,
- element: NonDeleted<ExcalidrawLinearElement>,
- ): "origin" | "end" => {
- const [, [px, py]] = element.points;
- const isResizeEnd =
- (transformHandleType === "nw" && (px < 0 || py < 0)) ||
- (transformHandleType === "ne" && px >= 0) ||
- (transformHandleType === "sw" && px <= 0) ||
- (transformHandleType === "se" && (px > 0 || py > 0));
- return isResizeEnd ? "end" : "origin";
- };
|