1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-10 11:35:52 +01:00

fix: resize multiple elements from center (#5560)

Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Alex Kim 2022-08-13 22:53:10 +05:00 committed by GitHub
parent b67a2b4f65
commit a0d413ab4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 245 additions and 195 deletions

@ -18,6 +18,7 @@ import { rescalePoints } from "../points";
// x and y position of top left corner, x and y position of bottom right corner // x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number]; export type Bounds = readonly [number, number, number, number];
type MaybeQuadraticSolution = [number | null, number | null] | false;
// If the element is created from right to left, the width is going to be negative // If the element is created from right to left, the width is going to be negative
// This set of functions retrieves the absolute position of the 4 points. // This set of functions retrieves the absolute position of the 4 points.
@ -68,11 +69,95 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
return shape.sets[0].ops; return shape.sets[0].ops;
}; };
// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
const getBezierValueForT = (
t: number,
p0: number,
p1: number,
p2: number,
p3: number,
) => {
const oneMinusT = 1 - t;
return (
Math.pow(oneMinusT, 3) * p0 +
3 * Math.pow(oneMinusT, 2) * t * p1 +
3 * oneMinusT * Math.pow(t, 2) * p2 +
Math.pow(t, 3) * p3
);
};
const solveQuadratic = (
p0: number,
p1: number,
p2: number,
p3: number,
): MaybeQuadraticSolution => {
const i = p1 - p0;
const j = p2 - p1;
const k = p3 - p2;
const a = 3 * i - 6 * j + 3 * k;
const b = 6 * j - 6 * i;
const c = 3 * i;
const sqrtPart = b * b - 4 * a * c;
const hasSolution = sqrtPart >= 0;
if (!hasSolution) {
return false;
}
const t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a);
const t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a);
let s1 = null;
let s2 = null;
if (t1 >= 0 && t1 <= 1) {
s1 = getBezierValueForT(t1, p0, p1, p2, p3);
}
if (t2 >= 0 && t2 <= 1) {
s2 = getBezierValueForT(t2, p0, p1, p2, p3);
}
return [s1, s2];
};
const getCubicBezierCurveBound = (
p0: Point,
p1: Point,
p2: Point,
p3: Point,
): Bounds => {
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
let minX = Math.min(p0[0], p3[0]);
let maxX = Math.max(p0[0], p3[0]);
if (solX) {
const xs = solX.filter((x) => x !== null) as number[];
minX = Math.min(minX, ...xs);
maxX = Math.max(maxX, ...xs);
}
let minY = Math.min(p0[1], p3[1]);
let maxY = Math.max(p0[1], p3[1]);
if (solY) {
const ys = solY.filter((y) => y !== null) as number[];
minY = Math.min(minY, ...ys);
maxY = Math.max(maxY, ...ys);
}
return [minX, minY, maxX, maxY];
};
const getMinMaxXYFromCurvePathOps = ( const getMinMaxXYFromCurvePathOps = (
ops: Op[], ops: Op[],
transformXY?: (x: number, y: number) => [number, number], transformXY?: (x: number, y: number) => [number, number],
): [number, number, number, number] => { ): [number, number, number, number] => {
let currentP: Point = [0, 0]; let currentP: Point = [0, 0];
const { minX, minY, maxX, maxY } = ops.reduce( const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => { (limits, { op, data }) => {
// There are only four operation types: // There are only four operation types:
@ -83,38 +168,29 @@ const getMinMaxXYFromCurvePathOps = (
// move operation does not draw anything; so, it always // move operation does not draw anything; so, it always
// returns false // returns false
} else if (op === "bcurveTo") { } else if (op === "bcurveTo") {
// create points from bezier curve const _p1 = [data[0], data[1]] as Point;
// bezier curve stores data as a flattened array of three positions const _p2 = [data[2], data[3]] as Point;
// [x1, y1, x2, y2, x3, y3] const _p3 = [data[4], data[5]] as Point;
const p1 = [data[0], data[1]] as Point;
const p2 = [data[2], data[3]] as Point;
const p3 = [data[4], data[5]] as Point;
const p0 = currentP; const p1 = transformXY ? transformXY(..._p1) : _p1;
currentP = p3; const p2 = transformXY ? transformXY(..._p2) : _p2;
const p3 = transformXY ? transformXY(..._p3) : _p3;
const equation = (t: number, idx: number) => const p0 = transformXY ? transformXY(...currentP) : currentP;
Math.pow(1 - t, 3) * p3[idx] + currentP = _p3;
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
let t = 0; const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
while (t <= 1.0) { p0,
let x = equation(t, 0); p1,
let y = equation(t, 1); p2,
if (transformXY) { p3,
[x, y] = transformXY(x, y); );
}
limits.minY = Math.min(limits.minY, y); limits.minX = Math.min(limits.minX, minX);
limits.minX = Math.min(limits.minX, x); limits.minY = Math.min(limits.minY, minY);
limits.maxX = Math.max(limits.maxX, x); limits.maxX = Math.max(limits.maxX, maxX);
limits.maxY = Math.max(limits.maxY, y); limits.maxY = Math.max(limits.maxY, maxY);
t += 0.1;
}
} else if (op === "lineTo") { } else if (op === "lineTo") {
// TODO: Implement this // TODO: Implement this
} else if (op === "qcurveTo") { } else if (op === "qcurveTo") {
@ -124,7 +200,6 @@ const getMinMaxXYFromCurvePathOps = (
}, },
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
); );
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };

@ -18,6 +18,7 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getCommonBounds, getCommonBounds,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
getCommonBoundingBox,
} from "./bounds"; } from "./bounds";
import { import {
isFreeDrawElement, isFreeDrawElement,
@ -137,8 +138,10 @@ export const transformElements = (
transformHandleType === "se" transformHandleType === "se"
) { ) {
resizeMultipleElements( resizeMultipleElements(
pointerDownState,
selectedElements, selectedElements,
transformHandleType, transformHandleType,
shouldResizeFromCenter,
pointerX, pointerX,
pointerY, pointerY,
); );
@ -637,146 +640,142 @@ export const resizeSingleElement = (
}; };
const resizeMultipleElements = ( const resizeMultipleElements = (
elements: readonly NonDeletedExcalidrawElement[], pointerDownState: PointerDownState,
selectedElements: readonly NonDeletedExcalidrawElement[],
transformHandleType: "nw" | "ne" | "sw" | "se", transformHandleType: "nw" | "ne" | "sw" | "se",
shouldResizeFromCenter: boolean,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
) => { ) => {
const [x1, y1, x2, y2] = getCommonBounds(elements); // map selected elements to the original elements. While it never should
let scale: number; // happen that pointerDownState.originalElements won't contain the selected
let getNextXY: ( // elements during resize, this coupling isn't guaranteed, so to ensure
element: NonDeletedExcalidrawElement, // type safety we need to transform only those elements we filter.
origCoords: readonly [number, number, number, number], const targetElements = selectedElements.reduce(
finalCoords: readonly [number, number, number, number], (
) => { x: number; y: number }; acc: {
switch (transformHandleType) { /** element at resize start */
case "se": orig: NonDeletedExcalidrawElement;
scale = Math.max( /** latest element */
(pointerX - x1) / (x2 - x1), latest: NonDeletedExcalidrawElement;
(pointerY - y1) / (y2 - y1), }[],
); element,
getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => { ) => {
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1; const origElement = pointerDownState.originalElements.get(element.id);
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1; if (origElement) {
return { x, y }; acc.push({ orig: origElement, latest: element });
}; }
break; return acc;
case "nw": },
scale = Math.max( [],
(x2 - pointerX) / (x2 - x1), );
(y2 - pointerY) / (y2 - y1),
); const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
getNextXY = (element, [, , origX2, origY2], [, , finalX2, finalY2]) => { targetElements.map(({ orig }) => orig),
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2; );
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2; const direction = transformHandleType;
return { x, y };
}; const mapDirectionsToAnchors: Record<typeof direction, Point> = {
break; ne: [minX, maxY],
case "ne": se: [minX, minY],
scale = Math.max( sw: [maxX, minY],
(pointerX - x1) / (x2 - x1), nw: [maxX, maxY],
(y2 - pointerY) / (y2 - y1), };
);
getNextXY = (element, [origX1, , , origY2], [finalX1, , , finalY2]) => { // anchor point must be on the opposite side of the dragged selection handle
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1; // or be the center of the selection if alt is pressed
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2; const [anchorX, anchorY]: Point = shouldResizeFromCenter
return { x, y }; ? [midX, midY]
}; : mapDirectionsToAnchors[direction];
break;
case "sw": const mapDirectionsToPointerSides: Record<
scale = Math.max( typeof direction,
(x2 - pointerX) / (x2 - x1), [x: boolean, y: boolean]
(pointerY - y1) / (y2 - y1), > = {
); ne: [pointerX >= anchorX, pointerY <= anchorY],
getNextXY = (element, [, origY1, origX2], [, finalY1, finalX2]) => { se: [pointerX >= anchorX, pointerY >= anchorY],
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2; sw: [pointerX <= anchorX, pointerY >= anchorY],
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1; nw: [pointerX <= anchorX, pointerY <= anchorY],
return { x, y }; };
};
break; // pointer side relative to anchor
const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
direction
].map((condition) => (condition ? 1 : -1));
// stop resizing if a pointer is on the other side of selection
if (pointerSideX < 0 && pointerSideY < 0) {
return;
} }
if (scale > 0) {
const updates = elements.reduce( const scale =
(prev, element) => { Math.max(
if (!prev) { (pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
return prev; (pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
) * (shouldResizeFromCenter ? 2 : 1);
if (scale === 1) {
return;
}
targetElements.forEach((element) => {
const width = element.orig.width * scale;
const height = element.orig.height * scale;
const x = anchorX + (element.orig.x - anchorX) * scale;
const y = anchorY + (element.orig.y - anchorY) * scale;
// readjust points for linear & free draw elements
const rescaledPoints = rescalePointsInElement(element.orig, width, height);
const update: {
width: number;
height: number;
x: number;
y: number;
points?: Point[];
fontSize?: number;
baseline?: number;
} = {
width,
height,
x,
y,
...rescaledPoints,
};
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) {
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
const textMeasurements = measureFontSizeFromWH(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding,
height - optionalPadding,
);
if (textMeasurements) {
if (isTextElement(element.orig)) {
update.fontSize = textMeasurements.size;
update.baseline = textMeasurements.baseline;
} }
const width = element.width * scale;
const height = element.height * scale;
const boundTextElement = getBoundTextElement(element);
let font: { fontSize?: number; baseline?: number } = {};
if (boundTextElement) { if (boundTextElement) {
const nextFont = measureFontSizeFromWH( boundTextUpdates = {
boundTextElement, fontSize: textMeasurements.size,
width - BOUND_TEXT_PADDING * 2, baseline: textMeasurements.baseline,
height - BOUND_TEXT_PADDING * 2,
);
if (nextFont === null) {
return null;
}
font = {
fontSize: nextFont.size,
baseline: nextFont.baseline,
}; };
} }
}
if (isTextElement(element)) {
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 boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
mutateElement(boundTextElement, {
fontSize: updates[index].fontSize,
baseline: updates[index].baseline,
});
handleBindTextResize(element, transformHandleType);
}
});
} }
}
mutateElement(element.latest, update);
if (boundTextElement && boundTextUpdates) {
mutateElement(boundTextElement, boundTextUpdates);
handleBindTextResize(element.latest, transformHandleType);
}
});
}; };
const rotateMultipleElements = ( const rotateMultipleElements = (

@ -9,46 +9,22 @@ export const getSizeFromPoints = (points: readonly Point[]) => {
}; };
}; };
/** @arg dimension, 0 for rescaling only x, 1 for y */
export const rescalePoints = ( export const rescalePoints = (
dimension: 0 | 1, dimension: 0 | 1,
nextDimensionSize: number, newSize: number,
prevPoints: readonly Point[], points: readonly Point[],
): Point[] => { ): Point[] => {
const prevDimValues = prevPoints.map((point) => point[dimension]); const coordinates = points.map((point) => point[dimension]);
const prevMaxDimension = Math.max(...prevDimValues); const maxCoordinate = Math.max(...coordinates);
const prevMinDimension = Math.min(...prevDimValues); const minCoordinate = Math.min(...coordinates);
const prevDimensionSize = prevMaxDimension - prevMinDimension; const size = maxCoordinate - minCoordinate;
const scale = size === 0 ? 1 : newSize / size;
const dimensionScaleFactor = return points.map((point): Point => {
prevDimensionSize === 0 ? 1 : nextDimensionSize / prevDimensionSize; const newCoordinate = point[dimension] * scale;
const newPoint = [...point];
let nextMinDimension = Infinity; newPoint[dimension] = newCoordinate;
return newPoint as unknown as Point;
const scaledPoints = prevPoints.map( });
(prevPoint) =>
prevPoint.map((value, currentDimension) => {
if (currentDimension !== dimension) {
return value;
}
const scaledValue = value * dimensionScaleFactor;
nextMinDimension = Math.min(scaledValue, nextMinDimension);
return scaledValue;
}) as [number, number],
);
if (scaledPoints.length === 2) {
// we don't translate two-point lines
return scaledPoints;
}
const translation = prevMinDimension - nextMinDimension;
const nextPoints = scaledPoints.map(
(scaledPoint) =>
scaledPoint.map((value, currentDimension) => {
return currentDimension === dimension ? value + translation : value;
}) as [number, number],
);
return nextPoints;
}; };