@ -64,6 +64,8 @@ import {
MQ_MAX_HEIGHT_LANDSCAPE ,
MQ_MAX_WIDTH_LANDSCAPE ,
MQ_MAX_WIDTH_PORTRAIT ,
MQ_RIGHT_SIDEBAR_MIN_WIDTH ,
MQ_SM_MAX_WIDTH ,
POINTER_BUTTON ,
SCROLL_TIMEOUT ,
TAP_TWICE_TIMEOUT ,
@ -194,7 +196,7 @@ import {
LibraryItems ,
PointerDownState ,
SceneData ,
Device Type ,
Device ,
} from "../types" ;
import {
debounce ,
@ -220,7 +222,6 @@ import {
} from "../utils" ;
import ContextMenu , { ContextMenuOption } from "./ContextMenu" ;
import LayerUI from "./LayerUI" ;
import { Stats } from "./Stats" ;
import { Toast } from "./Toast" ;
import { actionToggleViewMode } from "../actions/actionToggleViewMode" ;
import {
@ -259,12 +260,14 @@ import {
isLocalLink ,
} from "../element/Hyperlink" ;
const defaultDeviceTypeContext : DeviceType = {
const deviceContextInitialValue = {
isSmScreen : false ,
isMobile : false ,
isTouchScreen : false ,
canDeviceFitSidebar : false ,
} ;
const Device Type Context = React . createContext (de faultDe viceType Context) ;
export const useDevice Type = ( ) = > useContext (Devic eTyp eContext) ;
const Device Context = React . createContext <Device > (de viceContextInitialValue ) ;
export const useDevice = ( ) = > useContext <Device > (Devic eContext) ;
const ExcalidrawContainerContext = React . createContext < {
container : HTMLDivElement | null ;
id : string | null ;
@ -296,10 +299,7 @@ class App extends React.Component<AppProps, AppState> {
rc : RoughCanvas | null = null ;
unmounted : boolean = false ;
actionManager : ActionManager ;
deviceType : DeviceType = {
isMobile : false ,
isTouchScreen : false ,
} ;
device : Device = deviceContextInitialValue ;
detachIsMobileMqHandler ? : ( ) = > void ;
private excalidrawContainerRef = React . createRef < HTMLDivElement > ( ) ;
@ -353,12 +353,12 @@ class App extends React.Component<AppProps, AppState> {
width : window.innerWidth ,
height : window.innerHeight ,
showHyperlinkPopup : false ,
isLibraryMenuDocked : false ,
} ;
this . id = nanoid ( ) ;
this . library = new Library ( this ) ;
if ( excalidrawRef ) {
const readyPromise =
( "current" in excalidrawRef && excalidrawRef . current ? . readyPromise ) ||
@ -485,7 +485,7 @@ class App extends React.Component<AppProps, AppState> {
< div
className = { clsx ( "excalidraw excalidraw-container" , {
"excalidraw--view-mode" : viewModeEnabled ,
"excalidraw--mobile" : this . device Type . isMobile ,
"excalidraw--mobile" : this . device . isMobile ,
} ) }
ref = { this . excalidrawContainerRef }
onDrop = { this . handleAppOnDrop }
@ -497,7 +497,7 @@ class App extends React.Component<AppProps, AppState> {
< ExcalidrawContainerContext.Provider
value = { this . excalidrawContainerValue }
>
< Device Type Context.Provider value = { this . devic eTyp e} >
< Device Context.Provider value = { this . devic e} >
< LayerUI
canvas = { this . canvas }
appState = { this . state }
@ -521,6 +521,7 @@ class App extends React.Component<AppProps, AppState> {
isCollaborating = { this . props . isCollaborating }
renderTopRightUI = { renderTopRightUI }
renderCustomFooter = { renderFooter }
renderCustomStats = { renderCustomStats }
viewModeEnabled = { viewModeEnabled }
showExitZenModeBtn = {
typeof this . props ? . zenModeEnabled === "undefined" &&
@ -548,15 +549,6 @@ class App extends React.Component<AppProps, AppState> {
onLinkOpen = { this . props . onLinkOpen }
/ >
) }
{ this . state . showStats && (
< Stats
appState = { this . state }
setAppState = { this . setAppState }
elements = { this . scene . getNonDeletedElements ( ) }
onClose = { this . toggleStats }
renderCustomStats = { renderCustomStats }
/ >
) }
{ this . state . toastMessage !== null && (
< Toast
message = { this . state . toastMessage }
@ -564,7 +556,7 @@ class App extends React.Component<AppProps, AppState> {
/ >
) }
< main > { this . renderCanvas ( ) } < / main >
< / Device Type Context.Provider>
< / Device Context.Provider>
< / ExcalidrawContainerContext.Provider >
< / div >
) ;
@ -763,7 +755,12 @@ class App extends React.Component<AppProps, AppState> {
const scene = restore ( initialData , null , null ) ;
scene . appState = {
. . . scene . appState ,
isLibraryOpen : this.state.isLibraryOpen ,
// we're falling back to current (pre-init) state when deciding
// whether to open the library, to handle a case where we
// update the state outside of initialData (e.g. when loading the app
// with a library install link, which should auto-open the library)
isLibraryOpen :
initialData ? . appState ? . isLibraryOpen || this . state . isLibraryOpen ,
activeTool :
scene . appState . activeTool . type === "image"
? { . . . scene . appState . activeTool , type : "selection" }
@ -794,6 +791,21 @@ class App extends React.Component<AppProps, AppState> {
} ) ;
} ;
private refreshDeviceState = ( container : HTMLDivElement ) = > {
const { width , height } = container . getBoundingClientRect ( ) ;
const sidebarBreakpoint =
this . props . UIOptions . dockedSidebarBreakpoint != null
? this . props . UIOptions . dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH ;
this . device = updateObject ( this . device , {
isSmScreen : width < MQ_SM_MAX_WIDTH ,
isMobile :
width < MQ_MAX_WIDTH_PORTRAIT ||
( height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE ) ,
canDeviceFitSidebar : width > sidebarBreakpoint ,
} ) ;
} ;
public async componentDidMount() {
this . unmounted = false ;
this . excalidrawContainerValue . container =
@ -835,34 +847,53 @@ class App extends React.Component<AppProps, AppState> {
this . focusContainer ( ) ;
}
if (
this . excalidrawContainerRef . current &&
// bounding rects don't work in tests so updating
// the state on init would result in making the test enviro run
// in mobile breakpoint (0 width/height), making everything fail
process . env . NODE_ENV !== "test"
) {
this . refreshDeviceState ( this . excalidrawContainerRef . current ) ;
}
if ( "ResizeObserver" in window && this . excalidrawContainerRef ? . current ) {
this . resizeObserver = new ResizeObserver ( ( ) = > {
// compute isMobile state
// recompute device dimensions state
// ---------------------------------------------------------------------
const { width , height } =
this . excalidrawContainerRef . current ! . getBoundingClientRect ( ) ;
this . deviceType = updateObject ( this . deviceType , {
isMobile :
width < MQ_MAX_WIDTH_PORTRAIT ||
( height < MQ_MAX_HEIGHT_LANDSCAPE &&
width < MQ_MAX_WIDTH_LANDSCAPE ) ,
} ) ;
this . refreshDeviceState ( this . excalidrawContainerRef . current ! ) ;
// refresh offsets
// ---------------------------------------------------------------------
this . updateDOMRect ( ) ;
} ) ;
this . resizeObserver ? . observe ( this . excalidrawContainerRef . current ) ;
} else if ( window . matchMedia ) {
const m edia Query = window . matchMedia (
const mdScreenQuery = window . matchMedia (
` (max-width: ${ MQ_MAX_WIDTH_PORTRAIT } px), (max-height: ${ MQ_MAX_HEIGHT_LANDSCAPE } px) and (max-width: ${ MQ_MAX_WIDTH_LANDSCAPE } px) ` ,
) ;
const smScreenQuery = window . matchMedia (
` (max-width: ${ MQ_SM_MAX_WIDTH } px) ` ,
) ;
const canDeviceFitSidebarMediaQuery = window . matchMedia (
` (min-width: ${
// NOTE this won't update if a different breakpoint is supplied
// after mount
this . props . UIOptions . dockedSidebarBreakpoint != null
? this . props . UIOptions . dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH
} px ) ` ,
) ;
const handler = ( ) = > {
this . deviceType = updateObject ( this . deviceType , {
isMobile : mediaQuery.matches ,
this . excalidrawContainerRef . current ! . getBoundingClientRect ( ) ;
this . device = updateObject ( this . device , {
isSmScreen : smScreenQuery.matches ,
isMobile : mdScreenQuery.matches ,
canDeviceFitSidebar : canDeviceFitSidebarMediaQuery.matches ,
} ) ;
} ;
mediaQuery . addListener ( handler ) ;
this . detachIsMobileMqHandler = ( ) = > mediaQuery . removeListener ( handler ) ;
mdScreenQuery . addListener ( handler ) ;
this . detachIsMobileMqHandler = ( ) = >
mdScreenQuery . removeListener ( handler ) ;
}
const searchParams = new URLSearchParams ( window . location . search . slice ( 1 ) ) ;
@ -1003,6 +1034,14 @@ class App extends React.Component<AppProps, AppState> {
}
componentDidUpdate ( prevProps : AppProps , prevState : AppState ) {
if (
this . excalidrawContainerRef . current &&
prevProps . UIOptions . dockedSidebarBreakpoint !==
this . props . UIOptions . dockedSidebarBreakpoint
) {
this . refreshDeviceState ( this . excalidrawContainerRef . current ) ;
}
if (
prevState . scrollX !== this . state . scrollX ||
prevState . scrollY !== this . state . scrollY
@ -1175,7 +1214,7 @@ class App extends React.Component<AppProps, AppState> {
theme : this.state.theme ,
imageCache : this.imageCache ,
isExporting : false ,
renderScrollbars : ! this . device Type . isMobile ,
renderScrollbars : ! this . device . isMobile ,
} ,
) ;
@ -1453,11 +1492,15 @@ class App extends React.Component<AppProps, AppState> {
this . scene . replaceAllElements ( nextElements ) ;
this . history . resumeRecording ( ) ;
this . setState (
selectGroupsForSelectedElements (
{
. . . this . state ,
isLibraryOpen : false ,
isLibraryOpen :
this . state . isLibraryOpen && this . device . canDeviceFitSidebar
? this . state . isLibraryMenuDocked
: false ,
selectedElementIds : newElements.reduce ( ( map , element ) = > {
if ( ! isBoundToContainer ( element ) ) {
map [ element . id ] = true ;
@ -1529,7 +1572,7 @@ class App extends React.Component<AppProps, AppState> {
trackEvent (
"toolbar" ,
"toggleLock" ,
` ${ source } ( ${ this . device Type . isMobile ? "mobile" : "desktop" } ) ` ,
` ${ source } ( ${ this . device . isMobile ? "mobile" : "desktop" } ) ` ,
) ;
}
this . setState ( ( prevState ) = > {
@ -1560,10 +1603,6 @@ class App extends React.Component<AppProps, AppState> {
this . actionManager . executeAction ( actionToggleZenMode ) ;
} ;
toggleStats = ( ) = > {
this . actionManager . executeAction ( actionToggleStats ) ;
} ;
scrollToContent = (
target :
| ExcalidrawElement
@ -1721,7 +1760,16 @@ class App extends React.Component<AppProps, AppState> {
}
if ( event . code === CODES . ZERO ) {
this . setState ( { isLibraryOpen : ! this . state . isLibraryOpen } ) ;
const nextState = ! this . state . isLibraryOpen ;
this . setState ( { isLibraryOpen : nextState } ) ;
// track only openings
if ( nextState ) {
trackEvent (
"library" ,
"toggleLibrary (open)" ,
` keyboard ( ${ this . device . isMobile ? "mobile" : "desktop" } ) ` ,
) ;
}
}
if ( isArrowKey ( event . key ) ) {
@ -1815,7 +1863,7 @@ class App extends React.Component<AppProps, AppState> {
trackEvent (
"toolbar" ,
shape ,
` keyboard ( ${ this . device Type . isMobile ? "mobile" : "desktop" } ) ` ,
` keyboard ( ${ this . device . isMobile ? "mobile" : "desktop" } ) ` ,
) ;
}
this . setActiveTool ( { type : shape } ) ;
@ -2440,7 +2488,7 @@ class App extends React.Component<AppProps, AppState> {
element ,
this . state ,
[ scenePointer . x , scenePointer . y ] ,
this . device Type . isMobile ,
this . device . isMobile ,
)
) ;
} ) ;
@ -2472,7 +2520,7 @@ class App extends React.Component<AppProps, AppState> {
this . hitLinkElement ,
this . state ,
[ lastPointerDownCoords . x , lastPointerDownCoords . y ] ,
this . device Type . isMobile ,
this . device . isMobile ,
) ;
const lastPointerUpCoords = viewportCoordsToSceneCoords (
this . lastPointerUp ! ,
@ -2482,7 +2530,7 @@ class App extends React.Component<AppProps, AppState> {
this . hitLinkElement ,
this . state ,
[ lastPointerUpCoords . x , lastPointerUpCoords . y ] ,
this . device Type . isMobile ,
this . device . isMobile ,
) ;
if ( lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon ) {
const url = this . hitLinkElement . link ;
@ -2921,10 +2969,10 @@ class App extends React.Component<AppProps, AppState> {
}
if (
! this . device Type . isTouchScreen &&
! this . device . isTouchScreen &&
[ "pen" , "touch" ] . includes ( event . pointerType )
) {
this . device Type = updateObject ( this . devic eTyp e, { isTouchScreen : true } ) ;
this . device = updateObject ( this . devic e, { isTouchScreen : true } ) ;
}
if ( isPanning ) {
@ -3066,7 +3114,7 @@ class App extends React.Component<AppProps, AppState> {
event : React.PointerEvent < HTMLCanvasElement > ,
) = > {
this . lastPointerUp = event ;
if ( this . device Type . isTouchScreen ) {
if ( this . device . isTouchScreen ) {
const scenePointer = viewportCoordsToSceneCoords (
{ clientX : event.clientX , clientY : event.clientY } ,
this . state ,
@ -3084,7 +3132,7 @@ class App extends React.Component<AppProps, AppState> {
this . hitLinkElement &&
! this . state . selectedElementIds [ this . hitLinkElement . id ]
) {
this . redirectToLink ( event , this . device Type . isTouchScreen ) ;
this . redirectToLink ( event , this . device . isTouchScreen ) ;
}
this . removePointer ( event ) ;
@ -3456,7 +3504,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState . hit . element ,
this . state ,
[ pointerDownState . origin . x , pointerDownState . origin . y ] ,
this . device Type . isMobile ,
this . device . isMobile ,
)
) {
return false ;
@ -5563,7 +5611,7 @@ class App extends React.Component<AppProps, AppState> {
} else {
ContextMenu . push ( {
options : [
this . device Type . isMobile &&
this . device . isMobile &&
navigator . clipboard && {
trackEvent : false ,
name : "paste" ,
@ -5575,7 +5623,7 @@ class App extends React.Component<AppProps, AppState> {
} ,
contextItemLabel : "labels.paste" ,
} ,
this . device Type . isMobile && navigator . clipboard && separator ,
this . device . isMobile && navigator . clipboard && separator ,
probablySupportsClipboardBlob &&
elements . length > 0 &&
actionCopyAsPng ,
@ -5620,9 +5668,9 @@ class App extends React.Component<AppProps, AppState> {
} else {
ContextMenu . push ( {
options : [
this . device Type . isMobile && actionCut ,
this . device Type . isMobile && navigator . clipboard && actionCopy ,
this . device Type . isMobile &&
this . device . isMobile && actionCut ,
this . device . isMobile && navigator . clipboard && actionCopy ,
this . device . isMobile &&
navigator . clipboard && {
name : "paste" ,
trackEvent : false ,
@ -5634,7 +5682,7 @@ class App extends React.Component<AppProps, AppState> {
} ,
contextItemLabel : "labels.paste" ,
} ,
this . device Type . isMobile && separator ,
this . device . isMobile && separator ,
. . . options ,
separator ,
actionCopyStyles ,