diff --git a/src/element/bounds.ts b/src/element/bounds.ts index 8290b1ce4..a1e4197cf 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -18,6 +18,7 @@ import { rescalePoints } from "../points"; // x and y position of top left corner, x and y position of bottom right corner 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 // 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; }; +// 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 = ( ops: Op[], transformXY?: (x: number, y: number) => [number, number], ): [number, number, number, number] => { let currentP: Point = [0, 0]; + const { minX, minY, maxX, maxY } = ops.reduce( (limits, { op, data }) => { // There are only four operation types: @@ -83,38 +168,29 @@ const getMinMaxXYFromCurvePathOps = ( // move operation does not draw anything; so, it always // returns false } else if (op === "bcurveTo") { - // create points from bezier curve - // bezier curve stores data as a flattened array of three positions - // [x1, y1, x2, y2, x3, y3] - 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 _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; - currentP = p3; + const p1 = transformXY ? transformXY(..._p1) : _p1; + const p2 = transformXY ? transformXY(..._p2) : _p2; + const p3 = transformXY ? transformXY(..._p3) : _p3; - const equation = (t: number, idx: number) => - Math.pow(1 - t, 3) * p3[idx] + - 3 * t * Math.pow(1 - t, 2) * p2[idx] + - 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + - p0[idx] * Math.pow(t, 3); + const p0 = transformXY ? transformXY(...currentP) : currentP; + currentP = _p3; - let t = 0; - while (t <= 1.0) { - let x = equation(t, 0); - let y = equation(t, 1); - if (transformXY) { - [x, y] = transformXY(x, y); - } + const [minX, minY, maxX, maxY] = getCubicBezierCurveBound( + p0, + p1, + p2, + p3, + ); - limits.minY = Math.min(limits.minY, y); - limits.minX = Math.min(limits.minX, x); + limits.minX = Math.min(limits.minX, minX); + limits.minY = Math.min(limits.minY, minY); - limits.maxX = Math.max(limits.maxX, x); - limits.maxY = Math.max(limits.maxY, y); - - t += 0.1; - } + limits.maxX = Math.max(limits.maxX, maxX); + limits.maxY = Math.max(limits.maxY, maxY); } else if (op === "lineTo") { // TODO: Implement this } else if (op === "qcurveTo") { @@ -124,7 +200,6 @@ const getMinMaxXYFromCurvePathOps = ( }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, ); - return [minX, minY, maxX, maxY]; }; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index ff4925639..ac32a0702 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -18,6 +18,7 @@ import { getElementAbsoluteCoords, getCommonBounds, getResizedElementAbsoluteCoords, + getCommonBoundingBox, } from "./bounds"; import { isFreeDrawElement, @@ -137,8 +138,10 @@ export const transformElements = ( transformHandleType === "se" ) { resizeMultipleElements( + pointerDownState, selectedElements, transformHandleType, + shouldResizeFromCenter, pointerX, pointerY, ); @@ -637,146 +640,142 @@ export const resizeSingleElement = ( }; const resizeMultipleElements = ( - elements: readonly NonDeletedExcalidrawElement[], + pointerDownState: PointerDownState, + selectedElements: readonly NonDeletedExcalidrawElement[], transformHandleType: "nw" | "ne" | "sw" | "se", + shouldResizeFromCenter: boolean, 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; + // map selected elements to the original elements. While it never should + // happen that pointerDownState.originalElements won't contain the selected + // elements during resize, this coupling isn't guaranteed, so to ensure + // type safety we need to transform only those elements we filter. + const targetElements = selectedElements.reduce( + ( + acc: { + /** element at resize start */ + orig: NonDeletedExcalidrawElement; + /** latest element */ + latest: NonDeletedExcalidrawElement; + }[], + element, + ) => { + const origElement = pointerDownState.originalElements.get(element.id); + if (origElement) { + acc.push({ orig: origElement, latest: element }); + } + return acc; + }, + [], + ); + + const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox( + targetElements.map(({ orig }) => orig), + ); + const direction = transformHandleType; + + const mapDirectionsToAnchors: Record = { + ne: [minX, maxY], + se: [minX, minY], + sw: [maxX, minY], + nw: [maxX, maxY], + }; + + // anchor point must be on the opposite side of the dragged selection handle + // or be the center of the selection if alt is pressed + const [anchorX, anchorY]: Point = shouldResizeFromCenter + ? [midX, midY] + : mapDirectionsToAnchors[direction]; + + const mapDirectionsToPointerSides: Record< + typeof direction, + [x: boolean, y: boolean] + > = { + ne: [pointerX >= anchorX, pointerY <= anchorY], + se: [pointerX >= anchorX, pointerY >= anchorY], + sw: [pointerX <= anchorX, pointerY >= anchorY], + nw: [pointerX <= anchorX, pointerY <= anchorY], + }; + + // 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( - (prev, element) => { - if (!prev) { - return prev; + + const scale = + Math.max( + (pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX), + (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) { - const nextFont = measureFontSizeFromWH( - boundTextElement, - width - BOUND_TEXT_PADDING * 2, - height - BOUND_TEXT_PADDING * 2, - ); - - if (nextFont === null) { - return null; - } - font = { - fontSize: nextFont.size, - baseline: nextFont.baseline, + boundTextUpdates = { + fontSize: textMeasurements.size, + baseline: textMeasurements.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 = ( diff --git a/src/points.ts b/src/points.ts index c407ffbbf..311b8bbc7 100644 --- a/src/points.ts +++ b/src/points.ts @@ -9,46 +9,22 @@ export const getSizeFromPoints = (points: readonly Point[]) => { }; }; +/** @arg dimension, 0 for rescaling only x, 1 for y */ export const rescalePoints = ( dimension: 0 | 1, - nextDimensionSize: number, - prevPoints: readonly Point[], + newSize: number, + points: readonly Point[], ): Point[] => { - const prevDimValues = prevPoints.map((point) => point[dimension]); - const prevMaxDimension = Math.max(...prevDimValues); - const prevMinDimension = Math.min(...prevDimValues); - const prevDimensionSize = prevMaxDimension - prevMinDimension; + const coordinates = points.map((point) => point[dimension]); + const maxCoordinate = Math.max(...coordinates); + const minCoordinate = Math.min(...coordinates); + const size = maxCoordinate - minCoordinate; + const scale = size === 0 ? 1 : newSize / size; - const dimensionScaleFactor = - prevDimensionSize === 0 ? 1 : nextDimensionSize / prevDimensionSize; - - let nextMinDimension = Infinity; - - 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; + return points.map((point): Point => { + const newCoordinate = point[dimension] * scale; + const newPoint = [...point]; + newPoint[dimension] = newCoordinate; + return newPoint as unknown as Point; + }); };