@ -41,7 +41,7 @@ import {
} from "./scene" ;
import { renderScene } from "./renderer" ;
import { AppState , FlooredNumber } from "./types" ;
import { AppState , FlooredNumber , Gesture } from "./types" ;
import { ExcalidrawElement } from "./element/types" ;
import {
@ -108,6 +108,7 @@ import useIsMobile, { IsMobileProvider } from "./is-mobile";
import { copyToAppClipboard , getClipboardContent } from "./clipboard" ;
import { normalizeScroll } from "./scene/data" ;
import { getCenter , getDistance } from "./gesture" ;
let { elements } = createScene ( ) ;
const { history } = createHistory ( ) ;
@ -130,10 +131,11 @@ const CURSOR_TYPE = {
CROSSHAIR : "crosshair" ,
GRABBING : "grabbing" ,
} ;
const MOUSE _BUTTON = {
const POINTER _BUTTON = {
MAIN : 0 ,
WHEEL : 1 ,
SECONDARY : 2 ,
TOUCH : - 1 ,
} ;
// Block pinch-zooming on iOS outside of the content area
@ -148,7 +150,13 @@ document.addEventListener(
{ passive : false } ,
) ;
let lastMouseUp : ( ( e : any ) = > void ) | null = null ;
let lastPointerUp : ( ( e : any ) = > void ) | null = null ;
const gesture : Gesture = {
pointers : [ ] ,
lastCenter : null ,
initialDistance : null ,
initialScale : null ,
} ;
export function viewportCoordsToSceneCoords (
{ clientX , clientY } : { clientX : number ; clientY : number } ,
@ -202,7 +210,6 @@ let cursorX = 0;
let cursorY = 0 ;
let isHoldingSpace : boolean = false ;
let isPanning : boolean = false ;
let isHoldingMouseButton : boolean = false ;
interface LayerUIProps {
actionManager : ActionManager ;
@ -279,17 +286,15 @@ const LayerUI = React.memo(
) ;
}
function renderSelectedShapeActions (
elements : readonly ExcalidrawElement [ ] ,
) {
const showSelectedShapeActions =
( appState . editingElement || getSelectedElements ( elements ) . length ) &&
appState . elementType === "selection" ;
function renderSelectedShapeActions() {
const { elementType , editingElement } = appState ;
const targetElements = editingElement
? [ editingElement ]
: getSelectedElements ( elements ) ;
if ( ! targetElements . length && elementType === "selection" ) {
return null ;
}
return (
< div className = "panelColumn" >
{ actionManager . renderAction ( "changeStrokeColor" ) }
@ -331,8 +336,6 @@ const LayerUI = React.memo(
{ actionManager . renderAction ( "bringForward" ) }
< / div >
< / fieldset >
{ actionManager . renderAction ( "deleteSelectedElements" ) }
< / div >
) ;
}
@ -418,7 +421,7 @@ const LayerUI = React.memo(
< / Stack.Col >
< / div >
< / section >
) : appState . openedMenu === "shape" ? (
) : appState . openedMenu === "shape" && showSelectedShapeActions ? (
< section
className = "App-mobile-menu"
aria - labelledby = "selected-shape-title"
@ -427,7 +430,7 @@ const LayerUI = React.memo(
{ t ( "headings.selectedShapeActions" ) }
< / h2 >
< div className = "App-mobile-menu-scroller" >
{ renderSelectedShapeActions ( elements ) }
{ renderSelectedShapeActions ( ) }
< / div >
< / section >
) : null }
@ -444,6 +447,12 @@ const LayerUI = React.memo(
< / Stack.Row >
< / Stack.Col >
< / section >
< HintViewer
elementType = { appState . elementType }
multiMode = { appState . multiElement !== null }
isResizing = { appState . isResizing }
elements = { elements }
/ >
< / FixedSideContainer >
< footer className = "App-toolbar" >
< div className = "App-toolbar-content" >
@ -459,7 +468,18 @@ const LayerUI = React.memo(
} ) )
}
/ >
< div
style = { {
visibility : isSomeElementSelected ( elements )
? "visible"
: "hidden" ,
} }
>
{ " " }
{ actionManager . renderAction ( "deleteSelectedElements" ) }
< / div >
{ lockButton }
{ actionManager . renderAction ( "finalize" ) }
< div
style = { {
visibility : isSomeElementSelected ( elements )
@ -482,12 +502,6 @@ const LayerUI = React.memo(
}
/ >
< / div >
< HintViewer
elementType = { appState . elementType }
multiMode = { appState . multiElement !== null }
isResizing = { appState . isResizing }
elements = { elements }
/ >
{ appState . scrolledOutside && (
< button
className = "scroll-back-to-content"
@ -525,17 +539,17 @@ const LayerUI = React.memo(
< / Stack.Col >
< / Island >
< / section >
<section
className = "App-right-menu"
aria - labelledby = "selected-shape-title "
>
< h2 className ="visually-hidden" id = "selected-shape-title" >
{t ( "headings.selectedShapeActions" ) }
< / h2 >
< Island padding = { 4 } >
{renderSelectedShapeActions ( elements ) }
< / Island >
</ section >
{showSelectedShapeActions ? (
< section
className = "App-right-menu "
aria - labelledby = "selected-shape-title"
>
<h2 className = "visually-hidden" id = "selected-shape-title" >
{ t ( "headings.selectedShapeActions" ) }
< / h2 >
<Island padding = { 4 } > {renderSelectedShapeActions ( ) } < / Island >
< / section >
) : null }
< / Stack.Col >
< section aria - labelledby = "shapes-title" >
< Stack.Col gap = { 4 } align = "start" >
@ -858,7 +872,7 @@ export class App extends React.Component<any, AppState> {
this . setState ( { . . . data . appState } ) ;
}
}
} else if ( event . key === KEYS . SPACE && ! isHoldingMouseButton ) {
} else if ( event . key === KEYS . SPACE && gesture . pointers . length === 0 ) {
isHoldingSpace = true ;
document . documentElement . style . cursor = CURSOR_TYPE . GRABBING ;
}
@ -953,6 +967,10 @@ export class App extends React.Component<any, AppState> {
this . setState ( { } ) ;
} ;
removePointer = ( e : React.PointerEvent < HTMLElement > ) = > {
gesture . pointers = gesture . pointers . filter ( p = > p . id !== e . pointerId ) ;
} ;
public render() {
const canvasDOMWidth = window . innerWidth ;
const canvasDOMHeight = window . innerHeight ;
@ -1055,12 +1073,12 @@ export class App extends React.Component<any, AppState> {
left : e.clientX ,
} ) ;
} }
on Mouse Down= { e = > {
if ( last Mouse Up !== null ) {
// Unfortunately, sometimes we don't get a mouseup after a mouse down,
on Pointer Down= { e = > {
if ( last Pointer Up !== null ) {
// Unfortunately, sometimes we don't get a pointerup after a pointer down,
// this can happen when a contextual menu or alert is triggered. In order to avoid
// being in a weird state, we clean up on the next mouse down
last Mouse Up( e ) ;
// being in a weird state, we clean up on the next pointer down
last Pointer Up( e ) ;
}
if ( isPanning ) {
@ -1069,15 +1087,14 @@ export class App extends React.Component<any, AppState> {
// pan canvas on wheel button drag or space+drag
if (
! isHoldingMouseButton &&
( e . button === MOUSE _BUTTON. WHEEL ||
( e . button === MOUSE _BUTTON. MAIN && isHoldingSpace ) )
gesture . pointers . length === 0 &&
( e . button === POINTER _BUTTON. WHEEL ||
( e . button === POINTER _BUTTON. MAIN && isHoldingSpace ) )
) {
isHoldingMouseButton = true ;
isPanning = true ;
document . documentElement . style . cursor = CURSOR_TYPE . GRABBING ;
let { clientX : lastX , clientY : lastY } = e ;
const on MouseMove = ( e : Mouse Event) = > {
const on PointerMove = ( e : Pointer Event) = > {
const deltaX = lastX - e . clientX ;
const deltaY = lastY - e . clientY ;
lastX = e . clientX ;
@ -1092,30 +1109,44 @@ export class App extends React.Component<any, AppState> {
) ,
} ) ;
} ;
const teardown = ( last Mouse Up = ( ) = > {
last Mouse Up = null ;
const teardown = ( last Pointer Up = ( ) = > {
last Pointer Up = null ;
isPanning = false ;
isHoldingMouseButton = false ;
if ( ! isHoldingSpace ) {
setCursorForShape ( this . state . elementType ) ;
}
window . removeEventListener ( " mousemove", onMouse Move) ;
window . removeEventListener ( " mouse up", teardown ) ;
window . removeEventListener ( " pointermove", onPointer Move) ;
window . removeEventListener ( " pointer up", teardown ) ;
window . removeEventListener ( "blur" , teardown ) ;
} ) ;
window . addEventListener ( "blur" , teardown ) ;
window . addEventListener ( " mousemove", onMouse Move, {
window . addEventListener ( " pointermove", onPointer Move, {
passive : true ,
} ) ;
window . addEventListener ( " mouse up", teardown ) ;
window . addEventListener ( " pointer up", teardown ) ;
return ;
}
// only handle left mouse button
if ( e . button !== MOUSE_BUTTON . MAIN ) {
// only handle left mouse button or touch
if (
e . button !== POINTER_BUTTON . MAIN &&
e . button !== POINTER_BUTTON . TOUCH
) {
return ;
}
// fixes mousemove causing selection of UI texts #32
gesture . pointers . push ( {
id : e.pointerId ,
x : e.clientX ,
y : e.clientY ,
} ) ;
if ( gesture . pointers . length === 2 ) {
gesture . lastCenter = getCenter ( gesture . pointers ) ;
gesture . initialScale = this . state . zoom ;
gesture . initialDistance = getDistance ( gesture . pointers ) ;
}
// fixes pointermove causing selection of UI texts #32
e . preventDefault ( ) ;
// Preventing the event above disables default behavior
// of defocusing potentially focused element, which is what we
@ -1124,6 +1155,11 @@ export class App extends React.Component<any, AppState> {
document . activeElement . blur ( ) ;
}
// don't select while panning
if ( gesture . pointers . length > 1 ) {
return ;
}
// Handle scrollbars dragging
const {
isOverHorizontalScrollBar ,
@ -1216,7 +1252,7 @@ export class App extends React.Component<any, AppState> {
elementIsAddedToSelection = true ;
}
// We duplicate the selected element if alt is pressed on Mouse down
// We duplicate the selected element if alt is pressed on pointer down
if ( e . altKey ) {
elements = [
. . . elements . map ( element = > ( {
@ -1352,8 +1388,8 @@ export class App extends React.Component<any, AppState> {
p1 : Point ,
deltaX : number ,
deltaY : number ,
mouse X: number ,
mouse Y: number ,
pointer X: number ,
pointer Y: number ,
perfect : boolean ,
) = > void )
| null = null ;
@ -1363,8 +1399,8 @@ export class App extends React.Component<any, AppState> {
p1 : Point ,
deltaX : number ,
deltaY : number ,
mouse X: number ,
mouse Y: number ,
pointer X: number ,
pointer Y: number ,
perfect : boolean ,
) = > {
if ( perfect ) {
@ -1373,8 +1409,8 @@ export class App extends React.Component<any, AppState> {
const { width , height } = getPerfectElementSize (
element . type ,
mouse X - element . x - p1 [ 0 ] ,
mouse Y - element . y - p1 [ 1 ] ,
pointer X - element . x - p1 [ 0 ] ,
pointer Y - element . y - p1 [ 1 ] ,
) ;
const dx = element . x + width + p1 [ 0 ] ;
@ -1396,15 +1432,15 @@ export class App extends React.Component<any, AppState> {
p1 : Point ,
deltaX : number ,
deltaY : number ,
mouse X: number ,
mouse Y: number ,
pointer X: number ,
pointer Y: number ,
perfect : boolean ,
) = > {
if ( perfect ) {
const { width , height } = getPerfectElementSize (
element . type ,
mouse X - element . x ,
mouse Y - element . y ,
pointer X - element . x ,
pointer Y - element . y ,
) ;
p1 [ 0 ] = width ;
p1 [ 1 ] = height ;
@ -1414,7 +1450,7 @@ export class App extends React.Component<any, AppState> {
}
} ;
const on MouseMove = ( e : Mouse Event) = > {
const on PointerMove = ( e : Pointer Event) = > {
const target = e . target ;
if ( ! ( target instanceof HTMLElement ) ) {
return ;
@ -1447,7 +1483,7 @@ export class App extends React.Component<any, AppState> {
// 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 mouse move)
// triggering pointer move)
if (
! draggingOccurred &&
( this . state . elementType === "arrow" ||
@ -1691,7 +1727,7 @@ export class App extends React.Component<any, AppState> {
if ( hitElement ? . isSelected ) {
// Marking that click was used for dragging to check
// if elements should be deselected on mouse up
// if elements should be deselected on pointer up
draggingOccurred = true ;
const selectedElements = getSelectedElements ( elements ) ;
if ( selectedElements . length > 0 ) {
@ -1790,7 +1826,7 @@ export class App extends React.Component<any, AppState> {
this . setState ( { } ) ;
} ;
const on MouseUp = ( e : Mouse Event) = > {
const on PointerUp = ( e : Pointer Event) = > {
const {
draggingElement ,
resizingElement ,
@ -1806,10 +1842,9 @@ export class App extends React.Component<any, AppState> {
} ) ;
resizeArrowFn = null ;
lastMouseUp = null ;
isHoldingMouseButton = false ;
window . removeEventListener ( "mousemove" , onMouseMove ) ;
window . removeEventListener ( "mouseup" , onMouseUp ) ;
lastPointerUp = null ;
window . removeEventListener ( "pointermove" , onPointerMove ) ;
window . removeEventListener ( "pointerup" , onPointerUp ) ;
if ( elementType === "arrow" || elementType === "line" ) {
if ( draggingElement ! . points . length > 1 ) {
@ -1850,7 +1885,7 @@ export class App extends React.Component<any, AppState> {
draggingElement &&
isInvisiblySmallElement ( draggingElement )
) {
// remove invisible element which was added in on Mouse Down
// remove invisible element which was added in on Pointer Down
elements = elements . slice ( 0 , - 1 ) ;
this . setState ( {
draggingElement : null ,
@ -1882,7 +1917,7 @@ export class App extends React.Component<any, AppState> {
// from hitted element
//
// If click occurred and elements were dragged or some element
// was added to selection (on mouse down phase) we need to keep
// was added to selection (on pointer down phase) we need to keep
// selection unchanged
if (
hitElement &&
@ -1928,10 +1963,10 @@ export class App extends React.Component<any, AppState> {
}
} ;
last MouseUp = onMouse Up;
last PointerUp = onPointer Up;
window . addEventListener ( " mousemove", onMouse Move) ;
window . addEventListener ( " mouseup", onMouse Up) ;
window . addEventListener ( " pointermove", onPointer Move) ;
window . addEventListener ( " pointerup", onPointer Up) ;
} }
onDoubleClick = { e = > {
resetCursor ( ) ;
@ -2048,7 +2083,39 @@ export class App extends React.Component<any, AppState> {
} ,
} ) ;
} }
onMouseMove = { e = > {
onPointerMove = { e = > {
gesture . pointers = gesture . pointers . map ( p = >
p . id === e . pointerId
? {
id : e.pointerId ,
x : e.clientX ,
y : e.clientY ,
}
: p ,
) ;
if ( gesture . pointers . length === 2 ) {
const center = getCenter ( gesture . pointers ) ;
const deltaX = center . x - gesture . lastCenter ! . x ;
const deltaY = center . y - gesture . lastCenter ! . y ;
gesture . lastCenter = center ;
const distance = getDistance ( gesture . pointers ) ;
const scaleFactor = distance / gesture . initialDistance ! ;
this . setState ( {
scrollX : normalizeScroll (
this . state . scrollX + deltaX / this . state . zoom ,
) ,
scrollY : normalizeScroll (
this . state . scrollY + deltaY / this . state . zoom ,
) ,
zoom : getNormalizedZoom ( gesture . initialScale ! * scaleFactor ) ,
} ) ;
} else {
gesture . lastCenter = gesture . initialDistance = gesture . initialScale = null ;
}
if ( isHoldingSpace || isPanning ) {
return ;
}
@ -2101,6 +2168,8 @@ export class App extends React.Component<any, AppState> {
) ;
document . documentElement . style . cursor = hitElement ? "move" : "" ;
} }
onPointerUp = { this . removePointer }
onPointerCancel = { this . removePointer }
onDrop = { e = > {
const file = e . dataTransfer . files [ 0 ] ;
if ( file ? . type === "application/json" ) {