@ -14,17 +14,21 @@ import {
NonDeleted ,
ExcalidrawElement ,
ExcalidrawTextElementWithContainer ,
ExcalidrawImageElement ,
} from "./types" ;
import type { Mutable } from "../utility-types" ;
import {
getElementAbsoluteCoords ,
getCommonBounds ,
getResizedElementAbsoluteCoords ,
getCommonBoundingBox ,
getElementPointsCoords ,
} from "./bounds" ;
import {
isArrowElement ,
isBoundToContainer ,
isFreeDrawElement ,
isImageElement ,
isLinearElement ,
isTextElement ,
} from "./typeChecks" ;
@ -49,8 +53,12 @@ import {
measureText ,
getBoundTextMaxHeight ,
} from "./textElement" ;
import { LinearElementEditor } from "./linearElementEditor" ;
export const normalizeAngle = ( angle : number ) : number = > {
if ( angle < 0 ) {
return angle + 2 * Math . PI ;
}
if ( angle >= 2 * Math . PI ) {
return angle - 2 * Math . PI ;
}
@ -596,7 +604,7 @@ export const resizeSingleElement = (
}
} ;
const resizeMultipleElements = (
export const resizeMultipleElements = (
pointerDownState : PointerDownState ,
selectedElements : readonly NonDeletedExcalidrawElement [ ] ,
transformHandleType : "nw" | "ne" | "sw" | "se" ,
@ -627,8 +635,28 @@ const resizeMultipleElements = (
[ ] ,
) ;
// getCommonBoundingBox() uses getBoundTextElement() which returns null for
// original elements from pointerDownState, so we have to find and add these
// bound text elements manually. Additionally, the coordinates of bound text
// elements aren't always up to date.
const boundTextElements = targetElements . reduce ( ( acc , { orig } ) = > {
if ( ! isLinearElement ( orig ) ) {
return acc ;
}
const textId = getBoundTextElementId ( orig ) ;
if ( ! textId ) {
return acc ;
}
const text = pointerDownState . originalElements . get ( textId ) ? ? null ;
if ( ! isBoundToContainer ( text ) ) {
return acc ;
}
const xy = LinearElementEditor . getBoundTextElementPosition ( orig , text ) ;
return [ . . . acc , { . . . text , . . . xy } ] ;
} , [ ] as ExcalidrawTextElementWithContainer [ ] ) ;
const { minX , minY , maxX , maxY , midX , midY } = getCommonBoundingBox (
targetElements . map ( ( { orig } ) = > orig ) ,
targetElements . map ( ( { orig } ) = > orig ) .concat ( boundTextElements ) ,
) ;
const direction = transformHandleType ;
@ -640,12 +668,22 @@ const resizeMultipleElements = (
} ;
// anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if alt is pressed
// or be the center of the selection if shouldResizeFromCenter
const [ anchorX , anchorY ] : Point = shouldResizeFromCenter
? [ midX , midY ]
: mapDirectionsToAnchors [ direction ] ;
const mapDirectionsToPointerSides : Record <
const scale =
Math . max (
Math . abs ( pointerX - anchorX ) / ( maxX - minX ) || 0 ,
Math . abs ( pointerY - anchorY ) / ( maxY - minY ) || 0 ,
) * ( shouldResizeFromCenter ? 2 : 1 ) ;
if ( scale === 0 ) {
return ;
}
const mapDirectionsToPointerPositions : Record <
typeof direction ,
[ x : boolean , y : boolean ]
> = {
@ -655,68 +693,117 @@ const resizeMultipleElements = (
nw : [ pointerX <= anchorX , pointerY <= anchorY ] ,
} ;
// pointer side relative to anchor
const [ pointerSideX , pointerSideY ] = mapDirectionsToPointerSides [
/ * *
* to flip an element :
* 1 . determine over which axis is the element being flipped
* ( could be x , y , or both ) indicated by ` flipFactorX ` & ` flipFactorY `
* 2 . shift element ' s position by the amount of width or height ( or both ) or
* mirror points in the case of linear & freedraw elemenets
* 3 . adjust element angle
* /
const [ flipFactorX , flipFactorY ] = mapDirectionsToPointerPositions [
direction
] . map ( ( condition ) = > ( condition ? 1 : - 1 ) ) ;
const isFlippedByX = flipFactorX < 0 ;
const isFlippedByY = flipFactorY < 0 ;
const elementsAndUpdates : {
element : NonDeletedExcalidrawElement ;
update : Mutable <
Pick < ExcalidrawElement , " x " | " y " | " width " | " height " | " angle " >
> & {
points? : ExcalidrawLinearElement [ "points" ] ;
fontSize? : ExcalidrawTextElement [ "fontSize" ] ;
baseline? : ExcalidrawTextElement [ "baseline" ] ;
scale? : ExcalidrawImageElement [ "scale" ] ;
} ;
boundText : {
element : ExcalidrawTextElementWithContainer ;
fontSize : ExcalidrawTextElement [ "fontSize" ] ;
baseline : ExcalidrawTextElement [ "baseline" ] ;
} | null ;
} [ ] = [ ] ;
for ( const { orig , latest } of targetElements ) {
// bounded text elements are updated along with their container elements
if ( isTextElement ( orig ) && isBoundToContainer ( orig ) ) {
continue ;
}
// stop resizing if a pointer is on the other side of selection
if ( pointerSideX < 0 && pointerSideY < 0 ) {
return ;
}
const scale =
Math . max (
( pointerSideX * Math . abs ( pointerX - anchorX ) ) / ( maxX - minX ) ,
( pointerSideY * Math . abs ( pointerY - anchorY ) ) / ( maxY - minY ) ,
) * ( shouldResizeFromCenter ? 2 : 1 ) ;
if ( scale === 0 ) {
return ;
}
const width = orig . width * scale ;
const height = orig . height * scale ;
const angle = normalizeAngle ( orig . angle * flipFactorX * flipFactorY ) ;
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 ;
const isLinearOrFreeDraw = isLinearElement ( orig ) || isFreeDrawElement ( orig ) ;
const offsetX = orig . x - anchorX ;
const offsetY = orig . y - anchorY ;
const shiftX = isFlippedByX && ! isLinearOrFreeDraw ? width : 0 ;
const shiftY = isFlippedByY && ! isLinearOrFreeDraw ? height : 0 ;
const x = anchorX + flipFactorX * ( offsetX * scale + shiftX ) ;
const y = anchorY + flipFactorY * ( offsetY * scale + shiftY ) ;
// readjust points for linear & free draw elements
const rescaledPoints = rescalePointsInElement (
element . orig ,
width ,
height ,
orig ,
width * flipFactorX ,
height * flipFactorY ,
false ,
) ;
const update : {
width : number ;
height : number ;
x : number ;
y : number ;
points? : Point [ ] ;
fontSize? : number ;
baseline? : number ;
} = {
width ,
height ,
const update : typeof elementsAndUpdates [ 0 ] [ "update" ] = {
x ,
y ,
width ,
height ,
angle ,
. . . rescaledPoints ,
} ;
let boundTextUpdates : { fontSize : number ; baseline : number } | null = null ;
if ( isImageElement ( orig ) && targetElements . length === 1 ) {
update . scale = [ orig . scale [ 0 ] * flipFactorX , orig . scale [ 1 ] * flipFactorY ] ;
}
if ( isLinearElement ( orig ) && ( isFlippedByX || isFlippedByY ) ) {
const origBounds = getElementPointsCoords ( orig , orig . points ) ;
const newBounds = getElementPointsCoords (
{ . . . orig , x , y } ,
rescaledPoints . points ! ,
) ;
const origXY = [ orig . x , orig . y ] ;
const newXY = [ x , y ] ;
const linearShift = ( axis : "x" | "y" ) = > {
const i = axis === "x" ? 0 : 1 ;
return (
( newBounds [ i + 2 ] -
newXY [ i ] -
( origXY [ i ] - origBounds [ i ] ) * scale +
( origBounds [ i + 2 ] - origXY [ i ] ) * scale -
( newXY [ i ] - newBounds [ i ] ) ) /
2
) ;
} ;
const boundTextElement = getBoundTextElement ( element . latest ) ;
if ( isFlippedByX ) {
update . x -= linearShift ( "x" ) ;
}
if ( boundTextElement || isTextElement ( element . orig ) ) {
if ( isFlippedByY ) {
update . y -= linearShift ( "y" ) ;
}
}
let boundText : typeof elementsAndUpdates [ 0 ] [ "boundText" ] = null ;
const boundTextElement = getBoundTextElement ( latest ) ;
if ( boundTextElement || isTextElement ( orig ) ) {
const updatedElement = {
. . . element . latest ,
. . . latest,
width ,
height ,
} ;
const metrics = measureFontSizeFromWidth (
boundTextElement ? ? ( element . orig as ExcalidrawTextElement ) ,
boundTextElement ? ? ( orig as ExcalidrawTextElement ) ,
boundTextElement
? getBoundTextMaxWidth ( updatedElement )
: updatedElement . width ,
@ -729,29 +816,50 @@ const resizeMultipleElements = (
return ;
}
if ( isTextElement ( element. orig) ) {
if ( isTextElement ( orig) ) {
update . fontSize = metrics . size ;
update . baseline = metrics . baseline ;
}
if ( boundTextElement ) {
boundTextUpdates = {
boundText = {
element : boundTextElement ,
fontSize : metrics.size ,
baseline : metrics.baseline ,
} ;
}
}
updateBoundElements ( element . latest , { newSize : { width , height } } ) ;
elementsAndUpdates . push ( { element : latest , update , boundText } ) ;
}
mutateElement ( element . latest , update ) ;
const elementsToUpdate = elementsAndUpdates . map ( ( { element } ) = > element ) ;
if ( boundTextElement && boundText Updates) {
mutateElement ( boundTextElement , boundTextUpdates ) ;
for ( const { element , update , boundText } of elementsAnd Updates) {
const { width , height , angle } = update ;
handleBindTextResize ( element . latest , transformHandleType ) ;
mutateElement ( element , update , false ) ;
updateBoundElements ( element , {
simultaneouslyUpdated : elementsToUpdate ,
newSize : { width , height } ,
} ) ;
if ( boundText ) {
const { element : boundTextElement , . . . boundTextUpdates } = boundText ;
mutateElement (
boundTextElement ,
{
. . . boundTextUpdates ,
angle : isLinearElement ( element ) ? undefined : angle ,
} ,
false ,
) ;
handleBindTextResize ( element , transformHandleType ) ;
}
} ) ;
}
Scene . getScene ( elementsAndUpdates [ 0 ] . element ) ? . informMutation ( ) ;
} ;
const rotateMultipleElements = (