@ -42,7 +42,13 @@ import { renderScene } from "./renderer";
import { AppState } from "./types" ;
import { AppState } from "./types" ;
import { ExcalidrawElement } from "./element/types" ;
import { ExcalidrawElement } from "./element/types" ;
import { isInputLike , debounce , capitalizeString , distance } from "./utils" ;
import {
isInputLike ,
debounce ,
capitalizeString ,
distance ,
distance2d ,
} from "./utils" ;
import { KEYS , isArrowKey } from "./keys" ;
import { KEYS , isArrowKey } from "./keys" ;
import { findShapeByKey , shapesShortcutKeys , SHAPES } from "./shapes" ;
import { findShapeByKey , shapesShortcutKeys , SHAPES } from "./shapes" ;
@ -76,6 +82,7 @@ import {
actionSaveScene ,
actionSaveScene ,
actionCopyStyles ,
actionCopyStyles ,
actionPasteStyles ,
actionPasteStyles ,
actionFinalize ,
} from "./actions" ;
} from "./actions" ;
import { Action , ActionResult } from "./actions/types" ;
import { Action , ActionResult } from "./actions/types" ;
import { getDefaultAppState } from "./appState" ;
import { getDefaultAppState } from "./appState" ;
@ -88,6 +95,7 @@ import { ExportDialog } from "./components/ExportDialog";
import { withTranslation } from "react-i18next" ;
import { withTranslation } from "react-i18next" ;
import { LanguageList } from "./components/LanguageList" ;
import { LanguageList } from "./components/LanguageList" ;
import i18n , { languages , parseDetectedLang } from "./i18n" ;
import i18n , { languages , parseDetectedLang } from "./i18n" ;
import { Point } from "roughjs/bin/geometry" ;
import { StoredScenesList } from "./components/StoredScenesList" ;
import { StoredScenesList } from "./components/StoredScenesList" ;
let { elements } = createScene ( ) ;
let { elements } = createScene ( ) ;
@ -109,6 +117,7 @@ function setCursorForShape(shape: string) {
}
}
}
}
const DRAGGING_THRESHOLD = 10 ; // 10px
const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5 ;
const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5 ;
const ELEMENT_TRANSLATE_AMOUNT = 1 ;
const ELEMENT_TRANSLATE_AMOUNT = 1 ;
const TEXT_TO_CENTER_SNAP_THRESHOLD = 30 ;
const TEXT_TO_CENTER_SNAP_THRESHOLD = 30 ;
@ -168,6 +177,7 @@ export class App extends React.Component<any, AppState> {
canvasOnlyActions : Array < Action > ;
canvasOnlyActions : Array < Action > ;
constructor ( props : any ) {
constructor ( props : any ) {
super ( props ) ;
super ( props ) ;
this . actionManager . registerAction ( actionFinalize ) ;
this . actionManager . registerAction ( actionDeleteSelected ) ;
this . actionManager . registerAction ( actionDeleteSelected ) ;
this . actionManager . registerAction ( actionSendToBack ) ;
this . actionManager . registerAction ( actionSendToBack ) ;
this . actionManager . registerAction ( actionBringToFront ) ;
this . actionManager . registerAction ( actionBringToFront ) ;
@ -328,17 +338,7 @@ export class App extends React.Component<any, AppState> {
} ;
} ;
private onKeyDown = ( event : KeyboardEvent ) = > {
private onKeyDown = ( event : KeyboardEvent ) = > {
if ( event . key === KEYS . ESCAPE && ! this . state . draggingElement ) {
if ( isInputLike ( event . target ) && event . key !== KEYS . ESCAPE ) return ;
elements = clearSelection ( elements ) ;
this . setState ( { } ) ;
this . setState ( { elementType : "selection" } ) ;
if ( window . document . activeElement instanceof HTMLElement ) {
window . document . activeElement . blur ( ) ;
}
event . preventDefault ( ) ;
return ;
}
if ( isInputLike ( event . target ) ) return ;
const actionResult = this . actionManager . handleKeyDown (
const actionResult = this . actionManager . handleKeyDown (
event ,
event ,
@ -387,19 +387,27 @@ export class App extends React.Component<any, AppState> {
} else if ( event [ KEYS . META ] && event . code === "KeyZ" ) {
} else if ( event [ KEYS . META ] && event . code === "KeyZ" ) {
event . preventDefault ( ) ;
event . preventDefault ( ) ;
if (
this . state . resizingElement ||
this . state . multiElement ||
this . state . editingElement
) {
return ;
}
if ( event . shiftKey ) {
if ( event . shiftKey ) {
// Redo action
// Redo action
const data = history . redoOnce ( ) ;
const data = history . redoOnce ( ) ;
if ( data !== null ) {
if ( data !== null ) {
elements = data . elements ;
elements = data . elements ;
this . setState ( data . appState ) ;
this . setState ( { . . . data . appState } ) ;
}
}
} else {
} else {
// undo action
// undo action
const data = history . undoOnce ( ) ;
const data = history . undoOnce ( ) ;
if ( data !== null ) {
if ( data !== null ) {
elements = data . elements ;
elements = data . elements ;
this . setState ( data . appState ) ;
this . setState ( { . . . data . appState } ) ;
}
}
}
}
} else if ( event . key === KEYS . SPACE && ! isHoldingMouseButton ) {
} else if ( event . key === KEYS . SPACE && ! isHoldingMouseButton ) {
@ -570,7 +578,7 @@ export class App extends React.Component<any, AppState> {
aria - label = { capitalizeString ( label ) }
aria - label = { capitalizeString ( label ) }
aria - keyshortcuts = { ` ${ label [ 0 ] } ${ index + 1 } ` }
aria - keyshortcuts = { ` ${ label [ 0 ] } ${ index + 1 } ` }
onChange = { ( ) = > {
onChange = { ( ) = > {
this . setState ( { elementType : value } ) ;
this . setState ( { elementType : value , multiElement : null } ) ;
elements = clearSelection ( elements ) ;
elements = clearSelection ( elements ) ;
document . documentElement . style . cursor =
document . documentElement . style . cursor =
value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR ;
value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR ;
@ -1036,11 +1044,28 @@ export class App extends React.Component<any, AppState> {
editingElement : element ,
editingElement : element ,
} ) ;
} ) ;
return ;
return ;
} else if ( this . state . elementType === "arrow" ) {
if ( this . state . multiElement ) {
const { multiElement } = this . state ;
const { x : rx , y : ry } = multiElement ;
multiElement . isSelected = true ;
multiElement . points . push ( [ x - rx , y - ry ] ) ;
multiElement . shape = null ;
this . setState ( { draggingElement : multiElement } ) ;
} else {
element . isSelected = false ;
element . points . push ( [ 0 , 0 ] ) ;
element . shape = null ;
elements = [ . . . elements , element ] ;
this . setState ( {
draggingElement : element ,
} ) ;
}
} else {
elements = [ . . . elements , element ] ;
this . setState ( { multiElement : null , draggingElement : element } ) ;
}
}
elements = [ . . . elements , element ] ;
this . setState ( { draggingElement : element } ) ;
let lastX = x ;
let lastX = x ;
let lastY = y ;
let lastY = y ;
@ -1049,6 +1074,75 @@ export class App extends React.Component<any, AppState> {
lastY = e . clientY - CANVAS_WINDOW_OFFSET_TOP ;
lastY = e . clientY - CANVAS_WINDOW_OFFSET_TOP ;
}
}
let resizeArrowFn :
| ( (
element : ExcalidrawElement ,
p1 : Point ,
deltaX : number ,
deltaY : number ,
mouseX : number ,
mouseY : number ,
perfect : boolean ,
) = > void )
| null = null ;
const arrowResizeOrigin = (
element : ExcalidrawElement ,
p1 : Point ,
deltaX : number ,
deltaY : number ,
mouseX : number ,
mouseY : number ,
perfect : boolean ,
) = > {
// TODO: Implement perfect sizing for origin
if ( perfect ) {
const absPx = p1 [ 0 ] + element . x ;
const absPy = p1 [ 1 ] + element . y ;
let { width , height } = getPerfectElementSize (
"arrow" ,
mouseX - element . x - p1 [ 0 ] ,
mouseY - element . y - p1 [ 1 ] ,
) ;
const dx = element . x + width + p1 [ 0 ] ;
const dy = element . y + height + p1 [ 1 ] ;
element . x = dx ;
element . y = dy ;
p1 [ 0 ] = absPx - element . x ;
p1 [ 1 ] = absPy - element . y ;
} else {
element . x += deltaX ;
element . y += deltaY ;
p1 [ 0 ] -= deltaX ;
p1 [ 1 ] -= deltaY ;
}
} ;
const arrowResizeEnd = (
element : ExcalidrawElement ,
p1 : Point ,
deltaX : number ,
deltaY : number ,
mouseX : number ,
mouseY : number ,
perfect : boolean ,
) = > {
if ( perfect ) {
const { width , height } = getPerfectElementSize (
"arrow" ,
mouseX - element . x ,
mouseY - element . y ,
) ;
p1 [ 0 ] = width ;
p1 [ 1 ] = height ;
} else {
p1 [ 0 ] += deltaX ;
p1 [ 1 ] += deltaY ;
}
} ;
const onMouseMove = ( e : MouseEvent ) = > {
const onMouseMove = ( e : MouseEvent ) = > {
const target = e . target ;
const target = e . target ;
if ( ! ( target instanceof HTMLElement ) ) {
if ( ! ( target instanceof HTMLElement ) ) {
@ -1075,6 +1169,16 @@ export class App extends React.Component<any, AppState> {
return ;
return ;
}
}
// for arrows, don't start dragging until a given threshold
// to ensure we don't create a 2-point arrow by mistake when
// user clicks mouse in a way that it moves a tiny bit (thus
// triggering mousemove)
if ( ! draggingOccurred && this . state . elementType === "arrow" ) {
const { x , y } = viewportCoordsToSceneCoords ( e , this . state ) ;
if ( distance2d ( x , y , originX , originY ) < DRAGGING_THRESHOLD )
return ;
}
if ( isResizingElements && this . state . resizingElement ) {
if ( isResizingElements && this . state . resizingElement ) {
const el = this . state . resizingElement ;
const el = this . state . resizingElement ;
const selectedElements = elements . filter ( el = > el . isSelected ) ;
const selectedElements = elements . filter ( el = > el . isSelected ) ;
@ -1087,73 +1191,217 @@ export class App extends React.Component<any, AppState> {
element . type === "line" || element . type === "arrow" ;
element . type === "line" || element . type === "arrow" ;
switch ( resizeHandle ) {
switch ( resizeHandle ) {
case "nw" :
case "nw" :
element . width -= deltaX ;
if (
element . x += deltaX ;
element . type === "arrow" &&
element . points . length === 2
if ( e . shiftKey ) {
) {
if ( isLinear ) {
const [ , p1 ] = element . points ;
resizePerfectLineForNWHandler ( element , x , y ) ;
} else {
if ( ! resizeArrowFn ) {
element . y += element . height - element . width ;
if ( p1 [ 0 ] < 0 || p1 [ 1 ] < 0 ) {
element . height = element . width ;
resizeArrowFn = arrowResizeEnd ;
} else {
resizeArrowFn = arrowResizeOrigin ;
}
}
}
resizeArrowFn (
element ,
p1 ,
deltaX ,
deltaY ,
x ,
y ,
e . shiftKey ,
) ;
} else {
} else {
element . height -= deltaY ;
element . width -= deltaX ;
element . y += deltaY ;
element . x += deltaX ;
if ( e . shiftKey ) {
if ( isLinear ) {
resizePerfectLineForNWHandler ( element , x , y ) ;
} else {
element . y += element . height - element . width ;
element . height = element . width ;
}
} else {
element . height -= deltaY ;
element . y += deltaY ;
}
}
}
break ;
break ;
case "ne" :
case "ne" :
element . width += deltaX ;
if (
if ( e . shiftKey ) {
element . type === "arrow" &&
element . y += element . height - element . width ;
element . points . length === 2
element . height = element . width ;
) {
const [ , p1 ] = element . points ;
if ( ! resizeArrowFn ) {
if ( p1 [ 0 ] >= 0 ) {
resizeArrowFn = arrowResizeEnd ;
} else {
resizeArrowFn = arrowResizeOrigin ;
}
}
resizeArrowFn (
element ,
p1 ,
deltaX ,
deltaY ,
x ,
y ,
e . shiftKey ,
) ;
} else {
} else {
element . height -= deltaY ;
element . width += deltaX ;
element . y += deltaY ;
if ( e . shiftKey ) {
element . y += element . height - element . width ;
element . height = element . width ;
} else {
element . height -= deltaY ;
element . y += deltaY ;
}
}
}
break ;
break ;
case "sw" :
case "sw" :
element . width -= deltaX ;
if (
element . x += deltaX ;
element . type === "arrow" &&
if ( e . shiftKey ) {
element . points . length === 2
element . height = element . width ;
) {
const [ , p1 ] = element . points ;
if ( ! resizeArrowFn ) {
if ( p1 [ 0 ] <= 0 ) {
resizeArrowFn = arrowResizeEnd ;
} else {
resizeArrowFn = arrowResizeOrigin ;
}
}
resizeArrowFn (
element ,
p1 ,
deltaX ,
deltaY ,
x ,
y ,
e . shiftKey ,
) ;
} else {
} else {
element . height += deltaY ;
element . width -= deltaX ;
element . x += deltaX ;
if ( e . shiftKey ) {
element . height = element . width ;
} else {
element . height += deltaY ;
}
}
}
break ;
break ;
case "se" :
case "se" :
if ( e . shiftKey ) {
if (
if ( isLinear ) {
element . type === "arrow" &&
const { width , height } = getPerfectElementSize (
element . points . length === 2
element . type ,
) {
x - element . x ,
const [ , p1 ] = element . points ;
y - element . y ,
if ( ! resizeArrowFn ) {
) ;
if ( p1 [ 0 ] > 0 || p1 [ 1 ] > 0 ) {
element . width = width ;
resizeArrowFn = arrowResizeEnd ;
element . height = height ;
} else {
resizeArrowFn = arrowResizeOrigin ;
}
}
resizeArrowFn (
element ,
p1 ,
deltaX ,
deltaY ,
x ,
y ,
e . shiftKey ,
) ;
} else {
if ( e . shiftKey ) {
if ( isLinear ) {
const { width , height } = getPerfectElementSize (
element . type ,
x - element . x ,
y - element . y ,
) ;
element . width = width ;
element . height = height ;
} else {
element . width += deltaX ;
element . height = element . width ;
}
} else {
} else {
element . width += deltaX ;
element . width += deltaX ;
element . height = element . width ;
element . height += deltaY ;
}
}
} else {
element . width += deltaX ;
element . height += deltaY ;
}
}
break ;
break ;
case "n" :
case "n" : {
element . height -= deltaY ;
element . height -= deltaY ;
element . y += deltaY ;
element . y += deltaY ;
if ( element . points . length > 0 ) {
const len = element . points . length ;
const points = [ . . . element . points ] . sort (
( a , b ) = > a [ 1 ] - b [ 1 ] ,
) ;
for ( let i = 1 ; i < points . length ; ++ i ) {
const pnt = points [ i ] ;
pnt [ 1 ] -= deltaY / ( len - i ) ;
}
}
break ;
break ;
case "w" :
}
case "w" : {
element . width -= deltaX ;
element . width -= deltaX ;
element . x += deltaX ;
element . x += deltaX ;
if ( element . points . length > 0 ) {
const len = element . points . length ;
const points = [ . . . element . points ] . sort (
( a , b ) = > a [ 0 ] - b [ 0 ] ,
) ;
for ( let i = 0 ; i < points . length ; ++ i ) {
const pnt = points [ i ] ;
pnt [ 0 ] -= deltaX / ( len - i ) ;
}
}
break ;
break ;
case "s" :
}
case "s" : {
element . height += deltaY ;
element . height += deltaY ;
if ( element . points . length > 0 ) {
const len = element . points . length ;
const points = [ . . . element . points ] . sort (
( a , b ) = > a [ 1 ] - b [ 1 ] ,
) ;
for ( let i = 1 ; i < points . length ; ++ i ) {
const pnt = points [ i ] ;
pnt [ 1 ] += deltaY / ( len - i ) ;
}
}
break ;
break ;
case "e" :
}
case "e" : {
element . width += deltaX ;
element . width += deltaX ;
if ( element . points . length > 0 ) {
const len = element . points . length ;
const points = [ . . . element . points ] . sort (
( a , b ) = > a [ 0 ] - b [ 0 ] ,
) ;
for ( let i = 1 ; i < points . length ; ++ i ) {
const pnt = points [ i ] ;
pnt [ 0 ] += deltaX / ( len - i ) ;
}
}
break ;
break ;
}
}
}
if ( resizeHandle ) {
if ( resizeHandle ) {
@ -1235,6 +1483,30 @@ export class App extends React.Component<any, AppState> {
draggingElement . width = width ;
draggingElement . width = width ;
draggingElement . height = height ;
draggingElement . height = height ;
if ( this . state . elementType === "arrow" ) {
draggingOccurred = true ;
const points = draggingElement . points ;
let dx = x - draggingElement . x ;
let dy = y - draggingElement . y ;
if ( e . shiftKey && points . length === 2 ) {
( { width : dx , height : dy } = getPerfectElementSize (
this . state . elementType ,
dx ,
dy ,
) ) ;
}
if ( points . length === 1 ) {
points . push ( [ dx , dy ] ) ;
} else if ( points . length > 1 ) {
const pnt = points [ points . length - 1 ] ;
pnt [ 0 ] = dx ;
pnt [ 1 ] = dy ;
}
}
draggingElement . shape = null ;
draggingElement . shape = null ;
if ( this . state . elementType === "selection" ) {
if ( this . state . elementType === "selection" ) {
@ -1258,15 +1530,33 @@ export class App extends React.Component<any, AppState> {
const {
const {
draggingElement ,
draggingElement ,
resizingElement ,
resizingElement ,
multiElement ,
elementType ,
elementType ,
elementLocked ,
elementLocked ,
} = this . state ;
} = this . state ;
resizeArrowFn = null ;
lastMouseUp = null ;
lastMouseUp = null ;
isHoldingMouseButton = false ;
isHoldingMouseButton = false ;
window . removeEventListener ( "mousemove" , onMouseMove ) ;
window . removeEventListener ( "mousemove" , onMouseMove ) ;
window . removeEventListener ( "mouseup" , onMouseUp ) ;
window . removeEventListener ( "mouseup" , onMouseUp ) ;
if ( elementType === "arrow" ) {
if ( draggingElement ! . points . length > 1 ) {
history . resumeRecording ( ) ;
}
if ( ! draggingOccurred && ! multiElement ) {
this . setState ( { multiElement : this.state.draggingElement } ) ;
} else if ( draggingOccurred && ! multiElement ) {
this . state . draggingElement ! . isSelected = true ;
this . setState ( {
draggingElement : null ,
elementType : "selection" ,
} ) ;
}
return ;
}
if (
if (
elementType !== "selection" &&
elementType !== "selection" &&
draggingElement &&
draggingElement &&
@ -1351,9 +1641,15 @@ export class App extends React.Component<any, AppState> {
window . addEventListener ( "mousemove" , onMouseMove ) ;
window . addEventListener ( "mousemove" , onMouseMove ) ;
window . addEventListener ( "mouseup" , onMouseUp ) ;
window . addEventListener ( "mouseup" , onMouseUp ) ;
// We don't want to save history on mouseDown, only on mouseUp when it's fully configured
if (
history . skipRecording ( ) ;
! this . state . multiElement ||
this . setState ( { } ) ;
( this . state . multiElement &&
this . state . multiElement . points . length < 2 )
) {
// We don't want to save history on mouseDown, only on mouseUp when it's fully configured
history . skipRecording ( ) ;
this . setState ( { } ) ;
}
} }
} }
onDoubleClick = { e = > {
onDoubleClick = { e = > {
const { x , y } = viewportCoordsToSceneCoords ( e , this . state ) ;
const { x , y } = viewportCoordsToSceneCoords ( e , this . state ) ;