import { AffineTransform, CompassDirection, createBounds, getCompassDirectionAnchor, getCompassDirectionUnitAnchor, getOppositeCompassDirection, getRegionDelta, Point, Rect, Region, Size, unionRects, } from "@noya-app/noya-geometry"; import { getBestSnapDelta, getElementSnapDelta, getGridSnapDelta, getValuesOnAxis, } from "./snapUtils"; export type ScalingOriginMode = "extent" | "center"; export type ScalingOptions = { preserveAspectRatio: boolean; scalingOriginMode: ScalingOriginMode; ignoreSnapping: boolean; }; export function getScalingOptionsForEvent({ shiftKey, altKey, ctrlKey, }: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; }): ScalingOptions { return { preserveAspectRatio: shiftKey, scalingOriginMode: altKey ? "center" : "extent", ignoreSnapping: ctrlKey, }; } // This function doesn't ensure a positive width/height, since we use it when // scaling, which can result in a negative width/height. export function getRectExtentPoint(rect: Rect, direction: CompassDirection) { const minX = rect.x; const minY = rect.y; const maxX = rect.x + rect.width; const maxY = rect.y + rect.height; const midX = (maxX + minX) / 2; const midY = (maxY + minY) / 2; const x = direction.includes("w") ? minX : direction.includes("e") ? maxX : midX; const y = direction.includes("n") ? minY : direction.includes("s") ? maxY : midY; return { x, y }; } /** * Resize a rect in a compass direction */ export function getScaleTransform( rect: Rect, delta: Point | Region, direction: CompassDirection, { scalingOriginMode, preserveAspectRatio }: ScalingOptions ): AffineTransform { if ("anchor" in delta) { delta = getRegionDelta(delta); } const oppositeDirection = getOppositeCompassDirection(direction); const extent = getRectExtentPoint(rect, direction); const oppositeExtent = getRectExtentPoint(rect, oppositeDirection); if (scalingOriginMode === "center") { delta = { x: delta.x * 2, y: delta.y * 2, }; } const newExtent = { x: extent.x + delta.x, y: extent.y + delta.y }; const anchor = getCompassDirectionUnitAnchor(direction); const multiplier = { x: anchor.x * 2 - 1, y: anchor.y * 2 - 1, }; const newSize = { width: newExtent.x - oppositeExtent.x, height: newExtent.y - oppositeExtent.y, }; const scaleX = (multiplier.x * newSize.width) / rect.width; const scaleY = (multiplier.y * newSize.height) / rect.height; const largestMagnitude = Math.abs(scaleX) > Math.abs(scaleY) ? scaleX : scaleY; const scale = preserveAspectRatio ? { x: largestMagnitude, y: largestMagnitude } : { x: extent.x === oppositeExtent.x ? 1 : scaleX, y: extent.y === oppositeExtent.y ? 1 : scaleY, }; // Adjust the sign of the scale: // - If scaling along a single axis, ensure a positive sign so we don't flip along the opposite axis // - If scaling opposite corners, ensure the rect remains in the correct quadrant if (extent.y === oppositeExtent.y) { scale.y = Math.abs(scale.y); } else if (Math.sign(scale.y) !== Math.sign(scaleY)) { scale.y *= -1; } if (extent.x === oppositeExtent.x) { scale.x = Math.abs(scale.x); } else if (Math.sign(scale.x) !== Math.sign(scaleX)) { scale.x *= -1; } switch (scalingOriginMode) { case "extent": { return AffineTransform.scale(scale.x, scale.y, oppositeExtent); } case "center": { const bounds = createBounds(rect); return AffineTransform.scale(scale.x, scale.y, { x: bounds.midX, y: bounds.midY, }); } } } export function getScaleSnapDelta({ movingRects, stationaryRects, delta, direction, gridSize, }: { movingRects: Rect[]; stationaryRects: Rect[]; delta: Point; direction: CompassDirection; gridSize: Size; }): Point { const movingBoundingBox = unionRects(...movingRects); const anchor = getCompassDirectionAnchor(direction, movingBoundingBox); const possibleSnapsX = [ getElementSnapDelta({ movingValues: [anchor.x + delta.x], stationaryValues: getValuesOnAxis(stationaryRects, "x"), }), getGridSnapDelta({ value: anchor.x + delta.x, gridSize: gridSize.width, }), ]; const possibleSnapsY = [ getElementSnapDelta({ movingValues: [anchor.y + delta.y], stationaryValues: getValuesOnAxis(stationaryRects, "y"), }), getGridSnapDelta({ value: anchor.y + delta.y, gridSize: gridSize.height, }), ]; const snapDelta = { x: getBestSnapDelta(possibleSnapsX), y: getBestSnapDelta(possibleSnapsY), }; return { x: delta.x + snapDelta.x, y: delta.y + snapDelta.y, }; }