@ -100,6 +100,7 @@ import {
@@ -100,6 +100,7 @@ import {
randomInteger ,
CLASSES ,
Emitter ,
isMobile ,
MINIMUM_ARROW_SIZE ,
} from "@excalidraw/common" ;
@ -653,9 +654,14 @@ class App extends React.Component<AppProps, AppState> {
@@ -653,9 +654,14 @@ class App extends React.Component<AppProps, AppState> {
> ( ) ;
onRemoveEventListenersEmitter = new Emitter < [ ] > ( ) ;
defaultSelectionTool : "selection" | "lasso" = "selection" ;
constructor ( props : AppProps ) {
super ( props ) ;
const defaultAppState = getDefaultAppState ( ) ;
this . defaultSelectionTool = this . isMobileOrTablet ( )
? ( "lasso" as const )
: ( "selection" as const ) ;
const {
excalidrawAPI ,
viewModeEnabled = false ,
@ -1606,7 +1612,8 @@ class App extends React.Component<AppProps, AppState> {
@@ -1606,7 +1612,8 @@ class App extends React.Component<AppProps, AppState> {
renderWelcomeScreen = {
! this . state . isLoading &&
this . state . showWelcomeScreen &&
this . state . activeTool . type === "selection" &&
this . state . activeTool . type ===
this . defaultSelectionTool &&
! this . state . zenModeEnabled &&
! this . scene . getElementsIncludingDeleted ( ) . length
}
@ -2350,6 +2357,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -2350,6 +2357,7 @@ class App extends React.Component<AppProps, AppState> {
repairBindings : true ,
deleteInvisibleElements : true ,
} ) ;
const activeTool = scene . appState . activeTool ;
scene . appState = {
. . . scene . appState ,
theme : this.props.theme || scene . appState . theme ,
@ -2359,8 +2367,13 @@ class App extends React.Component<AppProps, AppState> {
@@ -2359,8 +2367,13 @@ class App extends React.Component<AppProps, AppState> {
// with a library install link, which should auto-open the library)
openSidebar : scene.appState?.openSidebar || this . state . openSidebar ,
activeTool :
scene . appState . activeTool . type === "image"
? { . . . scene . appState . activeTool , type : "selection" }
activeTool . type === "image" ||
activeTool . type === "lasso" ||
activeTool . type === "selection"
? {
. . . activeTool ,
type : this . defaultSelectionTool ,
}
: scene . appState . activeTool ,
isLoading : false ,
toast : this.state.toast ,
@ -2399,6 +2412,16 @@ class App extends React.Component<AppProps, AppState> {
@@ -2399,6 +2412,16 @@ class App extends React.Component<AppProps, AppState> {
}
} ;
private isMobileOrTablet = ( ) : boolean = > {
const hasTouch = "ontouchstart" in window || navigator . maxTouchPoints > 0 ;
const hasCoarsePointer =
"matchMedia" in window &&
window ? . matchMedia ( "(pointer: coarse)" ) ? . matches ;
const isTouchMobile = hasTouch && hasCoarsePointer ;
return isMobile || isTouchMobile ;
} ;
private isMobileBreakpoint = ( width : number , height : number ) = > {
return (
width < MQ_MAX_WIDTH_PORTRAIT ||
@ -3117,7 +3140,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -3117,7 +3140,7 @@ class App extends React.Component<AppProps, AppState> {
this . addElementsFromPasteOrLibrary ( {
elements ,
files : data.files || null ,
position : "cursor" ,
position : this.isMobileOrTablet ( ) ? "center" : "cursor" ,
retainSeed : isPlainPaste ,
} ) ;
} else if ( data . text ) {
@ -3135,7 +3158,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -3135,7 +3158,7 @@ class App extends React.Component<AppProps, AppState> {
this . addElementsFromPasteOrLibrary ( {
elements ,
files ,
position : "cursor" ,
position : this.isMobileOrTablet ( ) ? "center" : "cursor" ,
} ) ;
return ;
@ -3195,7 +3218,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -3195,7 +3218,7 @@ class App extends React.Component<AppProps, AppState> {
}
this . addTextFromPaste ( data . text , isPlainPaste ) ;
}
this . setActiveTool ( { type : "selection" } ) ;
this . setActiveTool ( { type : this . defaultSelectionTool } , true ) ;
event ? . preventDefault ( ) ;
} ,
) ;
@ -3341,7 +3364,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -3341,7 +3364,7 @@ class App extends React.Component<AppProps, AppState> {
}
} ,
) ;
this . setActiveTool ( { type : "selection" } ) ;
this . setActiveTool ( { type : this . defaultSelectionTool } , true ) ;
if ( opts . fitToContent ) {
this . scrollToContent ( duplicatedElements , {
@ -3587,7 +3610,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -3587,7 +3610,7 @@ class App extends React.Component<AppProps, AppState> {
. . . updateActiveTool (
this . state ,
prevState . activeTool . locked
? { type : "selection" }
? { type : this . defaultSelectionTool }
: prevState . activeTool ,
) ,
locked : ! prevState . activeTool . locked ,
@ -4500,7 +4523,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -4500,7 +4523,7 @@ class App extends React.Component<AppProps, AppState> {
! this . state . selectionElement &&
! this . state . selectedElementsAreBeingDragged
) {
const shape = findShapeByKey ( event . key ) ;
const shape = findShapeByKey ( event . key , this ) ;
if ( shape ) {
if ( this . state . activeTool . type !== shape ) {
trackEvent (
@ -4593,7 +4616,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -4593,7 +4616,7 @@ class App extends React.Component<AppProps, AppState> {
if ( event . key === KEYS . K && ! event . altKey && ! event [ KEYS . CTRL_OR_CMD ] ) {
if ( this . state . activeTool . type === "laser" ) {
this . setActiveTool ( { type : "selection" } ) ;
this . setActiveTool ( { type : this . defaultSelectionTool } ) ;
} else {
this . setActiveTool ( { type : "laser" } ) ;
}
@ -5438,7 +5461,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -5438,7 +5461,7 @@ class App extends React.Component<AppProps, AppState> {
return ;
}
// we should only be able to double click when mode is selection
if ( this . state . activeTool . type !== "selection" ) {
if ( this . state . activeTool . type !== this . defaultSelectionTool ) {
return ;
}
@ -6050,6 +6073,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -6050,6 +6073,7 @@ class App extends React.Component<AppProps, AppState> {
if (
hasDeselectedButton ||
( this . state . activeTool . type !== "selection" &&
this . state . activeTool . type !== "lasso" &&
this . state . activeTool . type !== "text" &&
this . state . activeTool . type !== "eraser" )
) {
@ -6212,7 +6236,12 @@ class App extends React.Component<AppProps, AppState> {
@@ -6212,7 +6236,12 @@ class App extends React.Component<AppProps, AppState> {
! isElbowArrow ( hitElement ) ||
! ( hitElement . startBinding || hitElement . endBinding )
) {
setCursor ( this . interactiveCanvas , CURSOR_TYPE . MOVE ) ;
if (
this . state . activeTool . type !== "lasso" ||
selectedElements . length > 0
) {
setCursor ( this . interactiveCanvas , CURSOR_TYPE . MOVE ) ;
}
if ( this . state . activeEmbeddable ? . state === "hover" ) {
this . setState ( { activeEmbeddable : null } ) ;
}
@ -6329,7 +6358,12 @@ class App extends React.Component<AppProps, AppState> {
@@ -6329,7 +6358,12 @@ class App extends React.Component<AppProps, AppState> {
! isElbowArrow ( element ) ||
! ( element . startBinding || element . endBinding )
) {
setCursor ( this . interactiveCanvas , CURSOR_TYPE . MOVE ) ;
if (
this . state . activeTool . type !== "lasso" ||
Object . keys ( this . state . selectedElementIds ) . length > 0
) {
setCursor ( this . interactiveCanvas , CURSOR_TYPE . MOVE ) ;
}
}
}
} else if ( this . hitElement ( scenePointerX , scenePointerY , element ) ) {
@ -6338,7 +6372,12 @@ class App extends React.Component<AppProps, AppState> {
@@ -6338,7 +6372,12 @@ class App extends React.Component<AppProps, AppState> {
! isElbowArrow ( element ) ||
! ( element . startBinding || element . endBinding )
) {
setCursor ( this . interactiveCanvas , CURSOR_TYPE . MOVE ) ;
if (
this . state . activeTool . type !== "lasso" ||
Object . keys ( this . state . selectedElementIds ) . length > 0
) {
setCursor ( this . interactiveCanvas , CURSOR_TYPE . MOVE ) ;
}
}
}
@ -6600,11 +6639,119 @@ class App extends React.Component<AppProps, AppState> {
@@ -6600,11 +6639,119 @@ class App extends React.Component<AppProps, AppState> {
}
if ( this . state . activeTool . type === "lasso" ) {
this . lassoTrail . startPath (
pointerDownState . origin . x ,
pointerDownState . origin . y ,
event . shiftKey ,
) ;
const hitSelectedElement =
pointerDownState . hit . element &&
this . isASelectedElement ( pointerDownState . hit . element ) ;
const isMobileOrTablet = this . isMobileOrTablet ( ) ;
if (
! pointerDownState . hit . hasHitCommonBoundingBoxOfSelectedElements &&
! pointerDownState . resize . handleType &&
! hitSelectedElement
) {
this . lassoTrail . startPath (
pointerDownState . origin . x ,
pointerDownState . origin . y ,
event . shiftKey ,
) ;
// block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately)
pointerDownState . drag . blockDragging = ! isMobileOrTablet ;
}
// only for mobile or tablet, if we hit an element, select it immediately like normal selection
if (
isMobileOrTablet &&
pointerDownState . hit . element &&
! hitSelectedElement
) {
this . setState ( ( prevState ) = > {
const nextSelectedElementIds : { [ id : string ] : true } = {
. . . prevState . selectedElementIds ,
[ pointerDownState . hit . element ! . id ] : true ,
} ;
const previouslySelectedElements : ExcalidrawElement [ ] = [ ] ;
Object . keys ( prevState . selectedElementIds ) . forEach ( ( id ) = > {
const element = this . scene . getElement ( id ) ;
element && previouslySelectedElements . push ( element ) ;
} ) ;
const hitElement = pointerDownState . hit . element ! ;
// if hitElement is frame-like, deselect all of its elements
// if they are selected
if ( isFrameLikeElement ( hitElement ) ) {
getFrameChildren ( previouslySelectedElements , hitElement . id ) . forEach (
( element ) = > {
delete nextSelectedElementIds [ element . id ] ;
} ,
) ;
} else if ( hitElement . frameId ) {
// if hitElement is in a frame and its frame has been selected
// disable selection for the given element
if ( nextSelectedElementIds [ hitElement . frameId ] ) {
delete nextSelectedElementIds [ hitElement . id ] ;
}
} else {
// hitElement is neither a frame nor an element in a frame
// but since hitElement could be in a group with some frames
// this means selecting hitElement will have the frames selected as well
// because we want to keep the invariant:
// - frames and their elements are not selected at the same time
// we deselect elements in those frames that were previously selected
const groupIds = hitElement . groupIds ;
const framesInGroups = new Set (
groupIds
. flatMap ( ( gid ) = >
getElementsInGroup ( this . scene . getNonDeletedElements ( ) , gid ) ,
)
. filter ( ( element ) = > isFrameLikeElement ( element ) )
. map ( ( frame ) = > frame . id ) ,
) ;
if ( framesInGroups . size > 0 ) {
previouslySelectedElements . forEach ( ( element ) = > {
if ( element . frameId && framesInGroups . has ( element . frameId ) ) {
// deselect element and groups containing the element
delete nextSelectedElementIds [ element . id ] ;
element . groupIds
. flatMap ( ( gid ) = >
getElementsInGroup (
this . scene . getNonDeletedElements ( ) ,
gid ,
) ,
)
. forEach ( ( element ) = > {
delete nextSelectedElementIds [ element . id ] ;
} ) ;
}
} ) ;
}
}
return {
. . . selectGroupsForSelectedElements (
{
editingGroupId : prevState.editingGroupId ,
selectedElementIds : nextSelectedElementIds ,
} ,
this . scene . getNonDeletedElements ( ) ,
prevState ,
this ,
) ,
showHyperlinkPopup :
hitElement . link || isEmbeddableElement ( hitElement )
? "info"
: false ,
} ;
} ) ;
pointerDownState . hit . wasAddedToSelection = true ;
}
} else if ( this . state . activeTool . type === "text" ) {
this . handleTextOnPointerDown ( event , pointerDownState ) ;
} else if (
@ -6984,6 +7131,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -6984,6 +7131,7 @@ class App extends React.Component<AppProps, AppState> {
hasOccurred : false ,
offset : null ,
origin : { . . . origin } ,
blockDragging : false ,
} ,
eventListeners : {
onMove : null ,
@ -7059,7 +7207,10 @@ class App extends React.Component<AppProps, AppState> {
@@ -7059,7 +7207,10 @@ class App extends React.Component<AppProps, AppState> {
event : React.PointerEvent < HTMLElement > ,
pointerDownState : PointerDownState ,
) : boolean = > {
if ( this . state . activeTool . type === "selection" ) {
if (
this . state . activeTool . type === "selection" ||
this . state . activeTool . type === "lasso"
) {
const elements = this . scene . getNonDeletedElements ( ) ;
const elementsMap = this . scene . getNonDeletedElementsMap ( ) ;
const selectedElements = this . scene . getSelectedElements ( this . state ) ;
@ -7266,7 +7417,18 @@ class App extends React.Component<AppProps, AppState> {
@@ -7266,7 +7417,18 @@ class App extends React.Component<AppProps, AppState> {
// on CMD/CTRL, drill down to hit element regardless of groups etc.
if ( event [ KEYS . CTRL_OR_CMD ] ) {
if ( event . altKey ) {
// ctrl + alt means we're lasso selecting
// ctrl + alt means we're lasso selecting - start lasso trail and switch to lasso tool
// Close any open dialogs that might interfere with lasso selection
if ( this . state . openDialog ? . name === "elementLinkSelector" ) {
this . setOpenDialog ( null ) ;
}
this . lassoTrail . startPath (
pointerDownState . origin . x ,
pointerDownState . origin . y ,
event . shiftKey ,
) ;
this . setActiveTool ( { type : "lasso" , fromSelection : true } ) ;
return false ;
}
if ( ! this . state . selectedElementIds [ hitElement . id ] ) {
@ -7487,7 +7649,9 @@ class App extends React.Component<AppProps, AppState> {
@@ -7487,7 +7649,9 @@ class App extends React.Component<AppProps, AppState> {
resetCursor ( this . interactiveCanvas ) ;
if ( ! this . state . activeTool . locked ) {
this . setState ( {
activeTool : updateActiveTool ( this . state , { type : "selection" } ) ,
activeTool : updateActiveTool ( this . state , {
type : this . defaultSelectionTool ,
} ) ,
} ) ;
}
} ;
@ -8271,15 +8435,18 @@ class App extends React.Component<AppProps, AppState> {
@@ -8271,15 +8435,18 @@ class App extends React.Component<AppProps, AppState> {
event . shiftKey &&
this . state . selectedLinearElement . elementId ===
pointerDownState . hit . element ? . id ;
if (
( hasHitASelectedElement ||
pointerDownState . hit . hasHitCommonBoundingBoxOfSelectedElements ) &&
! isSelectingPointsInLineEditor &&
this . state . activeTool . type !== "lasso"
! pointerDownState . drag . blockDragging
) {
const selectedElements = this . scene . getSelectedElements ( this . state ) ;
if ( selectedElements . every ( ( element ) = > element . locked ) ) {
if (
selectedElements . length > 0 &&
selectedElements . every ( ( element ) = > element . locked )
) {
return ;
}
@ -8300,6 +8467,29 @@ class App extends React.Component<AppProps, AppState> {
@@ -8300,6 +8467,29 @@ class App extends React.Component<AppProps, AppState> {
// if elements should be deselected on pointerup
pointerDownState . drag . hasOccurred = true ;
// prevent immediate dragging during lasso selection to avoid element displacement
// only allow dragging if we're not in the middle of lasso selection
// (on mobile, allow dragging if we hit an element)
if (
this . state . activeTool . type === "lasso" &&
this . lassoTrail . hasCurrentTrail &&
! ( this . isMobileOrTablet ( ) && pointerDownState . hit . element ) &&
! this . state . activeTool . fromSelection
) {
return ;
}
// Clear lasso trail when starting to drag selected elements with lasso tool
// Only clear if we're actually dragging (not during lasso selection)
if (
this . state . activeTool . type === "lasso" &&
selectedElements . length > 0 &&
pointerDownState . drag . hasOccurred &&
! this . state . activeTool . fromSelection
) {
this . lassoTrail . endPath ( ) ;
}
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen)
// Checking for editingTextElement to avoid jump while editing on mobile #6503
@ -8894,6 +9084,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -8894,6 +9084,7 @@ class App extends React.Component<AppProps, AppState> {
) : ( event : PointerEvent ) = > void {
return withBatchedUpdates ( ( childEvent : PointerEvent ) = > {
this . removePointer ( childEvent ) ;
pointerDownState . drag . blockDragging = false ;
if ( pointerDownState . eventListeners . onMove ) {
pointerDownState . eventListeners . onMove . flush ( ) ;
}
@ -9182,7 +9373,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -9182,7 +9373,7 @@ class App extends React.Component<AppProps, AppState> {
this . setState ( ( prevState ) = > ( {
newElement : null ,
activeTool : updateActiveTool ( this . state , {
type : "selection" ,
type : this . defaultSelectionTool ,
} ) ,
selectedElementIds : makeNextSelectedElementIds (
{
@ -9798,7 +9989,9 @@ class App extends React.Component<AppProps, AppState> {
@@ -9798,7 +9989,9 @@ class App extends React.Component<AppProps, AppState> {
this . setState ( {
newElement : null ,
suggestedBindings : [ ] ,
activeTool : updateActiveTool ( this . state , { type : "selection" } ) ,
activeTool : updateActiveTool ( this . state , {
type : this . defaultSelectionTool ,
} ) ,
} ) ;
} else {
this . setState ( {
@ -10092,7 +10285,9 @@ class App extends React.Component<AppProps, AppState> {
@@ -10092,7 +10285,9 @@ class App extends React.Component<AppProps, AppState> {
this . setState (
{
newElement : null ,
activeTool : updateActiveTool ( this . state , { type : "selection" } ) ,
activeTool : updateActiveTool ( this . state , {
type : this . defaultSelectionTool ,
} ) ,
} ,
( ) = > {
this . actionManager . executeAction ( actionFinalize ) ;
@ -10465,7 +10660,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -10465,7 +10660,7 @@ class App extends React.Component<AppProps, AppState> {
event . nativeEvent . pointerType === "pen" &&
// always allow if user uses a pen secondary button
event . button !== POINTER_BUTTON . SECONDARY ) ) &&
this . state . activeTool . type !== "selection"
this . state . activeTool . type !== this . defaultSelectionTool
) {
return ;
}